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