luci-base: add support for POST-only actions with CSRF token check
[project/luci.git] / modules / luci-base / luasrc / dispatcher.lua
1 -- Copyright 2008 Steven Barth <steven@midlink.org>
2 -- Copyright 2008-2015 Jo-Philipp Wich <jow@openwrt.org>
3 -- Licensed to the public under the Apache License 2.0.
4
5 local fs = require "nixio.fs"
6 local sys = require "luci.sys"
7 local util = require "luci.util"
8 local http = require "luci.http"
9 local nixio = require "nixio", require "nixio.util"
10
11 module("luci.dispatcher", package.seeall)
12 context = util.threadlocal()
13 uci = require "luci.model.uci"
14 i18n = require "luci.i18n"
15 _M.fs = fs
16
17 authenticator = {}
18
19 -- Index table
20 local index = nil
21
22 -- Fastindex
23 local fi
24
25
26 function build_url(...)
27         local path = {...}
28         local url = { http.getenv("SCRIPT_NAME") or "" }
29
30         local k, v
31         for k, v in pairs(context.urltoken) do
32                 url[#url+1] = "/;"
33                 url[#url+1] = http.urlencode(k)
34                 url[#url+1] = "="
35                 url[#url+1] = http.urlencode(v)
36         end
37
38         local p
39         for _, p in ipairs(path) do
40                 if p:match("^[a-zA-Z0-9_%-%.%%/,;]+$") then
41                         url[#url+1] = "/"
42                         url[#url+1] = p
43                 end
44         end
45
46         return table.concat(url, "")
47 end
48
49 function node_visible(node)
50    if node then
51           return not (
52                  (not node.title or #node.title == 0) or
53                  (not node.target or node.hidden == true) or
54                  (type(node.target) == "table" and node.target.type == "firstchild" and
55                   (type(node.nodes) ~= "table" or not next(node.nodes)))
56           )
57    end
58    return false
59 end
60
61 function node_childs(node)
62         local rv = { }
63         if node then
64                 local k, v
65                 for k, v in util.spairs(node.nodes,
66                         function(a, b)
67                                 return (node.nodes[a].order or 100)
68                                      < (node.nodes[b].order or 100)
69                         end)
70                 do
71                         if node_visible(v) then
72                                 rv[#rv+1] = k
73                         end
74                 end
75         end
76         return rv
77 end
78
79
80 function error404(message)
81         http.status(404, "Not Found")
82         message = message or "Not Found"
83
84         require("luci.template")
85         if not util.copcall(luci.template.render, "error404") then
86                 http.prepare_content("text/plain")
87                 http.write(message)
88         end
89         return false
90 end
91
92 function error500(message)
93         util.perror(message)
94         if not context.template_header_sent then
95                 http.status(500, "Internal Server Error")
96                 http.prepare_content("text/plain")
97                 http.write(message)
98         else
99                 require("luci.template")
100                 if not util.copcall(luci.template.render, "error500", {message=message}) then
101                         http.prepare_content("text/plain")
102                         http.write(message)
103                 end
104         end
105         return false
106 end
107
108 function authenticator.htmlauth(validator, accs, default)
109         local user = http.formvalue("luci_username")
110         local pass = http.formvalue("luci_password")
111
112         if user and validator(user, pass) then
113                 return user
114         end
115
116         if context.urltoken.stok then
117                 context.urltoken.stok = nil
118
119                 local cookie = 'sysauth=%s; expires=%s; path=%s/' %{
120                     http.getcookie('sysauth') or 'x',
121                         'Thu, 01 Jan 1970 01:00:00 GMT',
122                         build_url()
123                 }
124
125                 http.header("Set-Cookie", cookie)
126                 http.redirect(build_url())
127         else
128                 require("luci.i18n")
129                 require("luci.template")
130                 context.path = {}
131                 http.status(403, "Forbidden")
132                 luci.template.render("sysauth", {duser=default, fuser=user})
133         end
134
135         return false
136
137 end
138
139 function httpdispatch(request, prefix)
140         http.context.request = request
141
142         local r = {}
143         context.request = r
144         context.urltoken = {}
145
146         local pathinfo = http.urldecode(request:getenv("PATH_INFO") or "", true)
147
148         if prefix then
149                 for _, node in ipairs(prefix) do
150                         r[#r+1] = node
151                 end
152         end
153
154         local tokensok = true
155         for node in pathinfo:gmatch("[^/]+") do
156                 local tkey, tval
157                 if tokensok then
158                         tkey, tval = node:match(";(%w+)=([a-fA-F0-9]*)")
159                 end
160                 if tkey then
161                         context.urltoken[tkey] = tval
162                 else
163                         tokensok = false
164                         r[#r+1] = node
165                 end
166         end
167
168         local stat, err = util.coxpcall(function()
169                 dispatch(context.request)
170         end, error500)
171
172         http.close()
173
174         --context._disable_memtrace()
175 end
176
177 function dispatch(request)
178         --context._disable_memtrace = require "luci.debug".trap_memtrace("l")
179         local ctx = context
180         ctx.path = request
181
182         local conf = require "luci.config"
183         assert(conf.main,
184                 "/etc/config/luci seems to be corrupt, unable to find section 'main'")
185
186         local lang = conf.main.lang or "auto"
187         if lang == "auto" then
188                 local aclang = http.getenv("HTTP_ACCEPT_LANGUAGE") or ""
189                 for lpat in aclang:gmatch("[%w-]+") do
190                         lpat = lpat and lpat:gsub("-", "_")
191                         if conf.languages[lpat] then
192                                 lang = lpat
193                                 break
194                         end
195                 end
196         end
197         require "luci.i18n".setlanguage(lang)
198
199         local c = ctx.tree
200         local stat
201         if not c then
202                 c = createtree()
203         end
204
205         local track = {}
206         local args = {}
207         ctx.args = args
208         ctx.requestargs = ctx.requestargs or args
209         local n
210         local token = ctx.urltoken
211         local preq = {}
212         local freq = {}
213
214         for i, s in ipairs(request) do
215                 preq[#preq+1] = s
216                 freq[#freq+1] = s
217                 c = c.nodes[s]
218                 n = i
219                 if not c then
220                         break
221                 end
222
223                 util.update(track, c)
224
225                 if c.leaf then
226                         break
227                 end
228         end
229
230         if c and c.leaf then
231                 for j=n+1, #request do
232                         args[#args+1] = request[j]
233                         freq[#freq+1] = request[j]
234                 end
235         end
236
237         ctx.requestpath = ctx.requestpath or freq
238         ctx.path = preq
239
240         if track.i18n then
241                 i18n.loadc(track.i18n)
242         end
243
244         -- Init template engine
245         if (c and c.index) or not track.notemplate then
246                 local tpl = require("luci.template")
247                 local media = track.mediaurlbase or luci.config.main.mediaurlbase
248                 if not pcall(tpl.Template, "themes/%s/header" % fs.basename(media)) then
249                         media = nil
250                         for name, theme in pairs(luci.config.themes) do
251                                 if name:sub(1,1) ~= "." and pcall(tpl.Template,
252                                  "themes/%s/header" % fs.basename(theme)) then
253                                         media = theme
254                                 end
255                         end
256                         assert(media, "No valid theme found")
257                 end
258
259                 local function _ifattr(cond, key, val)
260                         if cond then
261                                 local env = getfenv(3)
262                                 local scope = (type(env.self) == "table") and env.self
263                                 return string.format(
264                                         ' %s="%s"', tostring(key),
265                                         util.pcdata(tostring( val
266                                          or (type(env[key]) ~= "function" and env[key])
267                                          or (scope and type(scope[key]) ~= "function" and scope[key])
268                                          or "" ))
269                                 )
270                         else
271                                 return ''
272                         end
273                 end
274
275                 tpl.context.viewns = setmetatable({
276                    write       = http.write;
277                    include     = function(name) tpl.Template(name):render(getfenv(2)) end;
278                    translate   = i18n.translate;
279                    translatef  = i18n.translatef;
280                    export      = function(k, v) if tpl.context.viewns[k] == nil then tpl.context.viewns[k] = v end end;
281                    striptags   = util.striptags;
282                    pcdata      = util.pcdata;
283                    media       = media;
284                    theme       = fs.basename(media);
285                    resource    = luci.config.main.resourcebase;
286                    ifattr      = function(...) return _ifattr(...) end;
287                    attr        = function(...) return _ifattr(true, ...) end;
288                    token       = ctx.urltoken.stok;
289                 }, {__index=function(table, key)
290                         if key == "controller" then
291                                 return build_url()
292                         elseif key == "REQUEST_URI" then
293                                 return build_url(unpack(ctx.requestpath))
294                         else
295                                 return rawget(table, key) or _G[key]
296                         end
297                 end})
298         end
299
300         track.dependent = (track.dependent ~= false)
301         assert(not track.dependent or not track.auto,
302                 "Access Violation\nThe page at '" .. table.concat(request, "/") .. "/' " ..
303                 "has no parent node so the access to this location has been denied.\n" ..
304                 "This is a software bug, please report this message at " ..
305                 "http://luci.subsignal.org/trac/newticket"
306         )
307
308         if track.sysauth then
309                 local authen = type(track.sysauth_authenticator) == "function"
310                  and track.sysauth_authenticator
311                  or authenticator[track.sysauth_authenticator]
312
313                 local def  = (type(track.sysauth) == "string") and track.sysauth
314                 local accs = def and {track.sysauth} or track.sysauth
315                 local sess = ctx.authsession
316                 local verifytoken = false
317                 if not sess then
318                         sess = http.getcookie("sysauth")
319                         sess = sess and sess:match("^[a-f0-9]*$")
320                         verifytoken = true
321                 end
322
323                 local sdat = (util.ubus("session", "get", { ubus_rpc_session = sess }) or { }).values
324                 local user
325
326                 if sdat then
327                         if not verifytoken or ctx.urltoken.stok == sdat.token then
328                                 user = sdat.user
329                         end
330                 else
331                         local eu = http.getenv("HTTP_AUTH_USER")
332                         local ep = http.getenv("HTTP_AUTH_PASS")
333                         if eu and ep and sys.user.checkpasswd(eu, ep) then
334                                 authen = function() return eu end
335                         end
336                 end
337
338                 if not util.contains(accs, user) then
339                         if authen then
340                                 local user, sess = authen(sys.user.checkpasswd, accs, def)
341                                 local token
342                                 if not user or not util.contains(accs, user) then
343                                         return
344                                 else
345                                         if not sess then
346                                                 local sdat = util.ubus("session", "create", { timeout = tonumber(luci.config.sauth.sessiontime) })
347                                                 if sdat then
348                                                         token = sys.uniqueid(16)
349                                                         util.ubus("session", "set", {
350                                                                 ubus_rpc_session = sdat.ubus_rpc_session,
351                                                                 values = {
352                                                                         user = user,
353                                                                         token = token,
354                                                                         section = sys.uniqueid(16)
355                                                                 }
356                                                         })
357                                                         sess = sdat.ubus_rpc_session
358                                                 end
359                                         end
360
361                                         if sess and token then
362                                                 http.header("Set-Cookie", 'sysauth=%s; path=%s/' %{
363                                                    sess, build_url()
364                                                 })
365
366                                                 ctx.urltoken.stok = token
367                                                 ctx.authsession = sess
368                                                 ctx.authuser = user
369
370                                                 http.redirect(build_url(unpack(ctx.requestpath)))
371                                         end
372                                 end
373                         else
374                                 http.status(403, "Forbidden")
375                                 return
376                         end
377                 else
378                         ctx.authsession = sess
379                         ctx.authuser = user
380                 end
381         end
382
383         if c and type(c.target) == "table" and c.target.post == true then
384                 if http.getenv("REQUEST_METHOD") ~= "POST" then
385                         http.status(405, "Method Not Allowed")
386                         http.header("Allow", "POST")
387                         return
388                 end
389
390                 if http.formvalue("token") ~= ctx.urltoken.stok then
391                         http.status(403, "Forbidden")
392                         luci.template.render("csrftoken")
393                         return
394                 end
395         end
396
397         if track.setgroup then
398                 sys.process.setgroup(track.setgroup)
399         end
400
401         if track.setuser then
402                 -- trigger ubus connection before dropping root privs
403                 util.ubus()
404
405                 sys.process.setuser(track.setuser)
406         end
407
408         local target = nil
409         if c then
410                 if type(c.target) == "function" then
411                         target = c.target
412                 elseif type(c.target) == "table" then
413                         target = c.target.target
414                 end
415         end
416
417         if c and (c.index or type(target) == "function") then
418                 ctx.dispatched = c
419                 ctx.requested = ctx.requested or ctx.dispatched
420         end
421
422         if c and c.index then
423                 local tpl = require "luci.template"
424
425                 if util.copcall(tpl.render, "indexer", {}) then
426                         return true
427                 end
428         end
429
430         if type(target) == "function" then
431                 util.copcall(function()
432                         local oldenv = getfenv(target)
433                         local module = require(c.module)
434                         local env = setmetatable({}, {__index=
435
436                         function(tbl, key)
437                                 return rawget(tbl, key) or module[key] or oldenv[key]
438                         end})
439
440                         setfenv(target, env)
441                 end)
442
443                 local ok, err
444                 if type(c.target) == "table" then
445                         ok, err = util.copcall(target, c.target, unpack(args))
446                 else
447                         ok, err = util.copcall(target, unpack(args))
448                 end
449                 assert(ok,
450                        "Failed to execute " .. (type(c.target) == "function" and "function" or c.target.type or "unknown") ..
451                        " dispatcher target for entry '/" .. table.concat(request, "/") .. "'.\n" ..
452                        "The called action terminated with an exception:\n" .. tostring(err or "(unknown)"))
453         else
454                 local root = node()
455                 if not root or not root.target then
456                         error404("No root node was registered, this usually happens if no module was installed.\n" ..
457                                  "Install luci-mod-admin-full and retry. " ..
458                                  "If the module is already installed, try removing the /tmp/luci-indexcache file.")
459                 else
460                         error404("No page is registered at '/" .. table.concat(request, "/") .. "'.\n" ..
461                                  "If this url belongs to an extension, make sure it is properly installed.\n" ..
462                                  "If the extension was recently installed, try removing the /tmp/luci-indexcache file.")
463                 end
464         end
465 end
466
467 function createindex()
468         local controllers = { }
469         local base = "%s/controller/" % util.libpath()
470         local _, path
471
472         for path in (fs.glob("%s*.lua" % base) or function() end) do
473                 controllers[#controllers+1] = path
474         end
475
476         for path in (fs.glob("%s*/*.lua" % base) or function() end) do
477                 controllers[#controllers+1] = path
478         end
479
480         if indexcache then
481                 local cachedate = fs.stat(indexcache, "mtime")
482                 if cachedate then
483                         local realdate = 0
484                         for _, obj in ipairs(controllers) do
485                                 local omtime = fs.stat(obj, "mtime")
486                                 realdate = (omtime and omtime > realdate) and omtime or realdate
487                         end
488
489                         if cachedate > realdate and sys.process.info("uid") == 0 then
490                                 assert(
491                                         sys.process.info("uid") == fs.stat(indexcache, "uid")
492                                         and fs.stat(indexcache, "modestr") == "rw-------",
493                                         "Fatal: Indexcache is not sane!"
494                                 )
495
496                                 index = loadfile(indexcache)()
497                                 return index
498                         end
499                 end
500         end
501
502         index = {}
503
504         for _, path in ipairs(controllers) do
505                 local modname = "luci.controller." .. path:sub(#base+1, #path-4):gsub("/", ".")
506                 local mod = require(modname)
507                 assert(mod ~= true,
508                        "Invalid controller file found\n" ..
509                        "The file '" .. path .. "' contains an invalid module line.\n" ..
510                        "Please verify whether the module name is set to '" .. modname ..
511                        "' - It must correspond to the file path!")
512
513                 local idx = mod.index
514                 assert(type(idx) == "function",
515                        "Invalid controller file found\n" ..
516                        "The file '" .. path .. "' contains no index() function.\n" ..
517                        "Please make sure that the controller contains a valid " ..
518                        "index function and verify the spelling!")
519
520                 index[modname] = idx
521         end
522
523         if indexcache then
524                 local f = nixio.open(indexcache, "w", 600)
525                 f:writeall(util.get_bytecode(index))
526                 f:close()
527         end
528 end
529
530 -- Build the index before if it does not exist yet.
531 function createtree()
532         if not index then
533                 createindex()
534         end
535
536         local ctx  = context
537         local tree = {nodes={}, inreq=true}
538         local modi = {}
539
540         ctx.treecache = setmetatable({}, {__mode="v"})
541         ctx.tree = tree
542         ctx.modifiers = modi
543
544         -- Load default translation
545         require "luci.i18n".loadc("base")
546
547         local scope = setmetatable({}, {__index = luci.dispatcher})
548
549         for k, v in pairs(index) do
550                 scope._NAME = k
551                 setfenv(v, scope)
552                 v()
553         end
554
555         local function modisort(a,b)
556                 return modi[a].order < modi[b].order
557         end
558
559         for _, v in util.spairs(modi, modisort) do
560                 scope._NAME = v.module
561                 setfenv(v.func, scope)
562                 v.func()
563         end
564
565         return tree
566 end
567
568 function modifier(func, order)
569         context.modifiers[#context.modifiers+1] = {
570                 func = func,
571                 order = order or 0,
572                 module
573                         = getfenv(2)._NAME
574         }
575 end
576
577 function assign(path, clone, title, order)
578         local obj  = node(unpack(path))
579         obj.nodes  = nil
580         obj.module = nil
581
582         obj.title = title
583         obj.order = order
584
585         setmetatable(obj, {__index = _create_node(clone)})
586
587         return obj
588 end
589
590 function entry(path, target, title, order)
591         local c = node(unpack(path))
592
593         c.target = target
594         c.title  = title
595         c.order  = order
596         c.module = getfenv(2)._NAME
597
598         return c
599 end
600
601 -- enabling the node.
602 function get(...)
603         return _create_node({...})
604 end
605
606 function node(...)
607         local c = _create_node({...})
608
609         c.module = getfenv(2)._NAME
610         c.auto = nil
611
612         return c
613 end
614
615 function _create_node(path)
616         if #path == 0 then
617                 return context.tree
618         end
619
620         local name = table.concat(path, ".")
621         local c = context.treecache[name]
622
623         if not c then
624                 local last = table.remove(path)
625                 local parent = _create_node(path)
626
627                 c = {nodes={}, auto=true}
628                 -- the node is "in request" if the request path matches
629                 -- at least up to the length of the node path
630                 if parent.inreq and context.path[#path+1] == last then
631                   c.inreq = true
632                 end
633                 parent.nodes[last] = c
634                 context.treecache[name] = c
635         end
636         return c
637 end
638
639 -- Subdispatchers --
640
641 function _firstchild()
642    local path = { unpack(context.path) }
643    local name = table.concat(path, ".")
644    local node = context.treecache[name]
645
646    local lowest
647    if node and node.nodes and next(node.nodes) then
648           local k, v
649           for k, v in pairs(node.nodes) do
650                  if not lowest or
651                         (v.order or 100) < (node.nodes[lowest].order or 100)
652                  then
653                         lowest = k
654                  end
655           end
656    end
657
658    assert(lowest ~= nil,
659                   "The requested node contains no childs, unable to redispatch")
660
661    path[#path+1] = lowest
662    dispatch(path)
663 end
664
665 function firstchild()
666    return { type = "firstchild", target = _firstchild }
667 end
668
669 function alias(...)
670         local req = {...}
671         return function(...)
672                 for _, r in ipairs({...}) do
673                         req[#req+1] = r
674                 end
675
676                 dispatch(req)
677         end
678 end
679
680 function rewrite(n, ...)
681         local req = {...}
682         return function(...)
683                 local dispatched = util.clone(context.dispatched)
684
685                 for i=1,n do
686                         table.remove(dispatched, 1)
687                 end
688
689                 for i, r in ipairs(req) do
690                         table.insert(dispatched, i, r)
691                 end
692
693                 for _, r in ipairs({...}) do
694                         dispatched[#dispatched+1] = r
695                 end
696
697                 dispatch(dispatched)
698         end
699 end
700
701
702 local function _call(self, ...)
703         local func = getfenv()[self.name]
704         assert(func ~= nil,
705                'Cannot resolve function "' .. self.name .. '". Is it misspelled or local?')
706
707         assert(type(func) == "function",
708                'The symbol "' .. self.name .. '" does not refer to a function but data ' ..
709                'of type "' .. type(func) .. '".')
710
711         if #self.argv > 0 then
712                 return func(unpack(self.argv), ...)
713         else
714                 return func(...)
715         end
716 end
717
718 function call(name, ...)
719         return {type = "call", argv = {...}, name = name, target = _call}
720 end
721
722 function post(name, ...)
723         return {
724                 type = "call",
725                 post = true,
726                 argv = { ... },
727                 name = name,
728                 target = _call
729         }
730 end
731
732
733 local _template = function(self, ...)
734         require "luci.template".render(self.view)
735 end
736
737 function template(name)
738         return {type = "template", view = name, target = _template}
739 end
740
741
742 local function _cbi(self, ...)
743         local cbi = require "luci.cbi"
744         local tpl = require "luci.template"
745         local http = require "luci.http"
746
747         local config = self.config or {}
748         local maps = cbi.load(self.model, ...)
749
750         local state = nil
751
752         for i, res in ipairs(maps) do
753                 res.flow = config
754                 local cstate = res:parse()
755                 if cstate and (not state or cstate < state) then
756                         state = cstate
757                 end
758         end
759
760         local function _resolve_path(path)
761                 return type(path) == "table" and build_url(unpack(path)) or path
762         end
763
764         if config.on_valid_to and state and state > 0 and state < 2 then
765                 http.redirect(_resolve_path(config.on_valid_to))
766                 return
767         end
768
769         if config.on_changed_to and state and state > 1 then
770                 http.redirect(_resolve_path(config.on_changed_to))
771                 return
772         end
773
774         if config.on_success_to and state and state > 0 then
775                 http.redirect(_resolve_path(config.on_success_to))
776                 return
777         end
778
779         if config.state_handler then
780                 if not config.state_handler(state, maps) then
781                         return
782                 end
783         end
784
785         http.header("X-CBI-State", state or 0)
786
787         if not config.noheader then
788                 tpl.render("cbi/header", {state = state})
789         end
790
791         local redirect
792         local messages
793         local applymap   = false
794         local pageaction = true
795         local parsechain = { }
796
797         for i, res in ipairs(maps) do
798                 if res.apply_needed and res.parsechain then
799                         local c
800                         for _, c in ipairs(res.parsechain) do
801                                 parsechain[#parsechain+1] = c
802                         end
803                         applymap = true
804                 end
805
806                 if res.redirect then
807                         redirect = redirect or res.redirect
808                 end
809
810                 if res.pageaction == false then
811                         pageaction = false
812                 end
813
814                 if res.message then
815                         messages = messages or { }
816                         messages[#messages+1] = res.message
817                 end
818         end
819
820         for i, res in ipairs(maps) do
821                 res:render({
822                         firstmap   = (i == 1),
823                         applymap   = applymap,
824                         redirect   = redirect,
825                         messages   = messages,
826                         pageaction = pageaction,
827                         parsechain = parsechain
828                 })
829         end
830
831         if not config.nofooter then
832                 tpl.render("cbi/footer", {
833                         flow       = config,
834                         pageaction = pageaction,
835                         redirect   = redirect,
836                         state      = state,
837                         autoapply  = config.autoapply
838                 })
839         end
840 end
841
842 function cbi(model, config)
843         return {type = "cbi", config = config, model = model, target = _cbi}
844 end
845
846
847 local function _arcombine(self, ...)
848         local argv = {...}
849         local target = #argv > 0 and self.targets[2] or self.targets[1]
850         setfenv(target.target, self.env)
851         target:target(unpack(argv))
852 end
853
854 function arcombine(trg1, trg2)
855         return {type = "arcombine", env = getfenv(), target = _arcombine, targets = {trg1, trg2}}
856 end
857
858
859 local function _form(self, ...)
860         local cbi = require "luci.cbi"
861         local tpl = require "luci.template"
862         local http = require "luci.http"
863
864         local maps = luci.cbi.load(self.model, ...)
865         local state = nil
866
867         for i, res in ipairs(maps) do
868                 local cstate = res:parse()
869                 if cstate and (not state or cstate < state) then
870                         state = cstate
871                 end
872         end
873
874         http.header("X-CBI-State", state or 0)
875         tpl.render("header")
876         for i, res in ipairs(maps) do
877                 res:render()
878         end
879         tpl.render("footer")
880 end
881
882 function form(model)
883         return {type = "cbi", model = model, target = _form}
884 end
885
886 translate = i18n.translate
887
888 -- This function does not actually translate the given argument but
889 -- is used by build/i18n-scan.pl to find translatable entries.
890 function _(text)
891         return text
892 end