Software /
code /
prosody-modules
File
mod_http_muc_log/mod_http_muc_log.lua @ 1581:9f6cd252d233
mod_http_muc_log: Revamp template system
author | Kim Alvefur <zash@zash.se> |
---|---|
date | Tue, 02 Dec 2014 15:12:01 +0100 |
parent | 1580:63571115302f |
child | 1582:8e282eb0c70c |
line wrap: on
line source
local st = require "util.stanza"; local datetime = require"util.datetime"; local jid_split = require"util.jid".split; local nodeprep = require"util.encodings".stringprep.nodeprep; local uuid = require"util.uuid".generate; local it = require"util.iterators"; local gettime = require"socket".gettime; local url = require"socket.url"; local xml_escape = st.xml_escape; local t_concat = table.concat; local archive = module:open_store("muc_log", "archive"); -- Support both old and new MUC code local mod_muc = module:depends"muc"; local rooms = rawget(mod_muc, "rooms"); local each_room = rawget(mod_muc, "each_room") or function() return it.values(rooms); end; local new_muc = not rooms; if new_muc then rooms = module:shared"muc/rooms"; end local get_room_from_jid = rawget(mod_muc, "get_room_from_jid") or function (jid) return rooms[jid]; end local function get_room(name) local jid = name .. '@' .. module.host; return get_room_from_jid(jid); end module:depends"http"; local function render(template, values) --[[ DOC {name} is substituted for values["name"] and is XML escaped {name!} is substituted without XML escaping {name?} is optional and is replaced with an empty string if no value exists {name# sub-template } renders a sub-template using an array of values ]] return (template:gsub("%b{}", function (block) local name, opt, e = block:sub(2, -2):match("([%a_][%w_]*)(%p?)()"); local value = values[name]; if opt == '#' then if not value or not value[1] then return ""; end local out, subtpl = {}, block:sub(e+1, -2); for i=1, #value do out[i] = render(subtpl, value[i]); end return t_concat(out); end if value ~= nil then if type(value) ~= "string" then value = tostring(value); end if opt ~= '!' then return xml_escape(value); end return value; elseif opt == '?' then return block:sub(e+1, -2); end end)); end -- TODO Move template into a file local template = [=[ <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{title}</title> <style> :link,:visited{text-decoration:none;color:#2e3436;text-decoration:none;} :link:hover,:visited:hover{color:#3465a4;} body{background-color:#eeeeec;margin:1ex 0;padding-bottom:3em;font-family:Arial,Helvetica,sans-serif;} ul,ol{padding:0;} li{list-style:none;} hr{visibility:hidden;clear:both;} br{clear:both;} header,footer{margin:1ex 1em;} footer{font-size:smaller;color:#babdb6;} nav{font-size:large;margin:1ex 1ex;clear:both;line-height:1.5em;} footer nav .up{display:none;} @media screen and (min-width: 460px) { nav {font-size:x-large;margin:1ex 1em;} } nav a{padding: 1ex;} nav .up{font-size:smaller;display:block;clear:both;} nav .prev{float:left;} nav .next{float:right;} nav .next::after{content:" →";} nav .prev::before{content:"← ";} nav :empty::after,nav :empty::before{content:""} .content{background-color:white;padding:1em;list-style-position:inside;} .time{float:right;font-size:small;opacity:0.2;} li:hover .time{opacity:1;} .description{font-size:smaller;} .body{white-space:pre-line;} .body::before,.body::after{content:"";} .presence .verb{font-style:normal;color:#30c030;} .unavailable .verb{color:#c03030;} </style> </head> <body> <header> <h1 title="xmpp:{jid?}">{title}</h1> <nav>{links# <a class="{rel?}" href="{href}" rel="{rel?}">{text}</a>} </nav> </header> <hr> <div class="content"> <nav> <dl class="room-list"> {rooms# <dt class="name"><a href="{href}">{name}</a></dt> <dd class="description">{description?}</dd>} </dl> <ul class="dates">{dates# <li><a href="{date}">{date}</a></li>} </ul> </nav> <ol class="chat-logs">{lines# <li class="{st_name} {st_type?}" id="{key}"> <a class="time" href="#{key}"><time datetime="{datetime}">{time}</time></a> <b class="nick">{nick}</b> <em class="verb">{verb?}</em> <q class="body">{body?}</q> </li>} </ol> </div> <hr> <footer> <nav>{links# <a class="{rel?}" href="{href}" rel="{rel?}">{text}</a>} </nav> <br> <div class="powered-by">Prosody</div> </footer> <script> /* * Local timestamps */ (function () { var timeTags = document.getElementsByTagName("time"); var i = 0, tag, date; while(timeTags[i]) { tag = timeTags[i++]; if(date = tag.getAttribute("datetime")) { date = new Date(date); tag.textContent = date.toLocaleTimeString(); tag.setAttribute("title", date.toString()); } } })(); </script> </body> </html> ]=]; local base_url = module:http_url() .. '/'; local get_link do local link, path = { path = '/' }, { "", "", is_directory = true }; function get_link(room, date) path[1], path[2] = room, date; path.is_directory = not date; link.path = url.build_path(path); return url.build(link); end end local function public_room(room) if type(room) == "string" then room = get_room(room); end return (room and not (room.get_hidden or room.is_hidden)(room) and not (room.get_members_only or room.is_members_only)(room) and room._data.logging == true); end -- FIXME Invent some more efficient API for this local function dates_page(event, path) local request, response = event.request, event.response; local room = nodeprep(path:match("^(.*)/$")); if not room or not public_room(room) then return end local dates, i = {}, 1; module:log("debug", "Find all dates with messages"); local next_day; repeat local iter = archive:find(room, { ["start"] = next_day; limit = 1; with = "message<groupchat"; }) if not iter then break end next_day = nil; for key, message, when in iter do next_day = datetime.date(when); dates[i], i = { date = next_day; }, i + 1; next_day = datetime.parse(next_day .. "T23:59:59Z") + 1; break; end until not next_day; response.headers.content_type = "text/html; charset=utf-8"; return render(template, { title = get_room(room):get_name(); jid = get_room(room).jid; dates = dates; links = { { href = "../", rel = "up", text = "Back to room list" }, }; }); end local function logs_page(event, path) local request, response = event.request, event.response; local room, date = path:match("^(.-)/(%d%d%d%d%-%d%d%-%d%d)$"); room = nodeprep(room); if not room then return dates_page(event, path); end if not public_room(room) then return end local logs, i = {}, 1; local iter, err = archive:find(room, { ["start"] = datetime.parse(date.."T00:00:00Z"); ["end"] = datetime.parse(date.."T23:59:59Z"); -- with = "message<groupchat"; }); if not iter then return 500; end local first, last; local verb, subject, body; for key, item, when in iter do body = item:get_child_text("body"); subject = item:get_child_text("subject"); verb = nil; if subject then verb, body = "set the topic to", subject; elseif body and body:sub(1,4) == "/me " then verb, body = body:sub(5), nil; elseif item.name == "presence" then verb = item.attr.type == "unavailable" and "has left" or "has joined"; end if body or verb then logs[i], i = { key = key; datetime = datetime.datetime(when); time = datetime.time(when); verb = verb; body = body; nick = select(3, jid_split(item.attr.from)); st_name = item.name; st_type = item.attr.type; }, i + 1; end first = first or key; last = key; end if i == 1 then return end -- No items local next_when = ""; local prev_when = ""; module:log("debug", "Find next date with messages"); for key, message, when in archive:find(room, { after = last; limit = 1; }) do next_when = datetime.date(when); module:log("debug", "Next message: %s", datetime.datetime(when)); end module:log("debug", "Find prev date with messages"); for key, message, when in archive:find(room, { before = first; limit = 1; reverse = true; }) do prev_when = datetime.date(when); module:log("debug", "Previous message: %s", datetime.datetime(when)); end response.headers.content_type = "text/html; charset=utf-8"; return render(template, { title = ("%s - %s"):format(get_room(room):get_name(), date); jid = get_room(room).jid; lines = logs; links = { { href = "./", rel = "up", text = "Back to date list" }, { href = prev_when, rel = "prev", text = prev_when}, { href = next_when, rel = "next", text = next_when}, }; }); end local function list_rooms(event) local request, response = event.request, event.response; local room_list, i = {}, 1; for room in each_room() do if public_room(room) then room_list[i], i = { href = get_link(jid_split(room.jid), nil); name = room:get_name(); description = room:get_description(); }, i + 1; end end response.headers.content_type = "text/html; charset=utf-8"; return render(template, { title = module:get_option_string("name", "Prosody Chatrooms"); jid = module.host; rooms = room_list; }); end local cache = setmetatable({}, {__mode = 'v'}); local function with_cache(f) return function (event, path) local request, response = event.request, event.response; local ckey = path or ""; local cached = cache[ckey]; if cached then local etag = cached.etag; local if_none_match = request.headers.if_none_match; if etag == if_none_match then module:log("debug", "Client cache hit"); return 304; end module:log("debug", "Server cache hit"); response.headers.etag = etag; response.headers.content_type = "text/html; charset=utf-8"; return cached[1]; end local start = gettime(); local render = f(event, path); module:log("debug", "Rendering took %dms", math.floor( (gettime() - start) * 1000 + 0.5)); if type(render) == "string" then local etag = uuid(); cached = { render, etag = etag, date = datetime.date() }; response.headers.etag = etag; cache[ckey] = cached; end response.headers.content_type = "text/html; charset=utf-8"; return render; end end -- How is cache invalidation a hard problem? ;) module:hook("muc-broadcast-message", function (event) local room = event.room; local room_name = jid_split(room.jid); local today = datetime.date(); cache[get_link(room_name)] = nil; cache[get_link(room_name, today)] = nil; end); module:provides("http", { route = { ["GET /"] = list_rooms; ["GET /*"] = with_cache(logs_page); }; });