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