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