Index: backend/pgsql.t ================================================================== --- backend/pgsql.t +++ backend/pgsql.t @@ -8,28 +8,28 @@ where key = $1::text limit 1 ]]; }; conf_set = { - params = {rawstring,rawstring}, sql = [[ + params = {rawstring,rawstring}, cmd=true, sql = [[ insert into parsav_config (key, value) values ($1::text, $2::text) on conflict (key) do update set value = $2::text ]]; }; conf_reset = { - params = {rawstring}, sql = [[ + params = {rawstring}, cmd=true, sql = [[ delete from parsav_config where key = $1::text ]]; }; actor_fetch_uid = { params = {uint64}, sql = [[ select a.id, a.nym, a.handle, a.origin, a.bio, - a.avataruri, a.rank, a.quota, a.key, + a.avataruri, a.rank, a.quota, a.key, a.epithet, extract(epoch from a.knownsince)::bigint, coalesce(a.handle || '@' || s.domain, '@' || a.handle) as xid from parsav_actors as a @@ -40,11 +40,11 @@ }; actor_fetch_xid = { params = {pstring}, sql = [[ select a.id, a.nym, a.handle, a.origin, a.bio, - a.avataruri, a.rank, a.quota, a.key, + a.avataruri, a.rank, a.quota, a.key, a.epithet, extract(epoch from a.knownsince)::bigint, coalesce(a.handle || '@' || s.domain, '@' || a.handle) as xid, coalesce(s.domain, @@ -72,11 +72,11 @@ sql = [[ insert into parsav_actors ( nym,handle, origin,knownsince, bio,avataruri,key, - title,rank,quota + epithet,rank,quota ) values ($1::text, $2::text, case when $3::bigint = 0 then null else $3::bigint end, to_timestamp($4::bigint), $5::bigint, $6::bigint, $7::bytea, @@ -100,11 +100,11 @@ }; actor_enum_local = { params = {}, sql = [[ select id, nym, handle, origin, bio, - null::text, rank, quota, key, + null::text, rank, quota, key, epithet, extract(epoch from knownsince)::bigint, handle ||'@'|| (select value from parsav_config where key='domain' limit 1) as xid from parsav_actors where origin is null @@ -112,11 +112,11 @@ }; actor_enum = { params = {}, sql = [[ select a.id, a.nym, a.handle, a.origin, a.bio, - a.avataruri, a.rank, a.quota, a.key, + a.avataruri, a.rank, a.quota, a.key, a.epithet, extract(epoch from a.knownsince)::bigint, coalesce(a.handle || '@' || s.domain, '@' || a.handle) as xid from parsav_actors a left join parsav_servers s on s.id = a.origin @@ -165,11 +165,11 @@ }; actor_session_fetch = { params = {uint64, lib.store.inet}, sql = [[ select a.id, a.nym, a.handle, a.origin, a.bio, - a.avataruri, a.rank, a.quota, a.key, + a.avataruri, a.rank, a.quota, a.key, a.epithet, extract(epoch from a.knownsince)::bigint, coalesce(a.handle || '@' || s.domain, '@' || a.handle) as xid, au.restrict, @@ -192,10 +192,28 @@ actor_powers_fetch = { params = {uint64}, sql = [[ select key, allow from parsav_rights where actor = $1::bigint ]] }; + + actor_power_insert = { + params = {uint64,lib.mem.ptr(int8),uint16}, cmd = true, sql = [[ + insert into parsav_rights (actor, key, allow) values ( + $1::bigint, $2::text, ($3::smallint)::integer::bool + ) + ]] + }; + + auth_create_pw = { + params = {uint64, lib.mem.ptr(uint8)}, cmd = true, sql = [[ + insert into parsav_auth (uid, name, kind, cred) values ( + $1::bigint, + (select handle from parsav_actors where id = $1::bigint), + 'pw-sha256', $2::bytea + ) + ]] + }; post_create = { params = {uint64, rawstring, rawstring, rawstring}, sql = [[ insert into parsav_posts ( author, subject, acl, body, @@ -341,15 +359,24 @@ end; } local con = symbol(&lib.pq.PGconn) local prep = {} -local sqlsquash = function(s) return s:gsub('%s+',' '):gsub('^%s*(.-)%s*$','%1') end +local function sqlsquash(s) return s + :gsub('%%include (.-)%%',function(f) + return sqlsquash(lib.util.ingest('backend/schema/' .. f)) + end) -- include dependencies + :gsub('%-%-.-\n','') -- remove disruptive line comments + :gsub('%-%-.-$','') -- remove unnecessary terminal comments + :gsub('%s+',' ') -- remove whitespace + :gsub('^%s*(.-)%s*$','%1') -- chomp +end + for k,q in pairs(queries) do local qt = sqlsquash(q.sql) local stmt = 'parsavpg_' .. k - prep[#prep + 1] = quote + terra q.prep([con]) var res = lib.pq.PQprepare([con], stmt, qt, [#q.params], nil) defer lib.pq.PQclear(res) if res == nil or lib.pq.PQresultStatus(res) ~= lib.pq.PGRES_COMMAND_OK then if res == nil then lib.bail('grievous error occurred preparing ',k,' statement') @@ -356,10 +383,11 @@ end lib.bail('could not prepare PGSQL statement ',k,': ',lib.pq.PQresultErrorMessage(res)) end lib.dbg('prepared PGSQL statement ',k) end + prep[#prep + 1] = quote q.prep([con]) end local args, casts, counters, fixers, ft, yield = {}, {}, {}, {}, {}, {} local dumpers = {} for i, ty in ipairs(q.params) do args[i] = symbol(ty) @@ -391,10 +419,12 @@ [args[i]] = lib.math.netswap(ty, [args[i]]) end end end + local okconst = lib.pq.PGRES_TUPLES_OK + if q.cmd then okconst = lib.pq.PGRES_COMMAND_OK end terra q.exec(src: &lib.store.source, [args]) var params = arrayof([&int8], [casts]) var params_sz = arrayof(int, [counters]) var params_ft = arrayof(int, [ft]) [fixers] @@ -401,11 +431,11 @@ --[dumpers] var res = lib.pq.PQexecPrepared([&lib.pq.PGconn](src.handle), stmt, [#args], params, params_sz, params_ft, 1) if res == nil then lib.bail(['grievous error occurred executing '..k..' against database']) - elseif lib.pq.PQresultStatus(res) ~= lib.pq.PGRES_TUPLES_OK then + elseif lib.pq.PQresultStatus(res) ~= okconst then lib.bail(['PGSQL database procedure '..k..' failed\n'], lib.pq.PQresultErrorMessage(res)) end var ct = lib.pq.PQntuples(res) @@ -450,10 +480,11 @@ local terra row_to_actor(r: &pqr, row: intptr): lib.mem.ptr(lib.store.actor) var a: lib.mem.ptr(lib.store.actor) var av: rawstring, avlen: intptr var nym: rawstring, nymlen: intptr var bio: rawstring, biolen: intptr + var epi: rawstring, epilen: intptr if r:null(row,5) then avlen = 0 av = nil else av = r:string(row,5) avlen = r:len(row,5)+1 end if r:null(row,1) then nymlen = 0 nym = nil else @@ -461,23 +492,28 @@ nymlen = r:len(row,1)+1 end if r:null(row,4) then biolen = 0 bio = nil else bio = r:string(row,4) biolen = r:len(row,4)+1 + end + if r:null(row,9) then epilen = 0 epi = nil else + epi = r:string(row,9) + epilen = r:len(row,9)+1 end a = [ lib.str.encapsulate(lib.store.actor, { nym = {`nym, `nymlen}; bio = {`bio, `biolen}; + epithet = {`epi, `epilen}; avatar = {`av,`avlen}; handle = {`r:string(row, 2); `r:len(row,2) + 1}; - xid = {`r:string(row, 10); `r:len(row,10) + 1}; + xid = {`r:string(row, 11); `r:len(row,11) + 1}; }) ] a.ptr.id = r:int(uint64, row, 0); a.ptr.rights = lib.store.rights_default(); a.ptr.rights.rank = r:int(uint16, row, 6); a.ptr.rights.quota = r:int(uint32, row, 7); - a.ptr.knownsince = r:int(int64,row, 9); + a.ptr.knownsince = r:int(int64,row, 10); if r:null(row,8) then a.ptr.key.ct = 0 a.ptr.key.ptr = nil else a.ptr.key = r:bin(row,8) end @@ -532,10 +568,13 @@ [vdrs] lib.dbg(['could not find password hash']) end end +local schema = sqlsquash(lib.util.ingest('backend/schema/pgsql.sql')) +local obliterator = sqlsquash(lib.util.ingest('backend/schema/pgsql-drop.sql')) + local b = `lib.store.backend { id = "pgsql"; open = [terra(src: &lib.store.source): &opaque lib.report('connecting to postgres database: ', src.string.ptr) var [con] = lib.pq.PQconnectdb(src.string.ptr) @@ -558,14 +597,49 @@ lib.warn('failed to secure postgres connection') lib.pq.PQfinish(con) return nil end - [prep] return con end]; + close = [terra(src: &lib.store.source) lib.pq.PQfinish([&lib.pq.PGconn](src.handle)) end]; + + conprep = [terra(src: &lib.store.source, mode: lib.store.prepmode.t) + var [con] = [&lib.pq.PGconn](src.handle) + if mode == lib.store.prepmode.full then [prep] + elseif mode == lib.store.prepmode.conf or + mode == lib.store.prepmode.admin then + queries.conf_get.prep(con) + queries.conf_set.prep(con) + queries.conf_reset.prep(con) + if mode == lib.store.prepmode.admin then + end + else lib.bail('unsupported connection preparation mode') end + end]; + + dbsetup = [terra(src: &lib.store.source) + var res = lib.pq.PQexec([&lib.pq.PGconn](src.handle), schema) + if lib.pq.PQresultStatus(res) == lib.pq.PGRES_COMMAND_OK then + lib.report('successfully instantiated schema in database') + return true + else + lib.warn('backend pgsql - failed to initialize database: \n', lib.pq.PQresultErrorMessage(res)) + return false + end + end]; + + obliterate_everything = [terra(src: &lib.store.source) + var res = lib.pq.PQexec([&lib.pq.PGconn](src.handle), obliterator) + if lib.pq.PQresultStatus(res) == lib.pq.PGRES_COMMAND_OK then + lib.report('successfully wiped out everything parsav-related in database') + return true + else + lib.warn('backend pgsql - failed to obliterate database: \n', lib.pq.PQresultErrorMessage(res)) + return false + end + end]; conf_get = [terra(src: &lib.store.source, key: rawstring) var r = queries.conf_get.exec(src, key) if r.sz == 0 then return [lib.mem.ptr(int8)] { ptr = nil, ct = 0 } else defer r:free() @@ -680,18 +754,18 @@ a.ptr.source = src var au = [lib.stat(lib.store.auth)] { ok = true } au.val.aid = aid au.val.uid = a.ptr.id - if not r:null(0,12) then -- restricted? + if not r:null(0,13) then -- restricted? au.val.privs:clear() - (au.val.privs.post << r:bool(0,13)) - (au.val.privs.edit << r:bool(0,14)) - (au.val.privs.acct << r:bool(0,15)) - (au.val.privs.upload << r:bool(0,16)) - (au.val.privs.censor << r:bool(0,17)) - (au.val.privs.admin << r:bool(0,18)) + (au.val.privs.post << r:bool(0,14)) + (au.val.privs.edit << r:bool(0,15)) + (au.val.privs.acct << r:bool(0,16)) + (au.val.privs.upload << r:bool(0,17)) + (au.val.privs.censor << r:bool(0,18)) + (au.val.privs.admin << r:bool(0,19)) else au.val.privs:fill() end return au, a end @@ -761,14 +835,49 @@ actor_create = [terra( src: &lib.store.source, ac: &lib.store.actor ): uint64 - var r = queries.actor_create.exec(src,ac.nym, ac.handle, ac.origin, ac.knownsince, ac.bio, ac.avatar, ac.key, ac.title, ac.rights.rank, ac.rights.quota) + var r = queries.actor_create.exec(src,ac.nym, ac.handle, ac.origin, ac.knownsince, ac.bio, ac.avatar, ac.key, ac.epithet, ac.rights.rank, ac.rights.quota) if r.sz == 0 then lib.bail('failed to create actor!') end - return r:int(uint64,0,0) + var uid = r:int(uint64,0,0) + + -- check against default rights, insert records for wherever powers differ + lib.dbg('created new actor, establishing powers') + var pdef = lib.store.rights_default().powers + var map = array([privmap]) + for i=0, [map.type.N] do + var d = pdef and map[i].priv + var u = ac.rights.powers and map[i].priv + if d:sz() > 0 and u:sz() == 0 then + lib.dbg('blocking power ', {map[i].name.ptr, map[i].name.ct}) + queries.actor_power_insert.exec(src, uid, map[i].name, 0) + elseif d:sz() == 0 and u:sz() > 0 then + lib.dbg('granting power ', {map[i].name.ptr, map[i].name.ct}) + queries.actor_power_insert.exec(src, uid, map[i].name, 1) + end + end + + lib.dbg('powers established') + return uid + end]; + + auth_create_pw = [terra( + src: &lib.store.source, + uid: uint64, + reset: bool, + pw: lib.mem.ptr(int8) + ): {} + -- TODO impl reset support + var hash: uint8[lib.crypt.algsz.sha256] + if lib.md.mbedtls_md(lib.md.mbedtls_md_info_from_type(lib.crypt.alg.sha256.id), + [&uint8](pw.ptr), pw.ct, &hash[0]) ~= 0 then + lib.bail('cannot hash password') + end + queries.auth_create_pw.exec(src, uid, [lib.mem.ptr(uint8)] {ptr = &hash[0], ct = [hash.type.N]}) end]; actor_auth_register_uid = nil; -- not necessary for view-based auth + } return b ADDED backend/schema/pgsql-auth.sql Index: backend/schema/pgsql-auth.sql ================================================================== --- backend/schema/pgsql-auth.sql +++ backend/schema/pgsql-auth.sql @@ -0,0 +1,51 @@ +-- in managed-auth configurations, parsav_auth is a table which is directly +-- controlled by the parsav daemon and utilities themselves. in unmanaged +-- configuration, you will need to create your own view with the same fields +-- as this table +create table parsav_auth ( + aid bigint primary key default (1+random()*(2^63-1))::bigint, + -- the AID is the value that links a session to its credentials, + -- so the aid needs to be stable over time. if you don't have a + -- convenient field to rely on in your own datasets, the best + -- approach is to use digest(str,'sha256') from the pgcrypto + -- extension to create a value that depends on the values of + -- kind, cred, and a unique user ID from your own dataset (NOT + -- uid, as the UID associated with a session will change when + -- a user logs in for the first time). + + uid bigint, + -- the UID links a credential set to an actor in the parsav + -- database. if it is equal to 0 (but not null) a new actor + -- will be created and associated with the authentication + -- records bearing its name when that user first logs in + + name text, + -- this is the handle of the actor that will be created when + -- a user first logs in with this as the username and one of + -- its associated credentials. the field is otherwise unused. + + kind text not null, -- see parsav.md + cred bytea, + restrict text[], + -- per-credential restrictions can be levelled, for instance + -- to prevent a certain API key from being used to post tweets + -- as that user, while allowing it to be used to collect data. + -- if restrict is null, no restrictions will be applied. + -- otherwise, it should be an array of privileges that will be + -- permitted when authenticated via this credential. + + netmask cidr, + -- if not null, the credential will only be valid when logging + -- in from an IP address contained by this netmask. + + blacklist bool not null default false, + -- if the credential matches, access will be denied, even if + -- non-blacklisted credentials match. most useful with + -- uid = null, kind = trust, cidr = (untrusted IP range) + + valperiod timestamp default now(), + -- cookies bearing timestamps earlier than this point in time + -- will be considered invalid and will not grant access + + unique(name,kind,cred) +); ADDED backend/schema/pgsql-drop.sql Index: backend/schema/pgsql-drop.sql ================================================================== --- backend/schema/pgsql-drop.sql +++ backend/schema/pgsql-drop.sql @@ -0,0 +1,18 @@ +-- destroy absolutely everything + +drop table if exists parsav_config cascade; +drop table if exists parsav_servers cascade; +drop table if exists parsav_actors cascade; +drop table if exists parsav_rights cascade; +drop table if exists parsav_posts cascade; +drop table if exists parsav_conversations cascade; +drop table if exists parsav_rels cascade; +drop table if exists parsav_acts cascade; +drop table if exists parsav_log cascade; +drop table if exists parsav_attach cascade; +drop table if exists parsav_circles cascade; +drop table if exists parsav_rooms cascade; +drop table if exists parsav_room_members cascade; +drop table if exists parsav_invites cascade; +drop table if exists parsav_interventions cascade; +drop table if exists parsav_auth cascade; ADDED backend/schema/pgsql.sql Index: backend/schema/pgsql.sql ================================================================== --- backend/schema/pgsql.sql +++ backend/schema/pgsql.sql @@ -0,0 +1,157 @@ +create table parsav_config ( + key text primary key, + value text +); + +insert into parsav_config (key,value) values ('schema-version','1'), + ('credential-store','managed'); +-- ('bind',:'bind'), +-- ('domain',:'domain'), +-- ('instance-name',:'inst'), +-- ('policy-security',:'secmode'), +-- ('policy-self-register',:'regpol'), +-- ('master',:'admin'), + +-- note that valid ids should always > 0, as 0 is reserved for null +-- on the client side, vastly simplifying code +create table parsav_servers ( + id bigint primary key default (1+random()*(2^63-1))::bigint, + domain text not null, + key bytea, + knownsince timestamp, + parsav boolean -- whether to use parsav protocol extensions +); + +create table parsav_actors ( + id bigint primary key default (1+random()*(2^63-1))::bigint, + nym text, + handle text not null, -- nym [@handle@origin] + origin bigint references parsav_servers(id) + on delete cascade, -- null origin = local actor + knownsince timestamp, + bio text, + avataruri text, -- null if local + rank smallint not null default 0, + quota integer not null default 1000, + key bytea, -- private if localactor; public if remote + epithet text, + authtime timestamp not null default now(), -- cookies earlier than this timepoint will not be accepted + + unique (handle,origin) +); + +create table parsav_rights ( + key text, + actor bigint references parsav_actors(id) + on delete cascade, + allow boolean not null, + scope bigint, -- for future expansion + + primary key (key,actor) +); + +create table parsav_posts ( + id bigint primary key default (1+random()*(2^63-1))::bigint, + author bigint references parsav_actors(id) + on delete cascade, + subject text, + acl text not null default 'all', -- just store the script raw 🤷 + body text, + posted timestamp not null, + discovered timestamp not null, + parent bigint not null default 0, + circles bigint[], -- TODO at edit or creation, iterate through each circle + mentions bigint[], -- a user has, check if it can see her post, and if so add + + convoheaduri text + -- only used for tracking foreign conversations and tying them to post heads; + -- local conversations are tracked directly and mapped to URIs based on the + -- head's ID. null if native tweet or not the first tweet in convo +); + +create table parsav_rels ( + relator bigint references parsav_actors(id) + on delete cascade, -- e.g. follower + relatee bigint references parsav_actors(id) + on delete cascade, -- e.g. followed + kind smallint, -- e.g. follow, block, mute + + primary key (relator, relatee, kind) +); + +create table parsav_acts ( + id bigint primary key default (1+random()*(2^63-1))::bigint, + kind text not null, -- like, react, so on + time timestamp not null default now(), + actor bigint references parsav_actors(id) + on delete cascade, + subject bigint -- may be post or act, depending on kind +); + +create table parsav_log ( + -- accesses are tracked for security & sending delete acts + id bigint primary key default (1+random()*(2^63-1))::bigint, + time timestamp not null default now(), + actor bigint references parsav_actors(id) + on delete cascade, + post bigint not null +); + +create table parsav_attach ( + id bigint primary key default (1+random()*(2^63-1))::bigint, + birth timestamp not null default now(), + content bytea not null, + mime text, -- null if unknown, will be reported as x-octet-stream + description text, + parent bigint -- post id, or userid for avatars +); + +create table parsav_circles ( + id bigint primary key default (1+random()*(2^63-1))::bigint, + owner bigint not null references parsav_actors(id), + name text not null, + members bigint[] not null default array[]::bigint[], + + unique (owner,name) +); + +create table parsav_rooms ( + id bigint primary key default (1+random()*(2^63-1))::bigint, + origin bigint references parsav_servers(id), + name text not null, + description text not null, + policy smallint not null +); + +create table parsav_room_members ( + room bigint references parsav_rooms(id), + member bigint references parsav_actors(id), + rank smallint not null default 0, + admin boolean not null default false, -- non-admins with rank can only moderate + invite + title text, -- admin-granted title like reddit flair + vouchedby bigint references parsav_actors(id) +); + +create table parsav_invites ( + id bigint primary key default (1+random()*(2^63-1))::bigint, + -- when a user is created from an invite, the invite is deleted and the invite + -- ID becomes the user ID. privileges granted on the invite ID during the invite + -- process are thus inherited by the user + issuer bigint references parsav_actors(id), + handle text, -- admin can lock invite to specific handle + rank smallint not null default 0, + quota integer not null default 1000 +); + +create table parsav_interventions ( + id bigint primary key default (1+random()*(2^63-1))::bigint, + issuer bigint references parsav_actors(id) not null, + scope bigint, -- can be null or room for local actions + nature smallint not null, -- silence, suspend, disemvowel, etc + victim bigint not null, -- could potentially target group as well + expire timestamp -- auto-expires if set +); + +-- create a temporary managed auth table; we can delete this later +-- if it ends up being replaced with a view +%include pgsql-auth.sql% Index: cmdparse.t ================================================================== --- cmdparse.t +++ cmdparse.t @@ -1,10 +1,12 @@ -- vim: ft=terra -return function(tbl) +return function(tbl,opts) + opts = opts or {} local options = terralib.types.newstruct('options') do local flags = '' for _,d in pairs(tbl) do flags = flags .. d[1] end - local helpstr = 'usage: parsav [-' .. flags .. '] [...]\n' + local flagstr = '[-' .. flags .. ']' + local helpstr = '\n' options.entries = { {field = 'arglist', type = lib.mem.ptr(rawstring)} } local shortcases, longcases, init, verifiers = {}, {}, {}, {} local self = symbol(&options) @@ -11,24 +13,31 @@ local arg = symbol(rawstring) local idx = symbol(uint) local argv = symbol(&rawstring) local argc = symbol(int) local optstack = symbol(intptr) + local subcmd = symbol(intptr) local skip = label() local sanitize = function(s) return s:gsub('_','-') end for o,desc in pairs(tbl) do - local consume = desc[3] or 0 + local consume = desc.consume or 0 + local incr = desc.inc or 0 options.entries[#options.entries + 1] = { - field = o, type = (consume > 0) and &rawstring or bool + field = o, type = (consume > 0) and &rawstring or + (incr > 0) and uint or bool } helpstr = helpstr .. string.format(' -%s --%s: %s\n', desc[1], sanitize(o), desc[2]) end for o,desc in pairs(tbl) do local flag = desc[1] - local consume = desc[3] or 0 - init[#init + 1] = quote [self].[o] = [(consume > 0 and `nil) or false] end + local consume = desc.consume or 0 + local incr = desc.inc or 0 + init[#init + 1] = quote [self].[o] = [ + (consume > 0 and `nil) or + (incr > 0 and `0 ) or false + ] end local ch if consume > 0 then ch = quote [self].[o] = argv+(idx+1+optstack) optstack = optstack + consume end @@ -36,10 +45,12 @@ var terminus = argv + argc if [self].[o] ~= nil and [self].[o] >= terminus then lib.bail(['missing argument for command line option ' .. sanitize(o)]) end end + elseif incr > 0 then + ch = quote [self].[o] = [self].[o] + incr end else ch = quote [self].[o] = true end end shortcases[#shortcases + 1] = quote case [int8]([string.byte(flag)]) then [ch] end @@ -51,10 +62,11 @@ terra options:free() self.arglist:free() end options.methods.parse = terra([self], [argc], [argv]) [init] var parseopts = true var [optstack] = 0 + var [subcmd] = [ opts.subcmd or 0 ] self.arglist = lib.mem.heapa(rawstring, argc) var finalargc = 0 for [idx]=1,argc do var [arg] = argv[idx] if optstack > 0 then optstack = optstack - 1 goto [skip] end @@ -70,16 +82,20 @@ end end else self.arglist.ptr[finalargc] = arg finalargc = finalargc + 1 + if subcmd > 0 then + subcmd = subcmd - 1 + if subcmd == 0 then parseopts = false end + end end ::[skip]:: end [verifiers] if finalargc == 0 then self.arglist:free() else self.arglist:resize(finalargc) end end - options.helptxt = helpstr + options.helptxt = { opts = helpstr, flags = flagstr } end return options end Index: common.lua ================================================================== --- common.lua +++ common.lua @@ -103,11 +103,11 @@ return kt end; ingest = function(f) local h = io.open(f, 'r') if h == nil then return nil end - local txt = f:read('*a') f:close() + local txt = h:read('*a') h:close() return chomp(txt) end; parseargs = function(a) local raw = false local opts, args = {}, {} Index: config.lua ================================================================== --- config.lua +++ config.lua @@ -35,16 +35,18 @@ online = u.tobool(default('parsav_online_documentation',true)); offline = u.tobool(default('parsav_offline_documentation',true)); }; outform = default('parsav_emit_type', 'o'); endian = default('parsav_arch_endian', 'little'); + prefix = default('parsav_install_prefix', './'); build = { id = u.rndstr(6); release = u.ingest('release'); when = os.date(); }; feat = {}; + debug = u.tobool(default('parsav_enable_debug',true)); backends = defaultlist('parsav_backends', 'pgsql'); braingeniousmode = false; embeds = { {'style.css', 'text/css'}; {'default-avatar.webp', 'image/webp'}; @@ -60,14 +62,16 @@ conf.build.branch = u.exec { 'fossil', 'branch', 'current' } conf.build.checkout = (u.exec { 'fossil', 'sql', [[select value from localdb.vvar where name = 'checkout-hash']] }):gsub("^'(.*)'$", '%1') end -conf.os = default('parsav_host_os', default_os); -conf.tgtos = default('parsav_target_os', default_os); +conf.os = default('parsav_host_os', default_os) +conf.tgtos = default('parsav_target_os', default_os) conf.posix = posixes[conf.os] -conf.exe = u.tobool(default('parsav_link',not conf.tgttrip)); -- turn off for partial builds +conf.exe = u.tobool(default('parsav_link',not conf.tgttrip)) -- turn off for partial builds +conf.prefix_conf = default('parsav_install_prefix_cfg', conf.prefix) +conf.prefix_static = default('parsav_install_prefix_static', nil) conf.build.origin = coalesce( os.getenv('parsav_builder'), string.format('%s@%s', coalesce ( os.getenv('USER'), u.exec{'whoami'} Index: crypt.t ================================================================== --- crypt.t +++ crypt.t @@ -36,15 +36,15 @@ -- md5 = {id = lib.md.MBEDTLS_MD_MD5};-- !!! }; local callbacks = {} if config.feat.randomizer == 'kern' then local rnd = terralib.externfunction('getrandom', {&opaque, intptr, uint} -> ptrdiff); - terra callbacks.randomize(ctx: &opaque, dest: &uint8, sz: intptr): int + terra m.spray(dest: &uint8, sz: intptr): int return rnd(dest, sz, 0) end elseif config.feat.randomizer == 'devfs' then - terra callbacks.randomize(ctx: &opaque, dest: &uint8, sz: intptr): int + terra m.spray(dest: &uint8, sz: intptr): int var gen = lib.io.open("/dev/urandom",0) lib.io.read(gen, dest, sz) lib.io.close(gen) return sz end @@ -52,15 +52,27 @@ local rnd = terralib.externfunction('rand', {} -> int); local srnd = terralib.externfunction('srand', uint -> int); local time = terralib.includec 'time.h' lib.init[#lib.init + 1] = quote srnd(time.time(nil)) end print '(warn) using libc soft-rand function for cryptographic purposes, this is very bad!' - terra callbacks.randomize(ctx: &opaque, dest: &uint8, sz: intptr): int + terra m.spray(dest: &uint8, sz: intptr): int for i=0,sz do dest[i] = [uint8](rnd()) end return sz end end + +m.random = macro(function(typ, from, to) + local ty = typ:astype() + return quote + var v: ty + m.spray([&uint8](&v), sizeof(ty)) + v = v % (to - from) + from -- only works with unsigned!! + in v end +end) + +terra callbacks.randomize(ctx: &opaque, dest: &uint8, sz: intptr) + return m.spray(dest,sz) end terra m.pem(pub: bool, key: &ctx, buf: &uint8): bool if pub then return lib.pk.mbedtls_pk_write_pubkey_pem(key, buf, const.maxpemsz) == 0 else ADDED html.t Index: html.t ================================================================== --- html.t +++ html.t @@ -0,0 +1,17 @@ +-- vim: ft=terra +local m={} +local pstr = lib.mem.ptr(int8) + +terra m.sanitize(txt: pstr, quo: bool) + var a: lib.str.acc a:init(txt.ct*1.3) + for i=0,txt.ct do + if txt(i) == @'<' then a:lpush('<') + elseif txt(i) == @'>' then a:lpush('>') + elseif txt(i) == @'&' then a:lpush('&') + elseif quo and txt(i) == @'"' then a:lpush('"') + else a:push(&txt(i),1) end + end + return a:finalize() +end + +return m Index: makefile ================================================================== --- makefile +++ makefile @@ -2,17 +2,17 @@ dbg-flags = $(if $(dbg),-g) images = $(addsuffix .webp, $(basename $(wildcard static/*.svg))) styles = $(addsuffix .css, $(basename $(wildcard static/*.scss))) -parsav: parsav.t config.lua pkgdata.lua $(images) $(styles) +parsav parsavd: parsav.t config.lua pkgdata.lua $(images) $(styles) terra $(dbg-flags) $< -parsav.o: parsav.t config.lua pkgdata.lua $(images) $(styles) +parsav.o parsavd.o: parsav.t config.lua pkgdata.lua $(images) $(styles) env parsav_link=no terra $(dbg-flags) $< -parsav.ll: parsav.t config.lua pkgdata.lua $(images) $(styles) +parsav.ll parsavd.ll: parsav.t config.lua pkgdata.lua $(images) $(styles) env parsav_emit_type=ll parsav_link=no terra $(dbg-flags) $< -parsav.s: parsav.ll +parsav.s parsavd.ss: parsav.ll llc --march=$(target) $< static/%.webp: static/%.png cwebp -q 90 $< -o $@ static/%.png: static/%.svg ADDED mgtool.t Index: mgtool.t ================================================================== --- mgtool.t +++ mgtool.t @@ -0,0 +1,256 @@ +-- vim: ft=terra +-- provides the functionality of the `parsav` utility that controls `parsavd` +local pstr = lib.mem.ptr(int8) +local ctloptions = lib.cmdparse({ + version = {'V', 'display information about the binary build and exit'}; + verbose = {'v', 'increase logging verbosity', inc=1}; + quiet = {'q', 'do not print to standard out'}; + help = {'h', 'display this list'}; + backend_file = {'B', 'init from specified backend file', consume=1}; + backend = {'b', 'operate on only the selected backend'}; + instance = {'i', 'specify the instance to control by name', consume=1}; + all = {'A', 'affect all running instances'}; +}, { subcmd = 1 }) + +local pbasic = lib.cmdparse { + help = {'h', 'display this list'} +} +local subcmds = { +} + +local ctlcmds = { + { 'start', 'start a new instance of the server' }; + { 'stop', 'stop a running instance' }; + { 'attach', 'capture log output from a running instance' }; + { 'db init ', 'initialize backend databases (or a single specified database) with the necessary schema and structures for the given FQDN' }; + { 'db vacuum', 'delete old remote content from the database' }; + { 'db extract (|/)', 'extracts an attachment artifact from the database and prints it to standard out' }; + { 'db excise ', 'extracts an attachment artifact from the database and prints it to standard out' }; + { 'db obliterate', 'completely purge all parsav-related content and structure from the database, destroying all user content (requires confirmation)' }; + { 'db insert', 'reads a file from standard in and inserts it into the attachment database, printing the resulting ID' }; + { 'mkroot ', 'establish a new root user with the given handle' }; + { 'user auth new', '(where applicable, managed auth only) create a new authentication token of the given type for a user' }; + { 'user auth reset', '(where applicable, managed auth only) delete all of a user\'s authentication tokens of the given type and issue a new one' }; + { 'user auth purge-credentials []', 'delete all credentials that would allow this user to log in (where possible)' }; + { 'user (grant|revoke) (|all)', 'grant or revoke a specific power to or from a user' }; + { 'user emasculate', 'strip all administrative powers from a user' }; + { 'user suspend []', '(e.g. \27[1muser jokester suspend 5d 6h 7m 3s\27[m to suspend "jokester" for five days, six hours, seven minutes, and three seconds) suspend a user'}; + { 'actor purge-all', 'remove all traces of a user from the database (except local user credentials -- use \27[1mauth purge-credentials\27[m to prevent a user from accessing the instance)' }; + { 'actor create', 'instantiate a new actor' }; + { 'actor bestow ', 'bestow an epithet upon an actor' }; + { 'conf set ', 'add or a change a server configuration parameter to the database' }; + { 'conf get ', 'report the value of a server setting' }; + { 'conf reset ', 'reset a server setting to its default value' }; + { 'conf refresh', 'instruct an instance to refresh its configuration cache' }; + { 'conf chsec', 'reset the server secret, invalidating all authentication cookies' }; + { 'serv dl', 'initiate an update cycle over foreign actors' }; + { 'tl', 'print the current local timeline to standard out' }; + { 'be pgsql setup-auth (managed|unmanaged)', '(PGSQL backends) select the authentication strategy to use' }; +} + +local ctlcmdhelp = 'commands:\n' +for _, v in ipairs(ctlcmds) do + ctlcmdhelp = ctlcmdhelp .. string.format ( + ' \27[1m%s\27[m: %s\n', v[1]:gsub('(<%w+>)','\27[36m%1\27[;1m'), v[2] + ) +end + +local struct idelegate { + all: bool + src: &lib.store.source + srv: &lib.srv.overlord +} +idelegate.metamethods.__methodmissing = macro(function(meth, self, ...) + local expr = {...} + local rt + for _,f in pairs(lib.store.backend.entries) do + local fn = f.field or f[1] + local ft = f.type or f[2] + if fn == meth then rt = ft.type.returntype break end + end + + return quote + var r: rt + if self.all + then r=self.srv:[meth]([expr]) + elseif self.src ~= nil then r=self.src:[meth]([expr]) + else lib.bail('no data source specified') + end + in r end +end) + +local terra gensec(sdest: rawstring) + var dest = [&uint8](sdest) + lib.crypt.spray(dest,64) + for i=0,64 do dest[i] = dest[i] % (0x7e - 0x20) + 0x20 end + dest[64] = 0 +end + +local terra entry_mgtool(argc: int, argv: &rawstring): int + if argc < 1 then lib.bail('bad invocation!') end + + lib.noise_init(2) + [lib.init] + + var srv: lib.srv.overlord + var dlg = idelegate { srv = &srv, src = nil } + + var mode: ctloptions + mode:parse(argc,argv) defer mode:free() + if mode.version then version() return 0 end + if mode.help then + [ lib.emit(false, 1, 'usage: ', `argv[0], ' ', ctloptions.helptxt.flags, ' […]', ctloptions.helptxt.opts, ctlcmdhelp) ] + return 0 + end + var cnf: rawstring + if mode.backend_file ~= nil + then cnf = @mode.backend_file + else cnf = lib.proc.getenv('parsav_backend_file') + end + if cnf == nil then cnf = "backend.conf" end + if mode.all then dlg.all = true else + -- iterate through and pick the right backend + end + + if mode.arglist.ct == 0 then lib.bail('no command') return 1 end + if lib.str.cmp(mode.arglist(0),'attach') == 0 then + elseif lib.str.cmp(mode.arglist(0),'start') == 0 then + elseif lib.str.cmp(mode.arglist(0),'stop') == 0 then + else + if lib.str.cmp(mode.arglist(0),'db') == 0 then + var dbmode: pbasic dbmode:parse(mode.arglist.ct, &mode.arglist(0)) + if dbmode.help then + [ lib.emit(false, 1, 'usage: ', `argv[0], ' db ', dbmode.type.helptxt.flags, ' […]', dbmode.type.helptxt.opts) ] + return 1 + end + if dbmode.arglist.ct < 1 then goto cmderr end + + srv:setup(cnf) + if lib.str.cmp(dbmode.arglist(0),'init') == 0 and dbmode.arglist.ct == 2 then + lib.report('initializing new database structure for domain ', dbmode.arglist(1)) + dlg:dbsetup() + srv:conprep(lib.store.prepmode.conf) + dlg:conf_set('instance-name', dbmode.arglist(1)) + do var sec: int8[65] gensec(&sec[0]) + dlg:conf_set('server-secret', &sec[0]) + end + lib.report('database setup complete; use mkroot to create an administrative user') + elseif lib.str.cmp(dbmode.arglist(0),'obliterate') == 0 then + var confirmstrs = array( + 'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'eta', 'nu', 'kappa' + ) + var cfmstr: int8[64] cfmstr[0] = 0 + var tdx = lib.osclock.time(nil) / 60 + for i=0,3 do + if i ~= 0 then lib.str.cat(&cfmstr[0], '-') end + lib.str.cat(&cfmstr[0], confirmstrs[(tdx + 49*i) % [confirmstrs.type.N]]) + end + + if dbmode.arglist.ct == 1 then + lib.bail('you are attempting to completely obliterate all data! make sure you have selected your target correctly. if you really want to do this, pass the confirmation string ', &cfmstr[0]) + elseif dbmode.arglist.ct == 2 then + if lib.str.cmp(dbmode.arglist(1), cfmstr) == 0 then + lib.warn('completely obliterating all data!') + dlg:obliterate_everything() + else + lib.bail('you passed an incorrect confirmation string; pass ', &cfmstr[0], ' if you really want to destroy everything') + end + else goto cmderr end + else goto cmderr end + elseif lib.str.cmp(mode.arglist(0),'be') == 0 then + srv:setup(cnf) + elseif lib.str.cmp(mode.arglist(0),'conf') == 0 then + srv:setup(cnf) + srv:conprep(lib.store.prepmode.conf) + var cfmode: lib.cmdparse { + help = {'h','display this list'}; + no_notify = {'n', "don't instruct the server to refresh its configuration cache after making changes; useful for \"transactional\" configuration changes."}; + } + cfmode:parse(mode.arglist.ct, &mode.arglist(0)) + if cfmode.help then + [ lib.emit(false, 1, 'usage: ', `argv[0], ' conf ', cfmode.type.helptxt.flags, ' […]', cfmode.type.helptxt.opts) ] + return 1 + end + if cfmode.arglist.ct < 1 then goto cmderr end + + if cfmode.arglist.ct == 1 then + if lib.str.cmp(cfmode.arglist(0),'chsec') == 0 then + var sec: int8[65] gensec(&sec[0]) + dlg:conf_set('server-secret', &sec[0]) + lib.report('server secret reset') + -- FIXME notify server to reload its config + elseif lib.str.cmp(cfmode.arglist(0),'refresh') == 0 then + -- TODO notify server to reload config + else goto cmderr end + elseif cfmode.arglist.ct == 3 and + lib.str.cmp(cfmode.arglist(0),'set') == 0 then + dlg:conf_set(cfmode.arglist(1),cfmode.arglist(2)) + lib.report('parameter set') + else goto cmderr end + else + srv:setup(cnf) + srv:conprep(lib.store.prepmode.full) + if lib.str.cmp(mode.arglist(0),'mkroot') == 0 then + var cfmode: pbasic cfmode:parse(mode.arglist.ct, &mode.arglist(0)) + if cfmode.help then + [ lib.emit(false, 1, 'usage: ', `argv[0], ' mkroot ', cfmode.type.helptxt.flags, ' ', cfmode.type.helptxt.opts) ] + return 1 + end + if cfmode.arglist.ct == 1 then + var am = dlg:conf_get('credential-store') + var mg: bool + if (not am) or am:cmp(lib.str.plit 'managed') then + mg = true + elseif am:cmp(lib.str.plit 'unmanaged') then + lib.warn('credential store is unmanaged; you will need to create credentials for the new root user manually!') + mg = false + else lib.bail('unknown credential store mode "',{am.ptr,am.ct},'"; should be either "managed" or "unmanaged"') end + var kbuf: uint8[lib.crypt.const.maxdersz] + var root = lib.store.actor.mk(&kbuf[0]) + root.handle = cfmode.arglist(0) + var epithets = array( + 'root', 'god', 'regional jehovah', 'titan king', + 'king of olympus', 'cyberpharaoh', 'electric ellimist', + "rampaging c'tan", 'deathless tweetlord', 'postmaster', + 'faerie queene', 'lord of the posts', 'ruthless cybercrat', + 'general secretary', 'commissar', 'kwisatz haderach' + -- feel free to add more + ) + root.epithet = epithets[lib.crypt.random(intptr,0,[epithets.type.N])] + root.rights.powers:fill() -- grant omnipotence + root.rights.rank = 1 + var ruid = dlg:actor_create(&root) + dlg:conf_set('master',root.handle) + lib.report('created new administrator') + if mg then + lib.dbg('generating temporary password') + var tmppw: uint8[33] + lib.crypt.spray(&tmppw[0],32) tmppw[32] = 0 + for i=0,32 do + tmppw[i] = tmppw[i] % (10 + 26*2) + if tmppw[i] >= 36 then + tmppw[i] = tmppw[i] + (0x61 - 36) + elseif tmppw[i] >= 10 then + tmppw[i] = tmppw[i] + (0x41 - 10) + else tmppw[i] = tmppw[i] + 0x30 end + end + lib.dbg('assigning temporary password') + dlg:auth_create_pw(ruid, false, pstr { + ptr = [rawstring](&tmppw[0]), ct = 32 + }) + lib.report('temporary root pw: ', {[rawstring](&tmppw[0]), 32}) + end + else goto cmderr end + elseif lib.str.cmp(mode.arglist(0),'user') == 0 then + elseif lib.str.cmp(mode.arglist(0),'actor') == 0 then + elseif lib.str.cmp(mode.arglist(0),'tl') == 0 then + elseif lib.str.cmp(mode.arglist(0),'serv') == 0 then + else goto cmderr end + end + end + + do return 0 end + ::cmderr:: lib.bail('invalid command') return 2 +end + +return entry_mgtool Index: parsav.md ================================================================== --- parsav.md +++ parsav.md @@ -41,24 +41,30 @@ ## configuring the `parsav` configuration is comprised of two components: the backends list and the config store. the backends list is a simple text file that tells `parsav` which data sources to draw from. the config store is a key-value store which contains the rest of the server's configuration, and is loaded from the backends. the configuration store can be spread across the backends; backends will be checked for configuration keys according to the order in which they are listed. changes to the configuration store affect parsav in real time; you only need to restart the server if you make a change to the backend list. -eventually, we'll add a command-line tool `parsav-cfg` to enable easy modification of the configuration store from the command line; for now, you'll need to modify the database by hand or use the online administration menu. the schema.sql file contains commands to prompt for various important values like the name of your administrative user. +you can directly modify the store from the command line with the `parsav conf` command; see `parsav conf -h` for more information. by default, parsav looks for a file called `backend.conf` in the current directory when it is launched. you can override this default with the `parsav_backend_file` environment or with the `-b`/`--backend-file` flag. `backend.conf` lists one backend per line, in the form `id type confstring`. for instance, if you had two postgresql databases, you might write a backend file like master pgsql host=localhost dbname=parsav tweets pgsql host=420.69.dread.cloud dbname=content the form the configuration string takes depends on the specific backend. +once you've set up a backend and confirmed parsav can connect succesfully to it, you can initialize the database with the command `parsav db init `, where `` is the name of the domain name you will be hosting `parsav` from. this will install all necessary structures and functions in the target and create all necessary files. it will not, however, create any users. you can create an initial administrative user with the `parsav mkroot ` command, where `` is the handle you want to use on the server. this will also assign a temporary password for the user if possible. you should now be able to log in and administer the server. + +by default, parsav binds to [::1]:10917. if you want to change this (to run it on a different port, or make it directly accessible to other servers on the network), you can use the command `parsav conf set bind
`, where `address` is a binding specification like `0.0.0.0:80`. it is recommended, however, that `parsavd` be kept accessible only from localhost, and that connections be forwarded to it from nginx, haproxy, or a similar reverse proxy. (this can also be changed with the online configuration UI) + ### postgresql backend -currently, postgres needs to be configured manually before parsav can make use of it to store data. the first step is to create a database for parsav's use. once you've done that, you need to create the database schema with the command `$ psql (-h $host) -d $database -f schema.sql`. you'll be prompted for some crucial settings to install in the configuration store, such as the name of the relation you want to use for authentication (we'll call it `parsav_auth` from here on out). +a database will need to be created for `parsav`'s use before `parsav db init` will work. this can be accomplished with a command like `$ createdb parsav`. you'll also of course need to set up some way for `parsavd` to authenticate itself to `postgres`. peer auth is the most secure option, and this is what you should use if postgres and `parsavd` are running on the same box. specify the database name to the backend the usual way, with a clause like `dbname=parsav` in your connection string. -parsav separates the storage of user credentials from the storage of other user data, in order to facilitate centralized user accounting. you don't need to take advantage of this feature, and if you don't want to, you can just create a `parsav_auth` table and have done. however, `parsav_auth` can also be a view, collecting a list of authorized users and their various credentials from whatever source you please. +the postgresql backend has some extra features that enable it to be integrated with existing authentication databases you may have. when you initialize the database, a table `parsav_auth` will be created to hold the credentials of the instance users and the authentication mode will be set to "managed", which will enable parsav's built-in credential administration tools. if you would prefer to use your own source of credentials, you'll need to set parsav to "unmanaged" mode with the command `parsav be pgsql setup-auth unmanaged`. + +this command will reconfigure `parsav` and remove the `parsav_auth` table, making room for you to create a view with the same name. if you want to go back to managed mode at any time, just run `parsav be psql setup-auth managed`; just be aware that this will delete your auth view! `parsav_auth` has the following schema: create table parsav_auth ( aid bigint primary key, @@ -71,15 +77,22 @@ blacklist bool ) `aid` is a unique value identifying the authentication method. it must be deterministic -- values based on time of creation or a hash of `uid`+`kind`+`cred` are ideal. `uid` is the identifier of the user the row specifies credentials for. `kind` is a string indicating the credential type, and `cred` is the content of that credential.for the meaning of these fields and use of this structure, see **authentication** below. -## authentication in the most basic case, an authentication record would be something like `{uid = 123, kind = "pw-sha512", cred = "\x12bf90…a10e"::bytea}`. but `parsav` is not restricted to username-password authentication, and in addition to various hashing styles, it also will support more esoteric forms of authentcation. any individual user can have as many auth rows as she likes. there is also a `restrict` field, which is normally null, but can be specified in order to restrict a particular credential to certain operations, such as posting tweets or updating a bio. `blacklist` indicates that any attempt to authenticate that matches this row will be denied, regardless of whether it matches other rows. if `netmask` is present, this authentication will only succeed if it comes from the specified IP mask. -`uid` can also be `0` (not null, which matches any user!), indicating that there is not yet a record in `parsav_actors` for this account. if this is the case, `name` must contain the handle of the account to be created when someone attempts to log in with this credential. whether `name` is used in the authentication process depends on whether the authentication method accepts a username. all rows with the same `uid` *must* have the same `name`. +`uid` can also be `0` (emphatically *not* null, which causes the rule to match any user!), indicating that there is not yet a record in `parsav_actors` for this account. if this is the case, `name` must contain the handle of the account to be created when someone attempts to log in with this credential. whether `name` is used in the authentication process depends on whether the authentication method accepts a username. all rows with the same `uid` *must* have the same `name`. + +## invoking +the build process generates two binaries, `parsav` and `parsavd`. `parsav` is a driver tool that can be used to set up and start a `parsav` instance, as well as administer it from the command line. it accesses databases directly and uses the same backend configuration file as parsav, but can also send IPC messages directly to running `parsavd` instances. + +as a convenience, the `parsav start` command can be used to start and daemonize a `parsav` instance. additionally, the `-l` option to `parsav start` can be used to redirect `parsavd`'s logging output to a file; without `-l`, logging output will be discarded and can be viewed only by connecting to the running instance with `parsav attach`. `parsav start` passes its arguments on to `parsavd`; you can use this to pass options by separating `parsav`'s arguments from `parsavd`'s with `--`. if you launch an instance with `parsav start -- -i chungus`, you can then stop that instance with `parsav -i chungus stop`. `parsav stop` can be used on its own if only one `parsavd` instance is running; otherwise, `parsav -a stop` will cleanly terminate all running instances. + +you generally should not invoke `parsavd` directly except for debugging purposes, or in the context of an init daemon (particularly systemd). if you launch `parsavd` directly it will not fork to the background. +## authentication below is a full list of authentication types we intend/hope to one day support. contributors should consider this a to-do list. a checked box indicates the scheme has been implemented. * ☑ pw-sha{512,384,256,224}: an ordinary password, hashed with the appropriate algorithm * ☐ pw-{sha1,md5,clear} (insecure, must be manually enabled at compile time with the config variable `parsav_let_me_be_a_dumbass="i know what i'm doing"`) * ☐ pw-pbkdf2-hmac-sha{…}: a password hashed with the Password-Based Key Derivation Function 2 instead of plain SHA2 @@ -94,15 +107,19 @@ * ☐ tls-cert-fp: a fingerprint of a client certificate * ☐ tls-cert-ca: a value of the form `fp/key=value` where a client certificate with the property `key=value` (e.g. `uid=cyberlord19`) signed by a certificate authority matching the given fingerprint `fp` can authenticate the user * ☐ challenge-rsa-sha256: an RSA public key. the user is presented with a challenge and must sign it with the corresponding private key using SHA256. * ☐ challenge-ecc-sha256: a Curve25519 public key. the user is presented with a challenge and must sign it with the corresponding private key using SHA256. * ☐ challenge-ecc448-sha256: a Curve448 public key. the user is presented with a challenge and must sign it with the corresponding private key using SHA256. -* ☑ trust: authentication always succeeds. only use in combination with netmask!!! +* ☑ trust: authentication always succeeds (or fails, if blacklisted). only use in combination with netmask!!! + +## legal + +parsav is released under the terms of the EUPL v1.2. copies of this license are included in the repository. by contributing any intellectual property to this project, you reassign ownership and all attendant rights over that intellectual property to the current maintainer. this is to ensure that the project can be relicensed without difficulty in the unlikely event that it is necessary. -## license +## code of conduct -parsav is released under the terms of the EUPL v1.2. copies of this license are included in the repository. dependencies are produced +when hacking on `parsav`, it is absolutely mandatory to wear a wizard hat and burgundy silk summoning cloak. this code of conduct is enforced capriciously by the Fair Folk, and violations are punishable by dancing hex. ## future direction parsav needs more storage backends, as it currently supports only postgres. some possibilities, in order of priority, are: Index: parsav.t ================================================================== --- parsav.t +++ parsav.t @@ -13,11 +13,11 @@ local tgt = lib for i=1,#path-1 do if tgt[path[i]] == nil then tgt[path[i]] = {} end tgt = tgt[path[i]] end - tgt[path[#path]] = terralib.loadfile(l:gsub(':','/') .. '.t')() + tgt[path[#path]:gsub('-','_')] = terralib.loadfile(l:gsub(':','/') .. '.t')() end end; loadlib = function(name,hdr) local p = config.pkg[name] -- for _,v in pairs(p.dylibs) do @@ -177,16 +177,21 @@ [ lib.emit(false, 2, ' \27[36m+', `&dfs[0]) ] end end local defrep = function(level,n,code) + if level >= 3 and config.debug == false then + return macro(function(...) return {} end) + end return macro(function(...) local fn = (...).filename local ln = tostring((...).linenumber) local dbgtag = string.format('\27[35m · \27[34m%s:\27[1m%s\27[m\n', fn,ln) local q = lib.emit(level < 3 and true or dbgtag, 2, noise_header(code,n), ...) - return quote if noise >= level then timehdr(); [q] end end + return quote + --lib.io.fmt(['attempting to emit at ' .. fn..':'..ln.. '\n']) + if noise >= level then timehdr(); [q] end end end); end lib.dbg = defrep(3,'debug', '32') lib.report = defrep(2,'info', '35') lib.warn = defrep(1,'warn', '33') @@ -330,11 +335,11 @@ lib.net = lib.loadlib('mongoose','mongoose.h') lib.pq = lib.loadlib('libpq','libpq-fe.h') lib.load { 'mem', 'math', 'str', 'file', 'crypt'; - 'http', 'session', 'tpl', 'store'; + 'http', 'html', 'session', 'tpl', 'store'; 'smackdown'; -- md-alike parser } local be = {} @@ -369,45 +374,54 @@ end lib.load { 'srv'; 'render:nav'; + 'render:nym'; 'render:login'; 'render:profile'; + 'render:compose'; 'render:tweet'; 'render:userpage'; 'render:timeline'; + 'render:docpage'; + + 'render:conf:profile'; + 'render:conf'; 'route'; } do local p = string.format('parsav: %s\nbuilt on %s\n', config.build.str, config.build.when) terra version() lib.io.send(1, p, [#p]) end end -terra noise_init() +terra lib.noise_init(default_level: uint) starttime = lib.osclock.time(nil) lastnoisetime = 0 var n = lib.proc.getenv('parsav_noise') if n ~= nil then if n[0] >= 0x30 and n[0] <= 0x39 and n[1] == 0 then noise = n[0] - 0x30 return end end - noise = 1 + noise = default_level end +lib.load{'mgtool'} local options = lib.cmdparse { version = {'V', 'display information about the binary build and exit'}; + verbose = {'v', 'increase logging verbosity', inc=1}; quiet = {'q', 'do not print to standard out'}; help = {'h', 'display this list'}; - backend_file = {'b', 'init from specified backend file', 1}; - static_dir = {'S', 'directory with overrides for static content', 1}; - builtin_data = {'B', 'do not load static content overrides at runtime under any circumstances'}; + backend_file = {'B', 'init from specified backend file', consume=1}; + static_dir = {'S', 'directory with overrides for static content', consume=1}; + builtin_data = {'D', 'do not load static content overrides at runtime under any circumstances'}; + instance = {'i', 'set an instance name to make it easier to control multiple daemons', consume = 1}; } local static_setup = quote end local mapin = quote end @@ -439,21 +453,25 @@ var [odir] = lib.proc.getenv('parsav_override_dir') if mode.static_dir ~= nil then odir=@mode.static_dir end - if odir == nil then return end + if odir == nil then [ + config.prefix_static and quote + odir = [config.prefix_static] + end or quote return end + ] end var [pathbuf] defer pathbuf:free() pathbuf:compose(odir,'/') [mapin] end -terra entry(argc: int, argv: &rawstring): int +local terra entry_daemon(argc: int, argv: &rawstring): int if argc < 1 then lib.bail('bad invocation!') end - noise_init() + lib.noise_init(1) [lib.init] -- shut mongoose the fuck up lib.net.mg_log_set_callback([terra(msg: &opaque, sz: int, u: &opaque) end], nil) var srv: lib.srv.overlord @@ -461,21 +479,22 @@ do var mode: options mode:parse(argc,argv) defer mode:free() static_init(&mode) if mode.version then version() return 0 end if mode.help then - lib.io.send(1, [options.helptxt], [#options.helptxt]) + [ lib.emit(true, 1, 'usage: ',`argv[0],' ', options.helptxt.flags, ' […]', options.helptxt.opts) ] return 0 end var cnf: rawstring if mode.backend_file ~= nil then cnf = @mode.backend_file else cnf = lib.proc.getenv('parsav_backend_file') end - if cnf == nil then cnf = "backend.conf" end + if cnf == nil then cnf = [config.prefix_conf .. "backend.conf"] end - srv:start(cnf) + srv:setup(cnf) + srv:start(lib.trn(mode.instance ~= nil, @mode.instance, nil)) end lib.report('listening for requests') while true do srv:poll() @@ -482,10 +501,11 @@ end srv:shutdown() return 0 end + local bflag = function(long,short) if short and util.has(buildopts, short) then return true end if long and util.has(buildopts, long) then return true end return false @@ -495,26 +515,27 @@ print(util.dump(config)) os.exit(0) end local holler = print -local out = config.exe and 'parsav' or ('parsav.' .. config.outform) +local suffix = config.exe and '' or ('.'..config.outform) +local out = 'parsavd' .. suffix local linkargs = {} +local target = config.tgttrip and terralib.newtarget { + Triple = config.tgttrip; + CPU = config.tgtcpu; + FloatABIHard = config.tgthf; +} or nil if bflag('quiet','q') then holler = function() end end if bflag('asan','s') then linkargs[#linkargs+1] = '-fsanitize=address' end if bflag('lsan','S') then linkargs[#linkargs+1] = '-fsanitize=leak' end +for _,p in pairs(config.pkg) do util.append(linkargs, p.linkargs) end +local linkargs_d = linkargs -- controller is not multithreaded if config.posix then - linkargs[#linkargs+1] = '-pthread' + linkargs_d[#linkargs_d+1] = '-pthread' end -for _,p in pairs(config.pkg) do util.append(linkargs, p.linkargs) end holler('linking with args',util.dump(linkargs)) -terralib.saveobj(out, { - main = entry - }, - linkargs, - config.tgttrip and terralib.newtarget { - Triple = config.tgttrip; - CPU = config.tgtcpu; - FloatABIHard = config.tgthf; - } or nil) + +terralib.saveobj('parsavd'..suffix, { main = entry_daemon }, linkargs_d, target) +terralib.saveobj('parsav' ..suffix, { main = lib.mgtool }, linkargs, target) Index: render/compose.t ================================================================== --- render/compose.t +++ render/compose.t @@ -6,10 +6,11 @@ if edit == nil then form = data.view.compose { content = lib.coalesce(target, ''); acl = lib.trn(target == nil, 'all', 'mentioned'); -- TODO default acl setting? handle = co.who.handle; + circles = ''; -- TODO: list user's circles, rooms, and saved aclexps } end var cotxt = form:tostr() defer cotxt:free() var doc = data.view.docskel { ADDED render/conf.t Index: render/conf.t ================================================================== --- render/conf.t +++ render/conf.t @@ -0,0 +1,74 @@ +-- vim: ft=terra +local pstr = lib.mem.ptr(int8) +local pref = lib.mem.ref(int8) + +local mappings = { + {url = 'profile', title = 'account profile', render = 'profile'}; + {url = 'avi', title = 'avatar', render = 'avatar'}; + {url = 'sec', title = 'security', render = 'sec'}; + {url = 'rel', title = 'relationships', render = 'rel'}; + {url = 'qnt', title = 'quarantine', render = 'quarantine'}; + {url = 'acl', title = 'access control shortcuts', render = 'acl'}; + {url = 'rooms', title = 'chatrooms', render = 'rooms'}; + {url = 'circles', title = 'circles', render = 'circles'}; + + {url = 'srv', title = 'server settings', render = 'srv'}; + {url = 'brand', title = 'instance branding', render = 'rebrand'}; + {url = 'censor', title = 'censorship & badthink suppression', render = 'rebrand'}; + {url = 'users', title = 'user accounting', render = 'users'}; + +} + +local path = symbol(lib.mem.ptr(pref)) +local co = symbol(&lib.srv.convo) +local panel = symbol(pstr) +local invoker = quote co:complain(404,'not found','no such control panel is available in this version of parsav') end + +for i, m in ipairs(mappings) do + if lib.render.conf[m.render] then + invoker = quote + if path(1):cmp(lib.str.lit([m.url])) then + var body = [lib.render.conf[m.render]] (co, path) + var a: lib.str.acc a:init(body.ct+48) + a:lpush(['

' .. m.title .. '

']):ppush(body) + panel = a:finalize() + body:free() + else [invoker] end + end + end +end + +local terra +render_conf([co], [path]) + var menu: lib.str.acc menu:init(64):lpush('
') defer menu:free() + + -- build menu + do var p = co.who.rights.powers + if p.config() then menu:lpush 'server settings' end + if p.rebrand() then menu:lpush 'instance branding' end + if p.censor() then menu:lpush 'badthink alerts' end + if p:affect_users() then menu:lpush 'users' end + end + + -- select the appropriate panel + var [panel] = pstr { ptr = ''; ct = 0 } + if path.ct >= 2 then [invoker] end + + -- avoid the hr if we didn't add any elements + var mptr = pstr { ptr = menu.buf, ct = menu.sz } + if menu.sz <= 4 then mptr.ct = 0 end -- 🙄 + var pg = data.view.conf { + menu = mptr; + panel = panel; + } + + var pgt = pg:tostr() defer pgt:free() + co:stdpage([lib.srv.convo.page] { + title = 'configure'; body = pgt; + class = lib.str.plit 'conf'; + }) + + if panel.ct ~= 0 then panel:free() end +end + +return render_conf ADDED render/conf/profile.t Index: render/conf/profile.t ================================================================== --- render/conf/profile.t +++ render/conf/profile.t @@ -0,0 +1,20 @@ +-- vim: ft=terra +local pstr = lib.mem.ptr(int8) +local pref = lib.mem.ref(int8) + +local terra cs(s: rawstring) + return pstr { ptr = s, ct = lib.str.sz(s) } +end + +local terra +render_conf_profile(co: &lib.srv.convo, path: lib.mem.ptr(pref)): pstr + + var c = data.view.conf_profile { + handle = cs(co.who.handle); + nym = cs(lib.coalesce(co.who.nym,'')); + bio = cs(lib.coalesce(co.who.bio,'')); + } + return c:tostr() +end + +return render_conf_profile Index: render/login.t ================================================================== --- render/login.t +++ render/login.t @@ -33,18 +33,21 @@ } if creds.pw() then ch.challenge = P'enter the password associated with your account' ch.label = P'password' ch.method = P'pw' + ch.auto = P'current-password'; elseif creds.otp() then ch.challenge = P'enter a valid one-time password for your account' ch.label = P'OTP code' ch.method = P'otp' + ch.auto = P'one-time-code'; elseif creds.challenge() then ch.challenge = P'sign the challenge token: ...' ch.label = P'digest' ch.method = P'challenge' + ch.auto = P'one-time-code'; else co:complain(500,'login failure','unknown login method') return end ADDED render/nym.t Index: render/nym.t ================================================================== --- render/nym.t +++ render/nym.t @@ -0,0 +1,21 @@ +-- vim: ft=terra +local pstr = lib.mem.ptr(int8) + +local terra +render_nym(who: &lib.store.actor, scope: uint64) + var n: lib.str.acc n:init(128) + if who.nym ~= nil and who.nym[0] ~= 0 then + n:compose('',who.nym,' [', + who.xid,']') + else n:compose('',who.xid,'') end + + if who.epithet ~= nil then + n:lpush(' '):push(who.epithet,0):lpush('') + end + + -- TODO: if scope == chat room then lookup titles in room member db + + return n:finalize() +end + +return render_nym Index: render/profile.t ================================================================== --- render/profile.t +++ render/profile.t @@ -30,14 +30,18 @@ var stats = co.srv:actor_stats(actor.id) var sn_posts = cs(lib.math.decstr_friendly(stats.posts, &strfbuf[ [strfbuf.type.N - 1] ])) var sn_follows = cs(lib.math.decstr_friendly(stats.follows, sn_posts.ptr - 1)) var sn_followers = cs(lib.math.decstr_friendly(stats.followers, sn_follows.ptr - 1)) var sn_mutuals = cs(lib.math.decstr_friendly(stats.mutuals, sn_followers.ptr - 1)) - + var bio = lib.str.plit "tall, dark, and mysterious" + if actor.bio ~= nil then + bio = lib.html.sanitize(cs(actor.bio), false) + end + var fullname = lib.render.nym(actor,0) defer fullname:free() var profile = data.view.profile { - nym = cs(lib.coalesce(actor.nym, actor.handle)); - bio = cs(lib.coalesce(actor.bio, "tall, dark, and mysterious")); + nym = fullname; + bio = bio; xid = cs(actor.xid); avatar = lib.trn(actor.origin == 0, pstr{ptr=avistr.buf,ct=avistr.sz}, cs(lib.coalesce(actor.avatar, '/s/default-avatar.webp'))); nposts = sn_posts, nfollows = sn_follows; @@ -49,9 +53,10 @@ } var ret = profile:tostr() if actor.origin == 0 then avistr:free() end if not (co.aid ~= 0 and co.who.id == actor.id) then auxp:free() end + if actor.bio ~= nil then bio:free() end return ret end return render_profile Index: render/tweet.t ================================================================== --- render/tweet.t +++ render/tweet.t @@ -24,16 +24,15 @@ var bhtml = lib.smackdown.html([lib.mem.ptr(int8)] {ptr=p.body,ct=0}) defer bhtml:free() var idbuf: int8[lib.math.shorthand.maxlen] var idlen = lib.math.shorthand.gen(p.id, idbuf) var permalink: lib.str.acc permalink:compose('/post/',{idbuf,idlen}) - + var fullname = lib.render.nym(author,0) defer fullname:free() var tpl = data.view.tweet { text = bhtml; subject = cs(lib.coalesce(p.subject,'')); - nym = cs(lib.coalesce(author.nym, author.handle)); - xid = cs(author.xid); + nym = fullname; when = cs(×tr[0]); avatar = cs(lib.trn(author.origin == 0, avistr.buf, lib.coalesce(author.avatar, '/s/default-avatar.webp'))); acctlink = cs(author.xid); permalink = permalink:finalize(); Index: route.t ================================================================== --- route.t +++ route.t @@ -83,13 +83,14 @@ -- the user is known to us but has not yet claimed an -- account on the server. create a template for the -- account that will be created once they log in fakeact = true fakeactor = lib.store.actor { - id = 0, handle = usn, nym = usn; + id = 0, handle = usn, nym = nil; origin = 0, bio = nil; - key = [lib.mem.ptr(uint8)] {ptr=nil, ct=0} + key = [lib.mem.ptr(uint8)] {ptr=nil, ct=0}; + epithet = nil; } act.ct = 1 act.ptr = &fakeactor act.ptr.rights = lib.store.rights_default() end @@ -171,10 +172,14 @@ lib.render.docpage(co, rstring.null()) else co:complain(404, 'no such documentation', 'invalid documentation URL') end end + +terra http.configure(co: &lib.srv.convo, path: hpath) + lib.render.conf(co,path) +end do local branches = quote end local filename, flen = symbol(&int8), symbol(intptr) local page = symbol(lib.http.page) local send = label() @@ -264,12 +269,16 @@ elseif path.ptr[0]:cmp(lib.str.lit('tl')) then http.timeline(co, path) elseif path.ptr[0]:cmp(lib.str.lit('doc')) then if meth ~= method.get and meth ~= method.head then goto wrongmeth end http.documentation(co, path) + elseif path.ptr[0]:cmp(lib.str.lit('conf')) then + if co.aid == 0 then goto unauth end + http.configure(co,path) else goto notfound end return end ::wrongmeth:: co:complain(405, 'method not allowed', 'that method is not meaningful for this endpoint') do return end ::notfound:: co:complain(404, 'not found', 'no such resource available') do return end + ::unauth:: co:complain(401, 'unauthorized', 'this content is not available at your clearance level') do return end end DELETED schema.sql Index: schema.sql ================================================================== --- schema.sql +++ schema.sql @@ -1,198 +0,0 @@ -\prompt 'domain name: ' domain -\prompt 'instance name: ' inst -\prompt 'bind to socket: ' bind -\qecho 'how locked down should this server be? public = anyone can see public timeline and tweets, private = anyone can see tweets with a link but login required for everything else, lockdown = login required for all activities, isolate = like lockdown but with federation protocols completely disabled' -\prompt 'security mode: ' secmode -\qecho 'should user self-registration be allowed? yes or no' -\prompt 'registration: ' regpol -\qecho 'by default, parsav tracks rights on its own. you can override this later by replacing the rights table with a view, but you''ll then need to set appropriate rules on the view to allow administrators to modify rights from the web UI, or set the rights-readonly flag in the config table to true. for now, enter the name of an actor who will be granted full rights when she logs in and identified as the server owner.' -\prompt 'master actor: ' admin -\qecho 'you will need to create an authentication view named parsav_auth mapping your user database to something parsav can understand; see auth.sql for an example.' - -begin; - -drop table if exists parsav_config; -create table if not exists parsav_config ( - key text primary key, - value text -); - -insert into parsav_config (key,value) values - ('bind',:'bind'), - ('domain',:'domain'), - ('instance-name',:'inst'), - ('policy-security',:'secmode'), - ('policy-self-register',:'regpol'), - ('master',:'admin'), - ('server-secret', encode( - digest(int8send((2^63 * (random()*2 - 1))::bigint), - 'sha512'), 'base64')); - --- note that valid ids should always > 0, as 0 is reserved for null --- on the client side, vastly simplifying code -drop table if exists parsav_servers cascade; -create table parsav_servers ( - id bigint primary key default (1+random()*(2^63-1))::bigint, - domain text not null, - key bytea, - knownsince timestamp, - parsav boolean -- whether to use parsav protocol extensions -); - -drop table if exists parsav_actors cascade; -create table parsav_actors ( - id bigint primary key default (1+random()*(2^63-1))::bigint, - nym text, - handle text not null, -- nym [@handle@origin] - origin bigint references parsav_servers(id) - on delete cascade, -- null origin = local actor - knownsince timestamp, - bio text, - avataruri text, -- null if local - rank smallint not null default 0, - quota integer not null default 1000, - key bytea, -- private if localactor; public if remote - title text, - - unique (handle,origin) -); - -drop table if exists parsav_rights cascade; -create table parsav_rights ( - key text, - actor bigint references parsav_actors(id) - on delete cascade, - allow boolean not null, - scope bigint, -- for future expansion - - primary key (key,actor) -); - -insert into parsav_actors (handle,rank,quota) values (:'admin',1,0); -insert into parsav_rights (actor,key,allow) - select (select id from parsav_actors where handle=:'admin'), a.column1, a.column2 from (values - ('purge',true), - ('config',true), - ('censor',true), - ('suspend',true), - ('cred',true), - ('elevate',true), - ('demote',true), - ('rebrand',true) - ) as a; - -drop table if exists parsav_posts cascade; -create table parsav_posts ( - id bigint primary key default (1+random()*(2^63-1))::bigint, - author bigint references parsav_actors(id) - on delete cascade, - subject text, - acl text not null default 'all', -- just store the script raw 🤷 - body text, - posted timestamp not null, - discovered timestamp not null, - parent bigint not null default 0, - circles bigint[], -- TODO at edit or creation, iterate through each circle - mentions bigint[], -- a user has, check if it can see her post, and if so add - - convoheaduri text - -- only used for tracking foreign conversations and tying them to post heads; - -- local conversations are tracked directly and mapped to URIs based on the - -- head's ID. null if native tweet or not the first tweet in convo -); - -drop table if exists parsav_conversations cascade; - -drop table if exists parsav_rels cascade; -create table parsav_rels ( - relator bigint references parsav_actors(id) - on delete cascade, -- e.g. follower - relatee bigint references parsav_actors(id) - on delete cascade, -- e.g. followed - kind smallint, -- e.g. follow, block, mute - - primary key (relator, relatee, kind) -); - -drop table if exists parsav_acts cascade; -create table parsav_acts ( - id bigint primary key default (1+random()*(2^63-1))::bigint, - kind text not null, -- like, react, so on - time timestamp not null default now(), - actor bigint references parsav_actors(id) - on delete cascade, - subject bigint -- may be post or act, depending on kind -); - -drop table if exists parsav_log cascade; -create table parsav_log ( - -- accesses are tracked for security & sending delete acts - id bigint primary key default (1+random()*(2^63-1))::bigint, - time timestamp not null default now(), - actor bigint references parsav_actors(id) - on delete cascade, - post bigint not null -); - -drop table if exists parsav_attach cascade; -create table parsav_attach ( - id bigint primary key default (1+random()*(2^63-1))::bigint, - birth timestamp not null default now(), - content bytea not null, - mime text, -- null if unknown, will be reported as x-octet-stream - description text, - parent bigint -- post id, or userid for avatars -); - -drop table if exists parsav_circles cascade; -create table parsav_circles ( - id bigint primary key default (1+random()*(2^63-1))::bigint, - owner bigint not null references parsav_actors(id), - name text not null, - members bigint[] not null default array[]::bigint[], - - unique (owner,name) -); - -drop table if exists parsav_rooms cascade; -create table parsav_rooms ( - id bigint primary key default (1+random()*(2^63-1))::bigint, - origin bigint references parsav_servers(id), - name text not null, - description text not null, - policy smallint not null -); - -drop table if exists parsav_room_members cascade; -create table parsav_room_members ( - room bigint references parsav_rooms(id), - member bigint references parsav_actors(id), - rank smallint not null default 0, - admin boolean not null default false, -- non-admins with rank can only moderate + invite - title text, -- admin-granted title like reddit flair - vouchedby bigint references parsav_actors(id) -); - -drop table if exists parsav_invites cascade; -create table parsav_invites ( - id bigint primary key default (1+random()*(2^63-1))::bigint, - -- when a user is created from an invite, the invite is deleted and the invite - -- ID becomes the user ID. privileges granted on the invite ID during the invite - -- process are thus inherited by the user - issuer bigint references parsav_actors(id), - handle text, -- admin can lock invite to specific handle - rank smallint not null default 0, - quota integer not null default 1000 -); - -drop table if exists parsav_interventions cascade; -create table parsav_interventions ( - id bigint primary key default (1+random()*(2^63-1))::bigint, - issuer bigint references parsav_actors(id) not null, - scope bigint, -- can be null or room for local actions - nature smallint not null, -- silence, suspend, disemvowel, etc - victim bigint not null, -- could potentially target group as well - expire timestamp -- auto-expires if set -); - -end; Index: smackdown.t ================================================================== --- smackdown.t +++ smackdown.t @@ -54,12 +54,15 @@ if sl == nil then return nil else sl = sl + nc end if sl >= l+max or isws(@sl) then return sl-nc end return nil end -terra m.html(md: pstr) - if md.ct == 0 then md.ct = lib.str.sz(md.ptr) end +terra m.html(input: pstr) + if input.ct == 0 then input.ct = lib.str.sz(input.ptr) end + + var md = lib.html.sanitize(input,false) + var styled: lib.str.acc styled:init(md.ct) do var i = 0 while i < md.ct do var wordstart = (i == 0 or isws(md.ptr[i-1])) var wordend = (i == md.ct - 1 or isws(md.ptr[i+1])) @@ -114,10 +117,11 @@ end ::fallback::styled:push(here,1) -- :/ i = i + 1 ::skip::end end + md:free() -- we make two passes: the first detects and transforms inline elements, -- the second carries out block-level organization var html: lib.str.acc html:init(styled.sz) Index: srv.t ================================================================== --- srv.t +++ srv.t @@ -13,10 +13,11 @@ local struct srv { sources: lib.mem.ptr(lib.store.source) webmgr: lib.net.mg_mgr webcon: &lib.net.mg_connection cfg: cfgcache + id: rawstring } terra cfgcache:free() -- :/ self.secret:free() self.instance:free() @@ -541,21 +542,11 @@ var aid,uid,newhnd = self.sources(i):actor_auth_pw(ip,user,pw) if aid ~= 0 then if uid == 0 then lib.dbg('new user just logged in, creating account entry') var kbuf: uint8[lib.crypt.const.maxdersz] - var newkp = lib.crypt.genkp() - var privsz = lib.crypt.der(false,&newkp,&kbuf[0]) - var na = lib.store.actor { - id = 0; nym = nil; handle = newhnd.ptr; - origin = 0; bio = nil; avatar = nil; - knownsince = lib.osclock.time(nil); - rights = lib.store.rights_default(); - title = nil, key = [lib.mem.ptr(uint8)] { - ptr = &kbuf[0], ct = privsz - }; - } + var na = lib.store.actor.mk(&kbuf[0]) var newuid: uint64 if self.sources(i).backend.actor_create ~= nil then newuid = self.sources(i):actor_create(&na) else newuid = self:actor_create(&na) end @@ -576,11 +567,11 @@ terra cfgcache:init(o: &srv) self.overlord = o self:load() end -srv.methods.start = terra(self: &srv, befile: rawstring) +terra srv:setup(befile: rawstring) cfg(self, befile) var success = false if self.sources.ct == 0 then lib.bail('no data sources specified') end for i=0,self.sources.ct do var src = self.sources.ptr + i lib.report('opening data source ', src.id.ptr, '(', src.backend.id, ')') @@ -588,34 +579,46 @@ if src.handle ~= nil then success = true end end if not success then lib.bail('could not connect to any data sources!') end +end +terra srv:start(iname: rawstring) + self:conprep(lib.store.prepmode.full) self.cfg:init(self) - var dbbind = self:conf_get('bind') + if iname == nil then iname = lib.proc.getenv('parsav_instance') end + if iname == nil then + self.id = self.cfg.instance.ptr; + -- let this leak -- it'll be needed for the lifetime of the process anyway + else self.id = iname end + + if iname ~= nil then + lib.report('parsav instance "',iname,'" starting') + end + var envbind = lib.proc.getenv('parsav_bind') var bind: rawstring if envbind ~= nil then bind = envbind elseif dbbind.ptr ~= nil then bind = dbbind.ptr - else bind = '[::]:10917' end + else bind = '[::1]:10917' end lib.report('binding to ', bind) lib.net.mg_mgr_init(&self.webmgr) self.webcon = lib.net.mg_http_listen(&self.webmgr, bind, handle.http, self) if dbbind.ptr ~= nil then dbbind:free() end end -srv.methods.poll = terra(self: &srv) +terra srv:poll() lib.net.mg_mgr_poll(&self.webmgr,1000) end -srv.methods.shutdown = terra(self: &srv) +terra srv:shutdown() lib.net.mg_mgr_free(&self.webmgr) for i=0,self.sources.ct do var src = self.sources.ptr + i lib.report('closing data source ', src.id.ptr, '(', src.backend.id, ')') src:close() end Index: static/style.scss ================================================================== --- static/style.scss +++ static/style.scss @@ -13,15 +13,15 @@ margin: 0; padding: 0; } a[href] { color: tone(10%); - text-decoration-color: adjust-color($color, $lightness: 10%, $alpha: -0.5); + text-decoration-color: tone(10%,-0.5); &:hover { color: white; text-shadow: 0 0 15px tone(20%); - text-decoration-color: adjust-color($color, $lightness: 10%, $alpha: -0.1); + text-decoration-color: tone(10%,-0.1); } } a[href^="//"], a[href^="http://"], a[href^="https://"] { // external link @@ -32,11 +32,11 @@ &::after { content: "↗"; display: inline-block; color: black; margin-left: 4pt; - background-color: adjust-color($color, $lightness: 10%); + background-color: tone(10%); padding: 0 4px; text-shadow: none; padding-right: 5px; vertical-align: baseline; font-size: 80%; @@ -47,105 +47,100 @@ width: 8in; margin: auto; } %glow { - box-shadow: 0 0 20px adjust-color($color, $alpha: -0.8); + box-shadow: 0 0 20px tone(0%,-0.8); } %button { @extend %sans; font-size: 14pt; padding: 0.1in 0.2in; border: 1px solid black; - color: adjust-color($color, $lightness: 25%); + color: tone(25%); text-shadow: 1px 1px black; text-decoration: none; text-align: center; + cursor: default; background: linear-gradient(to bottom, - adjust-color($color, $lightness: -45%), - adjust-color($color, $lightness: -50%) 15%, - adjust-color($color, $lightness: -50%) 75%, - adjust-color($color, $lightness: -55%) + tone(-47%), + tone(-50%) 15%, + tone(-50%) 75%, + tone(-53%) ); &:hover, &:focus { @extend %glow; outline: none; - color: adjust-color($color, $lightness: -55%); + color: tone(-55%); text-shadow: none; background: linear-gradient(to bottom, - adjust-color($color, $lightness: -25%), - adjust-color($color, $lightness: -30%) 15%, - adjust-color($color, $lightness: -30%) 75%, - adjust-color($color, $lightness: -35%) + tone(-27%), + tone(-30%) 15%, + tone(-30%) 75%, + tone(-35%) ); } &:active { color: black; padding-bottom: calc(0.1in - 2px); padding-top: calc(0.1in + 2px); background: linear-gradient(to top, - adjust-color($color, $lightness: -25%), - adjust-color($color, $lightness: -30%) 15%, - adjust-color($color, $lightness: -30%) 75%, - adjust-color($color, $lightness: -35%) + tone(-25%), + tone(-30%) 15%, + tone(-30%) 75%, + tone(-35%) ); } } button { @extend %button; &:first-of-type { @extend %button; color: white; - box-shadow: inset 0 1px adjust-color($color, $lightness: -25%), - inset 0 -1px adjust-color($color, $lightness: -50%); + box-shadow: inset 0 1px tone(-25%), + inset 0 -1px tone(-50%); background: linear-gradient(to bottom, - adjust-color($color, $lightness: -35%), - adjust-color($color, $lightness: -40%) 15%, - adjust-color($color, $lightness: -40%) 75%, - adjust-color($color, $lightness: -45%) + tone(-35%), + tone(-40%) 15%, + tone(-40%) 75%, + tone(-45%) ); &:hover, &:focus { - box-shadow: inset 0 1px adjust-color($color, $lightness: -15%), - inset 0 -1px adjust-color($color, $lightness: -40%); + box-shadow: inset 0 1px tone(-15%), + inset 0 -1px tone(-40%); } &:active { - box-shadow: inset 0 1px adjust-color($color, $lightness: -50%), - inset 0 -1px adjust-color($color, $lightness: -25%); + box-shadow: inset 0 1px tone(-50%), + inset 0 -1px tone(-25%); background: linear-gradient(to top, - adjust-color($color, $lightness: -30%), - adjust-color($color, $lightness: -35%) 15%, - adjust-color($color, $lightness: -35%) 75%, - adjust-color($color, $lightness: -40%) + tone(-30%), + tone(-35%) 15%, + tone(-35%) 75%, + tone(-40%) ); } } &:hover { font-weight: bold; } } $grad-ui-focus: linear-gradient(to bottom, - adjust-color($color, $lightness: -50%), - adjust-color($color, $lightness: -35%) + tone(-50%), + tone(-35%) ); input[type='text'], input[type='password'], textarea { @extend %serif; padding: 0.08in 0.1in; border: 1px solid black; - background: linear-gradient(to bottom, - adjust-color($color, $lightness: -55%), - adjust-color($color, $lightness: -40%) - ); + background: linear-gradient(to bottom, tone(-55%), tone(-40%)); font-size: 16pt; - color: adjust-color($color, $lightness: 25%); - box-shadow: inset 0 0 20px -3px adjust-color($color, $lightness: -55%); + color: tone(25%); + box-shadow: inset 0 0 20px -3px tone(-55%); &:focus { color: white; - border-image: linear-gradient(to bottom, - adjust-color($color, $lightness: -10%), - adjust-color($color, $lightness: -30%) - ) 1 / 1px; + border-image: linear-gradient(to bottom, tone(-10%), tone(-30%)) 1 / 1px; background: $grad-ui-focus; outline: none; @extend %glow; } } @@ -152,14 +147,14 @@ @mixin glass { @supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) { backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px); - background-color: adjust-color($color, $lightness: -53%, $alpha: -0.7); + background-color: tone(-53%, -0.7); } @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { - background-color: adjust-color($color, $lightness: -53%, $alpha: -0.1); + background-color: tone(-53%, -0.1); } } h1 { margin-top: 0 } @@ -217,11 +212,11 @@ margin-top: 0; margin-bottom: 0; padding: 0 0.4in; padding-top: 1.1in; padding-bottom: 0.1in; - background-color: adjust-color($color, $lightness: -45%, $alpha: 0.4); + background-color: tone(-45%,-0.3); border: { left: 1px solid black; right: 1px solid black; } } @@ -248,17 +243,17 @@ border: 1px solid black; } > .id { grid-column: 2 / 3; grid-row: 1 / 2; - color: adjust-color($color, $lightness: 25%, $alpha: -0.4); + color: tone(25%,-0.4); > .nym { font-weight: bold; - color: adjust-color($color, $lightness: 25%); + color: tone(25%); } > .xid { - color: adjust-color($color, $lightness: 20%, $alpha: -0.1); + color: tone(20%,-0.1); font-size: 80%; vertical-align: text-top; } } > .bio { @@ -289,27 +284,44 @@ width: 1px; border-left: 1px solid rgba(0,0,0,0.6); } } } + +.epithet { + display: inline-block; + background: tone(20%); + color: tone(-45%); + text-shadow: 0 0 3px tone(-30%, -0.4); + border-radius: 3px; + padding: 6px; + padding-top: 2px; + padding-bottom: 4px; + font-size: 80%; + vertical-align: top; + font-weight: 300; + letter-spacing: 0.5px; + margin: 0 5pt; + // transform: scale(80%) translateX(-10pt); // cheating! +} %box { margin: auto; - border: 1px solid adjust-color($color, $lightness: -55%); + border: 1px solid tone(-55%); border-bottom: 3px solid black; box-shadow: 0 0 1px black; border-image: linear-gradient(to bottom, - adjust-color($color, $lightness: -40%), - adjust-color($color, $lightness: -52%) 10%, - adjust-color($color, $lightness: -55%) 90%, - adjust-color($color, $lightness: -60%) + tone(-40%), + tone(-52%) 10%, + tone(-55%) 90%, + tone(-60%) ) 1 / 1px; background: linear-gradient(to bottom, - adjust-color($color, $lightness: -58%), - adjust-color($color, $lightness: -55%) 10%, - adjust-color($color, $lightness: -50%) 80%, - adjust-color($color, $lightness: -45%) + tone(-58%), + tone(-55%) 10%, + tone(-50%) 80%, + tone(-45%) ); // outline: 1px solid black; } body.error .message { @@ -329,13 +341,13 @@ padding: 0.3in; } > .msg:first-child { padding-top: 0; } > .user { width: min-content; margin: auto; - background: adjust-color($color, $lightness: -20%, $alpha: -0.3); + background: tone(-20%,-0.3); border: 1px solid black; - color: adjust-color($color, $lightness: -50%); + color: tone(-50%); padding: 0.1in; > img { width: 1in; height: 1in; border: 1px solid black; } > .name { @extend %serif; text-align: center; font-size: 130%; font-weight: bold; margin-top: 0.08in; } } >form { @@ -357,11 +369,15 @@ grid-template-columns: 1.1in 2fr min-content 1fr; grid-template-rows: 1fr min-content; grid-gap: 2px; padding: 0.1in; > img { grid-column: 1/2; grid-row: 1/3; width: 1in; height: 1in;} - > textarea { grid-column: 2/5; grid-row: 1/2; height: 3in;} + > textarea { + grid-column: 2/5; grid-row: 1/2; height: 3in; + resize: vertical; + margin-bottom: 0.08in; + } > input[name="acl"] { grid-column: 2/3; grid-row: 2/3; } > button { grid-column: 4/5; grid-row: 2/3; } a.help[href] { margin-right: 0.05in } } @@ -459,10 +475,11 @@ >.content { grid-column: 2/4; grid-row: 1/2; padding: 0.2in; @extend %serif; font-size: 110%; + text-align: justify; } > a[href].permalink { display: block; grid-column: 3/4; grid-row: 2/3; font-size: 80%; @@ -478,14 +495,54 @@ @extend %teletype; } body.doc main { @extend %serif; + text-align: justify; li { margin-top: 0.05in; } li:first-child { margin-top: 0; } h1, h2, h3, h4, h5, h6 { background: linear-gradient(to right, tone(-50%), transparent); margin-left: -0.4in; padding-left: 0.2in; text-shadow: 0 2px 0 black; } } + +body.conf main { + display: grid; + grid-template-columns: 2in 1fr; + grid-template-rows: max-content 1fr; + > .menu { + margin-left: -0.25in; + grid-column: 1/2; grid-row: 1/2; + background: linear-gradient(to bottom, tone(-45%),tone(-55%)); + border: 1px solid black; + padding: 0.1in; + > a[href] { + @extend %button; + display: block; + text-align: left; + } + > a[href] + a[href] { + border-top: none; + } + hr { + border: none; + } + } + > .panel { + grid-column: 2/3; grid-row: 1/3; + padding-left: 0.15in; + > h1 { + padding-bottom: 0.1in; + margin-bottom: 0.1in; + margin-left: -0.15in; + padding-left: 0.15in; + padding-top: 0.12in; + background: linear-gradient(to right, tone(-50%), tone(-50%,-0.7)); + border: 1px solid tone(-55%); + border-left: none; + text-shadow: 1px 1px 0 black; + } + } +} Index: store.t ================================================================== --- store.t +++ store.t @@ -6,10 +6,11 @@ 'personal', 'direct', 'circle'; }; notiftype = lib.enum { 'mention', 'like', 'rt', 'react' }; + relation = lib.enum { 'follow', 'mute', 'block' }; credset = lib.set { 'pw', 'otp', 'challenge', 'trust' @@ -22,18 +23,21 @@ 'login', 'visible', 'post', 'shout', 'propagate', 'upload', 'acct', 'edit'; -- admin powers -- default off 'purge', 'config', 'censor', 'suspend', - 'cred', 'elevate', 'demote', 'rebrand' -- modify site's brand identity + 'cred', 'elevate', 'demote', 'rebrand', -- modify site's brand identity + 'herald' -- grant serverwide epithets + }; + prepmode = lib.enum { + 'full','conf','admin' } } terra m.powerset:affect_users() return self.purge() or self.censor() or self.suspend() or - self.elevate() or self.demote() or self.rebrand() or - self.cred() + self.elevate() or self.demote() or self.cred() end local str = rawstring local pstr = lib.mem.ptr(int8) @@ -64,20 +68,34 @@ id: uint64 nym: str handle: str origin: uint64 bio: str - title: str + epithet: str avatar: str knownsince: m.timepoint rights: m.rights key: lib.mem.ptr(uint8) -- ephemera xid: str source: &m.source } + +terra m.actor.methods.mk(kbuf: &uint8) + var newkp = lib.crypt.genkp() + var privsz = lib.crypt.der(false,&newkp,kbuf) + return m.actor { + id = 0; nym = nil; handle = nil; + origin = 0; bio = nil; avatar = nil; + knownsince = lib.osclock.time(nil); + rights = m.rights_default(); + epithet = nil, key = [lib.mem.ptr(uint8)] { + ptr = &kbuf[0], ct = privsz + }; + } +end struct m.actor_stats { posts: intptr follows: intptr followers: intptr @@ -180,10 +198,13 @@ -- backends only handle content on the local server struct m.backend { id: rawstring open: &m.source -> &opaque close: &m.source -> {} + dbsetup: &m.source -> bool -- creates the schema needed to call conprep (called only once per database e.g. with `parsav db init`) + conprep: {&m.source, m.prepmode.t} -> {} -- prepares queries and similar tasks that require the schema to already be in place + obliterate_everything: &m.source -> bool -- wipes everything parsav-related out of the database conf_get: {&m.source, rawstring} -> lib.mem.ptr(int8) conf_set: {&m.source, rawstring, rawstring} -> {} conf_reset: {&m.source, rawstring} -> {} @@ -233,10 +254,15 @@ -- aid: uint64 -- uid: uint64 actor_conf_str: cnf(rawstring, lib.mem.ptr(int8)) actor_conf_int: cnf(intptr, lib.stat(intptr)) + + auth_create_pw: {&m.source, uint64, bool, lib.mem.ptr(int8)} -> {} + -- uid: uint64 + -- reset: bool (delete other passwords?) + -- pw: pstring post_save: {&m.source, &m.post} -> {} post_create: {&m.source, &m.post} -> uint64 post_enum_author_uid: {&m.source, uint64, m.range} -> lib.mem.ptr(lib.mem.ptr(m.post)) convo_fetch_xid: {&m.source,rawstring} -> lib.mem.ptr(m.post) Index: str.t ================================================================== --- str.t +++ str.t @@ -8,10 +8,12 @@ sz = terralib.externfunction('strlen', rawstring -> intptr); cmp = terralib.externfunction('strcmp', {rawstring, rawstring} -> int); ncmp = terralib.externfunction('strncmp', {rawstring, rawstring, intptr} -> int); cpy = terralib.externfunction('stpcpy',{rawstring, rawstring} -> rawstring); ncpy = terralib.externfunction('stpncpy',{rawstring, rawstring, intptr} -> rawstring); + cat = terralib.externfunction('strcat',{rawstring, rawstring} -> rawstring); + ncat = terralib.externfunction('strncat',{rawstring, rawstring, intptr} -> rawstring); dup = terralib.externfunction('strdup',rawstring -> rawstring); ndup = terralib.externfunction('strndup',{rawstring, intptr} -> rawstring); fmt = terralib.externfunction('asprintf', terralib.types.funcpointer({&rawstring,rawstring},{int},true)); bfmt = terralib.externfunction('sprintf', Index: tpl.t ================================================================== --- tpl.t +++ tpl.t @@ -2,10 +2,11 @@ -- string template generator: -- returns a function that fills out a template -- with the strings given local util = lib.util +local pstr = lib.mem.ptr(int8) local m = {} function m.mk(tplspec) local str if type(tplspec) == 'string' then str = tplspec tplspec = {} @@ -35,14 +36,14 @@ str = str:gsub(' ?', file) end) - for start, key, stop in string.gmatch(str,'()'..tplchar..'(%w+)()') do + for start, mode, key, stop in string.gmatch(str,'()'..tplchar..'([:!]?)(%w+)()') do if string.sub(str,start-1,start-1) ~= '\\' then segs[#segs+1] = string.sub(str,last,start-1) - fields[#segs] = key + fields[#segs] = { key = key, mode = (mode ~= '' and mode or nil) } last = stop end end segs[#segs+1] = string.sub(str,last) @@ -65,22 +66,26 @@ var [runningtally] = 1 + constlen end} local rec = terralib.types.newstruct(string.format('template<%s>',tplspec.id or '')) local symself = symbol(&rec) do local kfac = {} - for afterseg,key in pairs(fields) do - if not kfac[key] then + local sanmode = {} + for afterseg,fld in ipairs(fields) do + if not kfac[fld.key] then rec.entries[#rec.entries + 1] = { - field = key; + field = fld.key; type = lib.mem.ptr(int8); } end - kfac[key] = (kfac[key] or 0) + 1 + kfac[fld.key] = (kfac[fld.key] or 0) + 1 + sanmode[fld.key] = fld.mode == ':' and 6 or fld.mode == '!' and 5 or 1 end for key, fac in pairs(kfac) do + local sanfac = sanmode[key] + tallyup[#tallyup + 1] = quote - [runningtally] = [runningtally] + ([symself].[key].ct)*fac + [runningtally] = [runningtally] + ([symself].[key].ct)*fac*sanfac end end end local copiers = {} @@ -92,22 +97,49 @@ local destcon = symbol(&lib.net.mg_connection) for idx, seg in ipairs(segs) do copiers[#copiers+1] = quote [cpypos] = lib.mem.cpy([cpypos], [&opaque]([seg]), [#seg]) end senders[#senders+1] = quote lib.net.mg_send([destcon], [seg], [#seg]) end appenders[#appenders+1] = quote [accumulator]:push([seg], [#seg]) end - if fields[idx] then - --local fsz = `lib.str.sz(symself.[fields[idx]]) - local fval = `symself.[fields[idx]].ptr - local fsz = `symself.[fields[idx]].ct - copiers[#copiers+1] = quote - [cpypos] = lib.mem.cpy([cpypos], [&opaque]([fval]), [fsz]) + if fields[idx] and fields[idx].mode then + local f = fields[idx] + local fp = `symself.[f.key] + copiers[#copiers+1] = quote + if fp.ct > 0 then + var san = lib.html.sanitize(fp, [f.mode == ':']) + [cpypos] = lib.mem.cpy([cpypos], [&opaque](san.ptr), san.ct) + --san:free() + end + end + senders[#senders+1] = quote + if fp.ct > 0 then + var san = lib.html.sanitize(fp, [f.mode == ':']) + lib.net.mg_send([destcon], san.ptr, san.ct) + --san:free() + end + end + appenders[#appenders+1] = quote + if fp.ct > 0 then + var san = lib.html.sanitize(fp, [f.mode == ':']) + [accumulator]:ppush(san) + --san:free() + end + end + elseif fields[idx] then + local f = fields[idx] + local fp = `symself.[f.key] + copiers[#copiers+1] = quote + if fp.ct > 0 then + [cpypos] = lib.mem.cpy([cpypos], [&opaque](fp.ptr), fp.ct) + end end senders[#senders+1] = quote - lib.net.mg_send([destcon], [fval], [fsz]) + if fp.ct > 0 then + lib.net.mg_send([destcon], fp.ptr, fp.ct) + end end appenders[#appenders+1] = quote - [accumulator]:push([fval], [fsz]) + if fp.ct > 0 then [accumulator]:ppush(fp) end end end end local tid = tplspec.id or '' @@ -116,10 +148,11 @@ [tallyup] var [symtxt] = lib.mem.heapa(int8, [runningtally]) var [cpypos] = [&opaque](symtxt.ptr) [copiers] @[&int8](cpypos) = 0 + symtxt.ct = [&int8](cpypos) - symtxt.ptr return symtxt end rec.methods.append = terra([symself], [accumulator]) lib.dbg(['appending template ' .. tid]) [tallyup] Index: view/compose.tpl ================================================================== --- view/compose.tpl +++ view/compose.tpl @@ -1,6 +1,19 @@
- - @?acl + + @?acl
+ + + + + + + + + + + + @circles + ADDED view/conf-profile.tpl Index: view/conf-profile.tpl ================================================================== --- view/conf-profile.tpl +++ view/conf-profile.tpl @@ -0,0 +1,6 @@ +
+ + + + +
ADDED view/conf-sec.tpl Index: view/conf-sec.tpl ================================================================== --- view/conf-sec.tpl +++ view/conf-sec.tpl @@ -0,0 +1,10 @@ +
+

if you are concerned that your account may have been compromised, you can terminate all other login sessions by invalidating their session cookies. note that this will not have any effect on API tokens; these must be revoked separately!

+ + +
ADDED view/conf.tpl Index: view/conf.tpl ================================================================== --- view/conf.tpl +++ view/conf.tpl @@ -0,0 +1,15 @@ + + +
+ @panel +
Index: view/load.lua ================================================================== --- view/load.lua +++ view/load.lua @@ -6,12 +6,16 @@ local sources = { 'docskel'; 'tweet'; 'profile'; 'compose'; + 'login-username'; 'login-challenge'; + + 'conf'; + 'conf-profile'; } local ingest = function(filename) local hnd = io.open(path..'/'..filename) local txt = hnd:read('*a') Index: view/login-challenge.tpl ================================================================== --- view/login-challenge.tpl +++ view/login-challenge.tpl @@ -1,14 +1,14 @@ Index: view/login-username.tpl ================================================================== --- view/login-username.tpl +++ view/login-username.tpl @@ -1,8 +1,8 @@ Index: view/profile.tpl ================================================================== --- view/profile.tpl +++ view/profile.tpl @@ -1,9 +1,9 @@
@@ -12,12 +12,12 @@
followers @nfollowers
mutuals @nmutuals
@timephrase @tweetday
Index: view/tweet.tpl ================================================================== --- view/tweet.tpl +++ view/tweet.tpl @@ -1,11 +1,9 @@
-
- - @nym [@xid] - +
+ @nym
-
@subject
+
@!subject
@text