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 -------------------------------------------------------------------------------
86 -------------------------------------------------------------------------------
88 local Session = Class:newClass()
90 function Session.new(class, self)
92 self = Class.new(class, self or { })
94 assert(self.id, "No session Id")
95 assert(self.sessiondir, "No session directory")
97 self.name = self.id:gsub("(.)", function(a)
98 return ("%02x"):format(a:byte())
100 self.filename = self.sessiondir .. "/" .. self.name
101 -- remove non-dotted files (expired sessions) from sessions dir:
102 util.expire(self.sessiondir, "[^.]%S+", self.maxage or 600)
103 -- load session state:
104 self.data = lib.source(self.filename) or { }
109 function Session:save()
110 local f = open(self.filename, "wb")
111 assert(f, "Failed to open session file for writing")
112 lib.dump(self.data, function(...)
118 function Session:delete()
119 remove(self.filename)
122 -------------------------------------------------------------------------------
124 -------------------------------------------------------------------------------
129 function Loona:dbmsg(msg, detail)
130 return (msg and detail and self.authuser) and
131 ("%s : %s"):format(msg, detail) or msg
135 function Loona:checkprofilename(n)
136 assert(n:match("^%w+$") and n ~= "current",
137 self:dbmsg("Invalid profile name", n))
142 function Loona:checklanguage(n)
143 assert(n:match("^%l%l$"), self:dbmsg("Invalid language code", n))
148 function Loona:checkbodyname(s)
150 assert(s:match("^[%w_]*%w+[%w_]*$"), self:dbmsg("Invalid body name", s))
155 function Loona:deleteprofile(p, lang)
156 p = self.config.contentdir .. "/" .. p .. "_" .. (lang or self.lang)
157 for e in util.readdir(p) do
158 local success, msg = remove(p .. "/" .. e)
159 assert(success, self:dbmsg("Error removing entry in profile", msg))
165 function Loona:copyprofile(dstprof, srcprof, dstlang, srclang)
166 local contentdir = self.config.contentdir
167 local src = ("%s/%s_%s"):format(contentdir,
168 srcprof or self.profile, srclang or self.lang)
169 local dst = ("%s/%s_%s"):format(contentdir,
170 dstprof or self.profile, dstlang or self.lang)
171 assert(src ~= dst, self:dbmsg("Attempt to copy profile over itself"))
172 assert(posix.stat(src, "mode") == "directory",
173 self:dbmsg("Source profile not a directory", src))
174 local success, msg = posix.mkdir(dst)
175 assert(success, self:dbmsg("Error creating profile directory " .. dst, msg))
176 for e in util.readdir(src) do
177 local ext = e:match("^[^.].*%.([^.]*)$")
178 if ext ~= "LOCK" then
179 local f = src .. "/" .. e
180 if posix.stat(f, "mode") == "file" then
181 success, msg = lib.copyfile(f, dst .. "/" .. e)
182 assert(success, self:dbmsg("Error copying file", msg))
186 -- create "current" symlink if none exists for new profile/language
187 if not posix.readlink(contentdir .. "/current_" .. dstlang) then
188 self:makecurrent(dstprof, dstlang)
193 function Loona:makecurrent(prof, lang)
194 prof = prof or self.profile
195 lang = lang or self.lang
196 local contentdir = self.config.contentdir
197 local newpath = ("%s/current_%s"):format(contentdir, lang)
198 local tmppath = newpath .. "." .. self.session.name
199 local success, msg = posix.symlink(prof .. "_" .. lang, tmppath)
200 assert(success, self:dbmsg("Cannot create symlink", msg))
201 success, msg = rename(tmppath, newpath)
202 assert(success, self:dbmsg("Cannot put symlink in place", msg))
207 function Loona:publishprofile(profile, lang)
208 lang = lang or self.lang
209 local contentdir = self.config.contentdir
211 -- Get languages for the current profile
214 local lmatch = "^" .. self.profile .. "_(%w+)$"
215 for e in util.readdir(self.config.contentdir) do
216 local l = e:match(lmatch)
218 table.insert(plangs, l)
222 -- For all languages, update "current" symlink
224 for _, lang in ipairs(plangs) do
225 self:makecurrent(profile, lang)
228 -- These arguments are overwritten globally and need to get restored
230 local save_args = { self.args.lang, self.args.profile, self.args.session }
232 -- For all languages, unroll site to static HTML
234 for _, lang in ipairs(plangs) do
235 local ext = (#plangs == 1 and ".html") or (".html." .. lang)
236 self:recursesections(self.sections, function(self, s, e, path)
237 path = path and path .. "/" .. e.name or e.name
238 if not e.notvisible then
240 request = self.request, -- reuse request
241 userdata = self.userdata, -- reuse userdata
242 requestpath = path, requestlang = lang,
243 htmlext = ext, insecure = true
252 self.args.lang, self.args.profile, self.args.session = unpack(save_args)
256 local htdocs = self.config.htdocsdir
257 local cache = self.config.htmlcachedir
259 for e in util.readdir(cache) do
260 local f = e:match("^.*%.html%.?(%w*)$")
261 if f and f ~= "tmp" then
262 local success, msg = remove(htdocs .. "/" .. e)
263 success, msg = remove(cache .. "/" .. e)
265 self:dbmsg("Could not purge cached HTML file", msg))
269 for e in util.readdir(cache) do
270 local f = e:match("^(.*%.html%.?%w*)%.tmp$")
272 local success, msg = rename(cache .. "/" .. e, cache .. "/" .. f)
274 self:dbmsg("Could not update cached HTML file", msg))
275 success, msg = rename(htdocs .. "/" .. e, htdocs .. "/" .. f)
277 self:dbmsg("Could not update cached HTML file", msg))
283 function Loona:recursesections(s, func, ...)
284 for _, e in ipairs(s) do
285 local udata = { func(self, s, e, unpack(arg)) }
287 self:recursesections(e.subs, func, unpack(udata))
293 function Loona:indexsections()
294 local userperm = self.session and self.session.data.permissions
295 userperm = userperm and userperm ~= "" and "[" .. userperm .. "]"
296 self:recursesections(self.sections, function(self, s, e)
297 local permitted = true
298 local sectperm = e.permissions
299 if sectperm and sectperm ~= "" and not self.authuser_seeall then
302 local num = sectperm:len()
303 sectperm:gsub(userperm, function() num = num - 1 end)
307 e.notvalid = not permitted or (not self.secure and e.secure) or
308 (not self.authuser_visible and e.secret) or nil
309 e.notvisible = e.notvalid or not self.authuser_visible and e.hidden or nil
315 -- Decompose section path into a stack of sections, returning only up to
316 -- the last valid element in the path. additionally returns the table of
317 -- the last section path element (or the default section)
319 function Loona:getsection(path)
320 local default = not self.authuser and self.config.defname
321 local tab = { { entries = self.sections, name = default } }
322 local ss = self.sections
324 (path or ""):gsub("(%w+)/?", function(a)
327 if s and not s.notvalid then
332 table.insert(tab, { entries = ss })
339 if not self.section and not sectionpath then
340 sectionpath = self.sections[default]
342 table.insert(tab, { entries = sectionpath.subs })
345 return tab, sectionpath
349 function Loona:getpath(delimiter, maxdepth)
352 maxdepth = maxdepth or #self.submenus
353 for _, menu in ipairs(self.submenus) do
355 table.insert(t, menu.name)
358 if d == maxdepth then
362 return table.concat(t, delimiter or "/")
366 function Loona:deletesection(fname, all_bodies)
367 local fullname = self.contentdir .. "/" .. fname
368 local success, msg = remove(fullname)
370 local pat = "^" .. -- TODO: check
371 fname:gsub("%^%$%(%)%%%.%[%]%*%+%-%?", "%%%1") .. "%..*$"
372 for e in util.readdir(self.contentdir) do
374 remove(self.contentdir .. "/" .. e)
382 function Loona:addpath(path, e)
383 local tab = self.sections
384 path:gsub("(%w+)/?", function(a)
403 local function lookupname(tab, val)
404 for i, v in ipairs(tab) do
405 if v.name == val then
412 function Loona:rmpath(path)
414 local tab = self.sections
415 path:gsub("(%w+)/?", function(a)
417 local idx = lookupname(tab, a)
419 if tab[idx].subs then
423 table.remove(tab, idx)
425 if #tab == 0 and parent then
436 function Loona:checkpath(path)
437 if path ~= "index" then -- "index" is reserved
439 local tab = self.sections
440 path:gsub("(%w+)/?", function(a)
442 local i = lookupname(tab, a)
447 res, idx, tab = nil, nil, nil
456 function Loona:title()
457 return self.section and (self.section.title or self.section.label or
458 self.section.name) or ""
462 -- Run a site function snippet, with full error recovery
463 -- (also recovers from errors in error handling function)
465 function Loona:dosnippet(func, errfunc)
466 local ret = { lib.catch(func) }
467 if ret[1] == 0 or (errfunc and lib.catch(errfunc) == 0) then
470 self:out("<h2>Error</h2>")
471 self:out("<h3>" .. self:encodeform(ret[2] or "") .. "</h3>")
472 if self.authuser_debug then
473 if type(ret[3]) == "string" then
474 self:out("<p>" .. self:encodeform(ret[3]) .. "</p>")
477 self:out("<pre>" .. self:encodeform(ret[4]) .. "</pre>")
483 function Loona:lockfile(file)
484 return not self.session and true or
485 posix.symlink(self.session.filename, file .. ".LOCK")
489 function Loona:unlockfile(file)
490 return not self.session and true or remove(file .. ".LOCK")
494 function Loona:saveindex()
495 local tempname = self.indexfname .. "." .. self.session.name
496 local f, msg = open(tempname, "wb")
497 assert(f, self:dbmsg("Error opening section file for writing", msg))
498 lib.dump(self.sections, function(...)
502 local success, msg = rename(tempname, self.indexfname)
503 assert(success, self:dbmsg("Error renaming section file", msg))
507 function Loona:savebody(fname, content)
508 fname = self.contentdir .. "/" .. fname
509 local f, msg = open(fname, "wb")
510 assert(f, self:dbmsg("Could not open file for writing", msg))
511 f:write(content or "")
516 function Loona:runboxed(func, envitems, ...)
522 for k, v in pairs(envitems) do
526 setmetatable(fenv, { __index = boxed_G }) -- boxed global environment
532 function Loona:include(fname, ...)
533 assert(not fname:match("%W"), self:dbmsg("Invalid include name", fname))
534 local fname2 = ("%s/%s.lua"):format(self.config.extdir, fname)
535 local f, msg = open(fname2)
536 assert(f, self:dbmsg("Cannot open file", msg))
537 local parsed, msg = self:loadhtml(f, "loona:out", fname2)
538 msg = msg and (type(msg) == "string" and msg or msg.txt)
539 assert(parsed, self:dbmsg("Syntax error", msg))
540 return self:runboxed(parsed, nil, unpack(arg))
544 -- produce link target (simple)
546 function Loona:shref(section, arg)
547 local args2 = { } -- propagated or new arguments
548 for _, a in ipairs(arg) do
549 local key, val = a:match("^([%w_]+)=(.*)$")
550 if key and val then -- "arg=val" sets/overrides argument
551 table.insert(args2, { name = key, value = val })
552 elseif self.args[a] then -- just "arg" propagates argument
553 table.insert(args2, { name = a, value = self.args[a] })
556 local doc = self:getdocname(section, #args2 > 0)
557 local url, anch = doc:match("^(.+)(#.+)$")
558 local notfirst = doc:match("%?")
559 local href = { anch and url or doc }
560 for i, arg in ipairs(args2) do
561 if i > 1 or notfirst then
562 table.insert(href, "&")
564 table.insert(href, "?")
566 table.insert(href, arg.name .. "=" .. cgi.encodeurl(arg.value))
569 table.insert(href, anch)
571 return table.concat(href)
575 -- produce link target, implicit propagation of lang, profile, session
577 function Loona:href(section, ...)
579 table.insert(arg, 1, "profile")
580 table.insert(arg, 1, "session")
582 if self.explicitlang then
583 table.insert(arg, 1, "lang")
585 return self:shref(section, arg)
589 function Loona:ilink(target, text, extra)
590 return ('<a href="%s"%s>%s</a>'):format(target, extra or "", text)
594 -- internal link, implicit propagation of lang, profile, session
596 function Loona:link(section, text, ...)
597 return self:ilink(self:href(section, unpack(arg)), text or section,
602 -- external link (opens in a new window), no argument propagation
604 function Loona:elink(target, text)
605 return self:ilink(target, text or target, self.config.extlinkextra)
609 -- plain link, no implicit argument propagation
611 function Loona:plink(section, text, ...)
612 return self:ilink(self:shref(section, arg), text or section)
616 -- user interface link, implicit propagation of lang, profile, session
618 function Loona:uilink(section, text, ...)
619 return self:ilink(self:href(section, unpack(arg)), text or section,
624 -- produce a hidden input value in forms
626 function Loona:hidden(name, value)
627 return not value and "" or
628 ('<input type="hidden" name="%s" value="%s" />'):format(name, value)
632 function Loona:scanprofiles(func)
634 local dir = self.config.contentdir
635 for f in util.readdir(dir) do
636 if posix.lstat(dir .. "/" .. f, "mode") == "directory" then
644 for _, v in ipairs(tab) do
651 function Loona:getprofiles(lang)
652 lang = lang or self.lang
653 return self:scanprofiles(function(f)
654 return f:match("^(%w+)_" .. lang .. "$")
659 function Loona:getlanguages(prof)
660 prof = prof or self.profile
661 return self:scanprofiles(function(f)
662 return f:match("^" .. prof .. "_(%l%l)$")
667 -- Functions to produce a navigation menu
669 local newent = { name = "new", label = "[+]", action="actionnew=true" }
671 function Loona:rmenu(level, render, path, addnew, recurse)
672 local sub = (addnew and level == #self.submenus + 1) and
673 { name = "new", entries = { }} or self.submenus[level]
674 if sub and sub.entries then
676 for _, e in ipairs(sub.entries) do
677 if not e.notvisible then
678 table.insert(visible, e)
682 table.insert(visible, newent)
684 local numvis = #visible
686 render.listbegin(self, level, numvis, path)
687 for idx, e in ipairs(visible) do
688 local label = self:encodeform(e.label or e.name)
689 local newpath = path and path .. "/" .. e.name or e.name
690 local active = (e.name == sub.name)
691 render.itembegin(self, level, idx, label)
692 render.link(self, level, newpath, label, active, e.action)
693 if recurse and active then
694 self:rmenu(level + 1, render, newpath, addnew, recurse)
696 render.itemend(self, level, idx, label)
704 function Loona:menu(level, recurse, render)
706 render = render or { }
707 render.link = render.link or
708 function(self, level, path, label, active, ...)
709 self:out(('<a %shref="%s">%s</a>\n'):format(active and
710 'class="active" ' or "", self:href(path, unpack(arg)), label))
712 render.listbegin = render.listbegin or
713 function(self, level) -- , numvis, path
714 self:out('<ul id="menulevel' .. level .. '">\n')
716 render.listend = render.listend or
720 render.itembegin = render.itembegin or
721 function(self) -- , level, idx
724 render.itemend = render.itemend or
728 recurse = recurse == nil and true or recurse
729 local path = level > 1 and self:getpath("/", level - 1) or nil
730 local addnew = self.authuser_menu and
731 (not self.ispubprofile or self.config.editablepubprofile)
732 self:rmenu(level, render, path, addnew, recurse)
736 function Loona:loadcontent(fname)
738 local f = open(self.contentdir .. "/" .. fname)
739 local c = f:read("*a")
747 function Loona:loadmarkup(fname)
748 return (fname and fname ~= "") and
749 self:domarkup(self:loadcontent(fname)) or ""
753 function Loona:editable(editkey, fname, savename)
755 local contentdir = self.contentdir
756 local edit, show, hidden, extramsg, changed
758 if self.authuser_edit or self.authuser_profile or self.authuser_menu then
760 local hiddenvars = table.concat( {
761 self:hidden("lang", self.args.lang),
762 self:hidden("profile", self.profile),
763 self:hidden("session", self.session.id),
764 self:hidden("editkey", editkey) }, " ")
766 local lockfname = fname and (contentdir .. "/" .. fname)
768 if self.useralert and editkey == self.args.editkey then
770 -- display user alert/request/confirmation
774 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
776 <legend>]] .. self.useralert.text ..[[</legend>
777 ]] .. (self.useralert.confirm or "") .. [[
778 ]] .. (self.useralert.returnto or "") .. [[
779 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL ..[[" />
780 ]] .. hiddenvars .. [[
785 elseif self.args.actionnew and editkey == "main" and self.authuser_menu then
787 -- form for creating a new section
790 if self.ispubprofile then
791 self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
794 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
797 ]] .. self.locale.CREATE_NEW_SECTION_UNDER .. " " .. self.sectionpath .. [[
802 ]] .. self.locale.PATHNAME .. [[
805 <input size="30" maxlength="30" name="editname" />
810 ]] .. self.locale.MENULABEL .. [[
813 <input size="30" maxlength="50" name="editlabel" />
818 ]] .. self.locale.WINDOWTITLE .. [[
821 <input size="30" maxlength="50" name="edittitle" />
826 ]] .. self.locale.INVISIBLE .. [[
829 <input type="checkbox" name="editvisibility" />
834 ]] .. self.locale.SECRET .. [[
837 <input type="checkbox" name="editsecrecy" />
842 ]] .. self.locale.SECURE_CONNECTION .. [[
845 <input type="checkbox" name="editsecure" />
850 ]] .. self.locale.PERMISSIONS .. [[
853 <input size="30" maxlength="50" name="editpermissions" />
858 ]] .. self.locale.REDIRECT .. [[
861 <input size="30" maxlength="50" name="editredirect" />
865 <input type="submit" name="actioncreate" value="]] .. self.locale.CREATE .. [[" />
866 ]] .. hiddenvars .. [[
872 elseif self.args.actioneditprops and editkey == "main" and
873 self.authuser_menu then
875 if self.ispubprofile then
876 self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
879 <form action="]] ..self.document .. [[" method="post" accept-charset="utf-8">
882 ]] .. self.locale.MODIFY_PROPERTIES_OF_SECTION .. " " .. self.sectionpath .. [[
887 ]] .. self.locale.MENULABEL .. [[
890 <input size="30" maxlength="50" name="editlabel" value="]] .. (self.section.label or "") .. [[" />
895 ]] .. self.locale.WINDOWTITLE .. [[
898 <input size="30" maxlength="50" name="edittitle" value="]] .. (self.section.title or "") .. [[" />
903 ]] .. self.locale.INVISIBLE .. [[
906 <input type="checkbox" name="editvisibility" ]] .. (self.section.hidden and 'checked="checked"' or "") .. [[/>
911 ]] .. self.locale.SECRET .. [[
914 <input type="checkbox" name="editsecrecy" ]] .. (self.section.secret and 'checked="checked"' or "") .. [[/>
919 ]] .. self.locale.SECURE_CONNECTION .. [[
922 <input type="checkbox" name="editsecure" ]] .. (self.section.secure and 'checked="checked"' or "") .. [[/>
927 ]] .. self.locale.PERMISSIONS .. [[
930 <input size="30" maxlength="50" name="editpermissions" value="]] .. (self.section.permissions or "") .. [[" />
935 ]] .. self.locale.REDIRECT .. [[
938 <input size="30" maxlength="50" name="editredirect" value="]] .. (self.section.redirect or "") .. [[" />
942 <input type="submit" name="actionsaveprops" value="]] .. self.locale.SAVE .. [[" />
943 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
944 ]] .. hiddenvars .. [[
949 elseif (self.args.actioneditprofiles or
950 self.args.actioncreateprofile or
951 self.args.actionchangeprofile or
952 self.args.actionchangelanguage or
953 self.args.actionpublishprofile) and editkey == "main" and
954 self.authuser_profile then
957 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
960 ]] .. self.locale.CHANGEPROFILE .. [[
962 <select name="changeprofile" size="1">]])
963 for _, val in ipairs(self:getprofiles()) do
964 self:out('<option' .. (val == self.profile and ' selected="selected"' or '') .. '>')
966 self:out('</option>')
970 <input type="submit" name="actionchangeprofile" value="]] .. self.locale.CHANGE ..[[" />
971 ]] .. hiddenvars .. [[
974 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
977 ]] .. self.locale.CHANGELANGUAGE .. [[
979 <select name="changelanguage" size="1">]])
980 for _, val in ipairs(self:getlanguages()) do
981 self:out('<option' .. (val == self.lang and ' selected="selected"' or '') .. '>')
983 self:out('</option>')
987 <input type="submit" name="actionchangelanguage" value="]] .. self.locale.CHANGE ..[[" />
988 ]] .. hiddenvars .. [[
992 if self.authuser_publish then
994 <form action="]] .. self.document ..[[" method="post" accept-charset="utf-8">
997 ]] .. self.locale.CREATEPROFILE .. [[
999 <input size="20" maxlength="20" name="createprofile" />
1000 ]] .. self.locale.LANGUAGE ..[[
1001 <input size="2" maxlength="2" name="createlanguage" value="]] .. self.lang ..[[" />
1002 <input type="submit" name="actioncreateprofile" value="]] .. self.locale.CREATE .. [[" />
1003 ]] .. hiddenvars .. [[
1008 if (not self.ispubprofile or self.config.editablepubprofile) and
1009 self.authuser_publish then
1011 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
1014 ]] .. self.locale.PUBLISHPROFILE .. [[
1016 ]] .. self:hidden("publishprofile", self.profile) .. [[
1017 <input type="submit" name="actionpublishprofile" value="]] .. self.locale.PUBLISH .. [[" />
1018 ]] .. hiddenvars .. [[
1021 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
1024 ]] .. self.locale.DELETEPROFILE .. [[
1026 ]] .. self:hidden("deleteprofile", self.profile) .. [[
1027 <input type="submit" name="actiondeleteprofile" value="]] .. self.locale.DELETE .. [[" />
1028 ]] .. hiddenvars .. [[
1034 elseif self.args.actionedit and editkey == self.args.editkey and self.authuser_edit then
1035 if not self.section.redirect then
1036 extramsg = self.ispubprofile and
1037 self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE
1038 edit = self:loadcontent(fname):gsub("\194\160", " ") -- TODO
1039 changed = self.section and (self.section.revisiondate or self.section.creationdate)
1042 elseif self.args.actionpreview and editkey == self.args.editkey and
1043 self.authuser_edit then
1044 edit = self.args.editform
1045 show = self:domarkup(edit:gsub(" ", "\194\160")) -- TODO
1047 elseif self.args.actionsave and editkey == self.args.editkey
1048 and self.authuser_edit then
1049 local c = self.args.editform
1053 self:expire(contentdir, "[^.]%S+.LOCK")
1054 if self:lockfile(lockfname) then
1055 -- lock was expired, aquired a new one
1056 extramsg = self.locale.SECTION_COULD_HAVE_CHANGED
1059 local tab = lib.source(lockfname .. ".LOCK")
1060 if tab and tab.id == self.session.id then
1061 -- lock already held and is mine - try to save:
1062 local savec = c:gsub(" ", "\194\160") -- TODO
1063 remove(contentdir .. "/" .. savename .. ".html")
1064 self:savebody(savename, savec)
1065 -- TODO: error handling
1066 self:unlockfile(lockfname)
1067 show, dynamic = self:domarkup(savec)
1070 -- lock was expired and someone else has it now
1071 extramsg = self.locale.SECTION_IN_USE
1077 local savec = c:gsub(" ", "\194\160") -- TODO
1078 self:savebody(savename, savec)
1079 -- TODO: error handling
1080 show, dynamic = self:domarkup(savec)
1083 -- mark dynamic text bodies
1084 if not self.section.dynamic then
1085 self.section.dynamic = { }
1087 self.section.dynamic[editkey] = dynamic
1089 for _ in pairs(self.section.dynamic) do
1093 self.section.dynamic = nil
1098 elseif self.args.actioncancel and editkey == self.args.editkey then
1100 self:unlockfile(lockfname) -- remove lock
1104 if editkey == "main" and self.section and self.section.redirect then
1105 self:out('<h2>' .. self.locale.SECTION_IS_REDIRECT ..'</h2>')
1106 self:out(self:link(self.section.redirect))
1111 self:expire(contentdir, "[^.]%S+.LOCK")
1112 if fname and not self:lockfile(contentdir .. "/" .. fname) then
1113 local tab = lib.source(contentdir .. "/" .. fname .. ".LOCK")
1114 if tab and tab.id ~= self.session.id then
1115 extramsg = self.locale.SECTION_IN_USE
1117 -- else already owner
1120 self:out('<h2><span class="warn">' .. extramsg .. '</span></h2>')
1123 <form action="]] .. self.document .. [[#preview" method="post" accept-charset="utf-8">
1126 ]] .. self.locale.EDIT_SECTION .. [[
1128 <textarea cols="80" rows="25" name="editform">
1129 ]] .. self:encodeform(edit) .. [[</textarea>
1131 <input type="submit" name="actionsave" value="]] .. self.locale.SAVE .. [[" />
1132 <input type="submit" name="actionpreview" value="]] .. self.locale.PREVIEW .. [[" />
1133 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
1134 ]] .. hiddenvars .. [[
1142 self:dosnippet(function()
1144 show = self:loadmarkup(fname)
1145 changed = self.section and (self.section.revisiondate or self.section.creationdate)
1147 local parsed, msg = self:loadhtml(show, "loona:out", "<parsed html>")
1148 assert(parsed, msg and "Syntax error : " .. msg)
1149 self:runboxed(parsed)
1153 if self.authuser_profile or self.authuser_edit or self.authuser_menu then
1156 <div class="edit">]])
1157 if editkey == "main" then
1159 <a name="preview"></a>
1160 ]] .. self.authuser .. [[ : ]])
1161 if self.authuser_profile then
1162 self:out(self:uilink(self.sectionpath, "[" .. self.locale.PROFILE .. "]", "actioneditprofiles=true", "editkey=" .. editkey) .. [[ :
1163 ]] .. self.profile .. "_" .. self.lang)
1164 if self.ispubprofile then
1166 <span class="warn">[]] .. self.locale.PUBLIC .. [[]</span>]])
1170 self:out(self.sectionpath .. ' ')
1172 if self.section and (not self.ispubprofile or self.config.editablepubprofile) then
1173 if self.authuser_edit then
1174 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.EDIT .. "]", "actionedit=true", "editkey=" .. editkey) .. " ")
1176 if editkey == "main" and self.authuser_menu then
1177 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.PROPERTIES .. "]", "actioneditprops=true", "editkey=" .. editkey) .. " ")
1179 if (fname == savename or not self.section.subs) and (self.authuser_edit and self.authuser_menu) then
1180 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.DELETE .. "]", "actiondelete=true", "editkey=" .. editkey) .. " ")
1182 if editkey == "main" and self.authuser_menu then
1183 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEUP .. "]", "actionup=true", "editkey=" .. editkey) .. " ")
1184 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEDOWN .. "]", "actiondown=true", "editkey=" .. editkey) .. " ")
1186 if changed and editkey == "main" then
1187 self:out('- ' .. self.locale.CHANGED .. ': ' .. date("%d-%b-%Y %T", changed))
1196 -- Get pathname of an existing content file that
1197 -- the current path is determined by (or defaults to)
1199 function Loona:getsectionpath(bodyname, requestpath)
1200 local ext = (not bodyname or bodyname == "main") and "" or "." .. bodyname
1201 local t, path, section = { }
1202 for _, menu in ipairs(self.submenus) do
1203 if menu.entries and menu.entries[menu.name] then
1204 table.insert(t, menu.name)
1205 local fn = table.concat(t, "_")
1206 if posix.stat(self.contentdir .. "/" .. fn .. ext,
1207 "mode") == "file" then
1208 path, section = fn, menu
1212 return path, ext, section
1216 function Loona:body(name)
1217 name = self:checkbodyname(name)
1218 local path, ext = self:getsectionpath(name)
1219 self:dosnippet(function()
1220 self:editable(name, path and path .. ext, self.sectionname .. ext)
1225 function Loona:init()
1227 -- get list of languages, in order of preference
1228 -- TODO: respect quality parameter, not just order
1230 local l = self.requestlang or self.args.lang
1231 self.langs = { l and l:match("^%w+$") }
1232 local s = getenv("HTTP_ACCEPT_LANGUAGE")
1234 local l, r = s:match("^([%w.=]+)[,;](.*)$")
1237 if l:match("^%w+$") then
1238 table.insert(self.langs, l)
1241 table.insert(self.langs, self.config.deflang)
1243 -- get list of possible profiles
1245 local profiles = { }
1246 for e in util.readdir(self.config.contentdir) do
1252 for _, lang in ipairs(self.langs) do
1253 local p = posix.readlink(self.config.contentdir .. "/current_" .. lang)
1254 p = p and p:match("^(%w+)_" .. lang .. "$")
1263 local checkprofile = self.authuser and
1264 (self.authuser_profile and self.args.profile or self.session.data.profile)
1265 or self.config.defprofile or self.pubprofile or "work"
1267 for _, lang in ipairs(self.langs) do
1268 if profiles[checkprofile .. "_" .. lang] then
1269 self.profile = checkprofile
1275 assert(self.profile and self.lang, "Invalid profile or language")
1278 self.ispubprofile = self.profile == self.pubprofile
1280 -- write back language and profile
1282 self.args.lang = (self.explicitlang or self.lang ~= self.config.deflang)
1283 and self.lang or nil
1284 self.args.profile = self.profile
1286 -- determine content directory pathname and section filename
1289 ("%s/%s_%s"):format(self.config.contentdir, self.profile, self.lang)
1290 self.indexfname = self.contentdir .. "/.sections"
1294 self.sections = lib.source(self.indexfname)
1296 -- index sections, determine visibility in menu
1298 self:indexsections()
1300 -- decompose request path, produce a stack of sections
1302 self.submenus, self.section = self:getsection(self.requestpath)
1304 -- handle redirects if not logged on
1306 if not self.authuser_edit and self.section and self.section.redirect then
1307 self.submenus, self.section = self:getsection(self.section.redirect)
1310 -- section path and document name (refined)
1312 self.sectionpath = self:getpath()
1313 self.sectionname = self:getpath("_")
1318 function Loona:handlechanges()
1322 if self.args.editkey == "main" then
1324 -- In main editable section:
1326 if self.args.actioncreate then
1328 -- Create new section
1330 local editname = self.args.editname:lower()
1331 assert(not editname:match("%W"),
1332 self:dbmsg("Invalid section name", editname))
1333 if not (section and (section.subs or section)[editname]) then
1334 local newpath = (self.sectionpath and
1335 (self.sectionpath .. "/")) .. editname
1336 local s = self:addpath(newpath, { name = editname,
1337 label = self.args.editlabel ~= "" and
1338 self.args.editlabel or nil,
1339 title = self.args.edittitle ~= "" and
1340 self.args.edittitle or nil,
1341 redirect = self.args.editredirect ~= "" and
1342 self.args.editredirect or nil,
1343 permissions = self.args.editpermissions ~= "" and
1344 self.args.editpermissions or nil,
1345 hidden = self.args.editvisibility and true,
1346 secret = self.args.editsecrecy and true,
1347 secure = self.args.editsecure and true,
1348 creator = self.authuser,
1349 creationdate = time() })
1353 elseif self.args.actionsave then
1357 self.section.revisiondate = time()
1358 self.section.revisioner = self.authuser
1361 elseif self.args.actionsaveprops then
1365 self.section.hidden = self.args.editvisibility and true
1366 self.section.secret = self.args.editsecrecy and true
1367 self.section.secure = self.args.editsecure and true
1368 self.section.label = self.args.editlabel ~= "" and
1369 self.args.editlabel or nil
1370 self.section.title = self.args.edittitle ~= "" and
1371 self.args.edittitle or nil
1372 self.section.redirect =
1373 self.args.editredirect ~= "" and self.args.editredirect or nil
1374 self.section.permissions =
1375 self.args.editpermisisons ~= "" and self.args.editpermissions or nil
1378 elseif self.args.actionup then
1382 local t, i = self:checkpath(self.sectionpath)
1384 if self.ispubprofile and not self.args.actionconfirm then
1386 text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
1388 '<input type="submit" name="actionup" value="' ..
1389 self.locale.MOVE .. '" /> ' ..
1390 self:hidden("actionconfirm", "true")
1393 local item = table.remove(t, i)
1394 table.insert(t, i - 1, item)
1399 elseif self.args.actiondown then
1401 -- Move section down
1403 local t, i = self:checkpath(self.sectionpath)
1404 if t and i < #t then
1405 if self.ispubprofile and not self.args.actionconfirm then
1407 text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
1409 '<input type="submit" name="actiondown" value="' ..
1410 self.locale.MOVE .. '" /> ' ..
1411 self:hidden("actionconfirm", "true")
1414 local item = table.remove(t, i)
1415 table.insert(t, i + 1, item)
1420 elseif self.args.actioncreateprofile and self.args.createprofile then
1424 local c = self.args.createprofile
1428 c = self:checkprofilename(c:lower())
1429 local l = self:checklanguage((self.args.createlanguage or self.lang):lower())
1430 if c == self.profile and l == self.lang then
1432 text = self.locale.ALERT_CANNOT_COPY_PROFILE_TO_SELF,
1433 returnto = self:hidden("actioneditprofiles", "true")
1436 local profiles = self:getprofiles(l)
1438 if c == self.pubprofile then
1439 text = self.locale.ALERT_OVERWRITE_PUBLISHED_PROFILE
1440 elseif profiles[c] and l == self.lang then
1441 text = self.locale.ALERT_OVERWRITE_EXISTING_PROFILE
1443 if text and not self.args.actionconfirm then
1446 returnto = self:hidden("actioneditprofiles", "true"),
1447 confirm = '<input type="submit" ' ..
1448 'name="actioncreateprofile" value="' ..
1449 self.locale.OVERWRITE .. '" /> ' ..
1450 self:hidden("actionconfirm", "true") ..
1451 self:hidden("createlanguage", l) ..
1452 self:hidden("createprofile", c)
1456 self:deleteprofile(c, l)
1458 self:copyprofile(c, self.profile, l, self.lang)
1462 elseif self.args.actiondeleteprofile and self.args.deleteprofile then
1466 local c = self:checkprofilename(self.args.deleteprofile:lower())
1467 assert(c ~= self.pubprofile,
1468 self:dbmsg("Cannot delete published profile", c))
1469 if self.args.actionconfirm then
1470 self:deleteprofile(c)
1472 self.args.profile = nil
1477 text = self.locale.ALERT_DELETE_PROFILE,
1478 returnto = self:hidden("actioneditprofiles", "true"),
1479 confirm = '<input type="submit" ' ..
1480 'name="actiondeleteprofile" value="' ..
1481 self.locale.DELETE .. '" /> ' ..
1482 self:hidden("actionconfirm", "true") ..
1483 self:hidden("deleteprofile", c)
1487 elseif self.args.actionchangeprofile and self.args.changeprofile then
1491 local c = self:checkprofilename(self.args.changeprofile:lower())
1493 self.args.profile = c
1496 elseif self.args.actionchangelanguage and self.args.changelanguage then
1500 local l = self:checklanguage(self.args.changelanguage:lower())
1503 self.explicitlang = l
1506 elseif self.args.actionpublishprofile and self.args.publishprofile then
1510 local c = self:checkprofilename(self.args.publishprofile:lower())
1511 if c ~= self.publicprofile then
1512 if self.args.actionconfirm then
1513 self:publishprofile(c)
1517 text = self.locale.ALERT_PUBLISH_PROFILE,
1518 returnto = self:hidden("actioneditprofiles", "true"),
1519 confirm = '<input type="submit" ' ..
1520 'name="actionpublishprofile" value="' ..
1521 self.locale.PUBLISH .. '" /> ' ..
1522 self:hidden("actionconfirm", "true") ..
1523 self:hidden("publishprofile", c)
1531 if self.args.actiondelete then
1535 if not self.args.actionconfirm then
1537 text = self.ispubprofile and
1538 self.locale.ALERT_DELETE_IN_PUBLISHED_PROFILE or
1539 self.locale.ALERT_DELETE_SECTION,
1541 '<input type="submit" name="actiondelete" value="' ..
1542 self.locale.DELETE .. '" /> ' ..
1543 self:hidden("actionconfirm", "true")
1546 local key = self.args.editkey
1547 if key == "main" and not self.section.subs then
1548 self:deletesection(self.sectionname, true) -- all bodies
1549 self:rmpath(self.sectionpath) -- and node
1551 local ext = (key == "main" and "") or "." .. key
1552 self:deletesection(self.sectionname .. ext) -- only text
1553 if self.section.dynamic then
1554 self.section.dynamic[key] = nil
1556 for _ in pairs(self.section.dynamic) do
1560 self.section.dynamic = nil
1576 function Loona:encodeform(s)
1577 return util.encodeform(s)
1581 function Loona:loadhtml(src, outfunc, chunkname)
1582 return luahtml.load(src, outfunc, chunkname)
1586 function Loona:domarkup(s)
1588 local is_dynamic = Markup:new { input = s, features = "hespcadlintf",
1589 wrfunc = function(s) table.insert(t, s) end }:run()
1590 return table.concat(t), is_dynamic
1594 function Loona:expire(dir, pat, maxage)
1595 return util.expire(dir, pat, maxage or self.config.sessionmaxage)
1599 function Loona.new(class, self)
1601 self = Class.new(class, self or { })
1607 self.out = self.out or function(self, s)
1610 self.addheader = self.addheader or function(self, s)
1611 self.buf:addheader(s)
1614 -- Get configuration
1616 self.config = self.config or lib.source(self.conffile or "../etc/config.lua") or { }
1617 self.config.defname = self.config.defname or "home"
1618 self.config.deflang = self.config.deflang or "en"
1619 self.config.sessionmaxage = self.config.sessionmaxage or 6000
1620 self.config.secureport = self.config.secureport or 443
1621 self.config.passwdfile =
1622 posix.abspath(self.config.passwdfile or "../etc/passwd.lua")
1623 self.config.sessiondir =
1624 posix.abspath(self.config.sessiondir or "../var/sessions")
1625 self.config.extdir = posix.abspath(self.config.extdir or "../extensions")
1626 self.config.contentdir = posix.abspath(self.config.contentdir or "../content")
1627 self.config.localedir = posix.abspath(self.config.localedir or "../locale")
1628 self.config.htdocsdir = posix.abspath(self.config.htdocsdir or "../htdocs")
1629 self.config.htmlcachedir =
1630 posix.abspath(self.config.htmlcachedir or "../var/htmlcache")
1631 self.config.extlinkextra = self.config.extlinksamewindow and ' class="extlink"'
1632 or ' class="extlink" onclick="void(window.open(this.href, \'\', \'\')); return false;"'
1634 -- Create proxy for on-demand loading of locales
1638 locmt.__index = function(_, key)
1639 for _, l in ipairs(self.langs) do
1640 locmt.__locale = lib.source(self.config.localedir .. "/" .. l)
1641 if locmt.__locale then
1645 locmt.__index = function(tab, key)
1646 return locmt.__locale[key] or key
1648 return locmt.__locale[key] or key
1650 setmetatable(self.locale, locmt)
1652 -- Get request, args, document, script name, request path
1654 self.request = self.request or Request:new()
1655 self.args = self.request:getargs()
1656 self.cgi_document = self.request:getdocument()
1658 self.requesthandler = self.requesthandler or self.cgi_document.Handler
1659 self.requestdocument = self.requestdocument or self.cgi_document.Name
1660 self.requestpath = self.requestpath or self.cgi_document.VirtualPath
1661 self.explicitlang = not self.requestlang and self.args.lang
1662 self.secure = not self.insecure and (self.request.SERVER_PORT == self.config.secureport)
1664 -- Manage login and establish session
1666 if not self.nologin then
1667 local sid = self.args.session or self.request.UNIQUE_ID
1668 self.session = self.session or Session:new {
1670 sessiondir = self.config.sessiondir,
1671 maxage = self.config.sessionmaxage
1673 if self.args.login then
1674 -- write back session ID into request args:
1675 self.args.session = sid -- !
1676 if self.args.login == "false" then
1677 self.session:delete()
1679 elseif self.args.password then
1680 self.loginfailed = true
1681 local match, username, perm, profile =
1682 self:checkpw(self.args.login, self.args.password)
1684 self.session.data.authuser = self.args.login
1685 self.session.data.username = username
1686 self.session.data.permissions = perm
1687 self.session.data.profile = profile
1688 self.session.data.id = self.session.id
1689 self.loginfailed = nil
1693 self.authuser = self.session and self.session.data.authuser
1696 if self.nologin or not self.authuser then
1699 self.args.session = nil
1701 self.authuser_edit = self.session.data.permissions:find("e") and true
1702 self.authuser_menu = self.session.data.permissions:find("m") and true
1703 self.authuser_publish = self.session.data.permissions:find("p") and true
1704 self.authuser_profile = self.authuser_publish or
1705 self.session.data.permissions:find("c") and true
1706 self.authuser_visible = self.session.data.permissions:find("v") and true
1707 self.authuser_debug = self.session.data.permissions:find("d") and true
1708 self.authuser_seeall = self.session.data.permissions:find("a") and true
1712 -- Get lang, locale, profile, section
1716 if self.authuser then -- TODO?
1717 self:handlechanges()
1719 self.args.profile = nil
1725 self.document = self.requestdocument .. "/" .. self.sectionpath
1726 if self.authuser then
1727 self.getdocname = function(self, path)
1728 local url, anch = path:match("^([^#]*)(#?.*)$")
1729 path = url ~= "" and url
1731 return self.requestdocument .. "/" .. (path or self.sectionpath) .. anch
1734 self.getdocname = function(self, path, haveargs)
1735 local url, anch = path:match("^([^#]*)(#?.*)$")
1736 path = url ~= "" and url
1739 dyn, path, exists = self:isdynamic(path or self.sectionpath)
1740 if dyn or haveargs or not exists then
1741 return self.requestdocument .. "/" .. path .. anch
1743 path = path == self.config.defname and "index" or path
1744 return "/" .. path:gsub("/", "_") .. ".html" .. anch
1748 -- Save session state
1750 if self.session then
1758 function Loona:checkpw(login, passwd)
1759 local pwddb = lib.source(self.config.passwdfile)
1760 local pwdent = pwddb[login]
1761 if pwdent and pwdent.password == passwd then
1762 return true, pwdent.username or login,
1763 pwdent.permissions or "", pwdent.profile
1768 function Loona:run(fname)
1770 fname = fname or self.requesthandler
1771 local parsed, msg = self:loadhtml(open(fname), "loona:out", fname)
1772 assert(parsed, self:dbmsg("HTML/Lua parsing failed", msg))
1773 self:runboxed(parsed)
1778 function Loona:indexdynamic()
1779 self:recursesections(self.sections, function(self, s, e, path, dynamic)
1780 path = path and path .. "_" .. e.name or e.name
1781 dynamic = dynamic or { }
1782 for k in pairs(e.dynamic or { }) do
1785 for k in pairs(dynamic) do
1786 local ext = (k == "main" and "") or "." .. k
1787 if posix.stat(self.contentdir .. "/" .. path .. ext,
1788 "mode") == "file" then
1789 dynamic[k] = e.dynamic and e.dynamic[k]
1793 for k in pairs(dynamic) do
1798 for k in pairs(dynamic) do
1804 return path, dynamic
1809 function Loona:isdynamic(path)
1810 path = path or self.sectionpath
1812 local t, i = self:checkpath(path)
1815 if t[i].redirect then
1816 path = t[i].redirect
1817 t, i, exists = self:isdynamic(path) -- TODO: prohibit endless recursion
1825 return t, path, exists
1829 function Loona:dumphtml(o)
1833 o.out = function(self, s) table.insert(outbuf, s) end
1834 o.addheader = function(self, s) end
1836 o = self:new(o):run()
1837 if not o:isdynamic() then
1838 local path = o.sectionname
1839 path = path == o.config.defname and "index" or path
1840 local srcname = o.config.htdocsdir .. "/" .. path .. o.htmlext
1841 local fh, msg = open(srcname .. ".tmp", "wb")
1842 assert(fh, self:dbmsg("Could not write cached HTML", msg))
1843 for _, line in ipairs(outbuf) do
1847 local dstname = o.config.htmlcachedir .. "/" .. path .. o.htmlext
1848 local success, msg = posix.symlink(srcname, dstname .. ".tmp")
1849 -- assert(success, self:dbmsg("Could not link to cached HTML", msg))