#!/usr/bin/env lua5.3
-- this software is licensed under the terms of the GNU affero public license
-- v3 or later. view LICENSE.txt for more information.
-- if you host your own instance, please change the email address in the about
-- page and clearly distinguish your instance from https://zzcxz.citrons.xyz.
local env = os.getenv
local f = io.open("/dev/urandom", 'r')
local e = f and f:read(1)
if f then f:close() end
math.randomseed(os.time() + string.byte(e))
local function url_encode(str)
return (str:gsub("([^A-Za-z0-9%_%.%-%~])", function(v)
return string.upper(string.format("%%%02x", string.byte(v)))
end))
end
local esc_sequences = {
["<"] = "<",
[">"] = ">",
['"'] = """
}
local function html_encode(x)
local escaped = tostring(x)
escaped = escaped:gsub("&", "&")
for char,esc in pairs(esc_sequences) do
escaped = string.gsub(escaped, char, esc)
end
return escaped
end
local function parse_qs(str,sep)
sep = sep or '&'
local function decode(str, path)
local str = str
if not path then
str = str:gsub('+', ' ')
end
str = str:gsub("%%(%x%x)", function(c)
return string.char(tonumber(c, 16))
end)
return (str:gsub('\r\n', '\n'))
end
local values = {}
for key,val in str:gmatch(string.format('([^%q=]+)(=*[^%q=]*)', sep, sep)) do
local key = decode(key)
local keys = {}
key = key:gsub('%[([^%]]*)%]', function(v)
-- extract keys between balanced brackets
if string.find(v, "^-?%d+$") then
v = tonumber(v)
else
v = decode(v)
end
table.insert(keys, v)
return "="
end)
key = key:gsub('=+.*$', "")
key = key:gsub('%s', "_") -- remove spaces in parameter name
val = val:gsub('^=+', "")
if not values[key] then
values[key] = {}
end
if #keys > 0 and type(values[key]) ~= 'table' then
values[key] = {}
elseif #keys == 0 and type(values[key]) == 'table' then
values[key] = decode(val)
end
local t = values[key]
for i,k in ipairs(keys) do
if type(t) ~= 'table' then
t = {}
end
if k == "" then
k = #t+1
end
if not t[k] then
t[k] = {}
end
if i == #keys then
t[k] = decode(val)
end
t = t[k]
end
end
return values
end
local cookies = env "HTTP_COOKIE" and parse_qs(env "HTTP_COOKIE","; ") or {}
local history = {}
if cookies.history then
for page in cookies.history:gmatch "(%w%w%w%w%w)%," do
table.insert(history, page)
end
end
local flags = {}
for k,v in pairs(cookies) do
local flag = k:match "flag_(.+)"
if flag then
flags[flag] = v
end
end
local function redirect(to)
return "", {
status = '303 see other',
headers = { location = to },
}
end
local function template(str)
return function (t)
return (str:gsub("$([A-Za-z][A-Za-z0-9]*)", function(v)
return t[v] or ""
end))
end
end
local base = template [[
zzcxz: $title
zzcxz
$content
]]
local not_found = function()
return
base {
title = "not found",
content = "the content requested was not found.",
}, { status = '404 not found' }
end
local function parse_directive(line, directives, tmp_flags)
flags = flags or {}
local directive, args = line:match "^#([A-Za-z]+)%s*(.-)\n?$"
directive = directive and directive:lower()
if not directive then
return
elseif directive == "redirect" then
local redirect = args:match "^%s*(%w%w%w%w%w)%s*$"
if not redirect then return end
directives.redirect = redirect
elseif directive == "deadend" then
directives.deadend = true
elseif directive == "set" then
if args:match "^%s*$" then return end
local flagname, text = args:match "^%s*([%w%-_]+)%s*(.-)%s*$"
if not flagname then return end
text = text or ""
if utf8.len(flagname) > 150 or utf8.len(text) > 1000 then
return
end
directives.flags_updated[flagname] = text
tmp_flags[flagname] = text
elseif directive == "unset" then
local flagname = args:match "^%s*([%w%-_]+)%s*$"
if not flagname then return end
directives.flags_updated[flagname] = false
tmp_flags[flagname] = nil
elseif directive == "require" then
local flagname = args:match "^%s*([%w%-_]+)%s*$"
if not flagname then return end
directives.require[flagname] = true
else
return
end
return true
end
local load_page
local function convert_markup(m)
local result = {}
local tmp_flags = {}
for name, value in pairs(flags) do
tmp_flags[name] = value
end
local directives = {flags_updated = {}, require = {}}
local code_block = false
for line in (m..'\n'):gmatch "(.-)\n" do
if not code_block then
if line:match "^%s*$" then
goto continue
end
if line:sub(1,1) == '#' and
parse_directive(line, directives, tmp_flags) then
if directives.redirect then
local to = load_page(directives.redirect)
if to then
local m, d = convert_markup(to.content)
-- the final destination will not have a redirect
-- value in this directive. as such, this will store
-- final destination of a chain of redirects.
d.redirect = d.redirect or to
return m, d
else
directives.redirect = nil
end
else
goto continue
end
end
line = html_encode(line)
if line:sub(1,1) == ' ' then
table.insert(result, '
')
code_block = true
else
line = line:gsub("\\\\([%[%]\\])", "\%1")
line = line:gsub("\\([%[%]])",
{ ['['] = "[", [']'] = "]" })
line = line:gsub("%[(.-)%]",
function(s)
return ('%s'):format(s)
end
)
table.insert(result, ('
%s
'):format(line))
end
end
if code_block then
if line:sub(1,1) == ' ' then
table.insert(result, line .. '\n')
else
table.insert(result, '
')
code_block = false
end
end
::continue::
end
if code_block then
table.insert(result, '')
code_block = false
end
return table.concat(result), directives
end
local function target_requirements(p)
local page = load_page(p, false, true)
if not page then return true end
local _,directives = convert_markup(page.content)
for k,v in pairs(directives.require) do
if not flags[k] then return false end
end
return true
end
local function parse_page(s)
local page = {}
page.title = s:match "^(.-)\n"
page.actions = {}
local content = {}
for line in (s..'\n'):gmatch "(.-\n)" do
if line:sub(1,1) == '\t' then
table.insert(content, line:sub(2))
elseif line:match("^!image") then
page.illustration = line:match "^!image%s+(%w+)"
elseif line:match("^!archive") then
page.archive = true
else
local target, action = line:match "^(%w%w%w%w%w):(.-)\n$"
if action then
table.insert(page.actions, {action = action, target = target})
end
end
end
page.content = table.concat(content)
return page
end
function load_page(p, raw)
if not p:match("^%w%w%w%w%w$") then return nil end
local f, bee = io.open('content/'..p)
if not f then return nil end
local s = f:read "a"
f:close()
if not s then return nil end
if raw then return s end
local page = parse_page(s)
page.id = p
return page
end
local function new_action(page, action, result)
local _, directives = convert_markup(result)
::generate_name::
local new_name = {}
for i=1,5 do
table.insert(new_name, string.char(
string.byte 'a' + math.random(0,25)))
end
new_name = table.concat(new_name)
local exists = io.open('content/'..new_name, 'r')
if exists then
exists:close()
goto generate_name
end
local old = assert(io.open('content/'..page, 'a'))
local new = assert(io.open('content/'..new_name, 'w'))
action = action:gsub('\n', ' ')
assert(new:write(action..'\n'))
for line in (result..'\n'):gmatch "(.-\n)" do
assert(new:write('\t' .. line))
end
assert(old:write(('%s:%s\n'):format(new_name, action)))
if env "REMOTE_ADDR" then
new:write(('!ip %s\n'):format(env "REMOTE_ADDR"))
end
if directives.backlinks then
for _,d in ipairs(directives.backlinks) do
assert(new:write(('%s:%s\n'):format(d.page, d.action)))
end
end
new:close()
old:close()
return new_name
end
local hist_template = template [[
$log
]]
local function show_hist(show_ids)
local log = {}
if #history == 0 then return "" end
for i=#history,1,-1 do
local page = load_page(history[i])
if not page then goto continue end
-- highlight the current page
local title = i ~= #history and html_encode(page.title)
or ''..html_encode(page.title)..''
if show_ids then
table.insert(log,
('
]]
map["^/g/(%w%w%w%w%w)$"] = function(p)
local page = load_page(p)
if not page then return not_found() end
local content, directives = convert_markup(page.content)
if env "REQUEST_METHOD" ~= "POST" then
if history[#history] ~= p then
table.insert(history, p)
end
if #history > 75 then
table.remove(history, 1)
end
local title = page.title
if directives.redirect then
page = directives.redirect
end
local actions = {}
for _,a in ipairs(page.actions) do
if target_requirements(a.target) then
table.insert(actions,
('
-- ]]):format(p)
end
local cookies = {}
table.insert(cookies,
('history=%s; path=/; secure; max-age=99999999999')
:format(table.concat(history, ',')..','))
for flagname, text in pairs(directives.flags_updated) do
if text then
table.insert(cookies,
('flag_%s=%s; secure; max-age=999999999999')
:format(flagname, url_encode(text)))
else
-- clear the cookie by expiring it in the past
table.insert(cookies,
('flag_%s=; expires=Thu, 01 Jan 1970 00:00:00 GMT;')
:format(flagname))
end
end
return base {
title = html_encode(title),
content = page_template {
title = html_encode(title),
content = content,
actions = table.concat(actions),
illustration = illustration,
drawthis = draw_this,
log = show_hist(),
},
}, {headers = {["set-cookie"] = cookies, bees = "3.14"}}
else
if directives.deadend then
return base {
title = "error",
content = "forbidden",
}, { status = '403 forbidden' }
end
local form = parse_qs(io.read "a")
form.wyd = form.wyd or "something"
form.happens = form.happens or "something"
if utf8.len(form.wyd) > 150 then form.wyd = "something" end
if utf8.len(form.happens) > 10000 then form.wyd = "something" end
local new = new_action(p, form.wyd, form.happens)
return redirect("/g/"..new)
end
end
map["^/g/(%w%w%w%w%w)/$"] = function(p)
return redirect('/g/'..p)
end
local edit_template = template [[
$content
$preview
]]
local preview_template = template [[
$title
$content
]]
local submit_template = template [[
]]
map["^/g/(%w%w%w%w%w)/act$"] = function(p)
local page = load_page(p)
if not page then return not_found() end
local _, directives = convert_markup(page.content)
if directives.deadend then return not_found() end
local query = parse_qs(env "QUERY_STRING" or "")
local cancel = '/g/'..(query.back or p)
if env "REQUEST_METHOD" ~= "POST" then
return base {
title = "do something new",
content = edit_template {
page = p,
content = convert_markup(page.content),
log = show_hist(true),
cancel = html_encode(cancel),
},
}
else
local form = parse_qs(io.read "a")
form.wyd = form.wyd or "something"
form.happens = form.happens or "something"
local prev, prev_direct = convert_markup(form.happens)
local prev_title =
prev_direct.title and html_encode(prev_direct.title) or
html_encode(form.wyd)
if prev_direct.redirect then
local note =
('previewing %s')
:format(prev_direct.redirect.id)
prev = note..prev
end
return base {
title = "do something new",
content = edit_template {
page = p,
content = convert_markup(page.content),
preview = preview_template {
title = prev_title,
content = prev,
},
title = html_encode(form.wyd),
editing = html_encode(form.happens),
submit = submit_template { page = p },
cancel = html_encode(cancel),
log = show_hist(true),
},
}
end
end
local illustrate_template = template [[
$title
$content
what does this look like?
]]
-- map["^/g/(%w%w%w%w%w)/illustrate"] = function(p)
-- local page = load_page(p)
-- if not page then return not_found() end
--
-- if env "REQUEST_METHOD" ~= "POST" then
-- return base {
-- title = "illustration: " .. html_encode(page.title),
-- content = illustrate_template {
-- title = html_encode(page.title),
-- content = convert_markup(page.content),
-- page = p,
-- },
-- }
-- else
-- end
-- end
map["^/i/(%w%w%w%w%w).(%w+)$"] = function(p, format)
local page = load_page(p)
if not page or
not page.illustration or not page.illustration == format then
return not_found()
end
return
assert(io.open(('content/%s.%s'):format(p, format), 'r')),
{ content_type = 'image/'..format }
end
map["^/g/(%w%w%w%w%w)/raw$"] = function(p)
local page = load_page(p, true)
if not page then return not_found() end
page = page:gsub("\n!ip [^\n]*", "")
return page, {
content_type = 'text/plain',
headers = { ['access-control-allow-origin'] = "*" }
}
end
map["^/g/?$"] = function()
if #history > 0 then
return redirect('/g/'..history[#history])
else
return redirect '/g/zzcxz'
end
end
map["^/about/?$"] = function()
return assert(io.open("about.html", 'r'))
end
map["^/robots.txt$"] = function()
return assert(io.open("robots.txt", 'r')),
{ content_type = 'text/plain' }
end
local index_template = template [[
zzcxz
'
end
return index_template {actions = actions}
end
local function main()
for k,v in pairs(map) do
local m = {(env "PATH_INFO"):match(k)}
if m[1] then
return v(table.unpack(m))
end
end
return not_found()
end
local ok, content, resp = pcall(main)
if not ok or (type(content) ~= 'string' and type(content) ~= 'userdata') then
io.stderr:write(content..'\n')
content = base {
title = "internal error",
content = "an internal error occurred."
}
resp = { status = '500 internal server error' }
end
resp = resp or {}
resp.content_type = resp.content_type or 'text/html'
resp.status = resp.status or '200 OK'
resp.headers = resp.headers or {}
resp.headers['content-type'] = resp.content_type
resp.headers['apioforms'] = math.random(0,104942)/2
print("status: "..resp.status)
for k,v in pairs(resp.headers) do
if type(v) == "table" then
for _, v2 in ipairs(v) do
print(("%s: %s"):format(k, v2))
end
else
print(("%s: %s"):format(k, v))
end
end
print ""
if type(content) == 'string' then
io.write(content)
else
while 1 do
local data = content:read(1024)
if not data then break end
io.write(data)
end
end