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, nil, unpack(arg))
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,
446 -- external link (opens in a new window), no argument propagation
448 function loona:elink(target, text)
449 return self:ilink(target, text or target, self.config.extlinkextra)
453 -- plain link, no argument propagation
455 function loona:plink(section, text, ...)
456 return self:ilink(tek.web.gethref(section, arg), text or section)
460 -- user interface link, propagation of lang, profile, session
462 function loona:uilink(section, text, ...)
463 return self:ilink(self:href(section, unpack(arg)), text or section)
467 -- produce a hidden input value in forms
469 function loona:hidden(name, value)
470 return not value and "" or
471 ('<input type="hidden" name="%s" value="%s" />'):format(name, value)
475 function loona:getprofiles(lang)
476 lang = lang or self.lang
477 local dir = self.config.contentdir
479 for f in tek.util.readdir(dir) do
480 if posix.lstat(dir .. "/" .. f, "mode") == "directory" then
481 local e = f:match("^(%w+)_" .. lang .. "$")
491 -- Functions to produce a hierarchical navigation menu
493 local newent = { name = "new", label = "[+]", action="actionnew=true" }
495 function loona:rmenu(level, linkf, path, addnew)
496 local sub = (addnew and level == #self.submenus + 1) and
497 { name = "new", entries = { }} or self.submenus[level]
498 if sub and sub.entries then
500 for _, e in ipairs(sub.entries) do
501 if not e.notvisible then
502 table.insert(visible, e)
506 table.insert(visible, newent)
509 self:out('<ul id="menulevel' .. level .. '">\n')
510 for _, e in ipairs(visible) do
511 local label = self:encodeform(e.label or e.name)
512 local newpath = path and path .. "/" .. e.name or e.name
513 local active = (e.name == sub.name)
515 linkf(self, newpath, label, active, e.action)
517 self:rmenu(level + 1, linkf, newpath, addnew)
527 function loona:menulink(path, label, active, ...)
528 self:out(('<a %shref="%s">%s</a>\n'):format(active and
529 'class="active" ' or "", self:href(path, unpack(arg)), label))
533 function loona:menu(level, linkf)
534 local addnew = self.authuser and not self.ispubprofile
535 self:rmenu(level or 1, linkf or menulink, nil, addnew)
539 function loona:loadcontent(fname)
541 local f = open(self.contentdir .. "/" .. fname)
542 local c = f:read("*a")
550 function loona:loadmarkup(fname)
551 return (fname and fname ~= "") and
552 self:domarkup(self:loadcontent(fname)) or ""
556 function loona:editable(editkey, fname, savename)
558 local contentdir = self.contentdir
559 local edit, show, hidden, extramsg, changed
561 if self.authuser then
563 local hiddenvars = table.concat( {
564 self:hidden("lang", self.args.lang),
565 self:hidden("profile", self.profile),
566 self:hidden("session", self.session.id),
567 self:hidden("editkey", editkey) }, " ")
569 local lockfname = fname and (contentdir .. "/" .. fname)
571 if self.useralert and editkey == self.args.editkey then
573 -- display user alert/request/confirmation
577 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
579 <legend>]] .. self.useralert.text ..[[</legend>
580 ]] .. (self.useralert.confirm or "") .. [[
581 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL ..[[" />
582 ]] .. hiddenvars .. [[
587 elseif self.args.actionnew and editkey == "main" then
589 -- form for creating a new section
592 if self.ispubprofile then
593 self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
596 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
599 ]] .. self.locale.CREATE_NEW_SECTION_UNDER .. " " .. self.sectionpath .. [[
604 ]] .. self.locale.PATHNAME .. [[
607 <input size="30" maxlength="30" name="editname" />
612 ]] .. self.locale.MENULABEL .. [[
615 <input size="30" maxlength="50" name="editlabel" />
620 ]] .. self.locale.WINDOWTITLE .. [[
623 <input size="30" maxlength="50" name="edittitle" />
628 ]] .. self.locale.INVISIBLE .. [[
631 <input type="checkbox" name="editvisibility" />
636 ]] .. self.locale.SECRET .. [[
639 <input type="checkbox" name="editsecrecy" />
644 ]] .. self.locale.SECURE_CONNECTION .. [[
647 <input type="checkbox" name="editsecure" />
652 ]] .. self.locale.REDIRECT .. [[
655 <input size="30" maxlength="50" name="editredirect" />
659 <input type="submit" name="actioncreate" value="]] .. self.locale.CREATE .. [[" />
660 ]] .. hiddenvars .. [[
666 elseif self.args.actioneditprops and editkey == "main" then
668 if self.ispubprofile then
669 self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
672 <form action="]] ..self.document .. [[" method="post" accept-charset="utf-8">
675 ]] .. self.locale.MODIFY_PROPERTIES_OF_SECTION .. " " .. self.sectionpath .. [[
680 ]] .. self.locale.MENULABEL .. [[
683 <input size="30" maxlength="50" name="editlabel" value="]] .. (self.section.label or "") .. [[" />
688 ]] .. self.locale.WINDOWTITLE .. [[
691 <input size="30" maxlength="50" name="edittitle" value="]] .. (self.section.title or "") .. [[" />
696 ]] .. self.locale.INVISIBLE .. [[
699 <input type="checkbox" name="editvisibility" ]] .. (self.section.hidden and 'checked="checked"' or "") .. [[/>
704 ]] .. self.locale.SECRET .. [[
707 <input type="checkbox" name="editsecrecy" ]] .. (self.section.secret and 'checked="checked"' or "") .. [[/>
712 ]] .. self.locale.SECURE_CONNECTION .. [[
715 <input type="checkbox" name="editsecure" ]] .. (self.section.secure and 'checked="checked"' or "") .. [[/>
720 ]] .. self.locale.REDIRECT .. [[
723 <input size="30" maxlength="50" name="editredirect" value="]] .. (self.section.redirect or "") .. [[" />
727 <input type="submit" name="actionsaveprops" value="]] .. self.locale.SAVE .. [[" />
728 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
729 ]] .. hiddenvars .. [[
734 elseif (self.args.actioneditprofiles or
735 self.args.actioncreateprofile or
736 self.args.actionchangeprofile or
737 self.args.actionpublishprofile) and editkey == "main" then
740 for p in pairs(self:getprofiles()) do
741 table.insert(profiles, p)
745 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
748 ]] .. self.locale.CHANGEPROFILE .. [[
750 <select name="changeprofile" size="1">]])
751 for _, val in ipairs(profiles) do
752 self:out('<option' .. (val == self.profile and ' selected="selected"' or '') .. '>')
754 self:out('</option>')
758 <input type="submit" name="actionchangeprofile" value="]] .. self.locale.CHANGE ..[[" />
759 ]] .. hiddenvars .. [[
762 <form action="]] .. self.document ..[[" method="post" accept-charset="utf-8">
765 ]] .. self.locale.CREATEPROFILE .. [[
767 <input size="20" maxlength="20" name="createprofile" />
768 <input type="submit" name="actioncreateprofile" value="]] .. self.locale.CREATE .. [[" />
769 ]] .. hiddenvars .. [[
773 if not self.ispubprofile then
775 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
778 ]] .. self.locale.PUBLISHPROFILE .. [[
780 ]] .. self:hidden("publishprofile", self.profile) .. [[
781 <input type="submit" name="actionpublishprofile" value="]] .. self.locale.PUBLISH .. [[" />
782 ]] .. hiddenvars .. [[
785 <form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
788 ]] .. self.locale.DELETEPROFILE .. [[
790 ]] .. self:hidden("deleteprofile", self.profile) .. [[
791 <input type="submit" name="actiondeleteprofile" value="]] .. self.locale.DELETE .. [[" />
792 ]] .. hiddenvars .. [[
798 elseif self.args.actionedit and editkey == self.args.editkey then
799 if not self.section.redirect then
800 extramsg = self.ispubprofile and
801 self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE
802 edit = self:loadcontent(fname):gsub("\194\160", " ") -- TODO
803 changed = self.section and (self.section.revisiondate or self.section.creationdate)
806 elseif self.args.actionpreview and editkey == self.args.editkey then
807 edit = self.args.editform
808 show = self:domarkup(edit:gsub(" ", "\194\160")) -- TODO
810 elseif self.args.actionsave and editkey == self.args.editkey then
811 local c = self.args.editform
815 self:expire(contentdir, "[^.]%S+.LOCK")
816 if self:lockfile(lockfname) then
817 -- lock was expired, aquired a new one
818 extramsg = self.locale.SECTION_COULD_HAVE_CHANGED
821 local tab = tek.source(lockfname .. ".LOCK")
822 if tab and tab.id == self.session.id then
823 -- lock already held and is mine - try to save:
824 local savec = c:gsub(" ", "\194\160") -- TODO
825 remove(contentdir .. "/" .. savename .. ".html")
826 self:savebody(savename, savec)
827 -- TODO: error handling
828 self:unlockfile(lockfname)
829 show, dynamic = self:domarkup(savec)
832 -- lock was expired and someone else has it now
833 extramsg = self.locale.SECTION_IN_USE
839 local savec = c:gsub(" ", "\194\160") -- TODO
840 self:savebody(savename, savec)
841 -- TODO: error handling
842 show, dynamic = self:domarkup(savec)
845 -- mark dynamic text bodies
846 if not self.section.dynamic then
847 self.section.dynamic = { }
849 self.section.dynamic[editkey] = dynamic
851 for _ in pairs(self.section.dynamic) do
855 self.section.dynamic = nil
860 elseif self.args.actioncancel and editkey == self.args.editkey then
862 self:unlockfile(lockfname) -- remove lock
866 if editkey == "main" and self.section and self.section.redirect then
867 self:out('<h2>' .. self.locale.SECTION_IS_REDIRECT ..'</h2>')
868 self:out(self:link(self.section.redirect))
873 self:expire(contentdir, "[^.]%S+.LOCK")
874 if fname and not self:lockfile(contentdir .. "/" .. fname) then
875 local tab = tek.source(contentdir .. "/" .. fname .. ".LOCK")
876 if tab and tab.id ~= self.session.id then
877 extramsg = self.locale.SECTION_IN_USE
879 -- else already owner
882 self:out('<h2><span class="warn">' .. extramsg .. '</span></h2>')
885 <form action="]] .. self.document .. [[#preview" method="post" accept-charset="utf-8">
888 ]] .. self.locale.EDIT_SECTION .. [[
890 <textarea cols="80" rows="25" name="editform">]] .. self:encodeform(edit) .. [[</textarea>
892 <input type="submit" name="actionsave" value="]] .. self.locale.SAVE .. [[" />
893 <input type="submit" name="actionpreview" value="]] .. self.locale.PREVIEW .. [[" />
894 <input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
895 ]] .. hiddenvars .. [[
903 self:dosnippet(function()
905 show = self:loadmarkup(fname)
906 changed = self.section and (self.section.revisiondate or self.section.creationdate)
908 local parsed, msg = self:loadhtml(show, "loona:out", "<parsed html>")
909 assert(parsed, msg and "Syntax error : " .. msg)
910 self:runboxed(parsed)
914 if self.authuser then
917 <div class="edit">]])
918 if editkey == "main" then
920 <a name="preview"></a>
921 ]] .. self.authuser .. [[ :
922 ]] .. self:uilink(self.sectionpath, "[" .. self.locale.PROFILE .. "]", "actioneditprofiles=true", "editkey=" .. editkey) .. [[ :
923 ]] .. self.profile .. "_" .. self.lang)
924 if self.ispubprofile then
926 <span class="warn">[]] .. self.locale.PUBLIC .. [[]</span>]])
928 self:out(' : ' .. self.sectionpath .. ' ')
930 if self.section and not self.ispubprofile then
931 self:out(self:uilink(self.sectionpath, "[" .. self.locale.EDIT .. "]", "actionedit=true", "editkey=" .. editkey) .. " ")
932 if editkey == "main" then
933 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.PROPERTIES .. "]", "actioneditprops=true", "editkey=" .. editkey) .. " ")
935 if fname == savename or not self.section.subs then
936 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.DELETE .. "]", "actiondelete=true", "editkey=" .. editkey) .. " ")
938 if editkey == "main" then
939 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEUP .. "]", "actionup=true", "editkey=" .. editkey) .. " ")
940 self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEDOWN .. "]", "actiondown=true", "editkey=" .. editkey) .. " ")
942 if changed and editkey == "main" then
943 self:out('- ' .. self.locale.CHANGED .. ': ' .. date("%d-%b-%Y %T", changed))
952 -- Get pathname of an existing content file that
953 -- the current path is determined by (or defaults to)
955 function loona:getsectionpath(bodyname, requestpath)
956 local ext = (not bodyname or bodyname == "main") and "" or "." .. bodyname
957 local t, path, section = { }
958 for _, menu in ipairs(self.submenus) do
959 if menu.entries and menu.entries[menu.name] then
960 table.insert(t, menu.name)
961 local fn = table.concat(t, "_")
962 if posix.stat(self.contentdir .. "/" .. fn .. ext,
963 "mode") == "file" then
964 path, section = fn, menu
968 return path, ext, section
972 function loona:body(name)
973 name = self:checkbodyname(name)
974 local path, ext = self:getsectionpath(name)
975 self:dosnippet(function()
976 self:editable(name, path and path .. ext, self.sectionname .. ext)
981 function loona:init()
983 -- get list of languages, in order of preference
984 -- TODO: respect quality parameter, not just order
986 local l = self.requestlang or self.args.lang
987 self.langs = { l and l:match("^%w+$") }
988 if self.config.browserlang then
989 local s = getenv("HTTP_ACCEPT_LANGUAGE")
991 local l, r = s:match("^([%w.=]+)[,;](.*)$")
994 if l:match("^%w+$") then
995 table.insert(self.langs, l)
999 table.insert(self.langs, self.config.deflang)
1001 -- get list of possible profiles
1003 local profiles = { }
1004 for e in tek.util.readdir(self.config.contentdir) do
1010 for _, lang in ipairs(self.langs) do
1011 local p = posix.readlink(self.config.contentdir .. "/current_" .. lang)
1012 p = p and p:match("^(%w+)_" .. lang .. "$")
1021 local checkprofile =
1022 self.authuser and self.args.profile or self.pubprofile or "work"
1023 for _, lang in ipairs(self.langs) do
1024 if profiles[checkprofile .. "_" .. lang] then
1025 self.profile = checkprofile
1031 assert(self.profile and self.lang, "Invalid profile or language")
1034 self.ispubprofile = self.profile == self.pubprofile
1036 -- write back language and profile
1038 self.args.lang = self.lang ~= self.config.deflang and self.lang or nil
1039 self.args.profile = self.profile
1042 -- determine content directory pathname and section filename
1045 ("%s/%s_%s"):format(self.config.contentdir, self.profile, self.lang)
1046 self.indexfname = self.contentdir .. "/.sections"
1050 self.sections = tek.source(self.indexfname)
1052 -- index sections, determine visibility in menu
1054 self:indexsections()
1056 -- decompose request path, produce a stack of sections
1058 self.submenus, self.section = self:getsection(self.requestpath)
1060 -- handle redirects if not logged on
1062 if not self.authuser and self.section and self.section.redirect then
1063 self.submenus, self.section = self:getsection(self.section.redirect)
1066 -- section path and document name (refined)
1068 self.sectionpath = self:getpath()
1069 self.sectionname = self:getpath("_")
1074 function loona:handlechanges()
1078 if self.args.editkey == "main" then
1080 -- In main editable section:
1082 if self.args.actioncreate then
1084 -- Create new section
1086 local editname = self.args.editname:lower()
1087 assert(not editname:match("%W"),
1088 self:dbmsg("Invalid section name", editname))
1089 if not (section and (section.subs or section)[editname]) then
1090 local newpath = (self.sectionpath and
1091 (self.sectionpath .. "/")) .. editname
1092 local s = self:addpath(newpath, { name = editname,
1093 label = self.args.editlabel ~= "" and
1094 self.args.editlabel or nil,
1095 title = self.args.edittitle ~= "" and
1096 self.args.edittitle or nil,
1097 redirect = self.args.editredirect ~= "" and
1098 self.args.editredirect or nil,
1099 hidden = self.args.editvisibility and true,
1100 secret = self.args.editsecrecy and true,
1101 secure = self.args.editsecure and true,
1102 creator = self.authuser,
1103 creationdate = time() })
1107 elseif self.args.actionsave then
1111 self.section.revisiondate = time()
1112 self.section.revisioner = self.authuser
1115 elseif self.args.actionsaveprops then
1119 self.section.hidden = self.args.editvisibility and true
1120 self.section.secret = self.args.editsecrecy and true
1121 self.section.secure = self.args.editsecure and true
1122 self.section.label = self.args.editlabel ~= "" and
1123 self.args.editlabel or nil
1124 self.section.title = self.args.edittitle ~= "" and
1125 self.args.edittitle or nil
1126 self.section.redirect =
1127 self.args.editredirect ~= "" and self.args.editredirect or nil
1130 elseif self.args.actionup then
1134 local t, i = self:checkpath(self.sectionpath)
1136 if self.ispubprofile and not self.args.actionconfirm then
1138 text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
1140 '<input type="submit" name="actionup" value="' ..
1141 self.locale.MOVE .. '" /> ' ..
1142 self:hidden("actionconfirm", "true")
1145 local item = table.remove(t, i)
1146 table.insert(t, i - 1, item)
1151 elseif self.args.actiondown then
1153 -- Move section down
1155 local t, i = self:checkpath(self.sectionpath)
1156 if t and i < #t then
1157 if self.ispubprofile and not self.args.actionconfirm then
1159 text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
1161 '<input type="submit" name="actiondown" value="' ..
1162 self.locale.MOVE .. '" /> ' ..
1163 self:hidden("actionconfirm", "true")
1166 local item = table.remove(t, i)
1167 table.insert(t, i + 1, item)
1172 elseif self.args.actioncreateprofile and self.args.createprofile then
1176 local c = self:checkprofilename(self.args.createprofile:lower())
1177 if c == profile then
1179 text = self.locale.ALERT_CANNOT_COPY_PROFILE_TO_SELF
1182 local profiles = self:getprofiles()
1183 if profiles[c] and not self.args.actionconfirm then
1185 text = c == self.pubprofile and
1186 self.locale.ALERT_OVERWRITE_PUBLISHED_PROFILE or
1187 self.locale.ALERT_OVERWRITE_EXISTING_PROFILE,
1188 confirm = '<input type="submit" ' ..
1189 'name="actioncreateprofile" value="' ..
1190 self.locale.OVERWRITE .. '" /> ' ..
1191 self:hidden("actionconfirm", "true") ..
1192 self:hidden("createprofile", c)
1196 self:deleteprofile(c)
1202 elseif self.args.actiondeleteprofile and self.args.deleteprofile then
1206 local c = self:checkprofilename(self.args.deleteprofile:lower())
1207 assert(c ~= self.pubprofile,
1208 self:dbmsg("Cannot delete published profile", c))
1209 if self.args.actionconfirm then
1210 self:deleteprofile(c)
1212 self.args.profile = nil
1217 text = self.locale.ALERT_DELETE_PROFILE,
1218 confirm = '<input type="submit" ' ..
1219 'name="actiondeleteprofile" value="' ..
1220 self.locale.DELETE .. '" /> ' ..
1221 self:hidden("actionconfirm", "true") ..
1222 self:hidden("deleteprofile", c)
1226 elseif self.args.actionchangeprofile and self.args.changeprofile then
1230 local c = self:checkprofilename(self.args.changeprofile:lower())
1232 self.args.profile = c
1235 elseif self.args.actionpublishprofile and self.args.publishprofile then
1239 local c = self:checkprofilename(self.args.publishprofile:lower())
1240 if c ~= self.publicprofile then
1241 if self.args.actionconfirm then
1242 self:publishprofile(c)
1246 text = self.locale.ALERT_PUBLISH_PROFILE,
1247 confirm = '<input type="submit" ' ..
1248 'name="actionpublishprofile" value="' ..
1249 self.locale.PUBLISH .. '" /> ' ..
1250 self:hidden("actionconfirm", "true") ..
1251 self:hidden("publishprofile", c)
1259 if self.args.actiondelete then
1263 if not self.args.actionconfirm then
1265 text = self.ispubprofile and
1266 self.locale.ALERT_DELETE_IN_PUBLISHED_PROFILE or
1267 self.locale.ALERT_DELETE_SECTION,
1269 '<input type="submit" name="actiondelete" value="' ..
1270 self.locale.DELETE .. '" /> ' ..
1271 self:hidden("actionconfirm", "true")
1274 local key = self.args.editkey
1275 if key == "main" and not self.section.subs then
1276 self:deletesection(self.sectionname, true) -- all bodies
1277 self:rmpath(self.sectionpath) -- and node
1279 local ext = (key == "main" and "") or "." .. key
1280 self:deletesection(self.sectionname .. ext) -- only text
1281 if self.section.dynamic then
1282 self.section.dynamic[key] = nil
1284 for _ in pairs(self.section.dynamic) do
1288 self.section.dynamic = nil
1304 function loona:encodeform(s)
1305 return cgi.encodeform(s)
1309 function loona:loadhtml(src, outfunc, chunkname)
1310 return tek.web.include.load(src, outfunc, chunkname)
1314 function loona:domarkup(s)
1315 return tek.web.markup.main(s)
1319 function loona:expire(dir, pat, maxage)
1320 return tek.util.expire(dir, pat, maxage)
1324 function loona:new(o)
1329 o = atom.new(self, o)
1331 o.out = o.out or function(self, s) tek.web.out(s) end
1332 o.setheader = o.setheader or function(self, s) tek.web.setheader(s) end
1334 -- Get configuration
1336 o.config = o.config or tek.source(o.conffile or "../etc/config.lua") or { }
1337 o.config.defname = o.config.defname or "home"
1338 o.config.deflang = o.config.deflang or "en"
1339 o.config.sessionmaxage = o.config.sessionmaxage or 600
1340 o.config.secureport = o.config.secureport or 443
1341 o.config.passwdfile =
1342 posix.abspath(o.config.passwdfile or "../etc/passwd.lua")
1343 o.config.sessiondir =
1344 posix.abspath(o.config.sessiondir or "../var/sessions")
1345 o.config.extdir = posix.abspath(o.config.extdir or "../extensions")
1346 o.config.contentdir = posix.abspath(o.config.contentdir or "../content")
1347 o.config.localedir = posix.abspath(o.config.localedir or "../locale")
1348 o.config.htdocsdir = posix.abspath(o.config.htdocsdir or "../htdocs")
1349 o.config.htmlcachedir =
1350 posix.abspath(o.config.htmlcachedir or "../var/htmlcache")
1351 o.config.extlinkextra = o.config.extlinksamewindow and ' class="extlink"'
1352 or ' class="extlink" onclick="void(window.open(this.href, \'\', \'\')); return false;"'
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))