cgi-bin/tek/class/loona.lua
author Timm S. Mueller <tmueller@neoscientists.org>
Fri, 23 Nov 2007 01:46:43 +0100
changeset 201 d52b05a9fe9c
parent 199 8b5fc485edf4
child 206 450cd443de9f
permissions -rw-r--r--
Added permission for lua.cgi in Makefile; FastCGI class added; improved
configuration and statistics output in FastCGI runner; versionstring format
changed; inheritance is now based on tek.class - Atom class removed; Debug
library added; removed "attr" permission
     1 
     2 --
     3 --	loona - tiny CMS
     4 --	Written by Timm S. Mueller <tmueller at neoscientists.org>
     5 --	See copyright notice in COPYRIGHT
     6 --
     7 
     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.loona.markup"
    16 
    17 local boxed_G = {
    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,
    26 	xpcall = xpcall
    27 }
    28 
    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
    34 
    35 -------------------------------------------------------------------------------
    36 --	Module setup:
    37 -------------------------------------------------------------------------------
    38 
    39 module("tek.class.loona", Class)
    40 _VERSION = "LOona Class 5.0"
    41 
    42 -------------------------------------------------------------------------------
    43 --	class Session:
    44 -------------------------------------------------------------------------------
    45 
    46 local Session = Class:newClass()
    47 
    48 function Session.new(class, self)
    49 
    50 	self = Class.new(class, self or { })
    51 
    52 	assert(self.id, "No session Id")
    53  	assert(self.sessiondir, "No session directory")
    54 
    55 	self.name = self.id:gsub("(.)", function(a)
    56 		return ("%02x"):format(a:byte())
    57 	end)
    58 	self.filename = self.sessiondir .. "/" .. self.name
    59 	-- remove non-dotted files (expired sessions) from sessions dir:
    60 	util.expire(self.sessiondir, "[^.]%S+", self.maxage or 600)
    61 	-- load session state:
    62 	self.data = lib.source(self.filename) or { }
    63 
    64 	return self
    65 end
    66 
    67 function Session:save()
    68 	local f = open(self.filename, "wb")
    69 	assert(f, "Failed to open session file for writing")
    70 	lib.dump(self.data, function(...)
    71 		f:write(...)
    72 	end)
    73 	f:close()
    74 end
    75 
    76 function Session:delete()
    77 	remove(self.filename)
    78 end
    79 
    80 -------------------------------------------------------------------------------
    81 --	class Loona
    82 -------------------------------------------------------------------------------
    83 
    84 local Loona = _M
    85 
    86 
    87 function Loona:dbmsg(msg, detail)
    88  	return (msg and detail and self.authuser) and
    89  		("%s : %s"):format(msg, detail) or msg
    90 end
    91 
    92 
    93 function Loona:checkprofilename(n)
    94 	assert(n:match("^%w+$") and n ~= "current",
    95 		self:dbmsg("Invalid profile name", n))
    96 	return n
    97 end
    98 
    99 
   100 function Loona:checklanguage(n)
   101 	assert(n:match("^%l%l$"), self:dbmsg("Invalid language code", n))
   102 	return n
   103 end
   104 
   105 
   106 function Loona:checkbodyname(s)
   107 	s = s or "main"
   108 	assert(s:match("^[%w_]*%w+[%w_]*$"), self:dbmsg("Invalid body name", s))
   109 	return s
   110 end
   111 
   112 
   113 function Loona:deleteprofile(p, lang)
   114 	p = self.config.contentdir .. "/" .. p .. "_" .. (lang or self.lang)
   115 	for e in util.readdir(p) do
   116  		local success, msg = remove(p .. "/" .. e)
   117 		assert(success, self:dbmsg("Error removing entry in profile", msg))
   118 	end
   119 	return remove(p)
   120 end
   121 
   122 
   123 function Loona:copyprofile(dstprof, srcprof, dstlang, srclang)
   124 	local contentdir = self.config.contentdir
   125 	local src = ("%s/%s_%s"):format(contentdir,
   126 		srcprof or self.profile, srclang or self.lang)
   127 	local dst = ("%s/%s_%s"):format(contentdir,
   128 		dstprof or self.profile, dstlang or self.lang)
   129 	assert(src ~= dst, self:dbmsg("Attempt to copy profile over itself"))
   130 	assert(posix.stat(src, "mode") == "directory",
   131 		self:dbmsg("Source profile not a directory", src))
   132 	local success, msg = posix.mkdir(dst)
   133 	assert(success, self:dbmsg("Error creating profile directory " .. dst, msg))
   134 	for e in util.readdir(src) do
   135 		local ext = e:match("^[^.].*%.([^.]*)$")
   136 		if ext ~= "LOCK" then
   137 			local f = src .. "/" .. e
   138 			if posix.stat(f, "mode") == "file" then
   139 				success, msg = lib.copyfile(f, dst .. "/" .. e)
   140 				assert(success, self:dbmsg("Error copying file", msg))
   141 			end
   142 		end
   143 	end
   144 	-- create "current" symlink if none exists for new profile/language
   145 	if not posix.readlink(contentdir .. "/current_" .. dstlang) then
   146 		self:makecurrent(dstprof, dstlang)
   147 	end
   148 end
   149 
   150 
   151 function Loona:makecurrent(prof, lang)
   152 	prof = prof or self.profile
   153 	lang = lang or self.lang
   154 	local contentdir = self.config.contentdir
   155 	local newpath = ("%s/current_%s"):format(contentdir, lang)
   156 	local tmppath = newpath .. "." .. self.session.name
   157 	local success, msg = posix.symlink(prof .. "_" .. lang, tmppath)
   158 	assert(success, self:dbmsg("Cannot create symlink", msg))
   159 	success, msg = rename(tmppath, newpath)
   160 	assert(success, self:dbmsg("Cannot put symlink in place", msg))
   161 	return true
   162 end
   163 
   164 
   165 function Loona:publishprofile(profile, lang)
   166 	lang = lang or self.lang
   167 	local contentdir = self.config.contentdir
   168 
   169 	-- Get languages for the current profile
   170 
   171 	local plangs = { }
   172 	local lmatch = "^" .. self.profile .. "_(%w+)$"
   173 	for e in util.readdir(self.config.contentdir) do
   174 		local l = e:match(lmatch)
   175 		if l then
   176 			table.insert(plangs, l)
   177 		end
   178 	end
   179 
   180 	-- For all languages, update "current" symlink
   181 
   182 	for _, lang in ipairs(plangs) do
   183 		self:makecurrent(profile, lang)
   184 	end
   185 
   186 	-- These arguments are overwritten globally and need to get restored
   187 
   188 	local save_args = { self.args.lang, self.args.profile, self.args.session }
   189 
   190 	-- For all languages, unroll site to static HTML
   191 
   192 	for _, lang in ipairs(plangs) do
   193 		local ext = (#plangs == 1 and ".html") or (".html." .. lang)
   194 		self:recursesections(self.sections, function(self, s, e, path)
   195 			path = path and path .. "/" .. e.name or e.name
   196 			if not e.notvisible then
   197 				Loona:dumphtml {
   198 					request = self.request, -- reuse request
   199 					userdata = self.userdata, -- reuse userdata
   200 					requestpath = path, requestlang = lang,
   201 					htmlext = ext, insecure = true
   202 				}
   203 			end
   204 			return path
   205 		end)
   206 	end
   207 
   208 	-- Restore arguments
   209 
   210 	self.args.lang, self.args.profile, self.args.session = unpack(save_args)
   211 
   212 	-- Update file cache
   213 
   214 	local htdocs = self.config.htdocsdir
   215 	local cache = self.config.htmlcachedir
   216 
   217 	for e in util.readdir(cache) do
   218 		local f = e:match("^.*%.html%.?(%w*)$")
   219 		if f and f ~= "tmp" then
   220 			local success, msg = remove(htdocs .. "/" .. e)
   221 			success, msg = remove(cache .. "/" .. e)
   222  			assert(success,
   223  				self:dbmsg("Could not purge cached HTML file", msg))
   224 		end
   225 	end
   226 
   227 	for e in util.readdir(cache) do
   228 		local f = e:match("^(.*%.html%.?%w*)%.tmp$")
   229 		if f then
   230 			local success, msg = rename(cache .. "/" .. e, cache .. "/" .. f)
   231 			assert(success,
   232 				self:dbmsg("Could not update cached HTML file", msg))
   233 			success, msg = rename(htdocs .. "/" .. e, htdocs .. "/" .. f)
   234 			assert(success,
   235 				self:dbmsg("Could not update cached HTML file", msg))
   236 		end
   237 	end
   238 end
   239 
   240 
   241 function Loona:recursesections(s, func, ...)
   242 	for _, e in ipairs(s) do
   243 		local udata = { func(self, s, e, unpack(arg)) }
   244 		if e.subs then
   245 			self:recursesections(e.subs, func, unpack(udata))
   246 		end
   247 	end
   248 end
   249 
   250 
   251 function Loona:indexsections()
   252 	local userperm = self.session and self.session.data.permissions
   253 	userperm = userperm and userperm ~= "" and "[" .. userperm .. "]"
   254 	self:recursesections(self.sections, function(self, s, e)
   255 		local permitted = true
   256 		local sectperm = e.permissions
   257 		if sectperm and sectperm ~= "" then
   258 			permitted = false
   259 			if userperm then
   260 				local num = sectperm:len()
   261 				sectperm:gsub(userperm, function() num = num - 1 end)
   262 				permitted = num == 0
   263 			end
   264 		end
   265 		e.notvalid = not permitted or (not self.secure and e.secure) or
   266 			(not self.authuser_visible and e.secret) or nil
   267 		e.notvisible = e.notvalid or not self.authuser_visible and e.hidden or nil
   268 		s[e.name] = e
   269 	end)
   270 end
   271 
   272 
   273 --	Decompose section path into a stack of sections, returning only up to
   274 --	the last valid element in the path. additionally returns the table of
   275 --	the last section path element (or the default section)
   276 
   277 function Loona:getsection(path)
   278 	local default = not self.authuser and self.config.defname
   279 	local tab = { { entries = self.sections, name = default } }
   280 	local ss = self.sections
   281 	local sectionpath
   282 	(path or ""):gsub("(%w+)/?", function(a)
   283 		if ss then
   284 			local s = ss[a]
   285 			if s and not s.notvalid then
   286 				sectionpath = s
   287 				tab[#tab].name = a
   288 				ss = s.subs
   289 				if ss then
   290 					table.insert(tab, { entries = ss })
   291 				end
   292 			else
   293 				ss = nil -- stop.
   294 			end
   295 		end
   296 	end)
   297 	if not self.section and not sectionpath then
   298 		sectionpath = self.sections[default]
   299 		if sectionpath then
   300 			table.insert(tab, { entries = sectionpath.subs })
   301 		end
   302 	end
   303 	return tab, sectionpath
   304 end
   305 
   306 
   307 function Loona:getpath(delimiter, maxdepth)
   308 	local t = { }
   309 	local d = 0
   310 	maxdepth = maxdepth or #self.submenus
   311 	for _, menu in ipairs(self.submenus) do
   312 		if menu.name then
   313 			table.insert(t, menu.name)
   314 		end
   315 		d = d + 1
   316 		if d == maxdepth then
   317 			break
   318 		end
   319 	end
   320 	return table.concat(t, delimiter or "/")
   321 end
   322 
   323 
   324 function Loona:deletesection(fname, all_bodies)
   325 	local fullname = self.contentdir .. "/" .. fname
   326 	local success, msg = remove(fullname)
   327 	if all_bodies then
   328 		local pat = "^" .. -- TODO: check
   329 			fname:gsub("%^%$%(%)%%%.%[%]%*%+%-%?", "%%%1") .. "%..*$"
   330 		for e in util.readdir(self.contentdir) do
   331 			if e:match(pat) then
   332 				remove(self.contentdir .. "/" .. e)
   333 			end
   334 		end
   335 	end
   336 	return success, msg
   337 end
   338 
   339 
   340 function Loona:addpath(path, e)
   341 	local tab = self.sections
   342 	path:gsub("(%w+)/?", function(a)
   343 		if tab then
   344 			local s = tab[a]
   345 			if s then
   346 				if not s.subs then
   347 					s.subs = { }
   348 				end
   349 				tab = s.subs
   350 			else
   351 				table.insert(tab, e)
   352 				tab[a] = e
   353  				tab = nil -- stop
   354 			end
   355 		end
   356 	end)
   357 	return e
   358 end
   359 
   360 
   361 local function lookupname(tab, val)
   362 	for i, v in ipairs(tab) do
   363 		if v.name == val then
   364 			return i
   365 		end
   366 	end
   367 end
   368 
   369 
   370 function Loona:rmpath(path)
   371 	local parent
   372 	local tab = self.sections
   373 	path:gsub("(%w+)/?", function(a)
   374 		if tab then
   375 			local idx = lookupname(tab, a)
   376 			if idx then
   377 				if tab[idx].subs then
   378 					parent = tab[idx]
   379 					tab = tab[idx].subs
   380 				else
   381 					table.remove(tab, idx)
   382 					tab[a] = nil
   383 					if #tab == 0 and parent then
   384 						parent.subs = nil
   385 					end
   386 					tab = nil
   387 				end
   388 			end
   389 		end
   390 	end)
   391 end
   392 
   393 
   394 function Loona:checkpath(path)
   395 	if path ~= "index" then -- "index" is reserved
   396 		local res, idx
   397 		local tab = self.sections
   398 		path:gsub("(%w+)/?", function(a)
   399 			if tab then
   400 				local i = lookupname(tab, a)
   401 				if i then
   402 					res, idx = tab, i
   403 					tab = tab[i].subs
   404 				else
   405 					res, idx, tab = nil, nil, nil
   406 				end
   407 			end
   408 		end)
   409 		return res, idx
   410 	end
   411 end
   412 
   413 
   414 function Loona:title()
   415 	return self.section and (self.section.title or self.section.label or
   416 		self.section.name) or ""
   417 end
   418 
   419 
   420 --	Run a site function snippet, with full error recovery
   421 --	(also recovers from errors in error handling function)
   422 
   423 function Loona:dosnippet(func, errfunc)
   424 	local ret = { lib.catch(func) }
   425 	if ret[1] == 0 or (errfunc and lib.catch(errfunc) == 0) then
   426 		return unpack(ret)
   427 	end
   428 	self:out("<h2>Error</h2>")
   429 	self:out("<h3>" .. self:encodeform(ret[2] or "") .. "</h3>")
   430 	if self.authuser_debug then
   431 		if type(ret[3]) == "string" then
   432 			self:out("<p>" .. self:encodeform(ret[3]) .. "</p>")
   433 		end
   434 		if ret[4] then
   435 			self:out("<pre>" .. self:encodeform(ret[4]) .. "</pre>")
   436 		end
   437 	end
   438 end
   439 
   440 
   441 function Loona:lockfile(file)
   442 	return not self.session and true or
   443 		posix.symlink(self.session.filename, file .. ".LOCK")
   444 end
   445 
   446 
   447 function Loona:unlockfile(file)
   448 	return not self.session and true or remove(file .. ".LOCK")
   449 end
   450 
   451 
   452 function Loona:saveindex()
   453 	local tempname = self.indexfname .. "." .. self.session.name
   454 	local f, msg = open(tempname, "wb")
   455 	assert(f, self:dbmsg("Error opening section file for writing", msg))
   456 	lib.dump(self.sections, function(...)
   457 		f:write(unpack(arg))
   458 	end)
   459 	f:close()
   460 	local success, msg = rename(tempname, self.indexfname)
   461 	assert(success, self:dbmsg("Error renaming section file", msg))
   462 end
   463 
   464 
   465 function Loona:savebody(fname, content)
   466 	fname = self.contentdir .. "/" .. fname
   467 	local f, msg = open(fname, "wb")
   468 	assert(f, self:dbmsg("Could not open file for writing", msg))
   469 	f:write(content or "")
   470 	f:close()
   471 end
   472 
   473 
   474 function Loona:runboxed(func, envitems, ...)
   475 	local fenv = {
   476  		arg = arg,
   477  		loona = self
   478  	}
   479  	if envitems then
   480 	 	for k, v in pairs(envitems) do
   481  			fenv[k] = v
   482  		end
   483  	end
   484  	setmetatable(fenv, { __index = boxed_G }) -- boxed global environment
   485 	setfenv(func, fenv)
   486 	return func()
   487 end
   488 
   489 
   490 function Loona:include(fname, ...)
   491 	assert(not fname:match("%W"), self:dbmsg("Invalid include name", fname))
   492 	local fname2 = ("%s/%s.lua"):format(self.config.extdir, fname)
   493 	local f, msg = open(fname2)
   494 	assert(f, self:dbmsg("Cannot open file", msg))
   495 	local parsed, msg = self:loadhtml(f, "loona:out", fname2)
   496 	msg = msg and (type(msg) == "string" and msg or msg.txt)
   497 	assert(parsed, self:dbmsg("Syntax error", msg))
   498 	return self:runboxed(parsed, nil, unpack(arg))
   499 end
   500 
   501 
   502 --	produce link target (simple)
   503 
   504 function Loona:shref(section, arg)
   505 	local args2 = { } -- propagated or new arguments
   506 	for _, a in ipairs(arg) do
   507 		local key, val = a:match("^([%w_]+)=(.*)$")
   508 		if key and val then -- "arg=val" sets/overrides argument
   509 			table.insert(args2, { name = key, value = val })
   510 		elseif self.args[a] then -- just "arg" propagates argument
   511 			table.insert(args2, { name = a, value = self.args[a] })
   512 		end
   513 	end
   514 	local doc = self:getdocname(section, #args2 > 0)
   515 	local url, anch = doc:match("^(.+)(#.+)$")
   516 	local notfirst = doc:match("%?")
   517 	local href = { anch and url or doc }
   518 	for i, arg in ipairs(args2) do
   519 		if i > 1 or notfirst then
   520 			table.insert(href, "&amp;")
   521 		else
   522 			table.insert(href, "?")
   523 		end
   524 		table.insert(href, arg.name .. "=" .. cgi.encodeurl(arg.value))
   525 	end
   526 	if anch then
   527 		insert(href, anch)
   528 	end
   529 	return table.concat(href)
   530 end
   531 
   532 
   533 --	produce link target, implicit propagation of lang, profile, session
   534 
   535 function Loona:href(section, ...)
   536 	if self.session then
   537 		table.insert(arg, 1, "profile")
   538 		table.insert(arg, 1, "session")
   539 	end
   540 	if self.explicitlang then
   541 		table.insert(arg, 1, "lang")
   542 	end
   543 	return self:shref(section, arg)
   544 end
   545 
   546 
   547 function Loona:ilink(target, text, extra)
   548 	return ('<a href="%s"%s>%s</a>'):format(target, extra or "", text)
   549 end
   550 
   551 
   552 --	internal link, implicit propagation of lang, profile, session
   553 
   554 function Loona:link(section, text, ...)
   555 	return self:ilink(self:href(section, unpack(arg)), text or section,
   556 	' class="intlink"')
   557 end
   558 
   559 
   560 --	external link (opens in a new window), no argument propagation
   561 
   562 function Loona:elink(target, text)
   563 	return self:ilink(target, text or target, self.config.extlinkextra)
   564 end
   565 
   566 
   567 --	plain link, no implicit argument propagation
   568 
   569 function Loona:plink(section, text, ...)
   570 	return self:ilink(self:shref(section, arg), text or section)
   571 end
   572 
   573 
   574 --	user interface link, implicit propagation of lang, profile, session
   575 
   576 function Loona:uilink(section, text, ...)
   577 	return self:ilink(self:href(section, unpack(arg)), text or section,
   578 	' class="uilink"')
   579 end
   580 
   581 
   582 --	produce a hidden input value in forms
   583 
   584 function Loona:hidden(name, value)
   585 	return not value and "" or
   586 		('<input type="hidden" name="%s" value="%s" />'):format(name, value)
   587 end
   588 
   589 
   590 function Loona:scanprofiles(func)
   591 	local tab = { }
   592 	local dir = self.config.contentdir
   593 	for f in util.readdir(dir) do
   594 		if posix.lstat(dir .. "/" .. f, "mode") == "directory" then
   595 			f = func(f)
   596 			if f then
   597 				table.insert(tab, f)
   598 			end
   599 		end
   600 	end
   601 	table.sort(tab)
   602 	for _, v in ipairs(tab) do
   603 		tab[v] = v
   604 	end
   605 	return tab
   606 end
   607 
   608 
   609 function Loona:getprofiles(lang)
   610 	lang = lang or self.lang
   611 	return self:scanprofiles(function(f)
   612 		return f:match("^(%w+)_" .. lang .. "$")
   613 	end)
   614 end
   615 
   616 
   617 function Loona:getlanguages(prof)
   618 	prof = prof or self.profile
   619 	return self:scanprofiles(function(f)
   620 		return f:match("^" .. prof .. "_(%l%l)$")
   621 	end)
   622 end
   623 
   624 
   625 --	Functions to produce a navigation menu
   626 
   627 local newent = { name = "new", label = "[+]", action="actionnew=true" }
   628 
   629 function Loona:rmenu(level, render, path, addnew, recurse)
   630 	local sub = (addnew and level == #self.submenus + 1) and
   631 		{ name = "new", entries = { }} or self.submenus[level]
   632  	if sub and sub.entries then
   633 		local visible = { }
   634 		for _, e in ipairs(sub.entries) do
   635 			if not e.notvisible then
   636 				table.insert(visible, e)
   637 			end
   638 		end
   639 		if addnew then
   640 			table.insert(visible, newent)
   641 		end
   642 		local numvis = #visible
   643 		if numvis > 0 then
   644 			render.listbegin(self, level, numvis, path)
   645 			for idx, e in ipairs(visible) do
   646 				local label = self:encodeform(e.label or e.name)
   647 				local newpath = path and path .. "/" .. e.name or e.name
   648 				local active = (e.name == sub.name)
   649 				render.itembegin(self, level, idx, label)
   650 				render.link(self, level, newpath, label, active, e.action)
   651 				if recurse and active then
   652 					self:rmenu(level + 1, render, newpath, addnew, recurse)
   653 				end
   654 				render.itemend(self, level, idx, label)
   655 			end
   656 			render.listend(self)
   657 		end
   658 	end
   659 end
   660 
   661 
   662 function Loona:menu(level, recurse, render)
   663 	level = level or 1
   664 	render = render or { }
   665 	render.link = render.link or
   666 		function(self, level, path, label, active, ...)
   667 			self:out(('<a %shref="%s">%s</a>\n'):format(active and
   668 				'class="active" ' or "", self:href(path, unpack(arg)), label))
   669 		end
   670 	render.listbegin = render.listbegin or
   671 		function(self, level) -- , numvis, path
   672 			self:out('<ul id="menulevel' .. level .. '">\n')
   673 		end
   674 	render.listend = render.listend or
   675 		function(self)
   676 			self:out('</ul>\n')
   677 		end
   678 	render.itembegin = render.itembegin or
   679 		function(self) -- , level, idx
   680 			self:out('<li>\n')
   681 		end
   682 	render.itemend = render.itemend or
   683 		function(self)
   684 			self:out('</li>\n')
   685 		end
   686 	recurse = recurse == nil and true or recurse
   687 	local path = level > 1 and self:getpath("/", level - 1) or nil
   688 	local addnew = self.authuser_menu and
   689 		(not self.ispubprofile or self.config.editablepubprofile)
   690 	self:rmenu(level, render, path, addnew, recurse)
   691 end
   692 
   693 
   694 function Loona:loadcontent(fname)
   695 	if fname then
   696 		local f = open(self.contentdir .. "/" .. fname)
   697 		local c = f:read("*a")
   698 		f:close()
   699 		return c
   700 	end
   701 	return ""
   702 end
   703 
   704 
   705 function Loona:loadmarkup(fname)
   706 	return (fname and fname ~= "") and
   707 		self:domarkup(self:loadcontent(fname)) or ""
   708 end
   709 
   710 
   711 function Loona:editable(editkey, fname, savename)
   712 
   713 	local contentdir = self.contentdir
   714 	local edit, show, hidden, extramsg, changed
   715 
   716 	if self.authuser_edit or self.authuser_profile or
   717 		self.authuser_modifyprofile or self.authuser_menu then
   718 
   719 		local hiddenvars = table.concat( {
   720 			self:hidden("lang", self.args.lang),
   721 			self:hidden("profile", self.profile),
   722 			self:hidden("session", self.session.id),
   723 			self:hidden("editkey", editkey) }, " ")
   724 
   725 		local lockfname = fname and (contentdir .. "/" .. fname)
   726 
   727 		if self.useralert and editkey == self.args.editkey then
   728 
   729 			--	display user alert/request/confirmation
   730 
   731 			hidden = true
   732 			self:out([[
   733 			<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   734 				<fieldset>
   735 					<legend>]] .. self.useralert.text ..[[</legend>
   736 					]] .. (self.useralert.confirm or "") .. [[
   737 					]] .. (self.useralert.returnto or "") .. [[
   738 					<input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL  ..[[" />
   739 					]] .. hiddenvars .. [[
   740 				</fieldset>
   741 			</form>
   742 			]])
   743 
   744 		elseif self.args.actionnew and editkey == "main" and self.authuser_menu then
   745 
   746 			--	form for creating a new section
   747 
   748 			hidden = true
   749 			if self.ispubprofile then
   750 				self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
   751 			end
   752 			self:out([[
   753 			<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   754 				<fieldset>
   755 					<legend>
   756 						]] .. self.locale.CREATE_NEW_SECTION_UNDER .. " " .. self.sectionpath .. [[
   757 					</legend>
   758 					<table>
   759 						<tr>
   760 							<td align="right">
   761 								]] .. self.locale.PATHNAME .. [[
   762 							</td>
   763 							<td>
   764 								<input size="30" maxlength="30" name="editname" />
   765 							</td>
   766 						</tr>
   767 						<tr>
   768 							<td align="right">
   769 								]] .. self.locale.MENULABEL .. [[
   770 							</td>
   771 							<td>
   772 								<input size="30" maxlength="50" name="editlabel" />
   773 							</td>
   774 						</tr>
   775 						<tr>
   776 							<td align="right">
   777 								]] .. self.locale.WINDOWTITLE .. [[
   778 							</td>
   779 							<td>
   780 								<input size="30" maxlength="50" name="edittitle" />
   781 							</td>
   782 						</tr>
   783 						<tr>
   784 							<td align="right">
   785 								]] .. self.locale.INVISIBLE .. [[
   786 							</td>
   787 							<td>
   788 								<input type="checkbox" name="editvisibility" />
   789 							</td>
   790 						</tr>
   791 						<tr>
   792 							<td align="right">
   793 								]] .. self.locale.SECRET .. [[
   794 							</td>
   795 							<td>
   796 								<input type="checkbox" name="editsecrecy" />
   797 							</td>
   798 						</tr>
   799 						<tr>
   800 							<td align="right">
   801 								]] .. self.locale.SECURE_CONNECTION .. [[
   802 							</td>
   803 							<td>
   804 								<input type="checkbox" name="editsecure" />
   805 							</td>
   806 						</tr>
   807 						<tr>
   808 							<td align="right">
   809 								]] .. self.locale.PERMISSIONS .. [[
   810 							</td>
   811 							<td>
   812 								<input size="30" maxlength="50" name="editpermissions" />
   813 							</td>
   814 						</tr>
   815 						<tr>
   816 							<td align="right">
   817 								]] .. self.locale.REDIRECT .. [[
   818 							</td>
   819 							<td>
   820 								<input size="30" maxlength="50" name="editredirect" />
   821 							</td>
   822 						</tr>
   823 					</table>
   824 					<input type="submit" name="actioncreate" value="]] .. self.locale.CREATE .. [[" />
   825 					]] .. hiddenvars .. [[
   826 				</fieldset>
   827 			</form>
   828 			<hr />
   829 			]])
   830 
   831 		elseif self.args.actioneditprops and editkey == "main" and
   832 			self.authuser_menu then
   833 			hidden = true
   834 			if self.ispubprofile then
   835 				self:out([[<h2><span class="warn">]] .. self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE ..[[</span></h2>]])
   836 			end
   837 			self:out([[
   838 			<form action="]] ..self.document .. [[" method="post" accept-charset="utf-8">
   839 				<fieldset>
   840 					<legend>
   841 						]] .. self.locale.MODIFY_PROPERTIES_OF_SECTION .. " " .. self.sectionpath .. [[
   842 					</legend>
   843 					<table>
   844 						<tr>
   845 							<td align="right">
   846 								]] .. self.locale.MENULABEL .. [[
   847 							</td>
   848 							<td>
   849 								<input size="30" maxlength="50" name="editlabel" value="]] .. (self.section.label or "") .. [[" />
   850 							</td>
   851 						</tr>
   852 						<tr>
   853 							<td align="right">
   854 								]] .. self.locale.WINDOWTITLE .. [[
   855 							</td>
   856 							<td>
   857 								<input size="30" maxlength="50" name="edittitle" value="]] .. (self.section.title or "") .. [[" />
   858 							</td>
   859 						</tr>
   860 						<tr>
   861 							<td align="right">
   862 								]] .. self.locale.INVISIBLE .. [[
   863 							</td>
   864 							<td>
   865 								<input type="checkbox" name="editvisibility" ]] .. (self.section.hidden and 'checked="checked"' or "") .. [[/>
   866 							</td>
   867 						</tr>
   868 						<tr>
   869 							<td align="right">
   870 								]] .. self.locale.SECRET .. [[
   871 							</td>
   872 							<td>
   873 								<input type="checkbox" name="editsecrecy" ]] .. (self.section.secret and 'checked="checked"' or "") .. [[/>
   874 							</td>
   875 						</tr>
   876 						<tr>
   877 							<td align="right">
   878 								]] .. self.locale.SECURE_CONNECTION .. [[
   879 							</td>
   880 							<td>
   881 								<input type="checkbox" name="editsecure" ]] .. (self.section.secure and 'checked="checked"' or "") .. [[/>
   882 							</td>
   883 						</tr>
   884 						<tr>
   885 							<td align="right">
   886 								]] .. self.locale.PERMISSIONS .. [[
   887 							</td>
   888 							<td>
   889 								<input size="30" maxlength="50" name="editpermissions" value="]] .. (self.section.permissions or "") .. [[" />
   890 							</td>
   891 						</tr>
   892 						<tr>
   893 							<td align="right">
   894 								]] .. self.locale.REDIRECT .. [[
   895 							</td>
   896 							<td>
   897 								<input size="30" maxlength="50" name="editredirect" value="]] .. (self.section.redirect or "") .. [[" />
   898 							</td>
   899 						</tr>
   900 					</table>
   901 					<input type="submit" name="actionsaveprops" value="]] .. self.locale.SAVE .. [[" />
   902 					<input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
   903 					]] .. hiddenvars .. [[
   904 				</fieldset>
   905 			</form>
   906 			]])
   907 
   908 		elseif (self.args.actioneditprofiles or
   909 			self.args.actioncreateprofile or
   910 			self.args.actionchangeprofile or
   911 			self.args.actionchangelanguage or
   912 			self.args.actionpublishprofile) and editkey == "main" and
   913 			(self.authuser_profile or self.authuser_modifyprofile) then
   914 			hidden = true
   915 			if self.authuser_profile then
   916 				self:out([[
   917 				<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   918 					<fieldset>
   919 						<legend>
   920 							]] .. self.locale.CHANGEPROFILE .. [[
   921 						</legend>
   922 						<select name="changeprofile" size="1">]])
   923 							for _, val in ipairs(self:getprofiles()) do
   924 								self:out('<option' .. (val == self.profile and ' selected="selected"' or '') .. '>')
   925 								self:out(val)
   926 								self:out('</option>')
   927 							end
   928 						self:out([[
   929 						</select>
   930 						<input type="submit" name="actionchangeprofile" value="]] .. self.locale.CHANGE ..[[" />
   931 						]] .. hiddenvars .. [[
   932 					</fieldset>
   933 				</form>
   934 				<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   935 					<fieldset>
   936 						<legend>
   937 							]] .. self.locale.CHANGELANGUAGE .. [[
   938 						</legend>
   939 						<select name="changelanguage" size="1">]])
   940 							for _, val in ipairs(self:getlanguages()) do
   941 								self:out('<option' .. (val == self.lang and ' selected="selected"' or '') .. '>')
   942 								self:out(val)
   943 								self:out('</option>')
   944 							end
   945 						self:out([[
   946 						</select>
   947 						<input type="submit" name="actionchangelanguage" value="]] .. self.locale.CHANGE ..[[" />
   948 						]] .. hiddenvars .. [[
   949 					</fieldset>
   950 				</form>
   951 				]])
   952 			end
   953 			if self.authuser_modifyprofile then
   954 				self:out([[
   955 				<form action="]] .. self.document ..[[" method="post" accept-charset="utf-8">
   956 					<fieldset>
   957 						<legend>
   958 							]] .. self.locale.CREATEPROFILE .. [[
   959 						</legend>
   960 						<input size="20" maxlength="20" name="createprofile" />
   961 						]] .. self.locale.LANGUAGE ..[[
   962 						<input size="2" maxlength="2" name="createlanguage" value="]] .. self.lang ..[[" />
   963 						<input type="submit" name="actioncreateprofile" value="]] .. self.locale.CREATE .. [[" />
   964 						]] .. hiddenvars .. [[
   965 					</fieldset>
   966 				</form>
   967 				]])
   968 			end
   969 			if not self.ispubprofile or self.config.editablepubprofile and
   970 				self.authuser_modifyprofile then
   971 				self:out([[
   972 				<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   973 					<fieldset>
   974 						<legend>
   975 							]] .. self.locale.PUBLISHPROFILE .. [[
   976 						</legend>
   977 						]] .. self:hidden("publishprofile", self.profile) .. [[
   978 						<input type="submit" name="actionpublishprofile" value="]] .. self.locale.PUBLISH .. [[" />
   979 						]] .. hiddenvars .. [[
   980 					</fieldset>
   981 				</form>
   982 				<form action="]] .. self.document .. [[" method="post" accept-charset="utf-8">
   983 					<fieldset>
   984 						<legend>
   985 							]] .. self.locale.DELETEPROFILE .. [[
   986 						</legend>
   987 						]] .. self:hidden("deleteprofile", self.profile) .. [[
   988 						<input type="submit" name="actiondeleteprofile" value="]] .. self.locale.DELETE .. [[" />
   989 						]] .. hiddenvars .. [[
   990 					</fieldset>
   991 				</form>
   992 				]])
   993 			end
   994 
   995 		elseif self.args.actionedit and editkey == self.args.editkey then
   996 			if not self.section.redirect and self.authuser_edit then
   997 				extramsg = self.ispubprofile and
   998 					self.locale.WARNING_YOU_ARE_IN_PUBLISHED_PROFILE
   999 				edit = self:loadcontent(fname):gsub("\194\160", "&nbsp;") -- TODO
  1000 				changed = self.section and (self.section.revisiondate or self.section.creationdate)
  1001 			end
  1002 
  1003 		elseif self.args.actionpreview and editkey == self.args.editkey and
  1004 			self.authuser_edit then
  1005 			edit = self.args.editform
  1006 			show = self:domarkup(edit:gsub("&nbsp;", "\194\160")) -- TODO
  1007 
  1008 		elseif self.args.actionsave and editkey == self.args.editkey
  1009 			and self.authuser_edit then
  1010 			local c = self.args.editform
  1011 			local dynamic
  1012 
  1013 			if lockfname then
  1014 				self:expire(contentdir, "[^.]%S+.LOCK")
  1015 				if self:lockfile(lockfname) then
  1016 					-- lock was expired, aquired a new one
  1017 					extramsg = self.locale.SECTION_COULD_HAVE_CHANGED
  1018 					edit = c
  1019 				else
  1020 					local tab = lib.source(lockfname .. ".LOCK")
  1021 					if tab and tab.id == self.session.id then
  1022 						-- lock already held and is mine - try to save:
  1023 						local savec = c:gsub("&nbsp;", "\194\160") -- TODO
  1024 						remove(contentdir .. "/" .. savename .. ".html")
  1025 						self:savebody(savename, savec)
  1026 						-- TODO: error handling
  1027 						self:unlockfile(lockfname)
  1028 						show, dynamic = self:domarkup(savec)
  1029 						changed = time()
  1030 					else
  1031 						-- lock was expired and someone else has it now
  1032 						extramsg = self.locale.SECTION_IN_USE
  1033 						edit = c
  1034 					end
  1035 				end
  1036 			else
  1037 				-- new sidefile
  1038 				local savec = c:gsub("&nbsp;", "\194\160") -- TODO
  1039 				self:savebody(savename, savec)
  1040 				-- TODO: error handling
  1041 				show, dynamic = self:domarkup(savec)
  1042 			end
  1043 
  1044 			-- mark dynamic text bodies
  1045 			if not self.section.dynamic then
  1046 				self.section.dynamic = { }
  1047 			end
  1048 			self.section.dynamic[editkey] = dynamic
  1049 			local n = 0
  1050 			for _ in pairs(self.section.dynamic) do
  1051 				n = n + 1
  1052 			end
  1053 			if n == 0 then
  1054 				self.section.dynamic = nil
  1055 			end
  1056 
  1057 			self:saveindex()
  1058 
  1059 		elseif self.args.actioncancel and editkey == self.args.editkey then
  1060 			if lockfname then
  1061 				self:unlockfile(lockfname) -- remove lock
  1062 			end
  1063 		end
  1064 
  1065 		if editkey == "main" and self.section and self.section.redirect then
  1066 			self:out('<h2>' .. self.locale.SECTION_IS_REDIRECT ..'</h2>')
  1067 			self:out(self:link(self.section.redirect))
  1068 			self:out('<hr />')
  1069 		end
  1070 
  1071 		if edit then
  1072 			self:expire(contentdir, "[^.]%S+.LOCK")
  1073 			if fname and not self:lockfile(contentdir .. "/" .. fname) then
  1074 				local tab = lib.source(contentdir .. "/" .. fname .. ".LOCK")
  1075 				if tab and tab.id ~= self.session.id then
  1076 					extramsg = self.locale.SECTION_IN_USE
  1077 				end
  1078 				-- else already owner
  1079 			end
  1080 			if extramsg then
  1081 				self:out('<h2><span class="warn">' .. extramsg .. '</span></h2>')
  1082 			end
  1083 			self:out([[
  1084 			<form action="]] .. self.document .. [[#preview" method="post" accept-charset="utf-8">
  1085 				<fieldset>
  1086 					<legend>
  1087 						]] .. self.locale.EDIT_SECTION .. [[
  1088 					</legend>
  1089 					<textarea cols="80" rows="25" name="editform">
  1090 ]] .. self:encodeform(edit) .. [[</textarea>
  1091 					<br />
  1092 					<input type="submit" name="actionsave" value="]] .. self.locale.SAVE .. [[" />
  1093 					<input type="submit" name="actionpreview" value="]] .. self.locale.PREVIEW .. [[" />
  1094 					<input type="submit" name="actioncancel" value="]] .. self.locale.CANCEL .. [[" />
  1095 					]] .. hiddenvars .. [[
  1096 				</fieldset>
  1097 			</form>
  1098 			]])
  1099 		end
  1100 	end
  1101 
  1102 	if not hidden then
  1103 		self:dosnippet(function()
  1104 			if not show then
  1105 				show = self:loadmarkup(fname)
  1106 				changed = self.section and (self.section.revisiondate or self.section.creationdate)
  1107 			end
  1108 			local parsed, msg = self:loadhtml(show, "loona:out", "<parsed html>")
  1109 			assert(parsed, msg and "Syntax error : " .. msg)
  1110 			self:runboxed(parsed)
  1111 		end)
  1112 	end
  1113 
  1114 	if self.authuser_profile or self.authuser_edit or self.authuser_menu then
  1115 		self:out([[
  1116 		<hr />
  1117 		<div class="edit">]])
  1118 			if editkey == "main" then
  1119 				self:out([[
  1120 				<a name="preview"></a>
  1121 				]] .. self.authuser .. [[ : ]])
  1122 				if self.authuser_profile then
  1123 					self:out(self:uilink(self.sectionpath, "[" .. self.locale.PROFILE .. "]", "actioneditprofiles=true", "editkey=" .. editkey) .. [[ :
  1124 						]] .. self.profile .. "_" .. self.lang)
  1125 					if self.ispubprofile then
  1126 						self:out([[
  1127 							<span class="warn">[]] .. self.locale.PUBLIC .. [[]</span>]])
  1128 					end
  1129 					self:out(" : ")
  1130 				end
  1131 				self:out(self.sectionpath .. ' ')
  1132 			end
  1133 			if self.section and (not self.ispubprofile or self.config.editablepubprofile) then
  1134 				if self.authuser_edit then
  1135 					self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.EDIT .. "]", "actionedit=true", "editkey=" .. editkey) .. " ")
  1136 				end
  1137 				if editkey == "main" and self.authuser_menu then
  1138 					self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.PROPERTIES .. "]", "actioneditprops=true", "editkey=" .. editkey) .. " ")
  1139 				end
  1140 				if (fname == savename or not self.section.subs) and (self.authuser_edit and self.authuser_menu) then
  1141 					self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.DELETE .. "]", "actiondelete=true", "editkey=" .. editkey) .. " ")
  1142 				end
  1143 				if editkey == "main" and self.authuser_menu then
  1144 					self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEUP .. "]", "actionup=true", "editkey=" .. editkey) .. " ")
  1145 					self:out('- ' .. self:uilink(self.sectionpath, "[" .. self.locale.MOVEDOWN .. "]", "actiondown=true", "editkey=" .. editkey) .. " ")
  1146 				end
  1147 				if changed and editkey == "main" then
  1148 					self:out('- ' .. self.locale.CHANGED .. ': ' .. date("%d-%b-%Y %T", changed))
  1149 				end
  1150 			end
  1151 		self:out('</div>')
  1152 	end
  1153 
  1154 end
  1155 
  1156 
  1157 --	Get pathname of an existing content file that
  1158 --	the current path is determined by (or defaults to)
  1159 
  1160 function Loona:getsectionpath(bodyname, requestpath)
  1161 	local ext = (not bodyname or bodyname == "main") and "" or "." .. bodyname
  1162 	local t, path, section = { }
  1163 	for _, menu in ipairs(self.submenus) do
  1164 		if menu.entries and menu.entries[menu.name] then
  1165 			table.insert(t, menu.name)
  1166 			local fn = table.concat(t, "_")
  1167 			if posix.stat(self.contentdir .. "/" .. fn .. ext,
  1168 				"mode") == "file" then
  1169 				path, section = fn, menu
  1170 			end
  1171 		end
  1172 	end
  1173 	return path, ext, section
  1174 end
  1175 
  1176 
  1177 function Loona:body(name)
  1178 	name = self:checkbodyname(name)
  1179 	local path, ext = self:getsectionpath(name)
  1180 	self:dosnippet(function()
  1181 		self:editable(name, path and path .. ext, self.sectionname .. ext)
  1182 	end)
  1183 end
  1184 
  1185 
  1186 function Loona:init()
  1187 
  1188 	-- get list of languages, in order of preference
  1189 	-- TODO: respect quality parameter, not just order
  1190 
  1191 	local l = self.requestlang or self.args.lang
  1192 	self.langs = { l and l:match("^%w+$") }
  1193 	local s = getenv("HTTP_ACCEPT_LANGUAGE")
  1194 	while s do
  1195 		local l, r = s:match("^([%w.=]+)[,;](.*)$")
  1196 		l = l or s
  1197 		s = r
  1198 		if l:match("^%w+$") then
  1199 			table.insert(self.langs, l)
  1200 		end
  1201 	end
  1202 	table.insert(self.langs, self.config.deflang)
  1203 
  1204 	-- get list of possible profiles
  1205 
  1206 	local profiles = { }
  1207 	for e in util.readdir(self.config.contentdir) do
  1208 		profiles[e] = e
  1209 	end
  1210 
  1211 	-- get pubprofile
  1212 
  1213 	for _, lang in ipairs(self.langs) do
  1214 		local p = posix.readlink(self.config.contentdir .. "/current_" .. lang)
  1215 		p = p and p:match("^(%w+)_" .. lang .. "$")
  1216 		if p then
  1217 			self.pubprofile = p
  1218 			break
  1219 		end
  1220 	end
  1221 
  1222 	-- get profile
  1223 
  1224 	local checkprofile =
  1225 		self.authuser_profile and self.args.profile or self.pubprofile or "work"
  1226 	for _, lang in ipairs(self.langs) do
  1227 		if profiles[checkprofile .. "_" .. lang] then
  1228 			self.profile = checkprofile
  1229 			self.lang = lang
  1230 			break
  1231 		end
  1232 	end
  1233 
  1234 	assert(self.profile and self.lang, "Invalid profile or language")
  1235 
  1236 
  1237 	self.ispubprofile = self.profile == self.pubprofile
  1238 
  1239 	-- write back language and profile
  1240 
  1241 	self.args.lang = (self.explicitlang or self.lang ~= self.config.deflang)
  1242 		and self.lang or nil
  1243 	self.args.profile = self.profile
  1244 
  1245 	-- determine content directory pathname and section filename
  1246 
  1247 	self.contentdir =
  1248 		("%s/%s_%s"):format(self.config.contentdir, self.profile, self.lang)
  1249  	self.indexfname = self.contentdir .. "/.sections"
  1250 
  1251 	-- load sections
  1252 
  1253  	self.sections = lib.source(self.indexfname)
  1254 
  1255 	-- index sections, determine visibility in menu
  1256 
  1257 	self:indexsections()
  1258 
  1259 	-- decompose request path, produce a stack of sections
  1260 
  1261 	self.submenus, self.section = self:getsection(self.requestpath)
  1262 
  1263 	-- handle redirects if not logged on
  1264 
  1265 	if not self.authuser_edit and self.section and self.section.redirect then
  1266 		self.submenus, self.section = self:getsection(self.section.redirect)
  1267 	end
  1268 
  1269 	-- section path and document name (refined)
  1270 
  1271 	self.sectionpath = self:getpath()
  1272 	self.sectionname = self:getpath("_")
  1273 
  1274 end
  1275 
  1276 
  1277 function Loona:handlechanges()
  1278 
  1279 	local save
  1280 
  1281 	if self.args.editkey == "main" then
  1282 
  1283 		-- In main editable section:
  1284 
  1285 		if self.args.actioncreate then
  1286 
  1287 			-- Create new section
  1288 
  1289 			local editname = self.args.editname:lower()
  1290 			assert(not editname:match("%W"),
  1291 				self:dbmsg("Invalid section name", editname))
  1292 			if not (section and (section.subs or section)[editname]) then
  1293 				local newpath = (self.sectionpath and
  1294 					(self.sectionpath .. "/")) .. editname
  1295 				local s = self:addpath(newpath, { name = editname,
  1296 					label = self.args.editlabel ~= "" and
  1297 						self.args.editlabel or nil,
  1298 					title = self.args.edittitle ~= "" and
  1299 						self.args.edittitle or nil,
  1300 					redirect = self.args.editredirect ~= "" and
  1301 						self.args.editredirect or nil,
  1302 					permissions = self.args.editpermissions ~= "" and
  1303 						self.args.editpermissions or nil,
  1304 					hidden = self.args.editvisibility and true,
  1305 					secret = self.args.editsecrecy and true,
  1306 					secure = self.args.editsecure and true,
  1307 					creator = self.authuser,
  1308 					creationdate = time() })
  1309 				save = true
  1310 			end
  1311 
  1312 		elseif self.args.actionsave then
  1313 
  1314 			-- Save section
  1315 
  1316 			self.section.revisiondate = time()
  1317 			self.section.revisioner = self.authuser
  1318 			save = true
  1319 
  1320 		elseif self.args.actionsaveprops then
  1321 
  1322 			-- Save properties
  1323 
  1324 			self.section.hidden = self.args.editvisibility and true
  1325 			self.section.secret = self.args.editsecrecy and true
  1326 			self.section.secure = self.args.editsecure and true
  1327 			self.section.label = self.args.editlabel ~= "" and
  1328 				self.args.editlabel or nil
  1329 			self.section.title = self.args.edittitle ~= "" and
  1330 				self.args.edittitle or nil
  1331 			self.section.redirect =
  1332 				self.args.editredirect ~= "" and self.args.editredirect or nil
  1333 			self.section.permissions =
  1334 				self.args.editpermisisons ~= "" and self.args.editpermissions or nil
  1335 			save = true
  1336 
  1337 		elseif self.args.actionup then
  1338 
  1339 			-- Move section up
  1340 
  1341 			local t, i = self:checkpath(self.sectionpath)
  1342 			if t and i > 1 then
  1343 				if self.ispubprofile and not self.args.actionconfirm then
  1344 					self.useralert = {
  1345 						text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
  1346 						confirm =
  1347 							'<input type="submit" name="actionup" value="' ..
  1348 							self.locale.MOVE .. '" /> ' ..
  1349 							self:hidden("actionconfirm", "true")
  1350 					}
  1351 				else
  1352 					local item = table.remove(t, i)
  1353 					table.insert(t, i - 1, item)
  1354 					save = true
  1355 				end
  1356 			end
  1357 
  1358 		elseif self.args.actiondown then
  1359 
  1360 			-- Move section down
  1361 
  1362 			local t, i = self:checkpath(self.sectionpath)
  1363 			if t and i < #t then
  1364 				if self.ispubprofile and not self.args.actionconfirm then
  1365 					self.useralert = {
  1366 						text = self.locale.ALERT_MOVE_IN_PUBLISHED_PROFILE,
  1367 						confirm =
  1368 							'<input type="submit" name="actiondown" value="' ..
  1369 							self.locale.MOVE .. '" /> ' ..
  1370 							self:hidden("actionconfirm", "true")
  1371 					}
  1372 				else
  1373 					local item = table.remove(t, i)
  1374 					table.insert(t, i + 1, item)
  1375 					save = true
  1376 				end
  1377 			end
  1378 
  1379 		elseif self.args.actioncreateprofile and self.args.createprofile then
  1380 
  1381 			-- Create profile
  1382 
  1383 			local c = self.args.createprofile
  1384 			if c == "" then
  1385 				c = self.profile
  1386 			end
  1387 			c = self:checkprofilename(c:lower())
  1388 			local l = self:checklanguage((self.args.createlanguage or self.lang):lower())
  1389 			if c == self.profile and l == self.lang then
  1390 				self.useralert = {
  1391 					text = self.locale.ALERT_CANNOT_COPY_PROFILE_TO_SELF,
  1392 					returnto = self:hidden("actioneditprofiles", "true")
  1393 				}
  1394 			else
  1395 				local profiles = self:getprofiles(l)
  1396 				local text
  1397 				if c == self.pubprofile then
  1398 					text = self.locale.ALERT_OVERWRITE_PUBLISHED_PROFILE
  1399 				elseif profiles[c] and l == self.lang then
  1400 					text = self.locale.ALERT_OVERWRITE_EXISTING_PROFILE
  1401 				end
  1402 				if text and not self.args.actionconfirm then
  1403 					self.useralert = {
  1404 						text = text,
  1405 						returnto = self:hidden("actioneditprofiles", "true"),
  1406 						confirm = '<input type="submit" ' ..
  1407 							'name="actioncreateprofile" value="' ..
  1408 							self.locale.OVERWRITE .. '" /> ' ..
  1409 							self:hidden("actionconfirm", "true") ..
  1410 							self:hidden("createlanguage", l) ..
  1411 							self:hidden("createprofile", c)
  1412 					}
  1413 				else
  1414 					if profiles[c] then
  1415 						self:deleteprofile(c, l)
  1416 					end
  1417 					self:copyprofile(c, self.profile, l, self.lang)
  1418 				end
  1419 			end
  1420 
  1421 		elseif self.args.actiondeleteprofile and self.args.deleteprofile then
  1422 
  1423 			-- Delete profile
  1424 
  1425 			local c = self:checkprofilename(self.args.deleteprofile:lower())
  1426 			assert(c ~= self.pubprofile,
  1427 				self:dbmsg("Cannot delete published profile", c))
  1428 			if self.args.actionconfirm then
  1429 				self:deleteprofile(c)
  1430 				self.profile = nil
  1431 				self.args.profile = nil
  1432 				self:init()
  1433 				save = true
  1434 			else
  1435 				self.useralert = {
  1436 					text = self.locale.ALERT_DELETE_PROFILE,
  1437 					returnto = self:hidden("actioneditprofiles", "true"),
  1438 					confirm = '<input type="submit" ' ..
  1439 						'name="actiondeleteprofile" value="' ..
  1440 						self.locale.DELETE .. '" /> ' ..
  1441 						self:hidden("actionconfirm", "true") ..
  1442 						self:hidden("deleteprofile", c)
  1443 				}
  1444 			end
  1445 
  1446 		elseif self.args.actionchangeprofile and self.args.changeprofile then
  1447 
  1448 			-- Change profile
  1449 
  1450 			local c = self:checkprofilename(self.args.changeprofile:lower())
  1451 			self.profile = c
  1452 			self.args.profile = c
  1453 			save = true
  1454 
  1455 		elseif self.args.actionchangelanguage and self.args.changelanguage then
  1456 
  1457 			-- Change language
  1458 
  1459 			local l = self:checklanguage(self.args.changelanguage:lower())
  1460 			self.lang = l
  1461 			self.args.lang = l
  1462  			self.explicitlang = l
  1463 			save = true
  1464 
  1465 		elseif self.args.actionpublishprofile and self.args.publishprofile then
  1466 
  1467 			-- Publish profile
  1468 
  1469 			local c = self:checkprofilename(self.args.publishprofile:lower())
  1470 			if c ~= self.publicprofile then
  1471 				if self.args.actionconfirm then
  1472 					self:publishprofile(c)
  1473 					save = true
  1474 				else
  1475 					self.useralert = {
  1476 						text = self.locale.ALERT_PUBLISH_PROFILE,
  1477 						returnto = self:hidden("actioneditprofiles", "true"),
  1478 						confirm = '<input type="submit" ' ..
  1479 							'name="actionpublishprofile" value="' ..
  1480 							self.locale.PUBLISH .. '" /> ' ..
  1481 							self:hidden("actionconfirm", "true") ..
  1482 							self:hidden("publishprofile", c)
  1483 					}
  1484 				end
  1485 			end
  1486 		end
  1487 
  1488 	end
  1489 
  1490 	if self.args.actiondelete then
  1491 
  1492 		-- Delete section
  1493 
  1494 		if not self.args.actionconfirm then
  1495 			self.useralert = {
  1496 				text = self.ispubprofile and
  1497 					self.locale.ALERT_DELETE_IN_PUBLISHED_PROFILE or
  1498 					self.locale.ALERT_DELETE_SECTION,
  1499 				confirm =
  1500 					'<input type="submit" name="actiondelete" value="' ..
  1501 					self.locale.DELETE .. '" /> ' ..
  1502 					self:hidden("actionconfirm", "true")
  1503 			}
  1504 		else
  1505 			local key = self.args.editkey
  1506 			if key == "main" and not self.section.subs then
  1507 				self:deletesection(self.sectionname, true) -- all bodies
  1508 				self:rmpath(self.sectionpath) -- and node
  1509 			else
  1510 				local ext = (key == "main" and "") or "." .. key
  1511 				self:deletesection(self.sectionname .. ext) -- only text
  1512 				if self.section.dynamic then
  1513 					self.section.dynamic[key] = nil
  1514 					local n = 0
  1515 					for _ in pairs(self.section.dynamic) do
  1516 						n = n + 1
  1517 					end
  1518 					if n == 0 then
  1519 						self.section.dynamic = nil
  1520 					end
  1521 				end
  1522 			end
  1523 			save = true
  1524 		end
  1525 	end
  1526 
  1527 	if save then
  1528 		self:saveindex()
  1529 		self:init()
  1530 	end
  1531 
  1532 end
  1533 
  1534 
  1535 function Loona:encodeform(s)
  1536 	return util.encodeform(s)
  1537 end
  1538 
  1539 
  1540 function Loona:loadhtml(src, outfunc, chunkname)
  1541  	return luahtml.load(src, outfunc, chunkname)
  1542 end
  1543 
  1544 
  1545 function Loona:domarkup(s)
  1546 	return markup.load(s)
  1547 end
  1548 
  1549 
  1550 function Loona:expire(dir, pat, maxage)
  1551 	return util.expire(dir, pat, maxage or self.config.sessionmaxage)
  1552 end
  1553 
  1554 
  1555 function Loona.new(class, self)
  1556 
  1557 	self = Class.new(class, self or { })
  1558 
  1559 	local parsed, msg
  1560 
  1561 	-- Buffer
  1562 
  1563 	self.out = self.out or function(self, s)
  1564 		self.buf:out(s)
  1565 	end
  1566 	self.addheader = self.addheader or function(self, s)
  1567 		self.buf:addheader(s)
  1568 	end
  1569 
  1570 	-- Get configuration
  1571 
  1572 	self.config = self.config or lib.source(self.conffile or "../etc/config.lua") or { }
  1573 	self.config.defname = self.config.defname or "home"
  1574 	self.config.deflang = self.config.deflang or "en"
  1575 	self.config.sessionmaxage = self.config.sessionmaxage or 6000
  1576 	self.config.secureport = self.config.secureport or 443
  1577 	self.config.passwdfile =
  1578 		posix.abspath(self.config.passwdfile or "../etc/passwd.lua")
  1579 	self.config.sessiondir =
  1580 		posix.abspath(self.config.sessiondir or "../var/sessions")
  1581 	self.config.extdir = posix.abspath(self.config.extdir or "../extensions")
  1582 	self.config.contentdir = posix.abspath(self.config.contentdir or "../content")
  1583 	self.config.localedir = posix.abspath(self.config.localedir or "../locale")
  1584 	self.config.htdocsdir = posix.abspath(self.config.htdocsdir or "../htdocs")
  1585 	self.config.htmlcachedir =
  1586 		posix.abspath(self.config.htmlcachedir or "../var/htmlcache")
  1587 	self.config.extlinkextra = self.config.extlinksamewindow and ' class="extlink"'
  1588 		or ' class="extlink" onclick="void(window.open(this.href, \'\', \'\')); return false;"'
  1589 
  1590 	-- Create proxy for on-demand loading of locales
  1591 
  1592 	self.locale = { }
  1593 	local locmt = { }
  1594 	locmt.__index = function(_, key)
  1595 		for _, l in ipairs(self.langs) do
  1596 			locmt.__locale = lib.source(self.config.localedir .. "/" .. l)
  1597 			if locmt.__locale then
  1598 				break
  1599 			end
  1600 		end
  1601 		locmt.__index = function(tab, key)
  1602 			return locmt.__locale[key] or key
  1603 		end
  1604 		return locmt.__locale[key] or key
  1605 	end
  1606 	setmetatable(self.locale, locmt)
  1607 
  1608 	-- Get request, args, document, script name, request path
  1609 
  1610 	self.request = self.request or Request:new()
  1611 	self.args = self.request:getargs()
  1612 	self.cgi_document = self.request:getdocument()
  1613 
  1614 --  	self.scriptpath = self.scriptpath or self.cgi_document.Path
  1615 	self.requesthandler = self.requesthandler or self.cgi_document.Handler
  1616  	self.requestdocument = self.requestdocument or self.cgi_document.Name
  1617 	self.requestpath = self.requestpath or self.cgi_document.VirtualPath
  1618 	self.explicitlang = not self.requestlang and self.args.lang
  1619 	self.secure = not self.insecure and (self.request.SERVER_PORT == self.config.secureport)
  1620 
  1621 	-- Manage login and establish session
  1622 
  1623 	if not self.nologin then
  1624 		local sid = self.args.session or self.request.UNIQUE_ID
  1625 		self.session = self.session or Session:new {
  1626 			id = sid,
  1627 			sessiondir = self.config.sessiondir,
  1628 			maxage = self.config.sessionmaxage
  1629 		}
  1630 		if self.args.login then
  1631 			-- write back session ID into request args:
  1632 			self.args.session = sid -- !
  1633 			if self.args.login == "false" then
  1634 				self.session:delete()
  1635 				self.session = nil
  1636 			elseif self.args.password then
  1637 				self.loginfailed = true
  1638 				local match, username, perm =
  1639 					self:checkpw(self.args.login, self.args.password)
  1640 				if match then
  1641 					self.session.data.authuser = self.args.login
  1642 					self.session.data.username = username
  1643 					self.session.data.permissions = perm
  1644 					self.session.data.id = self.session.id
  1645 					self.loginfailed = nil
  1646 				end
  1647 			end
  1648 		end
  1649 		self.authuser = self.session and self.session.data.authuser
  1650 	end
  1651 
  1652 	if self.nologin or not self.authuser then
  1653 		self.authuser = nil
  1654 		self.session = nil
  1655 		self.args.session = nil
  1656 	else
  1657 		self.authuser_edit = self.session.data.permissions:find("e") and true
  1658 		self.authuser_menu = self.session.data.permissions:find("m") and true
  1659 		self.authuser_profile = self.session.data.permissions:find("p") and true
  1660 		self.authuser_modifyprofile = self.session.data.permissions:find("c") and true
  1661 		self.authuser_visible = self.session.data.permissions:find("v") and true
  1662 		self.authuser_debug = self.session.data.permissions:find("d") and true
  1663 	end
  1664 
  1665 
  1666 	-- Get lang, locale, profile, section
  1667 
  1668 	self:init()
  1669 
  1670 	if self.authuser then -- TODO?
  1671 		self:handlechanges()
  1672 	else
  1673 		self.args.profile = nil
  1674 	end
  1675 
  1676 
  1677 	-- Current document
  1678 
  1679 	self.document = self.requestdocument .. "/" .. self.sectionpath
  1680 	if self.authuser then
  1681 		self.getdocname = function(self, path)
  1682 			return self.requestdocument .. "/" .. (path or self.sectionpath)
  1683 		end
  1684 	else
  1685 		self.getdocname = function(self, path, haveargs)
  1686 			local dyn, exists
  1687 			dyn, path, exists = self:isdynamic(path or self.sectionpath)
  1688 			if dyn or haveargs or not exists then
  1689 				return self.requestdocument .. "/" .. path
  1690 			end
  1691 			path = path == self.config.defname and "index" or path
  1692 			return "/" .. path:gsub("/", "_") .. ".html"
  1693 		end
  1694 	end
  1695 
  1696 	-- Save session state
  1697 
  1698 	if self.session then
  1699 		self.session:save()
  1700 	end
  1701 
  1702 	return self
  1703 end
  1704 
  1705 
  1706 function Loona:checkpw(login, passwd)
  1707 	local pwddb = lib.source(self.config.passwdfile)
  1708 	local pwdentry = pwddb[login]
  1709 	if pwdentry and pwdentry.password == passwd then
  1710 		return true, pwdentry.username, pwdentry.permissions or ""
  1711 	end
  1712 end
  1713 
  1714 
  1715 function Loona:run(fname)
  1716 	self:indexdynamic()
  1717 	fname = fname or self.requesthandler
  1718 	local parsed, msg = self:loadhtml(open(fname), "loona:out", fname)
  1719 	assert(parsed, self:dbmsg("HTML/Lua parsing failed", msg))
  1720 	self:runboxed(parsed)
  1721 	return self
  1722 end
  1723 
  1724 
  1725 function Loona:indexdynamic()
  1726 	self:recursesections(self.sections, function(self, s, e, path, dynamic)
  1727 		path = path and path .. "_" .. e.name or e.name
  1728 		dynamic = dynamic or { }
  1729 		for k in pairs(e.dynamic or { }) do
  1730 			dynamic[k] = true
  1731 		end
  1732 		for k in pairs(dynamic) do
  1733 			local ext = (k == "main" and "") or "." .. k
  1734 			if posix.stat(self.contentdir .. "/" .. path .. ext,
  1735 				"mode") == "file" then
  1736 				dynamic[k] = e.dynamic and e.dynamic[k]
  1737 			end
  1738 		end
  1739 		local n = 0
  1740 		for k in pairs(dynamic) do
  1741 			n = n + 1
  1742 		end
  1743 		if n > 0 then
  1744 			e.dynamic = { }
  1745 			for k in pairs(dynamic) do
  1746 				e.dynamic[k] = true
  1747 			end
  1748 		else
  1749 			e.dynamic = nil
  1750 		end
  1751 		return path, dynamic
  1752 	end)
  1753 end
  1754 
  1755 
  1756 function Loona:isdynamic(path)
  1757 	path = path or self.sectionpath
  1758 	local exists
  1759 	local t, i = self:checkpath(path)
  1760 	if t then
  1761 		exists = true
  1762 		if t[i].redirect then
  1763 			path = t[i].redirect
  1764 			t, i, exists = self:isdynamic(path) -- TODO: prohibit endless recursion
  1765 		end
  1766 	end
  1767 	return t and t[i].dynamic, path, exists
  1768 end
  1769 
  1770 
  1771 function Loona:dumphtml(o)
  1772 	local outbuf = { }
  1773 	o = o or { }
  1774 	o.nologin = true
  1775 	o.out = function(self, s) table.insert(outbuf, s) end
  1776 	o.addheader = function(self, s) end
  1777 
  1778 	o = self:new(o):run()
  1779 	if not o:isdynamic() then
  1780 		local path = o.sectionname
  1781 		path = path == o.config.defname and "index" or path
  1782 		local srcname = o.config.htdocsdir .. "/" .. path .. o.htmlext
  1783 		local fh, msg = open(srcname .. ".tmp", "wb")
  1784 		assert(fh, self:dbmsg("Could not write cached HTML", msg))
  1785 		for _, line in ipairs(outbuf) do
  1786 			fh:write(line)
  1787 		end
  1788 		fh:close()
  1789 		local dstname = o.config.htmlcachedir .. "/" .. path .. o.htmlext
  1790 		local success, msg = posix.symlink(srcname, dstname .. ".tmp")
  1791 		-- assert(success, self:dbmsg("Could not link to cached HTML", msg))
  1792 	end
  1793 end