4 -- Written by Timm S. Mueller <tmueller at neoscientists.org>
5 -- See copyright notice in COPYRIGHT
8 local tek = require "tek"
9 local cgi = require "tek.cgi"
10 local posix = require "tek.posix"
11 require "tek.cgi.request"
12 require "tek.cgi.request.args"
13 require "tek.cgi.session"
15 require "tek.web.markup"
20 string = string, table = table,
21 assert = assert, collectgarbage = collectgarbage, dofile = dofile,
22 error = error, getfenv = getfenv, getmetatable = getmetatable,
23 ipairs = ipairs, load = load, loadfile = loadfile, loadstring = loadstring,
24 next = next, pairs = pairs, pcall = pcall, print = print,
25 rawequal = rawequal, rawget = rawget, rawset = rawset, require = require,
26 select = select, setfenv = setfenv, setmetatable = setmetatable,
27 tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
31 local table, string, assert, unpack, ipairs, pairs, type, require =
32 table, string, assert, unpack, ipairs, pairs, type, require
33 local setmetatable, setfenv, getfenv = setmetatable, setfenv, getfenv
34 local open, remove, rename, getenv, time, date =
35 io.open, os.remove, os.rename, os.getenv, os.time, os.date
45 local function lookupname(tab, val)
47 for i, v in ipairs(tab) do
67 local loona = atom:new(getfenv())
70 function loona:dbmsg(msg, detail)
71 return (msg and detail and self.authuser) and
72 ("%s : %s"):format(msg, detail) or msg
76 function loona:checkprofilename(n)
77 assert(n:match("^%w+$") and n ~= "current",
78 self:dbmsg("Invalid profile name", n))
83 function loona:checkbodyname(s)
85 assert(s:match("^[%w_]*%w+[%w_]*$"), self:dbmsg("Invalid body name", s))
90 function loona:deleteprofile(p, lang)
91 p = self.config.contentdir .. "/" .. p .. "_" .. (lang or self.lang)
92 for e in tek.util.readdir(p) do
93 local success, msg = remove(p .. "/" .. e)
94 assert(success, self:dbmsg("Error removing entry in profile", msg))
100 function loona:copyprofile(newprofile, srcprofile, lang)
101 srcprofile = srcprofile or self.profile
102 lang = lang or self.lang
103 local contentdir = self.config.contentdir
104 local src = ("%s/%s_%s"):format(contentdir, srcprofile or self.profile,
106 assert(posix.stat(src, "mode") == "directory",
107 self:dbmsg("Not a directory", src))
108 local dst = ("%s/%s_%s"):format(contentdir, newprofile, lang)
109 local success, msg = posix.mkdir(dst)
110 assert(success, self:dbmsg("Error creating profile directory", msg))
111 for e in tek.util.readdir(src) do
112 local ext = e:match("^[^.].*%.([^.]*)$")
113 if ext ~= "LOCK" then
114 local f = src .. "/" .. e
115 if posix.stat(f, "mode") == "file" then
116 success, msg = tek.copyfile(f, dst .. "/" .. e)
117 assert(success, self:dbmsg("Error copying file", msg))
124 function loona:publishprofile(profile, lang)
125 lang = lang or self.lang
126 local contentdir = self.config.contentdir
127 local newpath = ("%s/current_%s"):format(contentdir, lang)
128 local tmppath = newpath .. "." .. self.session.name
129 local success, msg = posix.symlink(profile .. "_" .. lang, tmppath)
130 assert(success, self:dbmsg("Cannot create symlink", msg))
131 success, msg = rename(tmppath, newpath)
132 assert(success, self:dbmsg("Cannot put symlink in place", msg))
134 -- Get languages in the current profile
137 local lmatch = "^" .. self.profile .. "_(%w+)$"
138 for e in tek.util.readdir(self.config.contentdir) do
139 local l = e:match(lmatch)
141 table.insert(plangs, l)
145 -- For all languages, unroll site to static HTML
147 for _, lang in ipairs(plangs) do
148 local ext = (#plangs == 1 and ".html") or (".html." .. lang)
149 self:recursesections(self.sections, function(self, s, e, path)
150 path = path and path .. "/" .. e.name or e.name
151 if not e.notvisible then
152 loona:dumphtml { requestpath = path, requestlang = lang,
153 htmlext = ext, insecure = true }
161 local htdocs = self.config.htdocsdir
162 local cache = self.config.htmlcachedir
164 for e in tek.util.readdir(cache) do
165 local f = e:match("^.*%.html%.?(%w*)$")
166 if f and f ~= "tmp" then
167 local success, msg = remove(htdocs .. "/" .. e)
168 success, msg = remove(cache .. "/" .. e)
170 self:dbmsg("Could not purge cached HTML file", msg))
174 for e in tek.util.readdir(cache) do
175 local f = e:match("^(.*%.html%.?%w*)%.tmp$")
177 local success, msg = rename(cache .. "/" .. e, cache .. "/" .. f)
179 self:dbmsg("Could not update cached HTML file", msg))
180 success, msg = rename(htdocs .. "/" .. e, htdocs .. "/" .. f)
182 self:dbmsg("Could not update cached HTML file", msg))
188 function loona:recursesections(s, func, ...)
189 for _, e in ipairs(s) do
190 local udata = { func(self, s, e, unpack(arg)) }
192 self:recursesections(e.subs, func, unpack(udata))
198 function loona:indexsections()
199 self:recursesections(self.sections, function(self, s, e)
200 e.notvalid = (not self.secure and e.secure) or
201 (not self.authuser and e.secret) or nil
202 e.notvisible = e.notvalid or not self.authuser and e.hidden or nil
208 -- Decompose section path into a stack of sections, returning only up to
209 -- the last valid element in the path. additionally returns the table of
210 -- the last section path element (or the default section)
212 function loona:getsection(path)
213 local default = not self.authuser and self.config.defname
214 local tab = { { entries = self.sections, name = default } }
215 local ss = self.sections
217 (path or ""):gsub("(%w+)/?", function(a)
220 if s and not s.notvalid then
225 table.insert(tab, { entries = ss })
232 if not self.section and not sectionpath then
233 sectionpath = self.sections[default]
235 table.insert(tab, { entries = sectionpath.subs })
238 return tab, sectionpath
242 function loona:getpath(delimiter)
244 for _, menu in ipairs(self.submenus) do
246 table.insert(t, menu.name)
249 return table.concat(t, delimiter or "/")
253 function loona:deletesection(fname, all_bodies)
254 local fullname = self.contentdir .. "/" .. fname
255 local success, msg = remove(fullname)
258 fname:gsub("%^%$%(%)%%%.%[%]%*%+%-%?", "%%%1") .. "%..*$"
259 for e in tek.util.readdir(self.contentdir) do
261 remove(self.contentdir .. "/" .. e)
269 function loona:addpath(path, e)
270 local tab = self.sections
271 path:gsub("(%w+)/?", function(a)
290 function loona:rmpath(path)
292 local tab = self.sections
293 path:gsub("(%w+)/?", function(a)
295 local idx = lookupname(tab, a)
297 if tab[idx].subs then
301 table.remove(tab, idx)
303 if #tab == 0 and parent then
314 function loona:checkpath(path)
315 if path ~= "index" then -- "index" is reserved
317 local tab = self.sections
318 path:gsub("(%w+)/?", function(a)
320 local i = lookupname(tab, a)
334 function loona:title()
335 return self.section and (self.section.title or self.section.label or
336 self.section.name) or ""
340 -- Run a site function snippet, with full error recovery
341 -- (also recovers from errors in error handling function)
343 function loona:dosnippet(func, errfunc)
344 local ret = { tek.catch(func) }
345 if ret[1] == 0 or (errfunc and tek.catch(errfunc) == 0) then
348 self:out("<h2>Error</h2>")
349 self:out("<h3>" .. self:encodeform(ret[2] or "") .. "</h3>")
350 if self.authuser then
351 if type(ret[3]) == "string" then
352 self:out("<p>" .. self:encodeform(ret[3]) .. "</p>")
355 self:out("<pre>" .. self:encodeform(ret[4]) .. "</pre>")
361 function loona:lockfile(file)
362 return not self.session and true or
363 posix.symlink(self.session.filename, file .. ".LOCK")
367 function loona:unlockfile(file)
368 return not self.session and true or remove(file .. ".LOCK")
372 function loona:saveindex()
373 local tempname = self.indexfname .. "." .. self.session.name
374 local f, msg = open(tempname, "wb")
375 assert(f, self:dbmsg("Error opening section file for writing", msg))
376 tek.dump(self.sections, function(...)
380 local success, msg = rename(tempname, self.indexfname)
381 assert(success, self:dbmsg("Error renaming section file", msg))
385 function loona:savebody(fname, content)
386 fname = self.contentdir .. "/" .. fname
387 local f, msg = open(fname, "wb")
388 assert(f, self:dbmsg("Could not open file for writing", msg))
389 f:write(content or "")
394 function loona:runboxed(func, envitems, ...)
400 for k, v in pairs(envitems) do
404 setmetatable(fenv, { __index = boxed_G }) -- boxed global environment
410 function loona:include(fname, ...)
411 assert(not fname:match("%W"), self:dbmsg("Invalid include name", fname))
412 local fname2 = ("%s/%s.lua"):format(self.config.extdir, fname)
413 local f, msg = open(fname2)
414 assert(f, self:dbmsg("Cannot open file", msg))
415 local parsed, msg = self:loadhtml(f, "loona:out", fname2)
416 assert(parsed, self:dbmsg("Syntax error", msg))
417 return self:runboxed(parsed)
421 -- produce link target, propagate lang, profile, session
423 function loona:href(section, ...)
424 local target = self:getdocname(section)
426 return tek.web.gethref(target, { "profile", "session", "lang",
429 return tek.web.gethref(target, { "lang", unpack(arg) })
433 function loona:ilink(target, text, extra)
434 return ('<a href="%s"%s>%s</a>'):format(target, extra or "", text)
438 -- internal link, propagation of lang, profile, session
440 function loona:link(section, text, ...)
441 return self:ilink(self:href(section, unpack(arg)), text or section)
445 -- external link (opens in a new window), no argument propagation
447 function loona:elink(target, text)
448 return self:ilink(target, text or target,
449 not self.config.extlinksamewindow and
450 ' class="extlink" onclick="void(window.open(this.href, \'\', \'\')); return false;"')
454 -- plain link, no argument propagation
456 function loona:plink(section, text, ...)
457 return self:ilink(tek.web.gethref(section, arg), text or section)
461 -- user interface link, propagation of lang, profile, session
463 function loona:uilink(section, text, ...)
464 return self:ilink(self:href(section, unpack(arg)), text or section)
468 -- produce a hidden input value in forms
470 function loona:hidden(name, value)
471 return not value and "" or
472 ('<input type="hidden" name="%s" value="%s" />'):format(name, value)
476 function loona:getprofiles(lang)
477 lang = lang or self.lang
478 local dir = self.config.contentdir
480 for f in tek.util.readdir(dir) do
481 if posix.lstat(dir .. "/" .. f, "mode") == "directory" then
482 local e = f:match("^(%w+)_" .. lang .. "$")
492 -- Functions to produce a hierarchical navigation menu
494 local newent = { name = "new", label = "[+]", action="actionnew=true" }
496 function loona:rmenu(level, linkf, path, addnew)
497 local sub = (addnew and level == #self.submenus + 1) and
498 { name = "new", entries = { }} or self.submenus[level]
499 if sub and sub.entries then
501 for _, e in ipairs(sub.entries) do
502 if not e.notvisible then
503 table.insert(visible, e)
507 table.insert(visible, newent)
510 self:out('<ul id="menulevel' .. level .. '">\n')
511 for _, e in ipairs(visible) do
512 local label = self:encodeform(e.label or e.name)
513 local newpath = path and path .. "/" .. e.name or e.name
514 local active = (e.name == sub.name)
516 linkf(self, newpath, label, active, e.action)
518 self:rmenu(level + 1, linkf, newpath, addnew)
528 function loona:menulink(path, label, active, ...)
529 self:out(('<a %shref="%s">%s</a>\n'):format(active and
530 'class="active" ' or "", self:href(path, unpack(arg)), label))
534 function loona:menu(level, linkf)
535 local addnew = self.authuser and not self.ispubprofile
536 self:rmenu(level or 1, linkf or menulink, nil, addnew)
540 function loona:loadcontent(fname)
542 local f = open(self.contentdir .. "/" .. fname)
543 local c = f:read("*a")
551 function loona:loadmarkup(fname)
552 return (fname and fname ~= "") and
553 self:domarkup(self:loadcontent(fname)) or ""
557 function loona:editable(editkey, fname, savename)
559 local contentdir = self.contentdir
560 local edit, show, hidden, extramsg, changed
562 if self.authuser then
564 local hiddenvars = table.concat( {
565 self:hidden("lang", self.args.lang),
566 self:hidden("profile", self.profile),
567 self:hidden("session", self.session.id),
568 self:hidden("editkey", editkey) }, " ")
570 local lockfname = fname and (contentdir .. "/" .. fname)
572 if self.useralert and editkey == self.args.editkey then
574 -- display user alert/request/confirmation
578 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
580 <legend>]] .. self.useralert.text ..[[</legend>
581 ]] .. (self.useralert.confirm or "") .. [[
582 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL ..[[" />
583 ]] .. hiddenvars .. [[
588 elseif self.args.actionnew and editkey == "main" then
590 -- form for creating a new section
593 if self.ispubprofile then
594 self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
597 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
600 ]] .. self.locale.CREATE_NEW_SECTION_UNDER .. " " .. self.sectionpath .. [[
605 ]] .. self.locale.PATHNAME .. [[
608 <input size="30" maxlength="30" name="editname" />
613 ]] .. self.locale.MENULABEL .. [[
616 <input size="30" maxlength="50" name="editlabel" />
621 ]] .. self.locale.WINDOWTITLE .. [[
624 <input size="30" maxlength="50" name="edittitle" />
629 ]] .. self.locale.INVISIBLE .. [[
632 <input type="checkbox" name="editvisibility" />
637 ]] .. self.locale.SECRET .. [[
640 <input type="checkbox" name="editsecrecy" />
645 ]] .. self.locale.SECURE_CONNECTION .. [[
648 <input type="checkbox" name="editsecure" />
653 ]] .. self.locale.REDIRECT .. [[
656 <input size="30" maxlength="50" name="editredirect" />
660 <input type="submit" name="actioncreate" value="]] .. self.locale.CREATE .. [[" />
661 ]] .. hiddenvars .. [[
667 elseif self.args.actioneditprops and editkey == "main" then
669 if self.ispubprofile then
670 self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
673 <form action="]] ..self.document .. [[" method="post" accept-charset="utf-8">
676 ]] .. self.locale.MODIFY_PROPERTIES_OF_SECTION .. " " .. self.sectionpath .. [[
681 ]] .. self.locale.MENULABEL .. [[
684 <input size="30" maxlength="50" name="editlabel" value="]] .. (self.section.label or "") .. [[" />
689 ]] .. self.locale.WINDOWTITLE .. [[
692 <input size="30" maxlength="50" name="edittitle" value="]] .. (self.section.title or "") .. [[" />
697 ]] .. self.locale.INVISIBLE .. [[
700 <input type="checkbox" name="editvisibility" ]] .. (self.section.hidden and 'checked="checked"' or "") .. [[/>
705 ]] .. self.locale.SECRET .. [[
708 <input type="checkbox" name="editsecrecy" ]] .. (self.section.secret and 'checked="checked"' or "") .. [[/>
713 ]] .. self.locale.SECURE_CONNECTION .. [[
716 <input type="checkbox" name="editsecure" ]] .. (self.section.secure and 'checked="checked"' or "") .. [[/>
721 ]] .. self.locale.REDIRECT .. [[
724 <input size="30" maxlength="50" name="editredirect" value="]] .. (self.section.redirect or "") .. [[" />
728 <input type="submit" name="actionsaveprops" value="]] .. self.locale.SAVE .. [[" />
729 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
730 ]] .. hiddenvars .. [[
735 elseif (self.args.actioneditprofiles or
736 self.args.actioncreateprofile or
737 self.args.actionchangeprofile or
738 self.args.actionpublishprofile) and editkey == "main" then
741 for p in pairs(self:getprofiles()) do
742 table.insert(profiles, p)
746 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
749 ]] .. self.locale.CHANGEPROFILE .. [[
751 <select name="changeprofile" size="1">]])
752 for _, val in ipairs(profiles) do
753 self:out('<option' .. (val == self.profile and " selected" or "") .. '>')
755 self:out('</option>')
759 <input type="submit" name="actionchangeprofile" value="]] .. self.locale.CHANGE ..[[" />
760 ]] .. hiddenvars .. [[
763 <form action="]] .. self.document ..[[" method="post" accept-charset="utf-8">
766 ]] .. self.locale.CREATEPROFILE .. [[
768 <input size="20" maxlength="20" name="createprofile" />
769 <input type="submit" name="actioncreateprofile" value="]] .. self.locale.CREATE .. [[" />
770 ]] .. hiddenvars .. [[
774 if not self.ispubprofile then
776 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
779 ]] .. self.locale.PUBLISHPROFILE .. [[
781 ]] .. self:hidden("publishprofile", self.profile) .. [[
782 <input type="submit" name="actionpublishprofile" value="]] .. self.locale.PUBLISH .. [[" />
783 ]] .. hiddenvars .. [[
786 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
789 ]] .. self.locale.DELETEPROFILE .. [[
791 ]] .. self:hidden("deleteprofile", self.profile) .. [[
792 <input type="submit" name="actiondeleteprofile" value="]] .. self.locale.DELETE .. [[" />
793 ]] .. hiddenvars .. [[
799 elseif self.args.actionedit and editkey == self.args.editkey then
800 if not self.section.redirect then
801 extramsg = self.ispubprofile and
802 self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE
803 edit = self:loadcontent(fname):gsub("\194\160", " ") -- TODO
804 changed = self.section and (self.section.revisiondate or self.section.creationdate)
807 elseif self.args.actionpreview and editkey == self.args.editkey then
808 edit = self.args.editform
809 show = self:domarkup(edit:gsub(" ", "\194\160")) -- TODO
811 elseif self.args.actionsave and editkey == self.args.editkey then
812 local c = self.args.editform
816 self:expire(contentdir, "[^.]%S+.LOCK")
817 if self:lockfile(lockfname) then
818 -- lock was expired, aquired a new one
819 extramsg = self.locale.SECTION_COULD_HAVE_CHANGED
822 local tab = tek.source(lockfname .. ".LOCK")
823 if tab and tab.id == self.session.id then
824 -- lock already held and is mine - try to save:
825 local savec = c:gsub(" ", "\194\160") -- TODO
826 remove(contentdir .. "/" .. savename .. ".html")
827 self:savebody(savename, savec)
828 -- TODO: error handling
829 self:unlockfile(lockfname)
830 show, dynamic = self:domarkup(savec)
833 -- lock was expired and someone else has it now
834 extramsg = self.locale.SECTION_IN_USE
840 local savec = c:gsub(" ", "\194\160") -- TODO
841 self:savebody(savename, savec)
842 -- TODO: error handling
843 show, dynamic = self:domarkup(savec)
846 -- mark dynamic text bodies
847 if not self.section.dynamic then
848 self.section.dynamic = { }
850 self.section.dynamic[editkey] = dynamic
852 for _ in pairs(self.section.dynamic) do
856 self.section.dynamic = nil
861 elseif self.args.actioncancel and editkey == self.args.editkey then
863 self:unlockfile(lockfname) -- remove lock
867 if editkey == "main" and self.section and self.section.redirect then
868 self:out('<h2>' .. self.locale.SECTION_IS_REDIRECT ..'</h2>')
869 self:out(self:link(self.section.redirect))
874 self:expire(contentdir, "[^.]%S+.LOCK")
875 if fname and not self:lockfile(contentdir .. "/" .. fname) then
876 local tab = tek.source(contentdir .. "/" .. fname .. ".LOCK")
877 if tab and tab.id ~= self.session.id then
878 extramsg = self.locale.SECTION_IN_USE
880 -- else already owner
883 self:out('<h2><span class="warn">' .. extramsg .. '</span></h2>')
886 <form action="]] .. self.document .. [[#preview" method="post" accept-charset="utf-8">
889 ]] .. self.locale.EDIT_SECTION .. [[
891 <textarea cols="80" rows="25" name="editform">]] .. self:encodeform(edit) .. [[</textarea>
893 <input type="submit" name="actionsave" value="]] .. self.locale.SAVE .. [[" />
894 <input type="submit" name="actionpreview" value="]] .. self.locale.PREVIEW .. [[" />
895 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
896 ]] .. hiddenvars .. [[
904 self:dosnippet(function()
906 show = self:loadmarkup(fname)
907 changed = self.section and (self.section.revisiondate or self.section.creationdate)
909 local parsed, msg = self:loadhtml(show, "loona:out", "<parsed html>")
910 assert(parsed, msg and "Syntax error : " .. msg)
911 self:runboxed(parsed)
915 if self.authuser then
918 <div class="edit">]])
919 if editkey == "main" then
921 <a name="preview"></a>
922 ]] .. self.authuser .. [[ :
923 ]] .. self:uilink(self.sectionpath, "[" .. self.locale.PROFILE .. "]", "actioneditprofiles=true", "editkey=" .. editkey) .. [[ :
924 ]] .. self.profile .. "_" .. self.lang)
925 if self.ispubprofile then
927 <span class="warn">[]] .. self.locale.PUBLIC .. [[]</span>]])
929 self:out(' : ' .. self.sectionpath .. ' ')
931 if self.section and not self.ispubprofile then
932 self:out(self:uilink(self.sectionpath, "[" .. self.locale.EDIT .. "]", "actionedit=true", "editkey=" .. editkey) .. " ")
933 if editkey == "main" then
934 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.PROPERTIES .. "]", "actioneditprops=true", "editkey=" .. editkey) .. " ")
936 if fname == savename or not self.section.subs then
937 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.DELETE .. "]", "actiondelete=true", "editkey=" .. editkey) .. " ")
939 if editkey == "main" then
940 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEUP .. "]", "actionup=true", "editkey=" .. editkey) .. " ")
941 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEDOWN .. "]", "actiondown=true", "editkey=" .. editkey) .. " ")
943 if changed and editkey == "main" then
944 self:out('- ' .. self.locale.CHANGED .. ': ' .. date("%d-%b-%Y %T", changed))
953 -- Get pathname of an existing content file that
954 -- the current path is determined by (or defaults to)
956 function loona:getsectionpath(bodyname, requestpath)
957 local ext = (not bodyname or bodyname == "main") and "" or "." .. bodyname
958 local t, path, section = { }
959 for _, menu in ipairs(self.submenus) do
960 if menu.entries and menu.entries[menu.name] then
961 table.insert(t, menu.name)
962 local fn = table.concat(t, "_")
963 if posix.stat(self.contentdir .. "/" .. fn .. ext,
964 "mode") == "file" then
965 path, section = fn, menu
969 return path, ext, section
973 function loona:body(name)
974 name = self:checkbodyname(name)
975 local path, ext = self:getsectionpath(name)
976 -- self:dosnippet(self:editable(name, path and path .. ext, self.sectionname .. ext))
977 self:dosnippet(function()
978 self:editable(name, path and path .. ext, self.sectionname .. ext)
983 function loona:init()
985 -- get list of languages, in order of preference
986 -- TODO: respect quality parameter, not just order
988 local l = self.requestlang or self.args.lang
989 self.langs = { l and l:match("^%w+$") }
990 if self.config.browserlang then
991 local s = getenv("HTTP_ACCEPT_LANGUAGE")
993 local l, r = s:match("^([%w.=]+)[,;](.*)$")
996 if l:match("^%w+$") then
997 table.insert(self.langs, l)
1001 table.insert(self.langs, self.config.deflang)
1003 -- get list of possible profiles
1005 local profiles = { }
1006 for e in tek.util.readdir(self.config.contentdir) do
1012 for _, lang in ipairs(self.langs) do
1013 local p = posix.readlink(self.config.contentdir .. "/current_" .. lang)
1014 p = p and p:match("^(%w+)_" .. lang .. "$")
1023 local checkprofile =
1024 self.authuser and self.args.profile or self.pubprofile or "work"
1025 for _, lang in ipairs(self.langs) do
1026 if profiles[checkprofile .. "_" .. lang] then
1027 self.profile = checkprofile
1033 assert(self.profile and self.lang, "Invalid profile or language")
1036 self.ispubprofile = self.profile == self.pubprofile
1038 -- write back language and profile
1040 self.args.lang = self.lang ~= self.config.deflang and self.lang or nil
1041 self.args.profile = self.profile
1044 -- determine content directory pathname and section filename
1047 ("%s/%s_%s"):format(self.config.contentdir, self.profile, self.lang)
1048 self.indexfname = self.contentdir .. "/.sections"
1052 self.sections = tek.source(self.indexfname)
1054 -- index sections, determine visibility in menu
1056 self:indexsections()
1058 -- decompose request path, produce a stack of sections
1060 self.submenus, self.section = self:getsection(self.requestpath)
1062 -- handle redirects if not logged on
1064 if not self.authuser and self.section and self.section.redirect then
1065 self.submenus, self.section = self:getsection(self.section.redirect)
1068 -- section path and document name (refined)
1070 self.sectionpath = self:getpath()
1071 self.sectionname = self:getpath("_")
1076 function loona:handlechanges()
1080 if self.args.editkey == "main" then
1082 -- In main editable section:
1084 if self.args.actioncreate then
1086 -- Create new section
1088 local editname = self.args.editname:lower()
1089 assert(not editname:match("%W"),
1090 dbmsg("Invalid section name", editname))
1091 if not (section and (section.subs or section)[editname]) then
1092 local newpath = (self.sectionpath and
1093 (self.sectionpath .. "/")) .. editname
1094 local s = self:addpath(newpath, { name = editname,
1095 label = self.args.editlabel ~= "" and
1096 self.args.editlabel or nil,
1097 title = self.args.edittitle ~= "" and
1098 self.args.edittitle or nil,
1099 redirect = self.args.editredirect ~= "" and
1100 self.args.editredirect or nil,
1101 hidden = self.args.editvisibility and true,
1102 secret = self.args.editsecrecy and true,
1103 secure = self.args.editsecure and true,
1104 creator = self.authuser,
1105 creationdate = time() })
1109 elseif self.args.actionsave then
1113 self.section.revisiondate = time()
1114 self.section.revisioner = self.authuser
1117 elseif self.args.actionsaveprops then
1121 self.section.hidden = self.args.editvisibility and true
1122 self.section.secret = self.args.editsecrecy and true
1123 self.section.secure = self.args.editsecure and true
1124 self.section.label = self.args.editlabel ~= "" and
1125 self.args.editlabel or nil
1126 self.section.title = self.args.edittitle ~= "" and
1127 self.args.edittitle or nil
1128 self.section.redirect =
1129 self.args.editredirect ~= "" and self.args.editredirect or nil
1132 elseif self.args.actionup then
1136 local t, i = self:checkpath(self.sectionpath)
1138 if self.ispubprofile and not self.args.actionconfirm then
1140 text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
1142 '<input type="submit" name="actionup" value="' ..
1143 self.locale.MOVE .. '" /> ' ..
1144 self:hidden("actionconfirm", "true")
1147 local item = table.remove(t, i)
1148 table.insert(t, i - 1, item)
1153 elseif self.args.actiondown then
1155 -- Move section down
1157 local t, i = self:checkpath(self.sectionpath)
1158 if t and i < #t then
1159 if self.ispubprofile and not self.args.actionconfirm then
1161 text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
1163 '<input type="submit" name="actiondown" value="' ..
1164 self.locale.MOVE .. '" /> ' ..
1165 self:hidden("actionconfirm", "true")
1168 local item = table.remove(t, i)
1169 table.insert(t, i + 1, item)
1174 elseif self.args.actioncreateprofile and self.args.createprofile then
1178 local c = self:checkprofilename(self.args.createprofile:lower())
1179 if c == profile then
1181 text = self.locale.ALERT_CANNOT_COPY_PROFILE_TO_SELF
1184 local profiles = self:getprofiles()
1185 if profiles[c] and not self.args.actionconfirm then
1187 text = c == self.pubprofile and
1188 self.locale.ALERT_OVERWRITE_PUBLISHED_PROFILE or
1189 self.locale.ALERT_OVERWRITE_EXISTING_PROFILE,
1190 confirm = '<input type="submit" ' ..
1191 'name="actioncreateprofile" value="' ..
1192 self.locale.OVERWRITE .. '" /> ' ..
1193 self:hidden("actionconfirm", "true") ..
1194 self:hidden("createprofile", c)
1198 self:deleteprofile(c)
1204 elseif self.args.actiondeleteprofile and self.args.deleteprofile then
1208 local c = self:checkprofilename(self.args.deleteprofile:lower())
1209 assert(c ~= self.pubprofile,
1210 self:dbmsg("Cannot delete published profile", c))
1211 if self.args.actionconfirm then
1212 self:deleteprofile(c)
1214 self.args.profile = nil
1219 text = self.locale.ALERT_DELETE_PROFILE,
1220 confirm = '<input type="submit" ' ..
1221 'name="actiondeleteprofile" value="' ..
1222 self.locale.DELETE .. '" /> ' ..
1223 self:hidden("actionconfirm", "true") ..
1224 self:hidden("deleteprofile", c)
1228 elseif self.args.actionchangeprofile and self.args.changeprofile then
1232 local c = self:checkprofilename(self.args.changeprofile:lower())
1234 self.args.profile = c
1237 elseif self.args.actionpublishprofile and self.args.publishprofile then
1241 local c = self:checkprofilename(self.args.publishprofile:lower())
1242 if c ~= self.publicprofile then
1243 if self.args.actionconfirm then
1244 self:publishprofile(c)
1248 text = self.locale.ALERT_PUBLISH_PROFILE,
1249 confirm = '<input type="submit" ' ..
1250 'name="actionpublishprofile" value="' ..
1251 self.locale.PUBLISH .. '" /> ' ..
1252 self:hidden("actionconfirm", "true") ..
1253 self:hidden("publishprofile", c)
1261 if self.args.actiondelete then
1265 if not self.args.actionconfirm then
1267 text = self.ispubprofile and
1268 self.locale.ALERT_DELETE_IN_PUBLISHED_PROFILE or
1269 self.locale.ALERT_DELETE_SECTION,
1271 '<input type="submit" name="actiondelete" value="' ..
1272 self.locale.DELETE .. '" /> ' ..
1273 self:hidden("actionconfirm", "true")
1276 local key = self.args.editkey
1277 if key == "main" and not self.section.subs then
1278 self:deletesection(self.sectionname, true) -- all bodies
1279 self:rmpath(self.sectionpath) -- and node
1281 local ext = (key == "main" and "") or "." .. key
1282 self:deletesection(self.sectionname .. ext) -- only text
1283 if self.section.dynamic then
1284 self.section.dynamic[key] = nil
1286 for _ in pairs(self.section.dynamic) do
1290 self.section.dynamic = nil
1306 function loona:encodeform(s)
1307 return cgi.encodeform(s)
1311 function loona:loadhtml(src, outfunc, chunkname)
1312 return tek.web.include.load(src, outfunc, chunkname)
1316 function loona:domarkup(s)
1317 return tek.web.markup.main(s)
1321 function loona:expire(dir, pat, maxage)
1322 return tek.util.expire(dir, pat, maxage)
1326 function loona:new(o)
1331 o = atom.new(self, o)
1333 o.out = o.out or function(self, s) tek.web.out(s) end
1334 o.setheader = o.setheader or function(self, s) tek.web.setheader(s) end
1336 -- Get configuration
1338 o.config = o.config or tek.source(o.conffile or "../etc/config.lua") or { }
1339 o.config.defname = o.config.defname or "home"
1340 o.config.deflang = o.config.deflang or "en"
1341 o.config.sessionmaxage = o.config.sessionmaxage or 600
1342 o.config.secureport = o.config.secureport or 443
1343 o.config.passwdfile =
1344 posix.abspath(o.config.passwdfile or "../etc/passwd.lua")
1345 o.config.sessiondir =
1346 posix.abspath(o.config.sessiondir or "../var/sessions")
1347 o.config.extdir = posix.abspath(o.config.extdir or "../extensions")
1348 o.config.contentdir = posix.abspath(o.config.contentdir or "../content")
1349 o.config.localedir = posix.abspath(o.config.localedir or "../locale")
1350 o.config.htdocsdir = posix.abspath(o.config.htdocsdir or "../htdocs")
1351 o.config.htmlcachedir =
1352 posix.abspath(o.config.htmlcachedir or "../var/htmlcache")
1354 -- Create proxy for on-demand loading of locales
1358 locmt.__index = function(_, key)
1359 for _, l in ipairs(o.langs) do
1360 locmt.__locale = tek.source(o.config.localedir .. "/" .. l)
1361 if locmt.__locale then
1365 locmt.__index = function(tab, key)
1366 return locmt.__locale[key] or key
1368 return locmt.__locale[key] or key
1370 setmetatable(o.locale, locmt)
1372 -- Get request, args, document, script name, request path
1374 o.request = o.request or cgi.request
1375 o.args = o.args or cgi.request.args
1376 o.session = o.session or cgi.session
1378 o.scriptpath = o.scriptpath or cgi.document.Path
1379 o.requesthandler = o.requesthandler or cgi.document.Handler
1380 o.requestdocument = o.requestdocument or cgi.document.Name
1381 o.requestpath = o.requestpath or cgi.document.VirtualPath
1383 -- Manage login and establish session
1385 o.session.init(o.config.sessiondir, o.args.session, o.config.sessionmaxage)
1386 if o.args.login then
1387 if o.args.login == "false" then
1390 elseif o.args.password then
1391 o.loginfailed = true
1392 local pwddb = tek.source(o.config.passwdfile)
1393 local pwdentry = pwddb[o.args.login]
1394 if pwdentry and pwdentry.password == o.args.password then
1395 o.session.data.authuser = pwdentry.username
1396 o.session.data.id = o.session.id
1402 o.secure = not o.insecure and (o.request.Port == o.config.secureport)
1403 o.authuser = o.session and o.session.data.authuser
1405 if o.nologin or not o.authuser then
1410 -- Get lang, locale, profile, section
1419 o.document = o.requestdocument .. "/" .. o.sectionpath
1421 o.getdocname = function(self, path)
1422 return path and self.requestdocument .. "/" .. path or
1423 self.requestdocument
1426 o.getdocname = function(self, path)
1428 path = path or self.config.defname
1429 dyn, path = self:isdynamic(path)
1431 return self.requestdocument .. "/" .. path
1433 path = path == self.config.defname and "index" or path
1434 return "/" .. path:gsub("/", "_") .. ".html"
1438 -- Save session state
1448 function loona:execute(fname)
1450 fname = fname or self.requesthandler
1451 local parsed, msg = self:loadhtml(open(fname), "loona:out", fname)
1452 assert(parsed, self:dbmsg("HTML/Lua parsing failed", msg))
1453 self:runboxed(parsed)
1458 function loona:indexdynamic()
1459 self:recursesections(self.sections, function(self, s, e, path, dynamic)
1460 path = path and path .. "_" .. e.name or e.name
1461 dynamic = dynamic or { }
1462 for k in pairs(e.dynamic or { }) do
1465 for k in pairs(dynamic) do
1466 local ext = (k == "main" and "") or "." .. k
1467 if posix.stat(self.contentdir .. "/" .. path .. ext,
1468 "mode") == "file" then
1469 dynamic[k] = e.dynamic and e.dynamic[k]
1473 for k in pairs(dynamic) do
1478 for k in pairs(dynamic) do
1484 return path, dynamic
1489 function loona:isdynamic(path)
1490 path = path or self.sectionpath
1491 local t, i = self:checkpath(path)
1492 if t and t[i].redirect then
1493 path = t[i].redirect
1494 t, i = self:isdynamic(path) -- TODO: prohibit endless recursion
1496 return t and t[i].dynamic, path
1500 function loona:dumphtml(o)
1504 o.out = function(self, s) table.insert(outbuf, s) end
1505 o.setheader = function(self, s) end
1506 o = self:new(o):execute()
1507 if not o:isdynamic() then
1508 local path = o.sectionname
1509 path = path == o.config.defname and "index" or path
1510 local srcname = o.config.htdocsdir .. "/" .. path .. o.htmlext
1511 local fh, msg = open(srcname .. ".tmp", "wb")
1512 assert(fh, self:dbmsg("Could not write cached HTML", msg))
1513 fh:write(unpack(outbuf))
1515 local dstname = o.config.htmlcachedir .. "/" .. path .. o.htmlext
1516 local success, msg = posix.symlink(srcname, dstname .. ".tmp")
1517 -- assert(success, self:dbmsg("Could not link to cached HTML", msg))