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