cgi-bin/loona.lua
author Timm S. Mueller <tmueller@neoscientists.org>
Sun, 11 Mar 2007 15:01:41 +0100
changeset 143 8495a4fdc479
parent 141 fc50232cf086
child 144 531eca7acb52
permissions -rw-r--r--
Added new variable ispubprofile
     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 tek = require "tek"
     9 local cgi = require "tek.cgi"
    10 local posix = require "tek.posix"
    11 require "tek.cgi.request"
    12 require "tek.cgi.request.args"
    13 require "tek.cgi.session"
    14 require "tek.web"
    15 require "tek.web.markup"
    16 require "tek.util"
    17 
    18 
    19 local boxed_G = { 
    20 	string = string, table = table,
    21 	assert = assert, collectgarbage = collectgarbage, dofile = dofile,
    22 	error = error, getfenv = getfenv, getmetatable = getmetatable,
    23 	ipairs = ipairs, load = load, loadfile = loadfile, loadstring = loadstring,
    24 	next = next, pairs = pairs, pcall = pcall, print = print,
    25 	rawequal = rawequal, rawget = rawget, rawset = rawset, require = require,
    26 	select = select, setfenv = setfenv, setmetatable = setmetatable,
    27 	tonumber = tonumber, tostring = tostring, type = type, unpack = unpack,
    28 	xpcall = xpcall
    29 }
    30 
    31 local table, string, assert, unpack, ipairs, pairs, type, require =
    32 	table, string, assert, unpack, ipairs, pairs, type, require
    33 local setmetatable, setfenv, getfenv = setmetatable, setfenv, getfenv
    34 local open, remove, rename, getenv, time =
    35 	io.open, os.remove, os.rename, os.getenv, os.time
    36 
    37 
    38 module "loona"
    39 
    40 
    41 _VERSION = 3
    42 _REVISION = 1
    43 
    44 
    45 local function lookupname(tab, val)
    46 	if tab then
    47 		for i, v in ipairs(tab) do
    48 			if v.name == val then
    49 				return i
    50 			end
    51 		end
    52 	end
    53 end
    54 
    55 
    56 local atom = { }
    57 
    58 function atom:new(o)
    59 	o = o or { }
    60 	setmetatable(o, self)
    61 	self.__index = self
    62 	return o
    63 end
    64 
    65 local loona = atom:new(getfenv())
    66 
    67 
    68 function loona:dbmsg(msg, detail)
    69  	return (msg and detail and self.authuser) and
    70  		("%s : %s"):format(msg, detail) or msg
    71 end
    72 
    73 
    74 function loona:checkprofilename(n)
    75 	assert(n:match("^%w+$") and n ~= "current",
    76 		self:dbmsg("Invalid profile name", n))
    77 	return n
    78 end
    79 
    80 
    81 function loona:checkbodyname(s)
    82 	s = s or "main"
    83 	assert(s:match("^[%w_]*%w+[%w_]*$"), self:dbmsg("Invalid body name", s))
    84 	return s
    85 end
    86 
    87 
    88 function loona:deleteprofile(p, lang)
    89 	p = self.config.contentdir .. "/" .. p .. "_" .. (lang or self.lang)
    90 	for e in tek.util.readdir(p) do
    91  		local success, msg = remove(p .. "/" .. e)
    92 		assert(success, self:dbmsg("Error removing entry in profile", msg))
    93 	end
    94 	return remove(p)
    95 end
    96 
    97 
    98 function loona:copyprofile(newprofile, srcprofile, lang)
    99 	srcprofile = srcprofile or self.profile
   100 	lang = lang or self.lang
   101 	local contentdir = self.config.contentdir
   102 	local src = ("%s/%s_%s"):format(contentdir, srcprofile or self.profile, 
   103 		lang)
   104 	assert(posix.stat(src, "mode") == "directory",
   105 		self:dbmsg("Not a directory", src))
   106 	local dst = ("%s/%s_%s"):format(contentdir, newprofile, lang)
   107 	local success, msg = posix.mkdir(dst)
   108 	assert(success, self:dbmsg("Error creating profile directory", msg))
   109 	for e in tek.util.readdir(src) do
   110 		local ext = e:match("^[^.].*%.([^.]*)$")
   111 		if ext ~= "LOCK" then
   112 			local f = src .. "/" .. e
   113 			if posix.stat(f, "mode") == "file" then
   114 				success, msg = tek.copyfile(f, dst .. "/" .. e)
   115 				assert(success, self:dbmsg("Error copying file", msg))
   116 			end
   117 		end
   118 	end
   119 end
   120 
   121 
   122 function loona:publishprofile(profile, lang)
   123 	lang = lang or self.lang
   124 	local contentdir = self.config.contentdir
   125 	local newpath = ("%s/current_%s"):format(contentdir, lang)
   126 	local tmppath = newpath .. "." .. self.session.name
   127 	local success, msg = posix.symlink(profile .. "_" .. lang, tmppath)
   128 	assert(success, self:dbmsg("Cannot create symlink", msg))
   129 	success, msg = rename(tmppath, newpath)
   130 	assert(success, self:dbmsg("Cannot put symlink in place", msg))
   131 	
   132 	-- get languages of the current profile
   133 	
   134 	local plangs = { }
   135 	local lmatch = "^" .. self.profile .. "_(%w+)$"
   136 	for e in tek.util.readdir(self.config.contentdir) do
   137 		local l = e:match(lmatch)
   138 		if l then
   139 			table.insert(plangs, l)
   140 		end
   141 	end
   142 	
   143 	-- Unroll site to static HTML
   144 	
   145 	for _, lang in ipairs(plangs) do
   146 		local ext = (#plangs == 1 and ".html") or (".html." .. lang)
   147 		self:recursesections(self.sections, function(self, s, e, path)
   148 			path = path and path .. "/" .. e.name or e.name
   149 			if not e.notvisible then
   150 				loona:dumphtml { requestpath = path, requestlang = lang,
   151 					htmlext = ext }
   152 			end
   153 			return path
   154 		end)
   155 	end
   156 	
   157 	-- Update file cache
   158 
   159 	local htdocs = self.config.htdocsdir
   160 	local cache = self.config.htmlcachedir
   161 
   162 	for e in tek.util.readdir(cache) do
   163 		local f = e:match("^.*%.html%.?(%w*)$")
   164 		if f and f ~= "tmp" then
   165 			local success, msg = remove(htdocs .. "/" .. e)
   166 			success, msg = remove(cache .. "/" .. e)
   167  			assert(success,
   168  				self:dbmsg("Could not purge cached HTML file", msg))
   169 		end
   170 	end
   171 	for e in tek.util.readdir(cache) do
   172 		local f = e:match("^(.*%.html%.?%w*)%.tmp$")
   173 		if f then
   174 			local success, msg = rename(cache .. "/" .. e, cache .. "/" .. f)
   175 			assert(success,
   176 				self:dbmsg("Could not update cached HTML file", msg))
   177 			success, msg = rename(htdocs .. "/" .. e, htdocs .. "/" .. f)
   178 			assert(success,
   179 				self:dbmsg("Could not update cached HTML file", msg))
   180 		end
   181 	end
   182 end
   183 
   184 
   185 function loona:recursesections(s, func, ...)
   186 	for _, e in ipairs(s) do
   187 		local udata = { func(self, s, e, unpack(arg)) }
   188 		if e.subs then
   189 			self:recursesections(e.subs, func, unpack(udata))
   190 		end
   191 	end
   192 end
   193 
   194 
   195 function loona:indexsections()
   196 	self:recursesections(self.sections, function(self, s, e)
   197 		e.notvalid = (not self.secure and e.secure) or 
   198 			(not self.authuser and e.secret) or nil
   199 		e.notvisible = e.notvalid or not self.authuser and e.hidden or nil
   200 		s[e.name] = e
   201 	end)
   202 end
   203 
   204 
   205 --	Decompose section path into a stack of sections, returning only up to
   206 --	the last valid element in the path. additionally returns the table of
   207 --	the last section path element (or the default section)
   208 
   209 function loona:getsection(path)
   210 	local default = not self.authuser and self.config.defname
   211 	local tab = { { entries = self.sections, name = default } }
   212 	local ss = self.sections
   213 	local sectionpath
   214 	(path or ""):gsub("(%w+)/?", function(a)
   215 		if ss then
   216 			local s = ss[a]
   217 			if s and not s.notvalid then
   218 				sectionpath = s
   219 				tab[#tab].name = a
   220 				ss = s.subs
   221 				if ss then
   222 					table.insert(tab, { entries = ss })
   223 				end
   224 			else
   225 				ss = nil -- stop.
   226 			end
   227 		end
   228 	end)
   229 	if not self.section and not sectionpath then
   230 		sectionpath = self.sections[default]
   231 		if sectionpath then
   232 			table.insert(tab, { entries = sectionpath.subs })
   233 		end
   234 	end
   235 	return tab, sectionpath
   236 end
   237 
   238 
   239 function loona:getpath(delimiter)
   240 	local t = { }
   241 	for _, menu in ipairs(self.submenus) do
   242 		if menu.name then
   243 			table.insert(t, menu.name)
   244 		end
   245 	end
   246 	return table.concat(t, delimiter or "/")
   247 end
   248 
   249 
   250 function loona:deletesection(fname, all_bodies)
   251 	local fullname = self.contentdir .. "/" .. fname
   252 	local success, msg = remove(fullname)
   253 	if all_bodies then
   254 		local pat = "^" .. 
   255 			fname:gsub("%^%$%(%)%%%.%[%]%*%+%-%?", "%%%1") .. "%..*$"
   256 		for e in tek.util.readdir(self.contentdir) do
   257 			if e:match(pat) then
   258 				remove(self.contentdir .. "/" .. e)
   259 			end
   260 		end
   261 	end
   262 	return success, msg
   263 end
   264 
   265 
   266 function loona:addpath(path, e)
   267 	local tab = self.sections
   268 	path:gsub("(%w+)/?", function(a)
   269 		if tab then
   270 			local s = tab[a]
   271 			if s then
   272 				if not s.subs then
   273 					s.subs = { }
   274 				end
   275 				tab = s.subs
   276 			else
   277 				table.insert(tab, e)
   278 				tab[a] = e
   279  				tab = nil -- stop
   280 			end
   281 		end
   282 	end)
   283 	return e
   284 end
   285 
   286 
   287 function loona:rmpath(path)
   288 	local parent
   289 	local tab = self.sections
   290 	path:gsub("(%w+)/?", function(a)
   291 		if tab then
   292 			local idx = lookupname(tab, a)
   293 			if idx then
   294 				if tab[idx].subs then
   295 					parent = tab[idx]
   296 					tab = tab[idx].subs
   297 				else
   298 					table.remove(tab, idx)
   299 					tab[a] = nil
   300 					if #tab == 0 and parent then
   301 						parent.subs = nil
   302 					end
   303 					tab = nil
   304 				end
   305 			end
   306 		end
   307 	end)
   308 end
   309 
   310 
   311 function loona:checkpath(path)
   312 	if path ~= "index" then -- "index" is reserved
   313 		local res, idx
   314 		local tab = self.sections
   315 		path:gsub("(%w+)/?", function(a)
   316 			if tab then
   317 				local i = lookupname(tab, a)
   318 				if i then
   319 					res, idx = tab, i
   320 					tab = tab[i].subs
   321 				else
   322 					res, idx = nil, nil
   323 				end
   324 			end
   325 		end)
   326 		return res, idx
   327 	end
   328 end
   329 
   330 
   331 function loona:title()
   332 	return self.section and (self.section.title or self.section.label or
   333 		self.section.name) or ""
   334 end
   335 
   336 
   337 --	Run a site function snippet, with full error recovery
   338 --	(also recovers from errors in error handling function)
   339 
   340 function loona:dosnippet(func, errfunc)
   341 	local ret = { tek.catch(func) }
   342 	if ret[1] == 0 or (errfunc and tek.catch(errfunc) == 0) then
   343 		return unpack(ret)
   344 	end
   345 	self:out("<h2>Error</h2>")
   346 	self:out("<h3>" .. self:encodeform(ret[2] or "") .. "</h3>")
   347 	if self.authuser then
   348 		if type(ret[3]) == "string" then
   349 			self:out("<p>" .. self:encodeform(ret[3]) .. "</p>")
   350 		end
   351 		if ret[4] then
   352 			self:out("<pre>" .. self:encodeform(ret[4]) .. "</pre>")
   353 		end
   354 	end
   355 end	
   356 
   357 
   358 function loona:lockfile(file)
   359 	return not self.session and true or 
   360 		posix.symlink(self.session.filename, file .. ".LOCK")
   361 end
   362 
   363 
   364 function loona:unlockfile(file)
   365 	return not self.session and true or remove(file .. ".LOCK")
   366 end
   367 
   368 
   369 function loona:saveindex()
   370 	local tempname = self.indexfname .. "." .. self.session.name
   371 	local f, msg = open(tempname, "wb")
   372 	assert(f, self:dbmsg("Error opening section file for writing", msg))
   373 	tek.dump(self.sections, function(...)
   374 		f:write(unpack(arg))
   375 	end)
   376 	f:close()
   377 	local success, msg = rename(tempname, self.indexfname)
   378 	assert(success, self:dbmsg("Error renaming section file", msg))
   379 end
   380 
   381 
   382 function loona:savebody(fname, content)
   383 	fname = self.contentdir .. "/" .. fname
   384 	local f, msg = open(fname, "wb")
   385 	assert(f, self:dbmsg("Could not open file for writing", msg))
   386 	f:write(content or "")
   387 	f:close()
   388 end
   389 
   390 
   391 function loona:runboxed(func, envitems, ...)
   392 	local fenv = {
   393  		arg = arg,
   394  		loona = self
   395  	}
   396  	if envitems then
   397 	 	for k, v in pairs(envitems) do
   398  			fenv[k] = v
   399  		end
   400  	end
   401  	setmetatable(fenv, { __index = boxed_G }) -- boxed global environment
   402 	setfenv(func, fenv)
   403 	return func()
   404 end
   405 
   406 
   407 function loona:include(fname, ...)
   408 	assert(not fname:match("%W"), self:dbmsg("Invalid include name", fname))
   409 	local fname2 = ("%s/%s.lua"):format(self.config.extdir, fname)
   410 	local f, msg = open(fname2)
   411 	assert(f, self:dbmsg("Cannot open file", msg))
   412 	local parsed, msg = self:loadhtml(f, "loona:out", fname2)
   413 	assert(parsed, self:dbmsg("Syntax error", msg))
   414 	return self:runboxed(parsed)
   415 end
   416 
   417 
   418 --	produce link target, propagate lang, profile, session
   419 
   420 function loona:href(section, ...)
   421 	local target = self:getdocname(section)
   422 -- 	if self.session or self.profile ~= self.pubprofile then
   423 	if self.session then
   424 		return tek.web.gethref(target, { "profile", "session", "lang", 
   425 			unpack(arg) })
   426 	end
   427 	return tek.web.gethref(target, { "lang", unpack(arg) })
   428 end
   429 
   430 function loona:ilink(target, text, extra)
   431 	return ('<a href="%s"%s>%s</a>'):format(target, extra or "", text)
   432 end
   433 
   434 --	internal link, propagation of lang, profile, session
   435 
   436 function loona:link(section, text, ...)
   437 	return self:ilink(self:href(section, unpack(arg)), text or section)
   438 end
   439 
   440 --	external link (opens in a new window), no argument propagation
   441 
   442 function loona:elink(target, text)
   443 	return self:ilink(target, text or target,
   444 		not self.config.extlinksamewindow and
   445 		' class="extlink" onclick="void(window.open(this.href, \'\', \'\')); return false;"')
   446 end
   447 
   448 --	plain link, no argument propagation
   449 
   450 function loona:plink(section, text, ...)
   451 	return self:ilink(tek.web.gethref(section, arg), text or section)
   452 end
   453 
   454 --	user interface link, propagation of lang, profile, session
   455 
   456 function loona:uilink(section, text, ...)
   457 	return self:ilink(self:href(section, unpack(arg)), text or section)
   458 end
   459 
   460 --	produce a hidden input value in forms
   461 
   462 function loona:hidden(name, value)
   463 	return not value and "" or
   464 		('<input type="hidden" name="%s" value="%s" />'):format(name, value)
   465 end
   466 
   467 
   468 function loona:getprofiles(lang)
   469 	lang = lang or self.lang
   470 	local dir = self.config.contentdir
   471 	local t = { }
   472 	for f in tek.util.readdir(dir) do
   473 		if posix.lstat(dir .. "/" .. f, "mode") == "directory" then
   474 			local e = f:match("^(%w+)_" .. lang .. "$")
   475  			if e then
   476 	 			t[e] = e
   477  	 		end
   478 		end
   479 	end
   480 	return t
   481 end
   482 
   483 
   484 --	Functions to produce a hierarchical navigation menu
   485 
   486 local newent = { name = "new", label = "[+]", action="actionnew=true" }
   487 
   488 function loona:rmenu(level, linkf, path, addnew)
   489 	local sub = (addnew and level == #self.submenus + 1) and
   490 		{ name = "new", entries = { }} or self.submenus[level]
   491  	if sub and sub.entries then
   492 		local visible = { }
   493 		for _, e in ipairs(sub.entries) do
   494 			if not e.notvisible then
   495 				table.insert(visible, e)
   496 			end
   497 		end
   498 		if addnew then
   499 			table.insert(visible, newent)
   500 		end
   501 		if #visible > 0 then
   502 			self:out('<ul id="menulevel' .. level .. '">\n')
   503 			for _, e in ipairs(visible) do
   504 				local label = self:encodeform(e.label or e.name)
   505 				local newpath = path and path .. "/" .. e.name or e.name
   506 				local active = (e.name == sub.name)
   507 				self:out('<li>\n')
   508 				linkf(self, newpath, label, active, e.action)
   509 				if active then
   510 					self:rmenu(level + 1, linkf, newpath)
   511 				end
   512 				self:out('</li>\n')
   513 			end
   514 			self:out('</ul>\n')
   515 		end
   516 	end
   517 end
   518 
   519 function loona:menulink(path, label, active, ...)
   520 	self:out(('<a %shref="%s">%s</a>\n'):format(active and 
   521 		'class="active" ' or "", self:href(path, unpack(arg)), label))
   522 end
   523 
   524 function loona:menu(level, linkf)
   525 	local addnew = self.authuser and not self.ispubprofile
   526 	self:rmenu(level or 1, linkf or menulink, nil, addnew)
   527 end
   528 
   529 
   530 function loona:init()
   531 	
   532 	-- get list of languages, in order of preference
   533 	-- TODO: respect quality parameter, not just order
   534 	
   535 	local l = self.requestlang or self.args.lang
   536 	self.langs = { l and l:match("^%w+$") }
   537 	if self.config.browserlang then
   538 		local s = getenv("HTTP_ACCEPT_LANGUAGE")
   539 		while s do
   540 			local l, r = s:match("^([%w.=]+)[,;](.*)$")
   541 			l = l or s
   542 			s = r
   543 			if l:match("^%w+$") then
   544 				table.insert(self.langs, l)
   545 			end
   546 		end
   547 	end
   548 	table.insert(self.langs, self.config.deflang)
   549 	
   550 	-- get list of possible profiles
   551 	
   552 	local profiles = { }
   553 	for e in tek.util.readdir(self.config.contentdir) do
   554 		profiles[e] = e
   555 	end
   556 	
   557 	-- get pubprofile
   558 	
   559 	for _, lang in ipairs(self.langs) do
   560 		local p = posix.readlink(self.config.contentdir .. "/current_" .. lang)
   561 		p = p and p:match("^(%w+)_" .. lang .. "$")
   562 		if p then
   563 			self.pubprofile = p
   564 			break
   565 		end
   566 	end
   567 	
   568 	-- get profile
   569 	
   570 	local checkprofile =
   571 		self.authuser and self.args.profile or self.pubprofile or "default"
   572 	for _, lang in ipairs(self.langs) do
   573 		if profiles[checkprofile .. "_" .. lang] then
   574 			self.profile = checkprofile
   575 			self.lang = lang
   576 			break
   577 		end
   578 	end
   579 	
   580 	assert(self.profile and self.lang, "Invalid profile or language")
   581 	
   582 	
   583 	self.ispubprofile = self.profile == self.pubprofile
   584 	
   585 	-- write back language and profile
   586 
   587 	self.args.lang = self.lang ~= self.config.deflang and self.lang or nil
   588 	self.args.profile = self.profile
   589 	
   590 	
   591 	-- determine content directory pathname and section filename
   592 	
   593 	self.contentdir =
   594 		("%s/%s_%s"):format(self.config.contentdir, self.profile, self.lang)
   595  	self.indexfname = self.contentdir .. "/.sections"
   596 	
   597 	-- load sections
   598 	
   599  	self.sections = tek.source(self.indexfname)
   600 	
   601 	-- index sections, determine visibility in menu
   602 	
   603 	self:indexsections()
   604 	
   605 	-- decompose request path, produce a stack of sections
   606 	
   607 	self.submenus, self.section = self:getsection(self.requestpath)
   608 
   609 	-- handle redirects if not logged on
   610 	
   611 	if not self.authuser and self.section and self.section.redirect then
   612 		self.submenus, self.section = self:getsection(self.section.redirect)
   613 	end
   614 			
   615 	-- section path and document name (refined)
   616 	
   617 	self.sectionpath = self:getpath()
   618 	self.sectionname = self:getpath("_")
   619 
   620 end
   621 
   622 
   623 function loona:handlechanges()
   624 	
   625 	local save
   626 
   627 	if self.args.editkey == "main" then
   628 		
   629 		-- In main editable section:
   630 		
   631 		if self.args.actioncreate then
   632 			
   633 			-- Create new section
   634 			
   635 			local editname = self.args.editname:lower()
   636 			assert(not editname:match("%W"),
   637 				dbmsg("Invalid section name", editname))
   638 			if not (section and (section.subs or section)[editname]) then
   639 				local newpath = (self.sectionpath and 
   640 					(self.sectionpath .. "/")) .. editname
   641 				local s = self:addpath(newpath, { name = editname,
   642 					label = self.args.editlabel ~= "" and
   643 						self.args.editlabel or nil,
   644 					title = self.args.edittitle ~= "" and
   645 						self.args.edittitle or nil,
   646 					redirect = self.args.editredirect ~= "" and
   647 						self.args.editredirect or nil,
   648 					hidden = self.args.editvisibility and true,
   649 					secret = self.args.editsecrecy and true,
   650 					secure = self.args.editsecure and true,
   651 					creator = self.authuser,
   652 					creationdate = time() })
   653 				save = true
   654 			end
   655 		
   656 		elseif self.args.actionsave then
   657 			
   658 			-- Save section
   659 			
   660 			self.section.revisiondate = time()
   661 			self.section.revisioner = self.authuser
   662 			save = true
   663  		
   664 		elseif self.args.actionsaveprops then
   665 			
   666 			-- Save properties
   667 			
   668 			self.section.hidden = self.args.editvisibility and true
   669 			self.section.secret = self.args.editsecrecy and true
   670 			self.section.secure = self.args.editsecure and true
   671 			self.section.label = self.args.editlabel ~= "" and
   672 				self.args.editlabel or nil
   673 			self.section.title = self.args.edittitle ~= "" and
   674 				self.args.edittitle or nil
   675 			self.section.redirect =
   676 				self.args.editredirect ~= "" and self.args.editredirect or nil
   677 			save = true
   678 		
   679 		elseif self.args.actionup then
   680 			
   681 			-- Move section up
   682 			
   683 			local t, i = self:checkpath(self.sectionpath)
   684 			if t and i > 1 then
   685 				if self.ispubprofile and not self.args.actionconfirm then
   686 					useralert = {
   687 						text = self.locale.ALERT_MOVE_SECTION_IN_PUBLISHED_PROFILE,
   688 						confirm =
   689 							'<input type="submit" name="actionup" value="' .. 
   690 							self.locale.MOVE .. '" /> ' ..
   691 							self:hidden("actionconfirm", "true")
   692 					}
   693 				else
   694 					local item = table.remove(t, i)
   695 					table.insert(t, i - 1, item)
   696 					save = true
   697 				end
   698 			end
   699 		
   700 		elseif self.args.actiondown then
   701 			
   702 			-- Move section down
   703 			
   704 			local t, i = self:checkpath(self.sectionpath)
   705 			if t and i < #t then
   706 				if self.ispubprofile and not self.args.actionconfirm then
   707 					useralert = {
   708 						text = self.locale.ALERT_MOVE_SECTION_IN_PUBLISHED_PROFILE,
   709 						confirm =
   710 							'<input type="submit" name="actiondown" value="' .. 
   711 							self.locale.MOVE .. '" /> ' ..
   712 							self:hidden("actionconfirm", "true")
   713 					}
   714 				else
   715 					local item = table.remove(t, i)
   716 					table.insert(t, i + 1, item)
   717 					save = true
   718 				end
   719 			end
   720 		
   721 		elseif self.args.actioncreateprofile and self.args.createprofile then
   722 			
   723 			-- Create profile
   724 			
   725 			local c = self:checkprofilename(self.args.createprofile:lower())
   726 			if c == profile then
   727 				useralert = { text = self.locale.ALERT_CANNOT_COPY_PROFILE_TO_SELF }
   728 			else
   729 				local profiles = self:getprofiles()
   730 				if profiles[c] and not self.args.actionconfirm then
   731 					useralert = {
   732 						text = c == self.pubprofile and 
   733 							self.locale.ALERT_OVERWRITE_PUBLISHED_PROFILE or
   734 							self.locale.ALERT_OVERWRITE_EXISTING_PROFILE,
   735 						confirm =
   736 							'<input type="submit" name="actioncreateprofile" value="' .. 
   737 							self.locale.OVERWRITE .. '" /> ' ..
   738 							self:hidden("actionconfirm", "true") .. 
   739 							self:hidden("createprofile", c)
   740 					}
   741 				else
   742 					if profiles[c] then
   743 						self:deleteprofile(c)
   744 					end
   745 					self:copyprofile(c)
   746 				end
   747 			end
   748 		
   749 		elseif self.args.actiondeleteprofile and self.args.deleteprofile then
   750 			
   751 			-- Delete profile
   752 			
   753 			local c = self:checkprofilename(self.args.deleteprofile:lower())
   754 			assert(c ~= self.pubprofile, self:dbmsg("Cannot delete published profile", c))
   755 			if self.args.actionconfirm then
   756 				self:deleteprofile(c)
   757 				self.profile = nil
   758 				self.args.profile = nil
   759 				self:init()
   760 				save = true
   761 			else
   762 				useralert = { 
   763 					text = self.locale.ALERT_DELETE_PROFILE,
   764 					confirm = 
   765 						'<input type="submit" name="actiondeleteprofile" value="' .. 
   766 						self.locale.DELETE .. '" /> ' ..
   767 						self:hidden("actionconfirm", "true") ..
   768 						self:hidden("deleteprofile", c)
   769 				}
   770 			end
   771 		
   772 		elseif self.args.actionchangeprofile and self.args.changeprofile then
   773 			
   774 			-- Change profile
   775 			
   776 			local c = self:checkprofilename(self.args.changeprofile:lower())
   777 			self.profile = c
   778 			self.args.profile = c
   779 			save = true
   780 		
   781 		elseif self.args.actionpublishprofile and self.args.publishprofile then
   782 			
   783 			-- Publish profile
   784 			
   785 			local c = self:checkprofilename(self.args.publishprofile:lower())
   786 			if c ~= self.publicprofile then
   787 				if self.args.actionconfirm then
   788 					self:publishprofile(c)
   789 					save = true
   790 				else
   791 					useralert = {
   792 						text = self.locale.ALERT_PUBLISH_PROFILE,
   793 						confirm =
   794 							'<input type="submit" name="actionpublishprofile" value="' ..
   795 							self.locale.PUBLISH .. '" /> ' ..
   796 							self:hidden("actionconfirm", "true") ..
   797 							self:hidden("publishprofile", c)
   798 					}
   799 				end
   800 			end
   801 		end
   802 		
   803 	end
   804 	
   805 	if self.args.actiondelete then
   806 		
   807 		-- Delete section
   808 		
   809 		if not self.args.actionconfirm then
   810 			useralert = {
   811 				text = self.ispubprofile and
   812 					self.locale.ALERT_DELETE_SECTION_IN_PUBLISHED_PROFILE or
   813 					self.locale.ALERT_DELETE_SECTION,
   814 				confirm =
   815 					'<input type="submit" name="actiondelete" value="' .. 
   816 					self.locale.DELETE .. '" /> ' ..
   817 					self:hidden("actionconfirm", "true")
   818 			}
   819 		else
   820 			local key = self.args.editkey
   821 			if key == "main" and not self.section.subs then
   822 				self:deletesection(self.sectionname, true) -- all bodies
   823 				self:rmpath(self.sectionpath) -- and node
   824 			else
   825 				local ext = (key == "main" and "") or "." .. key
   826 				self:deletesection(self.sectionname .. ext) -- only text
   827 				if self.section.dynamic then
   828 					self.section.dynamic[key] = nil
   829 					local n = 0
   830 					for _ in pairs(self.section.dynamic) do
   831 						n = n + 1
   832 					end
   833 					if n == 0 then
   834 						self.section.dynamic = nil
   835 					end
   836 				end
   837 			end
   838 			save = true
   839 		end
   840 	end
   841 		
   842 	if save then
   843 		self:saveindex()
   844 		self:init()
   845 	end
   846 	
   847 end
   848 
   849 
   850 function loona:encodeform(s)
   851 	return cgi.encodeform(s)
   852 end
   853 
   854 
   855 function loona:loadhtml(src, outfunc, chunkname)
   856 	return tek.web.include.load(src, outfunc, chunkname)
   857 end
   858 
   859 
   860 function loona:domarkup(s)
   861 	return tek.web.markup.main(s)
   862 end
   863 
   864 
   865 function loona:expire(dir, pat, maxage)
   866 	return tek.util.expire(dir, pat, maxage)
   867 end
   868 
   869 --
   870 --	Get pathname of an existing content file that
   871 --	the current path is determined by or defaults to
   872 --
   873 
   874 function loona:getsectionpath(bodyname, requestpath)
   875 	local ext = (not bodyname or bodyname == "main") and "" or "." .. bodyname
   876 	local t, path, section = { }
   877 	for _, menu in ipairs(self.submenus) do
   878 		if menu.entries and menu.entries[menu.name] then
   879 			table.insert(t, menu.name)
   880 			local fn = table.concat(t, "_")
   881 			if posix.stat(self.contentdir .. "/" .. fn .. ext, "mode") == "file" then
   882 				path, section = fn, menu
   883 			end
   884 		end
   885 	end
   886 	return path, ext, section
   887 end
   888 
   889 
   890 function loona:body(name)
   891 	name = self:checkbodyname(name)
   892 	local path, ext = self:getsectionpath(name)
   893 	self:dosnippet(self.editable(name, path and path .. ext, self.sectionname .. ext))
   894 end
   895 
   896 
   897 function loona:new(o)
   898 
   899 	local parsed, msg
   900 	
   901 	o = o or { }
   902 	o = atom.new(self, o)
   903 	
   904 	o.out = o.out or function(self, s) tek.web.out(s) end
   905 	o.setheader = o.setheader or function(self, s) tek.web.setheader(s) end
   906 	
   907 	-- Get configuration
   908 	
   909 	o.config = o.config or tek.source(o.conffile or "../etc/config.lua") or { }
   910 	o.config.defname = o.config.defname or "home"
   911 	o.config.deflang = o.config.deflang or "en"
   912 	o.config.sessionmaxage = o.config.sessionmaxage or 600
   913 	o.config.secureport = o.config.secureport or 443
   914 	o.config.passwdfile = posix.abspath(o.config.passwdfile or "../etc/passwd.lua")
   915 	o.config.sessiondir = posix.abspath(o.config.sessiondir or "../var/sessions")
   916 	o.config.extdir = posix.abspath(o.config.extdir or "../extensions")
   917 	o.config.contentdir = posix.abspath(o.config.contentdir or "../content")
   918 	o.config.localedir = posix.abspath(o.config.localedir or "../locale")
   919 	o.config.htdocsdir = posix.abspath(o.config.htdocsdir or "../htdocs")
   920 	o.config.htmlcachedir = posix.abspath(o.config.htmlcachedir or "../var/htmlcache")
   921 	
   922 	-- Create proxy for on-demand loading of locales
   923 	
   924 	o.locale = { }
   925 	local locmt = { }
   926 	locmt.__index = function(_, key)
   927 		for _, l in ipairs(o.langs) do
   928 			locmt.__locale = tek.source(o.config.localedir .. "/" .. l)
   929 			if locmt.__locale then
   930 				break
   931 			end
   932 		end
   933 		locmt.__index = function(tab, key)
   934 			return locmt.__locale[key] or key
   935 		end
   936 		return locmt.__locale[key] or key
   937 	end
   938 	setmetatable(o.locale, locmt)
   939 	
   940 	-- Get request, args, document, script name, request path
   941 	
   942 	o.request = o.request or cgi.request
   943 	o.args = o.args or cgi.request.args
   944 	o.session = o.session or cgi.session
   945  	
   946  	o.scriptpath = o.scriptpath or cgi.document.Path
   947 	o.requesthandler = o.requesthandler or cgi.document.Handler
   948  	o.requestdocument = o.requestdocument or cgi.document.Name
   949 	o.requestpath = o.requestpath or cgi.document.VirtualPath
   950 
   951 	-- Manage login and establish session
   952 	
   953 	o.session.init(o.config.sessiondir, o.args.session, o.config.sessionmaxage)
   954 	if o.args.login then
   955 		if o.args.login == "false" then
   956 			o.session.delete()
   957 			o.session = nil
   958 		elseif o.args.password then
   959 			o.loginfailed = true
   960 			local pwddb = tek.source(o.config.passwdfile)
   961 			local pwdentry = pwddb[o.args.login]
   962 			if pwdentry and pwdentry.password == o.args.password then
   963 				o.session.data.authuser = pwdentry.username
   964 				o.session.data.id = o.session.id
   965 				o.loginfailed = nil
   966 			end
   967 		end
   968 	end
   969 	
   970 	o.secure = o.request.Port == o.config.secureport
   971 	o.authuser = o.session and o.session.data.authuser
   972 	
   973 	if o.nologin or not o.authuser then
   974 		o.authuser = nil
   975 		o.session = nil
   976 -- 		o.args.session = nil -- TODO?
   977 	end
   978 	
   979 	-- Get lang, locale, profile, section
   980 	
   981 	o:init()
   982 	if o.authuser then
   983 		o:handlechanges()
   984 	end
   985 	
   986 	-- Current document
   987 	
   988 	o.document = o.requestdocument .. "/" .. o.sectionpath
   989 	if o.authuser then
   990 		o.getdocname = function(self, path)
   991 			return path and self.requestdocument .. "/" .. path or self.requestdocument
   992 		end
   993 	else
   994 		o.getdocname = function(self, path)
   995 			path = path or self.config.defname
   996 			if self:isdynamic(path) then
   997 				return self.requestdocument .. "/" .. path
   998 			end
   999 			path = path == self.config.defname and "index" or path
  1000 			return "/" .. path:gsub("/", "_") .. ".html"
  1001 		end
  1002 	end
  1003 	
  1004 	-- Create "editable section" function
  1005 	
  1006 	local func, msg = o:loadhtml(open("loona/editable.lua"),
  1007 		"loona:out", "loona/editable.lua")
  1008  	assert(func, o:dbmsg("Syntax error", msg))
  1009 	o.editable = o:runboxed(func)
  1010 	
  1011 	-- Save session state
  1012 	
  1013 	if o.session then
  1014 		o.session.save()
  1015 	end
  1016 
  1017 	return o
  1018 end
  1019 
  1020 
  1021 function loona:execute(fname)
  1022 	self:indexdynamic()
  1023 	fname = fname or self.requesthandler
  1024 	local parsed, msg = self:loadhtml(open(fname), "loona:out", fname)
  1025 	assert(parsed, self:dbmsg("HTML/Lua parsing failed", msg))
  1026 	self:runboxed(parsed)
  1027 	
  1028 	
  1029 		local fh = open("/tmp/dump", "wb")
  1030 		tek.dump(self, function(s) fh:write(s) end)
  1031 	
  1032 	
  1033 	return self
  1034 end
  1035 
  1036 
  1037 function loona:indexdynamic()
  1038 	self:recursesections(self.sections, function(self, s, e, path, dynamic)
  1039 		path = path and path .. "_" .. e.name or e.name
  1040 		dynamic = dynamic or { }
  1041 		for k in pairs(e.dynamic or { }) do
  1042 			dynamic[k] = true
  1043 		end
  1044 		for k in pairs(dynamic) do
  1045 			local ext = (k == "main" and "") or "." .. k
  1046 			if posix.stat(self.contentdir .. "/" .. path .. ext, "mode") == "file" then
  1047 				dynamic[k] = e.dynamic and e.dynamic[k]
  1048 			end
  1049 		end
  1050 		local n = 0
  1051 		for k in pairs(dynamic) do
  1052 			n = n + 1
  1053 		end
  1054 		if n > 0 then
  1055 			e.dynamic = { }
  1056 			for k in pairs(dynamic) do
  1057 				e.dynamic[k] = true
  1058 			end
  1059 		else
  1060 			e.dynamic = nil
  1061 		end
  1062 		return path, dynamic
  1063 	end)
  1064 end
  1065 
  1066 
  1067 function loona:isdynamic(path)
  1068 	path = path or self.sectionpath
  1069 	local t, i = self:checkpath(path)
  1070 	return t and t[i].dynamic
  1071 end
  1072 
  1073 
  1074 function loona:dumphtml(o)
  1075 	local outbuf = { }
  1076 	o = o or { }
  1077 	o.nologin = true
  1078 	o.out = function(self, s) table.insert(outbuf, s) end
  1079 	o.setheader = function(self, s) end
  1080 	o = self:new(o):execute()
  1081 	if not o:isdynamic() then
  1082 		local path = o.sectionname
  1083 		path = path == o.config.defname and "index" or path
  1084 		local srcname = o.config.htdocsdir .. "/" .. path .. o.htmlext
  1085 		local dstname = o.config.htmlcachedir .. "/" .. path .. o.htmlext .. ".tmp"
  1086 		local fh, msg = open(srcname .. ".tmp", "wb")
  1087 		assert(fh, self:dbmsg("Could not write cached HTML", msg))
  1088 		fh:write(unpack(outbuf))
  1089 		fh:close()
  1090 		local success, msg = posix.symlink(srcname, dstname)
  1091 -- 		assert(success, self:dbmsg("Could not link to cached HTML", msg))
  1092 	end
  1093 end