-- generated by cesdk 0.1.0, do not edit -- project: vellum 0.3.0 local __mods, __cache = {}, {} local function require(name) local c = __cache[name] if c ~= nil then return c end local f = __mods[name] if not f then error("cesdk bundle: module '" .. name .. "' not found", 2) end local r = f() if r == nil then r = true end __cache[name] = r return r end __mods["cesdk.manifest"] = function() return { name = "vellum", version = "0.3.0", protocols = { } } end __mods["main"] = function() -- Vellum - "writing meant to be kept". A web server on a CesPlex RUDP channel, -- nothing more: every page and every action is a plain HTTP request over -- ces.conn (lib/web), and the caller's bind-authenticated identity (req.pubkey) -- gates who may write. The name gate is at ENTRY, not publish: GET /write -- refuses the editor to a nameless caller and hands off to the account page. -- State (the feed + per-author shelves) is Vellum's own, in memory and -- kv-backed (state.lua), never loose files. Published stories are real files -- in the author's /f// zone. local web = require("web") local state = require("state") local pages = require("pages") CES_MANIFEST = { name = "Vellum", version = "0.3", description = "writing meant to be kept: typeset stories " .. "with rent-paid permanence" } local SOURCE = "/s/vellum.lua" local ENTRY_DESC = "Own what you write: typeset stories under your own " .. "name, kept alive by the people who love them." local HEARTBEAT_MS = 5 * 60 * 1000 local function hex(s) return (tostring(s or ""):gsub(".", function(c) return string.format("%02x", string.byte(c)) end)) end local function name_of(pubkey) if pubkey and #pubkey == 32 then return ces.keyname(pubkey) end return nil end -- A caller may act on a story path only if the authenticated identity owns the -- zone: /f// by key_name, /h// by the key itself. Returns the -- canonical owner, or nil + error. local function verify_story_owner(path, pubkey) if path:sub(1, 3) ~= "/f/" and path:sub(1, 3) ~= "/h/" then return nil, "not an author page" end if not pubkey or #pubkey ~= 32 then return nil, "no identity" end if path:sub(1, 3) == "/f/" then local owner = path:match("^/f/([^/]+)/") if not owner or ces.keyname(pubkey) ~= owner then return nil, "not owner" end return owner end local owner = path:match("^/h/([0-9a-fA-F]+)/") if not owner or owner:lower() ~= hex(pubkey) then return nil, "not owner" end return owner:lower() end -- POST /publish, body "|": verify + record in feed and shelf. local function on_publish(req) local path, title = (req.body or ""):match("^([^|]+)|(.*)$") if not path then return "bad announcement" end path, title = path:gsub("[\r\n]+$", ""), title:gsub("[\r\n]+$", "") if #path > 300 or #title > 200 then return "too long" end local owner, err = verify_story_owner(path, req.pubkey) if not owner then return err end local st = ces.file_stat(path) if not st or not st.size or st.size == 0 then return "no such story" end local rec = { ts = math.floor(ces.now() / 1000000), path = path, title = title, author = owner } if not state.feed_upsert(rec) then return "feed write failed" end state.author_upsert(owner, rec) ces.log("vellum: listed " .. path) return "ok" end -- POST /unlist, body "<path>": the owner takes a story out of the feed. The -- story file is untouched (unlist is not unpublish); re-announcing re-lists it. local function on_unlist(req) local path = (req.body or ""):gsub("[\r\n]+$", "") if path == "" or #path > 300 then return "bad unlist" end local owner, err = verify_story_owner(path, req.pubkey) if not owner then return err end if state.feed_remove(path) then ces.log("vellum: unlisted " .. path) end return "ok" end -- POST /listed, body "<path>": is this story in the feed right now? Public read. local function on_listed(req) local path = (req.body or ""):gsub("[\r\n]+$", "") if path == "" then return "bad listed" end return state.feed_has(path) and "yes" or "no" end -- path + method + caller identity -> a rendered page or a command reply. local function route(req) local m, path = req.method, req.path or "/" local host = (req.headers and req.headers.host) or "" if m == "GET" then if path == "/" or path == "" or path == "/index.html" then local feed, pruned = state.prune_dead(state.load_feed()) if pruned then state.save_feed(feed) end return web.html(pages.index(feed, host, name_of(req.pubkey))) elseif path:match("^/by/[%w_%.%-]+$") then local author = path:sub(5) local live, pruned = state.prune_dead(state.load_author(author)) if pruned then state.save_author(author, live) end return web.html(pages.author(author, host, live)) elseif path == "/write" then local name = name_of(req.pubkey) if not name or name == "" then return web.html(pages.register(host)) end local info = ces.server_info and ces.server_info() or nil local rpc = (type(info) == "table" and tonumber(info.rpc_port)) or 0 return web.html(pages.compose(name, SOURCE, rpc)) end elseif m == "POST" then if path == "/publish" then return web.text(on_publish(req)) end if path == "/unlist" then return web.text(on_unlist(req)) end if path == "/listed" then return web.text(on_listed(req)) end end return web.html("<h1>Not found</h1>", 404) end web.serve{ on_request = route } -- Vellum is a client of the live CWB directory service. Discover the current -- /s/cwb.lua instance through the compute catalog, dial its rpc port, and renew -- our in-memory lease. Fire-and-forget: cwb validates against the live-instance -- catalog and closes the channel. No shared registration file, no reply to read. local function register() local info = ces.server_info and ces.server_info() or nil if type(info) ~= "table" then return end local rpc = tonumber(info.rpc_port) or 0 local my_port = tonumber(ces.rpc_port and ces.rpc_port() or 0) or 0 if rpc == 0 or my_port == 0 then return end local cc = ces.compute_client("127.0.0.1:" .. rpc) if not cc then return end local rows = cc:instances("/s/cwb.lua") or {} cc:close() table.sort(rows, function(a, b) return (a.pid or 0) < (b.pid or 0) end) local cwb = rows[1] if not cwb or (tonumber(cwb.rpc_port) or 0) == 0 then return end local conn = ces.conn.connect("127.0.0.1:" .. tostring(cwb.rpc_port), cwb.program_pubkey, 5000) if conn then conn:write(("REGISTER|Vellum|%s|%s|%d\n"):format( SOURCE, ENTRY_DESC:gsub("|", " "), my_port)) end end ces.spawn(register) ces.every(HEARTBEAT_MS, function() ces.spawn(register) end) web.run() end __mods["web"] = function() -- web.lua - HTTP/1.1 + WebSocket server machinery over ces.conn. -- -- Rides the cesweb /i/ proxy. cesweb is the browser's TLS peer, so it terminates -- the wire protocol and hands a compute program one of two shapes on a ces.conn: -- -- HTTP : raw request bytes ("GET / HTTP/1.1\r\n...\r\n\r\n"); answer once with -- an HTTP response, then close (Connection: close). -- WebSocket : length-framed messages, [u32 BE len][payload]. Frame 0 is the -- handshake request (so the program can route by path / read cookies); -- frames 1..N are message payloads. A payload sent back is delivered to -- the browser as one text WebSocket message. -- -- The two are told apart by the first byte of the first chunk: an HTTP method is -- an uppercase ASCII letter (>= 0x41); a frame length's high byte is 0x00 -- (messages are far under 16 MiB). So byte0 == 0 => WebSocket, else => HTTP. -- -- Usage: -- local web = require("web") -- web.serve{ -- on_request = function(req) return web.html("<h1>hi</h1>") end, -- on_websocket = function(ws) -- ws.on_message = function(msg) ws.send("you said: "..msg) end -- end, -- } -- web.run() -- = ces.run() -- -- req = { method, target, path, query, headers = {lowercased->value}, body, -- pubkey = the bind-authenticated caller identity (32 bytes, or nil) } -- ws = { id, path, query, headers, send(msg), close(), on_message, on_close } local M = {} -- u32 big-endian pack/unpack via arithmetic (doubles are exact to 2^53). local function u32be(n) return string.char(math.floor(n / 16777216) % 256, math.floor(n / 65536) % 256, math.floor(n / 256) % 256, n % 256) end local function rd_u32be(s, i) -- i = 1-based index of the length's first byte local a, b, c, d = s:byte(i, i + 3) return ((a * 256 + b) * 256 + c) * 256 + d end -- Parse an HTTP request (or the WebSocket handshake request in frame 0). local function parse_request(raw) local head = raw:match("^(.-)\r\n\r\n") if not head then return nil end local body = raw:sub(#head + 5) local first = head:match("^(.-)\r\n") or head local method, target = first:match("^(%S+)%s+(%S+)") if not method then return nil end local path, query = target:match("^([^?]*)%??(.*)$") local headers = {} for line in head:gmatch("\r\n([^\r\n]+)") do local k, v = line:match("^([^:]+):%s*(.*)$") if k then headers[k:lower()] = v end end return { method = method, target = target, path = path, query = query, headers = headers, body = body } end local REASON = { [200] = "OK", [400] = "Bad Request", [404] = "Not Found", [500] = "Internal Server Error" } -- Build an HTTP/1.1 response from a string or a { status, headers, body } table. local function build_response(resp) if type(resp) == "string" then resp = { body = resp } end resp = resp or { status = 404 } local status = resp.status or 200 local reason = resp.reason or REASON[status] or "" local body = resp.body or "" local out = { ("HTTP/1.1 %d %s\r\n"):format(status, reason) } local have_ct = false if resp.headers then for k, v in pairs(resp.headers) do if k:lower() == "content-type" then have_ct = true end out[#out + 1] = k .. ": " .. tostring(v) .. "\r\n" end end if not have_ct then out[#out + 1] = "Content-Type: text/html; charset=utf-8\r\n" end out[#out + 1] = "Content-Length: " .. #body .. "\r\n" out[#out + 1] = "Connection: close\r\n\r\n" out[#out + 1] = body return table.concat(out) end -- Response helpers. function M.html(body, status) return { status = status or 200, body = body or "", headers = { ["Content-Type"] = "text/html; charset=utf-8" } } end function M.text(body, status) return { status = status or 200, body = body or "", headers = { ["Content-Type"] = "text/plain; charset=utf-8" } } end function M.response(status, body, headers) return { status = status, body = body, headers = headers } end local function make_ws(conn, hs) local ws = { id = conn.id, path = hs and hs.path or "/", query = hs and hs.query or "", headers = hs and hs.headers or {}, on_message = nil, on_close = nil, } function ws.send(msg) msg = tostring(msg) return conn:write(u32be(#msg) .. msg) end function ws.close() conn:close() end return ws end -- serve{ on_request, on_websocket }: arm the ces.conn listener and dispatch. function M.serve(opts) opts = opts or {} local on_request = opts.on_request local on_websocket = opts.on_websocket local conns = {} -- conn.id -> per-connection state local function handle_http(st, conn) if not st.buf:find("\r\n\r\n", 1, true) then return end -- headers incomplete local req = parse_request(st.buf) if not req then st.done = true conn:write(build_response(M.response(400, "bad request"))) conn:close() return end -- Wait for the full request body (POST/PUT/...) before dispatching. local clen = tonumber(req.headers["content-length"]) or 0 if #req.body < clen then return end st.done = true req.pubkey = conn.pubkey -- CesPlex-authenticated caller, for identity gating local resp if on_request then local ok, r = pcall(on_request, req) resp = ok and r or M.response(500, "internal error") end conn:write(build_response(resp)) conn:close() end local function handle_ws(st, conn) while true do if #st.buf < 4 then return end local len = rd_u32be(st.buf, 1) if #st.buf < 4 + len then return end local payload = st.buf:sub(5, 4 + len) st.buf = st.buf:sub(5 + len) if not st.ws then local ws = make_ws(conn, parse_request(payload)) -- frame 0 = handshake st.ws = ws if on_websocket then pcall(on_websocket, ws) end elseif st.ws.on_message then pcall(st.ws.on_message, payload) end end end ces.conn.set_listener{ on_open = function(conn) conns[conn.id] = { buf = "" } end, on_data = function(conn, data) local st = conns[conn.id] if not st then st = { buf = "" }; conns[conn.id] = st end if st.done then return end st.buf = st.buf .. data if not st.mode then local b0 = st.buf:byte(1) if b0 == nil then return end st.mode = (b0 == 0) and "ws" or "http" end if st.mode == "http" then handle_http(st, conn) else handle_ws(st, conn) end end, on_close = function(conn) local st = conns[conn.id] if st and st.ws and st.ws.on_close then pcall(st.ws.on_close) end conns[conn.id] = nil end, } end function M.run() return ces.run() end return M end __mods["state"] = function() -- state.lua - Vellum's own state: the recent-feed and per-author shelves. -- Held in memory, durably backed by ONE rent-exempt kv-file (/s/vellum.kv): -- key "feed" is the global recent list, "by:<name>" is an author's shelf. No -- loose flat files. Reads hit the in-memory cache; every mutation writes -- through. Each value is a bounded S|-line blob. The file store stays the -- truth, so callers prune dead records at render (prune_dead). local M = {} local KV_PATH = "/s/vellum.kv" local FEED_MAX = 30 local AUTHOR_MAX = 500 local kv = ces.store and ces.store(KV_PATH) or nil local kv_ready = false local feed_cache = nil -- list, or nil until first load local author_cache = {} -- name -> list local function kv_open() if kv_ready then return true end if not kv then return false end kv:create(0) -- idempotent; /s/ is unmetered, no deposit needed kv_ready = true return true end local function encode(list, max) local b = {} for i = 1, math.min(#list, max) do local s = list[i] b[#b + 1] = ("S|%d|%s|%s|%s"):format( s.ts, s.path, (s.title or ""):gsub("|", " "), (s.author or ""):gsub("|", " ")) end return table.concat(b, "\n") end local function decode(str, max) local seen, list = {}, {} for line in (str or ""):gmatch("[^\n]+") do local ts, path, title, author = line:match("^S|(%d+)|([^|]+)|([^|]*)|([^|]*)$") if ts and not seen[path] then seen[path] = true list[#list + 1] = { ts = tonumber(ts), path = path, title = title, author = author } if #list >= max then break end end end return list end local function kv_read(key, max) if not kv_open() then return {} end return decode(kv:get(key), max) end local function kv_save(key, list, max) if not kv_open() then return false end if #list == 0 then kv:erase(key); return true end return kv:put(key, encode(list, max)) and true or false end -- feed -------------------------------------------------------------------- function M.load_feed() if feed_cache == nil then feed_cache = kv_read("feed", FEED_MAX) end return feed_cache end function M.save_feed(list) feed_cache = list return kv_save("feed", list, FEED_MAX) end function M.feed_upsert(rec) local feed = M.load_feed() for i = #feed, 1, -1 do if feed[i].path == rec.path then table.remove(feed, i) end end table.insert(feed, 1, rec) return M.save_feed(feed) end function M.feed_remove(path) local feed, removed = M.load_feed(), false for i = #feed, 1, -1 do if feed[i].path == path then table.remove(feed, i); removed = true end end if removed then M.save_feed(feed) end return removed end function M.feed_has(path) for _, s in ipairs(M.load_feed()) do if s.path == path then return true end end return false end -- author shelf ------------------------------------------------------------ local function author_key(name) if not name or not name:match("^[%w_%.%-]+$") then return nil end return "by:" .. name end function M.load_author(name) local k = author_key(name) if not k then return {} end if author_cache[name] == nil then author_cache[name] = kv_read(k, AUTHOR_MAX) end return author_cache[name] end function M.save_author(name, list) local k = author_key(name) if not k then return end author_cache[name] = list kv_save(k, list, AUTHOR_MAX) end function M.author_upsert(name, rec) local list = M.load_author(name) for i = #list, 1, -1 do if list[i].path == rec.path then table.remove(list, i) end end table.insert(list, 1, rec) M.save_author(name, list) end -- The file store is the truth for every view: drop records whose story file no -- longer exists. Returns the live list and whether anything was dropped. function M.prune_dead(list) local live, dropped = {}, false for _, s in ipairs(list) do local st = ces.file_stat(s.path) if st and st.size and st.size > 0 then live[#live + 1] = s else dropped = true end end return live, dropped end return M end __mods["pages"] = function() -- pages.lua - Vellum's HTML, all generated live. Pure functions: data in, -- markup out. No state and no ces.* calls; the caller supplies host, the -- reader's name, and the already-pruned record lists. local M = {} local function esc(s) s = tostring(s or "") return (s:gsub("&", "&"):gsub("<", "<"):gsub(">", ">")) end -- The display form: the stored key_name is the underscore form; show spaces. local function pretty(name) return (tostring(name or ""):gsub("_", " ")) end -- A published story is a FILE in /f/<name>/, not a Vellum route; served over -- lua://<pid>, a bare "/f/..." would resolve against lua:// and 404. Link the -- file store explicitly (portless -> cwb's rpc lane). host = the Host header. local function story_href(host, path) if host and host ~= "" then return "file://" .. host .. path end return path end -- Unix seconds -> "YYYY-MM-DD" (no os.date in the sandbox; civil-from-days). local function iso_date(secs) local z = math.floor(secs / 86400) + 719468 local era = math.floor(z / 146097) local doe = z - era * 146097 local yoe = math.floor((doe - math.floor(doe / 1460) + math.floor(doe / 36524) - math.floor(doe / 146096)) / 365) local y = yoe + era * 400 local doy = doe - (365 * yoe + math.floor(yoe / 4) - math.floor(yoe / 100)) local mp = math.floor((5 * doy + 2) / 153) local d = doy - math.floor((153 * mp + 2) / 5) + 1 local m = mp < 10 and mp + 3 or mp - 9 if m <= 2 then y = y + 1 end return ("%04d-%02d-%02d"):format(y, m, d) end local HEAD = [[<!doctype html><html><head><meta charset=utf-8><style> body{margin:0;background:#ffffff;color:#242424; font-family:Charter,'Bitstream Charter',Georgia,'Noto Serif','Liberation Serif',serif} #w{max-width:680px;margin:0 auto;padding:56px 24px 80px} .vm{font-family:sans-serif;font-size:12px;letter-spacing:3px;color:#b3b3b1;margin-bottom:34px} h1{font-size:42px;font-weight:700;margin:0 0 10px;letter-spacing:-0.4px} .sub{font-size:21px;color:#6b6b6b;margin:0 0 34px;line-height:1.5} .cta{font-family:sans-serif;font-size:15px;margin:0 0 46px} .cta a{background:#1a8917;color:#ffffff;text-decoration:none;border-radius:18px;padding:9px 22px} .cta a:hover{background:#156d12} h2{font-family:sans-serif;font-size:13px;letter-spacing:1px;color:#6b6b6b;font-weight:600;margin:0 0 18px} .e{font-size:22px;line-height:1.4;margin:0 0 4px}.e a{color:#242424;text-decoration:none} .d{font-family:sans-serif;font-size:13px;color:#9c9c9a;margin:0 0 20px} .bar{font-family:sans-serif;font-size:12px;color:#9c9c9a;margin:0 0 24px} .bar .vm2{letter-spacing:3px;color:#b3b3b1}.bar a{color:#1a8917;text-decoration:none} .ft{font-family:sans-serif;font-size:12px;color:#9c9c9a;margin-top:56px;border-top:1px solid #ececeb;padding-top:14px} .ft a{color:#1a8917;text-decoration:none} </style></head><body><div id=w>]] -- The front page knows its reader (every request is authenticated), so the -- pitch is shown once in a person's life here: a nameless caller gets the full -- hero; a named citizen lands straight on the stories. A manual that never -- goes away is furniture; content is the page. function M.index(stories, host, name) local b = { HEAD } b[#b + 1] = "<div class=vm>VELLUM</div>" if not (name and name ~= "") then b[#b + 1] = "<h1>Own what you write</h1>" b[#b + 1] = "<div class=sub>A story here lives under your name, at an " .. "address of its own, on a server that cannot unpublish you. It " .. "stays alive as long as anyone who loves it — you, or a " .. "reader — keeps it fed.</div>" end b[#b + 1] = "<div class=cta><a href=\"/write\">Write a story</a></div>" b[#b + 1] = "<h2>RECENTLY PUBLISHED</h2>" if #stories == 0 then b[#b + 1] = "<div class=d>Nothing yet. The first story could be yours.</div>" else for _, s in ipairs(stories) do local by = (s.author and s.author ~= "") and ("by <a href=\"/by/" .. esc(s.author) .. "\">" .. esc(pretty(s.author)) .. "</a>") or "by an unnamed author" b[#b + 1] = ("<p class=e><a href=\"%s\">%s</a></p><div class=d>%s</div>") :format(esc(story_href(host, s.path)), esc(s.title ~= "" and s.title or "Untitled"), by) end end b[#b + 1] = "</div></body></html>" return table.concat(b) end -- The author's shelf. Records are hints; the caller has already pruned the -- dead against the file store, so a deleted story is simply absent. function M.author(author, host, live) local b = { HEAD, "<div class=bar><span class=vm2>VELLUM</span></div>", "<h1>Stories</h1>", ("<div class=sub>by %s</div>"):format(esc(pretty(author))) } if #live == 0 then b[#b + 1] = "<div class=d>Nothing here yet.</div>" else for _, s in ipairs(live) do b[#b + 1] = ("<p class=e><a href=\"%s\">%s</a></p><div class=d>%s</div>") :format(esc(story_href(host, s.path)), esc(s.title ~= "" and s.title or "Untitled"), esc(iso_date(s.ts))) end end b[#b + 1] = "<div class=ft><a href=\"/\">All stories</a> · vellum</div>" b[#b + 1] = "</div></body></html>" return table.concat(b) end -- The GATE page: shown at /write when the caller has no name. Vellum refuses -- the editor and LINKS to the browser's own account page, where names are set. -- Setting a name is a signed ledger op -- cwb's ces:// account UI, not Vellum's -- business. host = the Host header, so the link points back at this server. function M.register(host) local acct = "ces://" .. (host ~= "" and host or "") .. "/" return HEAD .. "<div class=bar><span class=vm2>VELLUM</span></div>" .. "<h1>First, your name</h1>" .. "<div class=sub>Your writing lives under your name -- it becomes the " .. "/f/<name>/ home your stories are kept in. " .. "<a href=\"" .. acct .. "\">Set your name on your account</a>, then come " .. "back here to write.</div>" .. "<div class=ft><a href=\"/\">All stories</a> · vellum</div>" .. "</div></body></html>" end -- The editor, shown at /write once the caller is named. A fullscreen native -- application proxy: cwb keeps its chrome; cwb-write owns the page canvas. -- rpcport is the SERVER's rpc lane, declared explicitly because this page may -- be reached over luarpc:// (our own direct port), from which the browser -- cannot infer where file/compute binds belong. function M.compose(name, source, rpcport) local rpcparam = (tonumber(rpcport) or 0) > 0 and ("<param name=\"rpcport\" value=\"" .. rpcport .. "\">") or "" return "<!doctype html><html><body style=\"margin:0\">" .. "<object type=\"application/x-cwb-write\">" .. "<param name=\"display\" value=\"fullscreen\">" .. "<param name=\"approot\" value=\"vellum\">" .. "<param name=\"appname\" value=\"Vellum\">" .. "<param name=\"appsource\" value=\"" .. source .. "\">" .. rpcparam .. "<param name=\"authorhandle\" value=\"" .. esc(name) .. "\">" .. "<param name=\"author\" value=\"" .. esc(pretty(name)) .. "\">" .. "</object></body></html>" end return M end CES_MANIFEST = { name = "vellum", version = "0.3.0", description = "" } return require("main")