4 -- Written by Timm S. Mueller <tmueller at neoscientists.org>
5 -- See copyright notice in COPYRIGHT
8 local Class = require "tek.class"
9 local lib = require "tek.lib"
10 local luahtml = require "tek.lib.luahtml"
11 local posix = require "tek.os.posix"
12 local cgi = require "tek.class.cgi"
13 local Request = require "tek.class.cgi.request"
14 local util = require "tek.class.loona.util"
15 local Markup = require "tek.class.markup"
18 string = string, table = table,
19 assert = assert, collectgarbage = collectgarbage, dofile = dofile,
20 error = error, getfenv = getfenv, getmetatable = getmetatable,
21 ipairs = ipairs, load = load, loadfile = loadfile, loadstring = loadstring,
22 next = next, pairs = pairs, pcall = pcall, print = print,
23 rawequal = rawequal, rawget = rawget, rawset = rawset, require = require,
24 select = select, setfenv = setfenv, setmetatable = setmetatable,
25 tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
29 local table, string, assert, unpack, ipairs, pairs, type, require, setfenv =
30 table, string, assert, unpack, ipairs, pairs, type, require, setfenv
31 local open, remove, rename, getenv, time, date =
32 io.open, os.remove, os.rename, os.getenv, os.time, os.date
33 local setmetatable = setmetatable
35 -------------------------------------------------------------------------------
37 -------------------------------------------------------------------------------
39 module("tek.class.loona", Class)
40 _VERSION = "LOona Class 5.1"
42 -------------------------------------------------------------------------------
44 -------------------------------------------------------------------------------
46 Markup = Markup:newClass()
48 function Markup:init()
53 function Markup:exit()
57 function Markup:argument(name, text)
58 return ', function()%>', '<%end'
61 function Markup:func(is_dynamic, args)
63 self.is_dynamic_content = true
65 local t = { '<%loona:include("', args[1], '"' }
67 for _, v in ipairs(args) do
68 table.insert(t, ',' .. v)
70 return table.concat(t), ')%>'
73 function Markup:link(link)
74 local func = link:match("^(.*)%(%)$")
76 return '<%=loona:link("' .. '#' .. self:encodeurl(func, true) .. '", [[', ']])%>'
77 elseif link:match("^%a*://.*$") then
78 return '<%=loona:elink("' .. self:encodeurl(link, true) .. '", [[', ']])%>'
80 return '<%=loona:link("' .. link .. '", [[', ']])%>'
84 function Markup:indent()
85 return '<div class="indent">', '</div>'
88 -------------------------------------------------------------------------------
90 -------------------------------------------------------------------------------
92 local Session = Class:newClass()
94 function Session.new(class, self)
96 self = Class.new(class, self or { })
98 assert(self.id, "No session Id")
99 assert(self.sessiondir, "No session directory")
101 self.name = self.id:gsub("(.)", function(a)
102 return ("%02x"):format(a:byte())
104 self.filename = self.sessiondir .. "/" .. self.name
105 -- remove non-dotted files (expired sessions) from sessions dir:
106 util.expire(self.sessiondir, "[^.]%S+", self.maxage or 600)
107 -- load session state:
108 self.data = lib.source(self.filename) or { }
113 function Session:save()
114 local f = open(self.filename, "wb")
115 assert(f, "Failed to open session file for writing")
116 lib.dump(self.data, function(...)
122 function Session:delete()
123 remove(self.filename)
126 -------------------------------------------------------------------------------
128 -------------------------------------------------------------------------------
133 function Loona:dbmsg(msg, detail)
134 return (msg and detail and self.authuser) and
135 ("%s : %s"):format(msg, detail) or msg
139 function Loona:checkprofilename(n)
140 assert(n:match("^%w+$") and n ~= "current",
141 self:dbmsg("Invalid profile name", n))
146 function Loona:checklanguage(n)
147 assert(n:match("^%l%l$"), self:dbmsg("Invalid language code", n))
152 function Loona:checkbodyname(s)
154 assert(s:match("^[%w_]*%w+[%w_]*$"), self:dbmsg("Invalid body name", s))
159 function Loona:deleteprofile(p, lang)
160 p = self.config.contentdir .. "/" .. p .. "_" .. (lang or self.lang)
161 for e in util.readdir(p) do
162 local success, msg = remove(p .. "/" .. e)
163 assert(success, self:dbmsg("Error removing entry in profile", msg))
169 function Loona:copyprofile(dstprof, srcprof, dstlang, srclang)
170 local contentdir = self.config.contentdir
171 local src = ("%s/%s_%s"):format(contentdir,
172 srcprof or self.profile, srclang or self.lang)
173 local dst = ("%s/%s_%s"):format(contentdir,
174 dstprof or self.profile, dstlang or self.lang)
175 assert(src ~= dst, self:dbmsg("Attempt to copy profile over itself"))
176 assert(posix.stat(src, "mode") == "directory",
177 self:dbmsg("Source profile not a directory", src))
178 local success, msg = posix.mkdir(dst)
179 assert(success, self:dbmsg("Error creating profile directory " .. dst, msg))
180 for e in util.readdir(src) do
181 local ext = e:match("^[^.].*%.([^.]*)$")
182 if ext ~= "LOCK" then
183 local f = src .. "/" .. e
184 if posix.stat(f, "mode") == "file" then
185 success, msg = lib.copyfile(f, dst .. "/" .. e)
186 assert(success, self:dbmsg("Error copying file", msg))
190 -- create "current" symlink if none exists for new profile/language
191 if not posix.readlink(contentdir .. "/current_" .. dstlang) then
192 self:makecurrent(dstprof, dstlang)
197 function Loona:makecurrent(prof, lang)
198 prof = prof or self.profile
199 lang = lang or self.lang
200 local contentdir = self.config.contentdir
201 local newpath = ("%s/current_%s"):format(contentdir, lang)
202 local tmppath = newpath .. "." .. self.session.name
203 local success, msg = posix.symlink(prof .. "_" .. lang, tmppath)
204 assert(success, self:dbmsg("Cannot create symlink", msg))
205 success, msg = rename(tmppath, newpath)
206 assert(success, self:dbmsg("Cannot put symlink in place", msg))
211 function Loona:publishprofile(profile, lang)
212 lang = lang or self.lang
213 local contentdir = self.config.contentdir
215 -- Get languages for the current profile
218 local lmatch = "^" .. self.profile .. "_(%w+)$"
219 for e in util.readdir(self.config.contentdir) do
220 local l = e:match(lmatch)
222 table.insert(plangs, l)
226 -- For all languages, update "current" symlink
228 for _, lang in ipairs(plangs) do
229 self:makecurrent(profile, lang)
232 -- These arguments are overwritten globally and need to get restored
234 local save_args = { self.args.lang, self.args.profile, self.args.session }
236 -- For all languages, unroll site to static HTML
238 for _, lang in ipairs(plangs) do
239 local ext = (#plangs == 1 and ".html") or (".html." .. lang)
240 self:recursesections(self.sections, function(self, s, e, path)
241 path = path and path .. "/" .. e.name or e.name
242 if not e.notvisible then
244 request = self.request, -- reuse request
245 userdata = self.userdata, -- reuse userdata
246 requestpath = path, requestlang = lang,
247 htmlext = ext, insecure = true
256 self.args.lang, self.args.profile, self.args.session = unpack(save_args)
260 local htdocs = self.config.htdocsdir
261 local cache = self.config.htmlcachedir
263 for e in util.readdir(cache) do
264 local f = e:match("^.*%.html%.?(%w*)$")
265 if f and f ~= "tmp" then
266 local success, msg = remove(htdocs .. "/" .. e)
267 success, msg = remove(cache .. "/" .. e)
269 self:dbmsg("Could not purge cached HTML file", msg))
273 for e in util.readdir(cache) do
274 local f = e:match("^(.*%.html%.?%w*)%.tmp$")
276 local success, msg = rename(cache .. "/" .. e, cache .. "/" .. f)
278 self:dbmsg("Could not update cached HTML file", msg))
279 success, msg = rename(htdocs .. "/" .. e, htdocs .. "/" .. f)
281 self:dbmsg("Could not update cached HTML file", msg))
287 function Loona:recursesections(s, func, ...)
288 for _, e in ipairs(s) do
289 local udata = { func(self, s, e, unpack(arg)) }
291 self:recursesections(e.subs, func, unpack(udata))
297 function Loona:indexsections()
298 local userperm = self.session and self.session.data.permissions
299 userperm = userperm and userperm ~= "" and "[" .. userperm .. "]"
300 self:recursesections(self.sections, function(self, s, e)
301 local permitted = true
302 local sectperm = e.permissions
303 if sectperm and sectperm ~= "" and not self.authuser_seeall then
306 local num = sectperm:len()
307 sectperm:gsub(userperm, function() num = num - 1 end)
311 e.notvalid = not permitted or (not self.secure and e.secure) or
312 (not self.authuser_visible and e.secret) or nil
313 e.notvisible = e.notvalid or not self.authuser_visible and e.hidden or nil
319 -- Decompose section path into a stack of sections, returning only up to
320 -- the last valid element in the path. additionally returns the table of
321 -- the last section path element (or the default section)
323 function Loona:getsection(path)
324 local default = not self.authuser and self.config.defname
325 local tab = { { entries = self.sections, name = default } }
326 local ss = self.sections
328 (path or ""):gsub("(%w+)/?", function(a)
331 if s and not s.notvalid then
336 table.insert(tab, { entries = ss })
343 if not self.section and not sectionpath then
344 sectionpath = self.sections[default]
346 table.insert(tab, { entries = sectionpath.subs })
349 return tab, sectionpath
353 function Loona:getpath(delimiter, maxdepth)
356 maxdepth = maxdepth or #self.submenus
357 for _, menu in ipairs(self.submenus) do
359 table.insert(t, menu.name)
362 if d == maxdepth then
366 return table.concat(t, delimiter or "/")
370 function Loona:deletesection(fname, all_bodies)
371 local fullname = self.contentdir .. "/" .. fname
372 local success, msg = remove(fullname)
374 local pat = "^" .. -- TODO: check
375 fname:gsub("%^%$%(%)%%%.%[%]%*%+%-%?", "%%%1") .. "%..*$"
376 for e in util.readdir(self.contentdir) do
378 remove(self.contentdir .. "/" .. e)
386 function Loona:addpath(path, e)
387 local tab = self.sections
388 path:gsub("(%w+)/?", function(a)
407 local function lookupname(tab, val)
408 for i, v in ipairs(tab) do
409 if v.name == val then
416 function Loona:rmpath(path)
418 local tab = self.sections
419 path:gsub("(%w+)/?", function(a)
421 local idx = lookupname(tab, a)
423 if tab[idx].subs then
427 table.remove(tab, idx)
429 if #tab == 0 and parent then
440 function Loona:checkpath(path)
441 if path ~= "index" then -- "index" is reserved
443 local tab = self.sections
444 path:gsub("(%w+)/?", function(a)
446 local i = lookupname(tab, a)
451 res, idx, tab = nil, nil, nil
460 function Loona:title()
461 return self.section and (self.section.title or self.section.label or
462 self.section.name) or ""
466 -- Run a site function snippet, with full error recovery
467 -- (also recovers from errors in error handling function)
469 function Loona:dosnippet(func, errfunc)
470 local ret = { lib.catch(func) }
471 if ret[1] == 0 or (errfunc and lib.catch(errfunc) == 0) then
474 self:out("<h2>Error</h2>")
475 self:out("<h3>" .. self:encodeform(ret[2] or "") .. "</h3>")
476 if self.authuser_debug then
477 if type(ret[3]) == "string" then
478 self:out("<p>" .. self:encodeform(ret[3]) .. "</p>")
481 self:out("<pre>" .. self:encodeform(ret[4]) .. "</pre>")
487 function Loona:lockfile(file)
488 return not self.session and true or
489 posix.symlink(self.session.filename, file .. ".LOCK")
493 function Loona:unlockfile(file)
494 return not self.session and true or remove(file .. ".LOCK")
498 function Loona:saveindex()
499 local tempname = self.indexfname .. "." .. self.session.name
500 local f, msg = open(tempname, "wb")
501 assert(f, self:dbmsg("Error opening section file for writing", msg))
502 lib.dump(self.sections, function(...)
506 local success, msg = rename(tempname, self.indexfname)
507 assert(success, self:dbmsg("Error renaming section file", msg))
511 function Loona:savebody(fname, content)
512 fname = self.contentdir .. "/" .. fname
513 local f, msg = open(fname, "wb")
514 assert(f, self:dbmsg("Could not open file for writing", msg))
515 f:write(content or "")
520 function Loona:runboxed(func, envitems, ...)
526 for k, v in pairs(envitems) do
530 setmetatable(fenv, { __index = boxed_G }) -- boxed global environment
536 function Loona:include(fname, ...)
537 assert(not fname:match("%W"), self:dbmsg("Invalid include name", fname))
538 local fname2 = ("%s/%s.lua"):format(self.config.extdir, fname)
539 local f, msg = open(fname2)
540 assert(f, self:dbmsg("Cannot open file", msg))
541 local parsed, msg = self:loadhtml(f, "loona:out", fname2)
542 msg = msg and (type(msg) == "string" and msg or msg.txt)
543 assert(parsed, self:dbmsg("Syntax error", msg))
544 return self:runboxed(parsed, nil, unpack(arg))
548 -- produce link target (simple)
550 function Loona:shref(section, arg)
551 local args2 = { } -- propagated or new arguments
552 for _, a in ipairs(arg) do
553 local key, val = a:match("^([%w_]+)=(.*)$")
554 if key and val then -- "arg=val" sets/overrides argument
555 table.insert(args2, { name = key, value = val })
556 elseif self.args[a] then -- just "arg" propagates argument
557 table.insert(args2, { name = a, value = self.args[a] })
560 local doc = self:getdocname(section, #args2 > 0)
561 local url, anch = doc:match("^(.+)(#.+)$")
562 local notfirst = doc:match("%?")
563 local href = { anch and url or doc }
564 for i, arg in ipairs(args2) do
565 if i > 1 or notfirst then
566 table.insert(href, "&")
568 table.insert(href, "?")
570 table.insert(href, arg.name .. "=" .. cgi.encodeurl(arg.value))
573 table.insert(href, anch)
575 return table.concat(href)
579 -- produce link target, implicit propagation of lang, profile, session
581 function Loona:href(section, ...)
583 table.insert(arg, 1, "profile")
584 table.insert(arg, 1, "session")
586 if self.explicitlang then
587 table.insert(arg, 1, "lang")
589 return self:shref(section, arg)
593 function Loona:ilink(target, text, extra)
594 return ('<a href="%s"%s>%s</a>'):format(target, extra or "", text)
598 -- internal link, implicit propagation of lang, profile, session
600 function Loona:link(section, text, ...)
601 return self:ilink(self:href(section, unpack(arg)), text or section,
606 -- external link (opens in a new window), no argument propagation
608 function Loona:elink(target, text)
609 return self:ilink(target, text or target, self.config.extlinkextra)
613 -- plain link, no implicit argument propagation
615 function Loona:plink(section, text, ...)
616 return self:ilink(self:shref(section, arg), text or section)
620 -- user interface link, implicit propagation of lang, profile, session
622 function Loona:uilink(section, text, ...)
623 return self:ilink(self:href(section, unpack(arg)), text or section,
628 -- produce a hidden input value in forms
630 function Loona:hidden(name, value)
631 return not value and "" or
632 ('<input type="hidden" name="%s" value="%s" />'):format(name, value)
636 function Loona:scanprofiles(func)
638 local dir = self.config.contentdir
639 for f in util.readdir(dir) do
640 if posix.lstat(dir .. "/" .. f, "mode") == "directory" then
648 for _, v in ipairs(tab) do
655 function Loona:getprofiles(lang)
656 lang = lang or self.lang
657 return self:scanprofiles(function(f)
658 return f:match("^(%w+)_" .. lang .. "$")
663 function Loona:getlanguages(prof)
664 prof = prof or self.profile
665 return self:scanprofiles(function(f)
666 return f:match("^" .. prof .. "_(%l%l)$")
671 -- Functions to produce a navigation menu
673 local newent = { name = "new", label = "[+]", action="actionnew=true" }
675 function Loona:rmenu(level, render, path, addnew, recurse)
676 local sub = (addnew and level == #self.submenus + 1) and
677 { name = "new", entries = { }} or self.submenus[level]
678 if sub and sub.entries then
680 for _, e in ipairs(sub.entries) do
681 if not e.notvisible then
682 table.insert(visible, e)
686 table.insert(visible, newent)
688 local numvis = #visible
690 render.listbegin(self, level, numvis, path)
691 for idx, e in ipairs(visible) do
692 local label = self:encodeform(e.label or e.name)
693 local newpath = path and path .. "/" .. e.name or e.name
694 local active = (e.name == sub.name)
695 render.itembegin(self, level, idx, label)
696 render.link(self, level, newpath, label, active, e.action)
697 if recurse and active then
698 self:rmenu(level + 1, render, newpath, addnew, recurse)
700 render.itemend(self, level, idx, label)
708 function Loona:menu(level, recurse, render)
710 render = render or { }
711 render.link = render.link or
712 function(self, level, path, label, active, ...)
713 self:out(('<a %shref="%s">%s</a>\n'):format(active and
714 'class="active" ' or "", self:href(path, unpack(arg)), label))
716 render.listbegin = render.listbegin or
717 function(self, level) -- , numvis, path
718 self:out('<ul id="menulevel' .. level .. '">\n')
720 render.listend = render.listend or
724 render.itembegin = render.itembegin or
725 function(self) -- , level, idx
728 render.itemend = render.itemend or
732 recurse = recurse == nil and true or recurse
733 local path = level > 1 and self:getpath("/", level - 1) or nil
734 local addnew = self.authuser_menu and
735 (not self.ispubprofile or self.config.editablepubprofile)
736 self:rmenu(level, render, path, addnew, recurse)
740 function Loona:loadcontent(fname)
742 local f = open(self.contentdir .. "/" .. fname)
743 local c = f:read("*a")
751 function Loona:loadmarkup(fname)
752 return (fname and fname ~= "") and
753 self:domarkup(self:loadcontent(fname)) or ""
757 function Loona:editable(editkey, fname, savename)
759 local contentdir = self.contentdir
760 local edit, show, hidden, extramsg, changed
762 if self.authuser_edit or self.authuser_profile or self.authuser_menu then
764 local hiddenvars = table.concat( {
765 self:hidden("lang", self.args.lang),
766 self:hidden("profile", self.profile),
767 self:hidden("session", self.session.id),
768 self:hidden("editkey", editkey) }, " ")
770 local lockfname = fname and (contentdir .. "/" .. fname)
772 if self.useralert and editkey == self.args.editkey then
774 -- display user alert/request/confirmation
778 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
780 <legend>]] .. self.useralert.text ..[[</legend>
781 ]] .. (self.useralert.confirm or "") .. [[
782 ]] .. (self.useralert.returnto or "") .. [[
783 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL ..[[" />
784 ]] .. hiddenvars .. [[
789 elseif self.args.actionnew and editkey == "main" and self.authuser_menu then
791 -- form for creating a new section
794 if self.ispubprofile then
795 self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
798 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
801 ]] .. self.locale.CREATE_NEW_SECTION_UNDER .. " " .. self.sectionpath .. [[
806 ]] .. self.locale.PATHNAME .. [[
809 <input size="30" maxlength="30" name="editname" />
814 ]] .. self.locale.MENULABEL .. [[
817 <input size="30" maxlength="50" name="editlabel" />
822 ]] .. self.locale.WINDOWTITLE .. [[
825 <input size="30" maxlength="50" name="edittitle" />
830 ]] .. self.locale.INVISIBLE .. [[
833 <input type="checkbox" name="editvisibility" />
838 ]] .. self.locale.SECRET .. [[
841 <input type="checkbox" name="editsecrecy" />
846 ]] .. self.locale.SECURE_CONNECTION .. [[
849 <input type="checkbox" name="editsecure" />
854 ]] .. self.locale.PERMISSIONS .. [[
857 <input size="30" maxlength="50" name="editpermissions" />
862 ]] .. self.locale.REDIRECT .. [[
865 <input size="30" maxlength="50" name="editredirect" />
869 <input type="submit" name="actioncreate" value="]] .. self.locale.CREATE .. [[" />
870 ]] .. hiddenvars .. [[
876 elseif self.args.actioneditprops and editkey == "main" and
877 self.authuser_menu then
879 if self.ispubprofile then
880 self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
883 <form action="]] ..self.document .. [[" method="post" accept-charset="utf-8">
886 ]] .. self.locale.MODIFY_PROPERTIES_OF_SECTION .. " " .. self.sectionpath .. [[
891 ]] .. self.locale.MENULABEL .. [[
894 <input size="30" maxlength="50" name="editlabel" value="]] .. (self.section.label or "") .. [[" />
899 ]] .. self.locale.WINDOWTITLE .. [[
902 <input size="30" maxlength="50" name="edittitle" value="]] .. (self.section.title or "") .. [[" />
907 ]] .. self.locale.INVISIBLE .. [[
910 <input type="checkbox" name="editvisibility" ]] .. (self.section.hidden and 'checked="checked"' or "") .. [[/>
915 ]] .. self.locale.SECRET .. [[
918 <input type="checkbox" name="editsecrecy" ]] .. (self.section.secret and 'checked="checked"' or "") .. [[/>
923 ]] .. self.locale.SECURE_CONNECTION .. [[
926 <input type="checkbox" name="editsecure" ]] .. (self.section.secure and 'checked="checked"' or "") .. [[/>
931 ]] .. self.locale.PERMISSIONS .. [[
934 <input size="30" maxlength="50" name="editpermissions" value="]] .. (self.section.permissions or "") .. [[" />
939 ]] .. self.locale.REDIRECT .. [[
942 <input size="30" maxlength="50" name="editredirect" value="]] .. (self.section.redirect or "") .. [[" />
946 <input type="submit" name="actionsaveprops" value="]] .. self.locale.SAVE .. [[" />
947 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
948 ]] .. hiddenvars .. [[
953 elseif (self.args.actioneditprofiles or
954 self.args.actioncreateprofile or
955 self.args.actionchangeprofile or
956 self.args.actionchangelanguage or
957 self.args.actionpublishprofile) and editkey == "main" and
958 self.authuser_profile then
961 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
964 ]] .. self.locale.CHANGEPROFILE .. [[
966 <select name="changeprofile" size="1">]])
967 for _, val in ipairs(self:getprofiles()) do
968 self:out('<option' .. (val == self.profile and ' selected="selected"' or '') .. '>')
970 self:out('</option>')
974 <input type="submit" name="actionchangeprofile" value="]] .. self.locale.CHANGE ..[[" />
975 ]] .. hiddenvars .. [[
978 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
981 ]] .. self.locale.CHANGELANGUAGE .. [[
983 <select name="changelanguage" size="1">]])
984 for _, val in ipairs(self:getlanguages()) do
985 self:out('<option' .. (val == self.lang and ' selected="selected"' or '') .. '>')
987 self:out('</option>')
991 <input type="submit" name="actionchangelanguage" value="]] .. self.locale.CHANGE ..[[" />
992 ]] .. hiddenvars .. [[
996 if self.authuser_publish then
998 <form action="]] .. self.document ..[[" method="post" accept-charset="utf-8">
1001 ]] .. self.locale.CREATEPROFILE .. [[
1003 <input size="20" maxlength="20" name="createprofile" />
1004 ]] .. self.locale.LANGUAGE ..[[
1005 <input size="2" maxlength="2" name="createlanguage" value="]] .. self.lang ..[[" />
1006 <input type="submit" name="actioncreateprofile" value="]] .. self.locale.CREATE .. [[" />
1007 ]] .. hiddenvars .. [[
1012 if (not self.ispubprofile or self.config.editablepubprofile) and
1013 self.authuser_publish then
1015 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
1018 ]] .. self.locale.PUBLISHPROFILE .. [[
1020 ]] .. self:hidden("publishprofile", self.profile) .. [[
1021 <input type="submit" name="actionpublishprofile" value="]] .. self.locale.PUBLISH .. [[" />
1022 ]] .. hiddenvars .. [[
1025 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
1028 ]] .. self.locale.DELETEPROFILE .. [[
1030 ]] .. self:hidden("deleteprofile", self.profile) .. [[
1031 <input type="submit" name="actiondeleteprofile" value="]] .. self.locale.DELETE .. [[" />
1032 ]] .. hiddenvars .. [[
1038 elseif self.args.actionedit and editkey == self.args.editkey and self.authuser_edit then
1039 if not self.section.redirect then
1040 extramsg = self.ispubprofile and
1041 self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE
1042 edit = self:loadcontent(fname):gsub("\194\160", " ") -- TODO
1043 changed = self.section and (self.section.revisiondate or self.section.creationdate)
1046 elseif self.args.actionpreview and editkey == self.args.editkey and
1047 self.authuser_edit then
1048 edit = self.args.editform
1049 show = self:domarkup(edit:gsub(" ", "\194\160")) -- TODO
1051 elseif self.args.actionsave and editkey == self.args.editkey
1052 and self.authuser_edit then
1053 local c = self.args.editform
1057 self:expire(contentdir, "[^.]%S+.LOCK")
1058 if self:lockfile(lockfname) then
1059 -- lock was expired, aquired a new one
1060 extramsg = self.locale.SECTION_COULD_HAVE_CHANGED
1063 local tab = lib.source(lockfname .. ".LOCK")
1064 if tab and tab.id == self.session.id then
1065 -- lock already held and is mine - try to save:
1066 local savec = c:gsub(" ", "\194\160") -- TODO
1067 remove(contentdir .. "/" .. savename .. ".html")
1068 self:savebody(savename, savec)
1069 -- TODO: error handling
1070 self:unlockfile(lockfname)
1071 show, dynamic = self:domarkup(savec)
1074 -- lock was expired and someone else has it now
1075 extramsg = self.locale.SECTION_IN_USE
1081 local savec = c:gsub(" ", "\194\160") -- TODO
1082 self:savebody(savename, savec)
1083 -- TODO: error handling
1084 show, dynamic = self:domarkup(savec)
1087 -- mark dynamic text bodies
1088 if not self.section.dynamic then
1089 self.section.dynamic = { }
1091 self.section.dynamic[editkey] = dynamic
1093 for _ in pairs(self.section.dynamic) do
1097 self.section.dynamic = nil
1102 elseif self.args.actioncancel and editkey == self.args.editkey then
1104 self:unlockfile(lockfname) -- remove lock
1108 if editkey == "main" and self.section and self.section.redirect then
1109 self:out('<h2>' .. self.locale.SECTION_IS_REDIRECT ..'</h2>')
1110 self:out(self:link(self.section.redirect))
1115 self:expire(contentdir, "[^.]%S+.LOCK")
1116 if fname and not self:lockfile(contentdir .. "/" .. fname) then
1117 local tab = lib.source(contentdir .. "/" .. fname .. ".LOCK")
1118 if tab and tab.id ~= self.session.id then
1119 extramsg = self.locale.SECTION_IN_USE
1121 -- else already owner
1124 self:out('<h2><span class="warn">' .. extramsg .. '</span></h2>')
1127 <form action="]] .. self.document .. [[#preview" method="post" accept-charset="utf-8">
1130 ]] .. self.locale.EDIT_SECTION .. [[
1132 <textarea cols="80" rows="25" name="editform">
1133 ]] .. self:encodeform(edit) .. [[</textarea>
1135 <input type="submit" name="actionsave" value="]] .. self.locale.SAVE .. [[" />
1136 <input type="submit" name="actionpreview" value="]] .. self.locale.PREVIEW .. [[" />
1137 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
1138 ]] .. hiddenvars .. [[
1146 self:dosnippet(function()
1148 show = self:loadmarkup(fname)
1149 changed = self.section and (self.section.revisiondate or self.section.creationdate)
1151 local parsed, msg = self:loadhtml(show, "loona:out", "<parsed html>")
1152 assert(parsed, msg and "Syntax error : " .. msg)
1153 self:runboxed(parsed)
1157 if self.authuser_profile or self.authuser_edit or self.authuser_menu then
1160 <div class="edit">]])
1161 if editkey == "main" then
1163 <a name="preview"></a>
1164 ]] .. self.authuser .. [[ : ]])
1165 if self.authuser_profile then
1166 self:out(self:uilink(self.sectionpath, "[" .. self.locale.PROFILE .. "]", "actioneditprofiles=true", "editkey=" .. editkey) .. [[ :
1167 ]] .. self.profile .. "_" .. self.lang)
1168 if self.ispubprofile then
1170 <span class="warn">[]] .. self.locale.PUBLIC .. [[]</span>]])
1174 self:out(self.sectionpath .. ' ')
1176 if self.section and (not self.ispubprofile or self.config.editablepubprofile) then
1177 if self.authuser_edit then
1178 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.EDIT .. "]", "actionedit=true", "editkey=" .. editkey) .. " ")
1180 if editkey == "main" and self.authuser_menu then
1181 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.PROPERTIES .. "]", "actioneditprops=true", "editkey=" .. editkey) .. " ")
1183 if (fname == savename or not self.section.subs) and (self.authuser_edit and self.authuser_menu) then
1184 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.DELETE .. "]", "actiondelete=true", "editkey=" .. editkey) .. " ")
1186 if editkey == "main" and self.authuser_menu then
1187 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEUP .. "]", "actionup=true", "editkey=" .. editkey) .. " ")
1188 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEDOWN .. "]", "actiondown=true", "editkey=" .. editkey) .. " ")
1190 if changed and editkey == "main" then
1191 self:out('- ' .. self.locale.CHANGED .. ': ' .. date("%d-%b-%Y %T", changed))
1200 -- Get pathname of an existing content file that
1201 -- the current path is determined by (or defaults to)
1203 function Loona:getsectionpath(bodyname, requestpath)
1204 local ext = (not bodyname or bodyname == "main") and "" or "." .. bodyname
1205 local t, path, section = { }
1206 for _, menu in ipairs(self.submenus) do
1207 if menu.entries and menu.entries[menu.name] then
1208 table.insert(t, menu.name)
1209 local fn = table.concat(t, "_")
1210 if posix.stat(self.contentdir .. "/" .. fn .. ext,
1211 "mode") == "file" then
1212 path, section = fn, menu
1216 return path, ext, section
1220 function Loona:body(name)
1221 name = self:checkbodyname(name)
1222 local path, ext = self:getsectionpath(name)
1223 self:dosnippet(function()
1224 self:editable(name, path and path .. ext, self.sectionname .. ext)
1229 function Loona:init()
1231 -- get list of languages, in order of preference
1232 -- TODO: respect quality parameter, not just order
1234 local l = self.requestlang or self.args.lang
1235 self.langs = { l and l:match("^%w+$") }
1236 local s = getenv("HTTP_ACCEPT_LANGUAGE")
1238 local l, r = s:match("^([%w.=]+)[,;](.*)$")
1241 if l:match("^%w+$") then
1242 table.insert(self.langs, l)
1245 table.insert(self.langs, self.config.deflang)
1247 -- get list of possible profiles
1249 local profiles = { }
1250 for e in util.readdir(self.config.contentdir) do
1256 for _, lang in ipairs(self.langs) do
1257 local p = posix.readlink(self.config.contentdir .. "/current_" .. lang)
1258 p = p and p:match("^(%w+)_" .. lang .. "$")
1267 local checkprofile = self.authuser and
1268 (self.authuser_profile and self.args.profile or self.session.data.profile)
1269 or self.config.defprofile or self.pubprofile or "work"
1271 for _, lang in ipairs(self.langs) do
1272 if profiles[checkprofile .. "_" .. lang] then
1273 self.profile = checkprofile
1279 assert(self.profile and self.lang, "Invalid profile or language")
1282 self.ispubprofile = self.profile == self.pubprofile
1284 -- write back language and profile
1286 self.args.lang = (self.explicitlang or self.lang ~= self.config.deflang)
1287 and self.lang or nil
1288 self.args.profile = self.profile
1290 -- determine content directory pathname and section filename
1293 ("%s/%s_%s"):format(self.config.contentdir, self.profile, self.lang)
1294 self.indexfname = self.contentdir .. "/.sections"
1298 self.sections = lib.source(self.indexfname)
1300 -- index sections, determine visibility in menu
1302 self:indexsections()
1304 -- decompose request path, produce a stack of sections
1306 self.submenus, self.section = self:getsection(self.requestpath)
1308 -- handle redirects if not logged on
1310 if not self.authuser_edit and self.section and self.section.redirect then
1311 self.submenus, self.section = self:getsection(self.section.redirect)
1314 -- section path and document name (refined)
1316 self.sectionpath = self:getpath()
1317 self.sectionname = self:getpath("_")
1322 function Loona:handlechanges()
1326 if self.args.editkey == "main" then
1328 -- In main editable section:
1330 if self.args.actioncreate then
1332 -- Create new section
1334 local editname = self.args.editname:lower()
1335 assert(not editname:match("%W"),
1336 self:dbmsg("Invalid section name", editname))
1337 if not (section and (section.subs or section)[editname]) then
1338 local newpath = (self.sectionpath and
1339 (self.sectionpath .. "/")) .. editname
1340 local s = self:addpath(newpath, { name = editname,
1341 label = self.args.editlabel ~= "" and
1342 self.args.editlabel or nil,
1343 title = self.args.edittitle ~= "" and
1344 self.args.edittitle or nil,
1345 redirect = self.args.editredirect ~= "" and
1346 self.args.editredirect or nil,
1347 permissions = self.args.editpermissions ~= "" and
1348 self.args.editpermissions or nil,
1349 hidden = self.args.editvisibility and true,
1350 secret = self.args.editsecrecy and true,
1351 secure = self.args.editsecure and true,
1352 creator = self.authuser,
1353 creationdate = time() })
1357 elseif self.args.actionsave then
1361 self.section.revisiondate = time()
1362 self.section.revisioner = self.authuser
1365 elseif self.args.actionsaveprops then
1369 self.section.hidden = self.args.editvisibility and true
1370 self.section.secret = self.args.editsecrecy and true
1371 self.section.secure = self.args.editsecure and true
1372 self.section.label = self.args.editlabel ~= "" and
1373 self.args.editlabel or nil
1374 self.section.title = self.args.edittitle ~= "" and
1375 self.args.edittitle or nil
1376 self.section.redirect =
1377 self.args.editredirect ~= "" and self.args.editredirect or nil
1378 self.section.permissions =
1379 self.args.editpermisisons ~= "" and self.args.editpermissions or nil
1382 elseif self.args.actionup then
1386 local t, i = self:checkpath(self.sectionpath)
1388 if self.ispubprofile and not self.args.actionconfirm then
1390 text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
1392 '<input type="submit" name="actionup" value="' ..
1393 self.locale.MOVE .. '" /> ' ..
1394 self:hidden("actionconfirm", "true")
1397 local item = table.remove(t, i)
1398 table.insert(t, i - 1, item)
1403 elseif self.args.actiondown then
1405 -- Move section down
1407 local t, i = self:checkpath(self.sectionpath)
1408 if t and i < #t then
1409 if self.ispubprofile and not self.args.actionconfirm then
1411 text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
1413 '<input type="submit" name="actiondown" value="' ..
1414 self.locale.MOVE .. '" /> ' ..
1415 self:hidden("actionconfirm", "true")
1418 local item = table.remove(t, i)
1419 table.insert(t, i + 1, item)
1424 elseif self.args.actioncreateprofile and self.args.createprofile then
1428 local c = self.args.createprofile
1432 c = self:checkprofilename(c:lower())
1433 local l = self:checklanguage((self.args.createlanguage or self.lang):lower())
1434 if c == self.profile and l == self.lang then
1436 text = self.locale.ALERT_CANNOT_COPY_PROFILE_TO_SELF,
1437 returnto = self:hidden("actioneditprofiles", "true")
1440 local profiles = self:getprofiles(l)
1442 if c == self.pubprofile then
1443 text = self.locale.ALERT_OVERWRITE_PUBLISHED_PROFILE
1444 elseif profiles[c] and l == self.lang then
1445 text = self.locale.ALERT_OVERWRITE_EXISTING_PROFILE
1447 if text and not self.args.actionconfirm then
1450 returnto = self:hidden("actioneditprofiles", "true"),
1451 confirm = '<input type="submit" ' ..
1452 'name="actioncreateprofile" value="' ..
1453 self.locale.OVERWRITE .. '" /> ' ..
1454 self:hidden("actionconfirm", "true") ..
1455 self:hidden("createlanguage", l) ..
1456 self:hidden("createprofile", c)
1460 self:deleteprofile(c, l)
1462 self:copyprofile(c, self.profile, l, self.lang)
1466 elseif self.args.actiondeleteprofile and self.args.deleteprofile then
1470 local c = self:checkprofilename(self.args.deleteprofile:lower())
1471 assert(c ~= self.pubprofile,
1472 self:dbmsg("Cannot delete published profile", c))
1473 if self.args.actionconfirm then
1474 self:deleteprofile(c)
1476 self.args.profile = nil
1481 text = self.locale.ALERT_DELETE_PROFILE,
1482 returnto = self:hidden("actioneditprofiles", "true"),
1483 confirm = '<input type="submit" ' ..
1484 'name="actiondeleteprofile" value="' ..
1485 self.locale.DELETE .. '" /> ' ..
1486 self:hidden("actionconfirm", "true") ..
1487 self:hidden("deleteprofile", c)
1491 elseif self.args.actionchangeprofile and self.args.changeprofile then
1495 local c = self:checkprofilename(self.args.changeprofile:lower())
1497 self.args.profile = c
1500 elseif self.args.actionchangelanguage and self.args.changelanguage then
1504 local l = self:checklanguage(self.args.changelanguage:lower())
1507 self.explicitlang = l
1510 elseif self.args.actionpublishprofile and self.args.publishprofile then
1514 local c = self:checkprofilename(self.args.publishprofile:lower())
1515 if c ~= self.publicprofile then
1516 if self.args.actionconfirm then
1517 self:publishprofile(c)
1521 text = self.locale.ALERT_PUBLISH_PROFILE,
1522 returnto = self:hidden("actioneditprofiles", "true"),
1523 confirm = '<input type="submit" ' ..
1524 'name="actionpublishprofile" value="' ..
1525 self.locale.PUBLISH .. '" /> ' ..
1526 self:hidden("actionconfirm", "true") ..
1527 self:hidden("publishprofile", c)
1535 if self.args.actiondelete then
1539 if not self.args.actionconfirm then
1541 text = self.ispubprofile and
1542 self.locale.ALERT_DELETE_IN_PUBLISHED_PROFILE or
1543 self.locale.ALERT_DELETE_SECTION,
1545 '<input type="submit" name="actiondelete" value="' ..
1546 self.locale.DELETE .. '" /> ' ..
1547 self:hidden("actionconfirm", "true")
1550 local key = self.args.editkey
1551 if key == "main" and not self.section.subs then
1552 self:deletesection(self.sectionname, true) -- all bodies
1553 self:rmpath(self.sectionpath) -- and node
1555 local ext = (key == "main" and "") or "." .. key
1556 self:deletesection(self.sectionname .. ext) -- only text
1557 if self.section.dynamic then
1558 self.section.dynamic[key] = nil
1560 for _ in pairs(self.section.dynamic) do
1564 self.section.dynamic = nil
1580 function Loona:encodeform(s)
1581 return util.encodeform(s)
1585 function Loona:loadhtml(src, outfunc, chunkname)
1586 return luahtml.load(src, outfunc, chunkname)
1590 function Loona:domarkup(s)
1592 local is_dynamic = Markup:new { input = s,
1593 features = "hespcadlintf", indentchar = " ",
1594 wrfunc = function(s) table.insert(t, s) end }:run()
1595 return table.concat(t), is_dynamic
1599 function Loona:expire(dir, pat, maxage)
1600 return util.expire(dir, pat, maxage or self.config.sessionmaxage)
1604 function Loona.new(class, self)
1606 self = Class.new(class, self or { })
1612 self.out = self.out or function(self, s)
1615 self.addheader = self.addheader or function(self, s)
1616 self.buf:addheader(s)
1619 -- Get configuration
1621 self.config = self.config or lib.source(self.conffile or "../etc/config.lua") or { }
1622 self.config.defname = self.config.defname or "home"
1623 self.config.deflang = self.config.deflang or "en"
1624 self.config.sessionmaxage = self.config.sessionmaxage or 6000
1625 self.config.secureport = self.config.secureport or 443
1626 self.config.passwdfile =
1627 posix.abspath(self.config.passwdfile or "../etc/passwd.lua")
1628 self.config.sessiondir =
1629 posix.abspath(self.config.sessiondir or "../var/sessions")
1630 self.config.extdir = posix.abspath(self.config.extdir or "../extensions")
1631 self.config.contentdir = posix.abspath(self.config.contentdir or "../content")
1632 self.config.localedir = posix.abspath(self.config.localedir or "../locale")
1633 self.config.htdocsdir = posix.abspath(self.config.htdocsdir or "../htdocs")
1634 self.config.htmlcachedir =
1635 posix.abspath(self.config.htmlcachedir or "../var/htmlcache")
1636 self.config.extlinkextra = self.config.extlinksamewindow and ' class="extlink"'
1637 or ' class="extlink" onclick="void(window.open(this.href, \'\', \'\')); return false;"'
1639 -- Create proxy for on-demand loading of locales
1643 locmt.__index = function(_, key)
1644 for _, l in ipairs(self.langs) do
1645 locmt.__locale = lib.source(self.config.localedir .. "/" .. l)
1646 if locmt.__locale then
1650 locmt.__index = function(tab, key)
1651 return locmt.__locale[key] or key
1653 return locmt.__locale[key] or key
1655 setmetatable(self.locale, locmt)
1657 -- Get request, args, document, script name, request path
1659 self.request = self.request or Request:new()
1660 self.args = self.request:getargs()
1661 self.cgi_document = self.request:getdocument()
1663 self.requesthandler = self.requesthandler or self.cgi_document.Handler
1664 self.requestdocument = self.requestdocument or self.cgi_document.Name
1665 self.requestpath = self.requestpath or self.cgi_document.VirtualPath
1666 self.explicitlang = not self.requestlang and self.args.lang
1667 self.secure = not self.insecure and (self.request.SERVER_PORT == self.config.secureport)
1669 -- Manage login and establish session
1671 if not self.nologin then
1672 local sid = self.args.session or self.request.UNIQUE_ID
1673 self.session = self.session or Session:new {
1675 sessiondir = self.config.sessiondir,
1676 maxage = self.config.sessionmaxage
1678 if self.args.login then
1679 -- write back session ID into request args:
1680 self.args.session = sid -- !
1681 if self.args.login == "false" then
1682 self.session:delete()
1684 elseif self.args.password then
1685 self.loginfailed = true
1686 local match, username, perm, profile =
1687 self:checkpw(self.args.login, self.args.password)
1689 self.session.data.authuser = self.args.login
1690 self.session.data.username = username
1691 self.session.data.permissions = perm
1692 self.session.data.profile = profile
1693 self.session.data.id = self.session.id
1694 self.loginfailed = nil
1698 self.authuser = self.session and self.session.data.authuser
1701 if self.nologin or not self.authuser then
1704 self.args.session = nil
1706 self.authuser_edit = self.session.data.permissions:find("e") and true
1707 self.authuser_menu = self.session.data.permissions:find("m") and true
1708 self.authuser_publish = self.session.data.permissions:find("p") and true
1709 self.authuser_profile = self.authuser_publish or
1710 self.session.data.permissions:find("c") and true
1711 self.authuser_visible = self.session.data.permissions:find("v") and true
1712 self.authuser_debug = self.session.data.permissions:find("d") and true
1713 self.authuser_seeall = self.session.data.permissions:find("a") and true
1717 -- Get lang, locale, profile, section
1721 if self.authuser then -- TODO?
1722 self:handlechanges()
1724 self.args.profile = nil
1730 self.document = self.requestdocument .. "/" .. self.sectionpath
1731 if self.authuser then
1732 self.getdocname = function(self, path)
1733 local url, anch = path:match("^([^#]*)(#?.*)$")
1734 path = url ~= "" and url
1736 return self.requestdocument .. "/" .. (path or self.sectionpath) .. anch
1739 self.getdocname = function(self, path, haveargs)
1740 local url, anch = path:match("^([^#]*)(#?.*)$")
1741 path = url ~= "" and url
1744 dyn, path, exists = self:isdynamic(path or self.sectionpath)
1745 if dyn or haveargs or not exists then
1746 return self.requestdocument .. "/" .. path .. anch
1748 path = path == self.config.defname and "index" or path
1749 return "/" .. path:gsub("/", "_") .. ".html" .. anch
1753 -- Save session state
1755 if self.session then
1763 function Loona:checkpw(login, passwd)
1764 local pwddb = lib.source(self.config.passwdfile)
1765 local pwdent = pwddb[login]
1766 if pwdent and pwdent.password == passwd then
1767 return true, pwdent.username or login,
1768 pwdent.permissions or "", pwdent.profile
1773 function Loona:run(fname)
1775 fname = fname or self.requesthandler
1776 local parsed, msg = self:loadhtml(open(fname), "loona:out", fname)
1777 assert(parsed, self:dbmsg("HTML/Lua parsing failed", msg))
1778 self:runboxed(parsed)
1783 function Loona:indexdynamic()
1784 self:recursesections(self.sections, function(self, s, e, path, dynamic)
1785 path = path and path .. "_" .. e.name or e.name
1786 dynamic = dynamic or { }
1787 for k in pairs(e.dynamic or { }) do
1790 for k in pairs(dynamic) do
1791 local ext = (k == "main" and "") or "." .. k
1792 if posix.stat(self.contentdir .. "/" .. path .. ext,
1793 "mode") == "file" then
1794 dynamic[k] = e.dynamic and e.dynamic[k]
1798 for k in pairs(dynamic) do
1803 for k in pairs(dynamic) do
1809 return path, dynamic
1814 function Loona:isdynamic(path)
1815 path = path or self.sectionpath
1817 local t, i = self:checkpath(path)
1820 if t[i].redirect then
1821 path = t[i].redirect
1822 t, i, exists = self:isdynamic(path) -- TODO: prohibit endless recursion
1830 return t, path, exists
1834 function Loona:dumphtml(o)
1838 o.out = function(self, s) table.insert(outbuf, s) end
1839 o.addheader = function(self, s) end
1841 o = self:new(o):run()
1842 if not o:isdynamic() then
1843 local path = o.sectionname
1844 path = path == o.config.defname and "index" or path
1845 local srcname = o.config.htdocsdir .. "/" .. path .. o.htmlext
1846 local fh, msg = open(srcname .. ".tmp", "wb")
1847 assert(fh, self:dbmsg("Could not write cached HTML", msg))
1848 for _, line in ipairs(outbuf) do
1852 local dstname = o.config.htmlcachedir .. "/" .. path .. o.htmlext
1853 local success, msg = posix.symlink(srcname, dstname .. ".tmp")
1854 -- assert(success, self:dbmsg("Could not link to cached HTML", msg))