parsav  Check-in [d228cd7fcb]

Overview
Comment:vastly improve the setup process
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: d228cd7fcb7c10b982f8c2129c50644044573c531e939c97096757c38a4520ae
User & Date: lexi on 2020-12-28 23:42:22
Other Links: manifest | tags
Context
2020-12-29
00:57
add privilege control verbs check-in: a64461061f user: lexi tags: trunk
2020-12-28
23:42
vastly improve the setup process check-in: d228cd7fcb user: lexi tags: trunk
2020-12-27
04:08
look ma, im tweetin check-in: 8f954221a1 user: lexi tags: trunk
Changes

Modified backend/pgsql.t from [2c2a215381] to [0fdc39456b].

     6      6   		params = {rawstring}, sql = [[
     7      7   			select value from parsav_config
     8      8   				where key = $1::text limit 1
     9      9   		]];
    10     10   	};
    11     11   
    12     12   	conf_set = {
    13         -		params = {rawstring,rawstring}, sql = [[
           13  +		params = {rawstring,rawstring}, cmd=true, sql = [[
    14     14   			insert into parsav_config (key, value)
    15     15   				values ($1::text, $2::text)
    16     16   				on conflict (key) do update set value = $2::text
    17     17   		]];
    18     18   	};
    19     19   
    20     20   	conf_reset = {
    21         -		params = {rawstring}, sql = [[
           21  +		params = {rawstring}, cmd=true, sql = [[
    22     22   			delete from parsav_config where
    23     23   				key = $1::text 
    24     24   		]];
    25     25   	};
    26     26   
    27     27   	actor_fetch_uid = {
    28     28   		params = {uint64}, sql = [[
    29     29   			select a.id, a.nym, a.handle, a.origin, a.bio,
    30         -			       a.avataruri, a.rank, a.quota, a.key, 
           30  +			       a.avataruri, a.rank, a.quota, a.key, a.epithet,
    31     31   			       extract(epoch from a.knownsince)::bigint,
    32     32   				   coalesce(a.handle || '@' || s.domain,
    33     33   				            '@' || a.handle) as xid
    34     34   
    35     35   			from      parsav_actors  as a
    36     36   			left join parsav_servers as s
    37     37   				on a.origin = s.id
................................................................................
    38     38   			where a.id = $1::bigint
    39     39   		]];
    40     40   	};
    41     41   
    42     42   	actor_fetch_xid = {
    43     43   		params = {pstring}, sql = [[
    44     44   			select a.id, a.nym, a.handle, a.origin, a.bio,
    45         -			       a.avataruri, a.rank, a.quota, a.key, 
           45  +			       a.avataruri, a.rank, a.quota, a.key, a.epithet,
    46     46   			       extract(epoch from a.knownsince)::bigint,
    47     47   				   coalesce(a.handle || '@' || s.domain,
    48     48   				            '@' || a.handle) as xid,
    49     49   
    50     50   				coalesce(s.domain,
    51     51   				        (select value from parsav_config
    52     52   							where key='domain' limit 1)) as domain
................................................................................
    70     70   			rawstring, uint16, uint32
    71     71   		};
    72     72   		sql = [[
    73     73   			insert into parsav_actors (
    74     74   				nym,handle,
    75     75   				origin,knownsince,
    76     76   				bio,avataruri,key,
    77         -				title,rank,quota
           77  +				epithet,rank,quota
    78     78   			) values ($1::text, $2::text,
    79     79   				case when $3::bigint = 0 then null
    80     80   				     else $3::bigint end,
    81     81   				to_timestamp($4::bigint),
    82     82   				$5::bigint, $6::bigint, $7::bytea,
    83     83   				$8::text, $9::smallint, $10::integer
    84     84   			) returning id
................................................................................
    98     98   			order by blacklist desc limit 1
    99     99   		]];
   100    100   	};
   101    101   
   102    102   	actor_enum_local = {
   103    103   		params = {}, sql = [[
   104    104   			select id, nym, handle, origin, bio,
   105         -			       null::text, rank, quota, key,
          105  +			       null::text, rank, quota, key, epithet,
   106    106   			       extract(epoch from knownsince)::bigint,
   107    107   				handle ||'@'||
   108    108   				(select value from parsav_config
   109    109   					where key='domain' limit 1) as xid
   110    110   			from parsav_actors where origin is null
   111    111   		]];
   112    112   	};
   113    113   
   114    114   	actor_enum = {
   115    115   		params = {}, sql = [[
   116    116   			select a.id, a.nym, a.handle, a.origin, a.bio,
   117         -			       a.avataruri, a.rank, a.quota, a.key,
          117  +			       a.avataruri, a.rank, a.quota, a.key, a.epithet,
   118    118   			       extract(epoch from a.knownsince)::bigint,
   119    119   				   coalesce(a.handle || '@' || s.domain,
   120    120   				            '@' || a.handle) as xid
   121    121   			from parsav_actors a
   122    122   			left join parsav_servers s on s.id = a.origin
   123    123   		]];
   124    124   	};
................................................................................
   163    163   				(select count(*) from mts where kind = 'trust') > 0
   164    164   		]]; -- cheat
   165    165   	};
   166    166   
   167    167   	actor_session_fetch = {
   168    168   		params = {uint64, lib.store.inet}, sql = [[
   169    169   			select a.id, a.nym, a.handle, a.origin, a.bio,
   170         -			       a.avataruri, a.rank, a.quota, a.key,
          170  +			       a.avataruri, a.rank, a.quota, a.key, a.epithet,
   171    171   			       extract(epoch from a.knownsince)::bigint,
   172    172   				   coalesce(a.handle || '@' || s.domain,
   173    173   				            '@' || a.handle) as xid,
   174    174   
   175    175   			       au.restrict,
   176    176   						array['post'  ] <@ au.restrict as can_post,
   177    177   						array['edit'  ] <@ au.restrict as can_edit,
................................................................................
   190    190   	};
   191    191   
   192    192   	actor_powers_fetch = {
   193    193   		params = {uint64}, sql = [[
   194    194   			select key, allow from parsav_rights where actor = $1::bigint
   195    195   		]]
   196    196   	};
          197  +
          198  +	actor_power_insert = {
          199  +		params = {uint64,lib.mem.ptr(int8),uint16}, cmd = true, sql = [[
          200  +			insert into parsav_rights (actor, key, allow) values (
          201  +				$1::bigint, $2::text, ($3::smallint)::integer::bool
          202  +			)
          203  +		]]
          204  +	};
          205  +
          206  +	auth_create_pw = {
          207  +		params = {uint64, lib.mem.ptr(uint8)}, cmd = true, sql = [[
          208  +			insert into parsav_auth (uid, name, kind, cred) values (
          209  +				$1::bigint,
          210  +				(select handle from parsav_actors where id = $1::bigint),
          211  +				'pw-sha256', $2::bytea
          212  +			)
          213  +		]]
          214  +	};
   197    215   
   198    216   	post_create = {
   199    217   		params = {uint64, rawstring, rawstring, rawstring}, sql = [[
   200    218   			insert into parsav_posts (
   201    219   				author, subject, acl, body,
   202    220   				posted, discovered,
   203    221   				circles, mentions
................................................................................
   339    357   			return buf
   340    358   		end
   341    359   	end;
   342    360   }
   343    361   
   344    362   local con = symbol(&lib.pq.PGconn)
   345    363   local prep = {}
   346         -local sqlsquash = function(s) return s:gsub('%s+',' '):gsub('^%s*(.-)%s*$','%1') end
          364  +local function sqlsquash(s) return s
          365  +	:gsub('%%include (.-)%%',function(f)
          366  +		return sqlsquash(lib.util.ingest('backend/schema/' .. f))
          367  +	end) -- include dependencies
          368  +	:gsub('%-%-.-\n','') -- remove disruptive line comments
          369  +	:gsub('%-%-.-$','') -- remove unnecessary terminal comments
          370  +	:gsub('%s+',' ') -- remove whitespace
          371  +	:gsub('^%s*(.-)%s*$','%1') -- chomp
          372  +end
          373  +
   347    374   for k,q in pairs(queries) do
   348    375   	local qt = sqlsquash(q.sql)
   349    376   	local stmt = 'parsavpg_' .. k
   350         -	prep[#prep + 1] = quote
          377  +	terra q.prep([con])
   351    378   		var res = lib.pq.PQprepare([con], stmt, qt, [#q.params], nil)
   352    379   		defer lib.pq.PQclear(res)
   353    380   		if res == nil or lib.pq.PQresultStatus(res) ~= lib.pq.PGRES_COMMAND_OK then
   354    381   			if res == nil then
   355    382   				lib.bail('grievous error occurred preparing ',k,' statement')
   356    383   			end
   357    384   			lib.bail('could not prepare PGSQL statement ',k,': ',lib.pq.PQresultErrorMessage(res))
   358    385   		end
   359    386   		lib.dbg('prepared PGSQL statement ',k) 
   360    387   	end
          388  +	prep[#prep + 1] = quote q.prep([con]) end
   361    389   
   362    390   	local args, casts, counters, fixers, ft, yield = {}, {}, {}, {}, {}, {}
   363    391   	local dumpers = {}
   364    392   	for i, ty in ipairs(q.params) do
   365    393   		args[i] = symbol(ty)
   366    394   		ft[i] = `1
   367    395   		if ty == rawstring then
................................................................................
   389    417   			dumpers[#dumpers+1] = `lib.io.fmt([tostring(i)..'. got int %llu\n'], [args[i]])
   390    418   			fixers[#fixers + 1] = quote
   391    419   				[args[i]] = lib.math.netswap(ty, [args[i]])
   392    420   			end
   393    421   		end
   394    422   	end
   395    423   
          424  +	local okconst = lib.pq.PGRES_TUPLES_OK
          425  +	if q.cmd then okconst = lib.pq.PGRES_COMMAND_OK end
   396    426   	terra q.exec(src: &lib.store.source, [args])
   397    427   		var params = arrayof([&int8], [casts])
   398    428   		var params_sz = arrayof(int, [counters])
   399    429   		var params_ft = arrayof(int, [ft])
   400    430   		[fixers]
   401    431   		--[dumpers]
   402    432   		var res = lib.pq.PQexecPrepared([&lib.pq.PGconn](src.handle), stmt,
   403    433   			[#args], params, params_sz, params_ft, 1)
   404    434   		if res == nil then
   405    435   			lib.bail(['grievous error occurred executing '..k..' against database'])
   406         -		elseif lib.pq.PQresultStatus(res) ~= lib.pq.PGRES_TUPLES_OK then
          436  +		elseif lib.pq.PQresultStatus(res) ~= okconst then
   407    437   			lib.bail(['PGSQL database procedure '..k..' failed\n'],
   408    438   			lib.pq.PQresultErrorMessage(res))
   409    439   		end
   410    440   
   411    441   		var ct = lib.pq.PQntuples(res)
   412    442   		if ct == 0 then
   413    443   			lib.pq.PQclear(res)
................................................................................
   448    478   	return p
   449    479   end
   450    480   local terra row_to_actor(r: &pqr, row: intptr): lib.mem.ptr(lib.store.actor)
   451    481   	var a: lib.mem.ptr(lib.store.actor)
   452    482   	var av: rawstring, avlen: intptr
   453    483   	var nym: rawstring, nymlen: intptr
   454    484   	var bio: rawstring, biolen: intptr
          485  +	var epi: rawstring, epilen: intptr
   455    486   	if r:null(row,5) then avlen = 0 av = nil else
   456    487   		av = r:string(row,5)
   457    488   		avlen = r:len(row,5)+1
   458    489   	end
   459    490   	if r:null(row,1) then nymlen = 0 nym = nil else
   460    491   		nym = r:string(row,1)
   461    492   		nymlen = r:len(row,1)+1
   462    493   	end
   463    494   	if r:null(row,4) then biolen = 0 bio = nil else
   464    495   		bio = r:string(row,4)
   465    496   		biolen = r:len(row,4)+1
          497  +	end
          498  +	if r:null(row,9) then epilen = 0 epi = nil else
          499  +		epi = r:string(row,9)
          500  +		epilen = r:len(row,9)+1
   466    501   	end
   467    502   	a = [ lib.str.encapsulate(lib.store.actor, {
   468    503   		nym = {`nym, `nymlen};
   469    504   		bio = {`bio, `biolen};
          505  +		epithet = {`epi, `epilen};
   470    506   		avatar = {`av,`avlen};
   471    507   		handle = {`r:string(row, 2); `r:len(row,2) + 1};
   472         -		xid = {`r:string(row, 10); `r:len(row,10) + 1};
          508  +		xid = {`r:string(row, 11); `r:len(row,11) + 1};
   473    509   	}) ]
   474    510   	a.ptr.id = r:int(uint64, row, 0);
   475    511   	a.ptr.rights = lib.store.rights_default();
   476    512   	a.ptr.rights.rank = r:int(uint16, row, 6);
   477    513   	a.ptr.rights.quota = r:int(uint32, row, 7);
   478         -	a.ptr.knownsince = r:int(int64,row, 9);
          514  +	a.ptr.knownsince = r:int(int64,row, 10);
   479    515   	if r:null(row,8) then
   480    516   		a.ptr.key.ct = 0 a.ptr.key.ptr = nil
   481    517   	else
   482    518   		a.ptr.key = r:bin(row,8)
   483    519   	end
   484    520   	if r:null(row,3) then a.ptr.origin = 0
   485    521   	else a.ptr.origin = r:int(uint64,row,3) end
................................................................................
   530    566   		lib.dbg(['searching for hashed password credentials in format SHA' .. tostring(hash)])
   531    567   		var [out]
   532    568   		[vdrs]
   533    569   		lib.dbg(['could not find password hash'])
   534    570   	end
   535    571   end
   536    572   
          573  +local schema = sqlsquash(lib.util.ingest('backend/schema/pgsql.sql'))
          574  +local obliterator = sqlsquash(lib.util.ingest('backend/schema/pgsql-drop.sql'))
          575  +
   537    576   local b = `lib.store.backend {
   538    577   	id = "pgsql";
   539    578   	open = [terra(src: &lib.store.source): &opaque
   540    579   		lib.report('connecting to postgres database: ', src.string.ptr)
   541    580   		var [con] = lib.pq.PQconnectdb(src.string.ptr)
   542    581   		if lib.pq.PQstatus(con) ~= lib.pq.CONNECTION_OK then
   543    582   			lib.warn('postgres backend connection failed')
................................................................................
   556    595   		defer lib.pq.PQclear(res)
   557    596   		if lib.pq.PQresultStatus(res) ~= lib.pq.PGRES_TUPLES_OK then
   558    597   			lib.warn('failed to secure postgres connection')
   559    598   			lib.pq.PQfinish(con)
   560    599   			return nil
   561    600   		end
   562    601   
   563         -		[prep]
   564    602   		return con
   565    603   	end];
          604  +
   566    605   	close = [terra(src: &lib.store.source) lib.pq.PQfinish([&lib.pq.PGconn](src.handle)) end];
          606  +
          607  +	conprep = [terra(src: &lib.store.source, mode: lib.store.prepmode.t)
          608  +		var [con] = [&lib.pq.PGconn](src.handle)
          609  +		if mode == lib.store.prepmode.full then [prep]
          610  +		elseif mode == lib.store.prepmode.conf or
          611  +		       mode == lib.store.prepmode.admin then 
          612  +			queries.conf_get.prep(con)
          613  +			queries.conf_set.prep(con)
          614  +			queries.conf_reset.prep(con)
          615  +			if mode == lib.store.prepmode.admin then 
          616  +			end
          617  +		else lib.bail('unsupported connection preparation mode') end
          618  +	end];
          619  +
          620  +	dbsetup = [terra(src: &lib.store.source)
          621  +		var res = lib.pq.PQexec([&lib.pq.PGconn](src.handle), schema)
          622  +		if lib.pq.PQresultStatus(res) == lib.pq.PGRES_COMMAND_OK then
          623  +			lib.report('successfully instantiated schema in database')
          624  +			return true
          625  +		else
          626  +			lib.warn('backend pgsql - failed to initialize database: \n', lib.pq.PQresultErrorMessage(res))
          627  +			return false
          628  +		end
          629  +	end];
          630  +
          631  +	obliterate_everything = [terra(src: &lib.store.source)
          632  +		var res = lib.pq.PQexec([&lib.pq.PGconn](src.handle), obliterator)
          633  +		if lib.pq.PQresultStatus(res) == lib.pq.PGRES_COMMAND_OK then
          634  +			lib.report('successfully wiped out everything parsav-related in database')
          635  +			return true
          636  +		else
          637  +			lib.warn('backend pgsql - failed to obliterate database: \n', lib.pq.PQresultErrorMessage(res))
          638  +			return false
          639  +		end
          640  +	end];
   567    641   
   568    642   	conf_get = [terra(src: &lib.store.source, key: rawstring)
   569    643   		var r = queries.conf_get.exec(src, key)
   570    644   		if r.sz == 0 then return [lib.mem.ptr(int8)] { ptr = nil, ct = 0 } else
   571    645   			defer r:free()
   572    646   			return r:String(0,0)
   573    647   		end
................................................................................
   678    752   
   679    753   			var a = row_to_actor(&r, 0)
   680    754   			a.ptr.source = src
   681    755   
   682    756   			var au = [lib.stat(lib.store.auth)] { ok = true }
   683    757   			au.val.aid = aid
   684    758   			au.val.uid = a.ptr.id
   685         -			if not r:null(0,12) then -- restricted?
          759  +			if not r:null(0,13) then -- restricted?
   686    760   				au.val.privs:clear()
   687         -				(au.val.privs.post   << r:bool(0,13)) 
   688         -				(au.val.privs.edit   << r:bool(0,14))
   689         -				(au.val.privs.acct   << r:bool(0,15))
   690         -				(au.val.privs.upload << r:bool(0,16))
   691         -				(au.val.privs.censor << r:bool(0,17))
   692         -				(au.val.privs.admin  << r:bool(0,18))
          761  +				(au.val.privs.post   << r:bool(0,14)) 
          762  +				(au.val.privs.edit   << r:bool(0,15))
          763  +				(au.val.privs.acct   << r:bool(0,16))
          764  +				(au.val.privs.upload << r:bool(0,17))
          765  +				(au.val.privs.censor << r:bool(0,18))
          766  +				(au.val.privs.admin  << r:bool(0,19))
   693    767   			else au.val.privs:fill() end
   694    768   
   695    769   			return au, a
   696    770   		end
   697    771   
   698    772   		::fail:: return [lib.stat   (lib.store.auth) ] { ok = false        },
   699    773   			            [lib.mem.ptr(lib.store.actor)] { ptr = nil, ct = 0 }
................................................................................
   759    833   		return powers
   760    834   	end];
   761    835   
   762    836   	actor_create = [terra(
   763    837   		src: &lib.store.source,
   764    838   		ac: &lib.store.actor
   765    839   	): uint64
   766         -		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)
          840  +		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)
   767    841   		if r.sz == 0 then lib.bail('failed to create actor!') end
   768         -		return r:int(uint64,0,0)
          842  +		var uid = r:int(uint64,0,0)
          843  +
          844  +		-- check against default rights, insert records for wherever powers differ
          845  +		lib.dbg('created new actor, establishing powers')
          846  +		var pdef = lib.store.rights_default().powers
          847  +		var map = array([privmap])
          848  +		for i=0, [map.type.N] do
          849  +			var d = pdef and map[i].priv
          850  +			var u = ac.rights.powers and map[i].priv
          851  +			if d:sz() > 0 and u:sz() == 0 then
          852  +				lib.dbg('blocking power ', {map[i].name.ptr, map[i].name.ct})
          853  +				queries.actor_power_insert.exec(src, uid, map[i].name, 0)
          854  +			elseif d:sz() == 0 and u:sz() > 0 then
          855  +				lib.dbg('granting power ', {map[i].name.ptr, map[i].name.ct})
          856  +				queries.actor_power_insert.exec(src, uid, map[i].name, 1)
          857  +			end
          858  +		end
          859  +
          860  +		lib.dbg('powers established')
          861  +		return uid
          862  +	end];
          863  +
          864  +	auth_create_pw = [terra(
          865  +		src: &lib.store.source,
          866  +		uid: uint64,
          867  +		reset: bool,
          868  +		pw: lib.mem.ptr(int8)
          869  +	): {}
          870  +		-- TODO impl reset support
          871  +		var hash: uint8[lib.crypt.algsz.sha256]
          872  +		if lib.md.mbedtls_md(lib.md.mbedtls_md_info_from_type(lib.crypt.alg.sha256.id),
          873  +			[&uint8](pw.ptr), pw.ct, &hash[0]) ~= 0 then
          874  +			lib.bail('cannot hash password')
          875  +		end
          876  +		queries.auth_create_pw.exec(src, uid, [lib.mem.ptr(uint8)] {ptr = &hash[0], ct = [hash.type.N]})
   769    877   	end];
   770    878   
   771    879   	actor_auth_register_uid = nil; -- not necessary for view-based auth
          880  +
   772    881   }
   773    882   
   774    883   return b

Added backend/schema/pgsql-auth.sql version [1170b3857b].

            1  +-- in managed-auth configurations, parsav_auth is a table which is directly
            2  +-- controlled by the parsav daemon and utilities themselves. in unmanaged
            3  +-- configuration, you will need to create your own view with the same fields
            4  +-- as this table
            5  +create table parsav_auth (
            6  +	aid bigint primary key default (1+random()*(2^63-1))::bigint,
            7  +		-- the AID is the value that links a session to its credentials,
            8  +		-- so the aid needs to be stable over time. if you don't have a
            9  +		-- convenient field to rely on in your own datasets, the best
           10  +		-- approach is to use digest(str,'sha256') from the pgcrypto
           11  +		-- extension to create a value that depends on the values of
           12  +		-- kind, cred, and a unique user ID from your own dataset (NOT
           13  +		-- uid, as the UID associated with a session will change when
           14  +		-- a user logs in for the first time).
           15  +
           16  +	uid bigint,
           17  +		-- the UID links a credential set to an actor in the parsav
           18  +		-- database. if it is equal to 0 (but not null) a new actor
           19  +		-- will be created and associated with the authentication
           20  +		-- records bearing its name when that user first logs in 
           21  +
           22  +	name text,
           23  +		-- this is the handle of the actor that will be created when
           24  +		-- a user first logs in with this as the username and one of
           25  +		-- its associated credentials. the field is otherwise unused.
           26  +
           27  +	kind text not null, -- see parsav.md
           28  +	cred bytea,
           29  +	restrict text[],
           30  +		-- per-credential restrictions can be levelled, for instance
           31  +		-- to prevent a certain API key from being used to post tweets
           32  +		-- as that user, while allowing it to be used to collect data.
           33  +		-- if restrict is null, no restrictions will be applied.
           34  +		-- otherwise, it should be an array of privileges that will be
           35  +		-- permitted when authenticated via this credential.
           36  +
           37  +	netmask cidr,
           38  +		-- if not null, the credential will only be valid when logging
           39  +		-- in from an IP address contained by this netmask.
           40  +
           41  +	blacklist bool not null default false,
           42  +		-- if the credential matches, access will be denied, even if
           43  +		-- non-blacklisted credentials match. most useful with
           44  +		-- uid = null, kind = trust, cidr = (untrusted IP range)
           45  +
           46  +	valperiod timestamp default now(),
           47  +		-- cookies bearing timestamps earlier than this point in time
           48  +		-- will be considered invalid and will not grant access
           49  +
           50  +	unique(name,kind,cred)
           51  +);

Added backend/schema/pgsql-drop.sql version [e1fb43be2e].

            1  +-- destroy absolutely everything
            2  +
            3  +drop table if exists parsav_config cascade;
            4  +drop table if exists parsav_servers cascade;
            5  +drop table if exists parsav_actors cascade;
            6  +drop table if exists parsav_rights cascade;
            7  +drop table if exists parsav_posts cascade;
            8  +drop table if exists parsav_conversations cascade;
            9  +drop table if exists parsav_rels cascade;
           10  +drop table if exists parsav_acts cascade;
           11  +drop table if exists parsav_log cascade;
           12  +drop table if exists parsav_attach cascade;
           13  +drop table if exists parsav_circles cascade;
           14  +drop table if exists parsav_rooms cascade;
           15  +drop table if exists parsav_room_members cascade;
           16  +drop table if exists parsav_invites cascade;
           17  +drop table if exists parsav_interventions cascade;
           18  +drop table if exists parsav_auth cascade;

Modified backend/schema/pgsql.sql from [097969b0cb] to [0ef43163b5].

     1         -\prompt 'domain name: ' domain
     2         -\prompt 'instance name: ' inst
     3         -\prompt 'bind to socket: ' bind
     4         -\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'
     5         -\prompt 'security mode: ' secmode
     6         -\qecho 'should user self-registration be allowed? yes or no'
     7         -\prompt 'registration: ' regpol
     8         -\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.'
     9         -\prompt 'master actor: ' admin
    10         -\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.'
    11         -
    12         -begin;
    13         -
    14         -drop table if exists parsav_config;
    15         -create table if not exists parsav_config (
            1  +create table parsav_config (
    16      2   	key   text primary key,
    17      3   	value text
    18      4   );
    19      5   
    20         -insert into parsav_config (key,value) values
    21         -	('bind',:'bind'),
    22         -	('domain',:'domain'),
    23         -	('instance-name',:'inst'),
    24         -	('policy-security',:'secmode'),
    25         -	('policy-self-register',:'regpol'),
    26         -	('master',:'admin'),
    27         -	('server-secret', encode(
    28         -			digest(int8send((2^63 * (random()*2 - 1))::bigint),
    29         -		'sha512'), 'base64'));
            6  +insert into parsav_config (key,value) values ('schema-version','1'),
            7  +	('credential-store','managed');
            8  +--	('bind',:'bind'),
            9  +--	('domain',:'domain'),
           10  +--	('instance-name',:'inst'),
           11  +--	('policy-security',:'secmode'),
           12  +--	('policy-self-register',:'regpol'),
           13  +--	('master',:'admin'),
    30     14   
    31     15   -- note that valid ids should always > 0, as 0 is reserved for null
    32     16   -- on the client side, vastly simplifying code
    33         -drop table if exists parsav_servers cascade;
    34     17   create table parsav_servers (
    35     18   	id     bigint primary key default (1+random()*(2^63-1))::bigint,
    36     19   	domain text not null,
    37     20   	key    bytea,
    38     21   	knownsince timestamp,
    39     22   	parsav boolean -- whether to use parsav protocol extensions
    40     23   );
    41     24   
    42         -drop table if exists parsav_actors cascade;
    43     25   create table parsav_actors (
    44     26   	id        bigint primary key default (1+random()*(2^63-1))::bigint,
    45     27   	nym       text,
    46     28   	handle    text not null, -- nym [@handle@origin] 
    47     29   	origin    bigint references parsav_servers(id)
    48     30   		on delete cascade, -- null origin = local actor
    49     31   	knownsince timestamp,
    50     32   	bio       text,
    51     33   	avataruri text, -- null if local
    52     34   	rank      smallint not null default 0,
    53     35   	quota     integer not null default 1000,
    54     36   	key       bytea, -- private if localactor; public if remote
    55         -	title     text,
           37  +	epithet   text,
           38  +	authtime  timestamp not null default now(), -- cookies earlier than this timepoint will not be accepted
    56     39   	
    57     40   	unique (handle,origin)
    58     41   );
    59     42   
    60         -drop table if exists parsav_rights cascade;
    61     43   create table parsav_rights (
    62     44   	key text,
    63     45   	actor bigint references parsav_actors(id)
    64     46   		on delete cascade,
    65     47   	allow boolean not null,
    66     48   	scope bigint, -- for future expansion
    67     49   
    68     50   	primary key (key,actor)
    69     51   );
    70     52   
    71         -insert into parsav_actors (handle,rank,quota) values (:'admin',1,0);
    72         -insert into parsav_rights (actor,key,allow)
    73         -	select (select id from parsav_actors where handle=:'admin'), a.column1, a.column2 from (values
    74         -		('purge',true),
    75         -		('config',true),
    76         -		('censor',true),
    77         -		('suspend',true),
    78         -		('cred',true),
    79         -		('elevate',true),
    80         -		('demote',true),
    81         -		('rebrand',true)
    82         -	) as a;
    83         -
    84         -drop table if exists parsav_posts cascade;
    85     53   create table parsav_posts (
    86     54   	id         bigint primary key default (1+random()*(2^63-1))::bigint,
    87     55   	author     bigint references parsav_actors(id)
    88     56   		on delete cascade,
    89     57   	subject    text,
    90     58   	acl        text not null default 'all', -- just store the script raw 🤷
    91     59   	body       text,
................................................................................
    97     65   
    98     66   	convoheaduri text
    99     67   	-- only used for tracking foreign conversations and tying them to post heads;
   100     68   	-- local conversations are tracked directly and mapped to URIs based on the
   101     69   	-- head's ID. null if native tweet or not the first tweet in convo
   102     70   );
   103     71   
   104         -drop table if exists parsav_conversations cascade;
   105         -
   106         -drop table if exists parsav_rels cascade;
   107     72   create table parsav_rels (
   108     73   	relator bigint references parsav_actors(id)
   109     74   		on delete cascade, -- e.g. follower
   110     75   	relatee bigint references parsav_actors(id)
   111     76   		on delete cascade, -- e.g. followed
   112     77   	kind    smallint, -- e.g. follow, block, mute
   113     78   
   114     79   	primary key (relator, relatee, kind)
   115     80   );
   116     81   
   117         -drop table if exists parsav_acts cascade;
   118     82   create table parsav_acts (
   119     83   	id      bigint primary key default (1+random()*(2^63-1))::bigint,
   120     84   	kind    text not null, -- like, react, so on
   121     85   	time    timestamp not null default now(),
   122     86   	actor   bigint references parsav_actors(id)
   123     87   		on delete cascade,
   124     88   	subject bigint -- may be post or act, depending on kind
   125     89   );
   126     90   
   127         -drop table if exists parsav_log cascade;
   128     91   create table parsav_log (
   129     92   	-- accesses are tracked for security & sending delete acts
   130     93   	id    bigint primary key default (1+random()*(2^63-1))::bigint,
   131     94   	time  timestamp not null default now(),
   132     95   	actor bigint references parsav_actors(id)
   133     96   		on delete cascade,
   134     97   	post  bigint not null
   135     98   );
   136     99   
   137         -drop table if exists parsav_attach cascade;
   138    100   create table parsav_attach (
   139    101   	id          bigint primary key default (1+random()*(2^63-1))::bigint,
   140    102   	birth       timestamp not null default now(),
   141    103   	content     bytea not null,
   142    104   	mime        text, -- null if unknown, will be reported as x-octet-stream
   143    105   	description text,
   144    106   	parent      bigint -- post id, or userid for avatars
   145    107   );
   146    108   
   147         -drop table if exists parsav_circles cascade;
   148    109   create table parsav_circles (
   149    110   	id          bigint primary key default (1+random()*(2^63-1))::bigint,
   150    111   	owner       bigint not null references parsav_actors(id),
   151    112   	name        text not null,
   152    113   	members     bigint[] not null default array[]::bigint[],
   153    114   
   154    115   	unique (owner,name)
   155    116   );
   156    117   
   157         -drop table if exists parsav_rooms cascade;
   158    118   create table parsav_rooms (
   159    119   	id          bigint primary key default (1+random()*(2^63-1))::bigint,
   160    120   	origin		bigint references parsav_servers(id),
   161    121   	name		text not null,
   162    122   	description text not null,
   163    123   	policy      smallint not null
   164    124   );
   165    125   
   166         -drop table if exists parsav_room_members cascade;
   167    126   create table parsav_room_members (
   168    127   	room   bigint references parsav_rooms(id),
   169    128   	member bigint references parsav_actors(id),
   170    129   	rank   smallint not null default 0,
   171    130   	admin  boolean not null default false, -- non-admins with rank can only moderate + invite
   172    131   	title  text, -- admin-granted title like reddit flair
   173    132   	vouchedby bigint references parsav_actors(id)
   174    133   );
   175    134   
   176         -drop table if exists parsav_invites cascade;
   177    135   create table parsav_invites (
   178    136   	id          bigint primary key default (1+random()*(2^63-1))::bigint,
   179    137   	-- when a user is created from an invite, the invite is deleted and the invite
   180    138   	-- ID becomes the user ID. privileges granted on the invite ID during the invite
   181    139   	-- process are thus inherited by the user
   182    140   	issuer bigint references parsav_actors(id),
   183    141   	handle text, -- admin can lock invite to specific handle
   184    142   	rank   smallint not null default 0,
   185    143   	quota  integer not null  default 1000
   186    144   );
   187    145   
   188         -drop table if exists parsav_interventions cascade;
   189    146   create table parsav_interventions (
   190    147   	id     bigint primary key default (1+random()*(2^63-1))::bigint,
   191    148   	issuer bigint references parsav_actors(id) not null,
   192    149   	scope  bigint, -- can be null or room for local actions
   193    150   	nature smallint not null, -- silence, suspend, disemvowel, etc
   194    151   	victim bigint not null, -- could potentially target group as well
   195    152   	expire timestamp -- auto-expires if set
   196    153   );
   197    154   
   198         -end;
          155  +-- create a temporary managed auth table; we can delete this later
          156  +-- if it ends up being replaced with a view
          157  +%include pgsql-auth.sql%

Modified cmdparse.t from [50677a3c0c] to [bfedd61eec].

     1      1   -- vim: ft=terra
     2         -return function(tbl)
            2  +return function(tbl,opts)
            3  +	opts = opts or {}
     3      4   	local options = terralib.types.newstruct('options') do
     4      5   		local flags = '' for _,d in pairs(tbl) do flags = flags .. d[1] end
     5         -		local helpstr = 'usage: parsav [-' .. flags .. '] [<arg>...]\n'
            6  +		local flagstr = '[-' .. flags .. ']'
            7  +		local helpstr = '\n'
     6      8   		options.entries = {
     7      9   			{field = 'arglist', type = lib.mem.ptr(rawstring)}
     8     10   		}
     9     11   		local shortcases, longcases, init, verifiers = {}, {}, {}, {}
    10     12   		local self = symbol(&options)
    11     13   		local arg = symbol(rawstring)
    12     14   		local idx = symbol(uint)
    13     15   		local argv = symbol(&rawstring)
    14     16   		local argc = symbol(int)
    15     17   		local optstack = symbol(intptr)
           18  +		local subcmd = symbol(intptr)
    16     19   		local skip = label()
    17     20   		local sanitize = function(s) return s:gsub('_','-') end
    18     21   		for o,desc in pairs(tbl) do
    19         -			local consume = desc[3] or 0
           22  +			local consume = desc.consume or 0
           23  +			local incr = desc.inc or 0
    20     24   			options.entries[#options.entries + 1] = {
    21         -				field = o, type = (consume > 0) and &rawstring or bool
           25  +				field = o, type = (consume > 0) and &rawstring or
           26  +				                  (incr    > 0) and uint       or bool
    22     27   			}
    23     28   			helpstr = helpstr .. string.format('    -%s --%s: %s\n',
    24     29   				desc[1], sanitize(o), desc[2])
    25     30   		end
    26     31   		for o,desc in pairs(tbl) do
    27     32   			local flag = desc[1]
    28         -			local consume = desc[3] or 0
    29         -			init[#init + 1] = quote [self].[o] = [(consume > 0 and `nil) or false] end
           33  +			local consume = desc.consume or 0
           34  +			local incr = desc.inc or 0
           35  +			init[#init + 1] = quote [self].[o] = [
           36  +				(consume > 0 and `nil) or 
           37  +				(incr    > 0 and `0  ) or false
           38  +			] end
    30     39   			local ch if consume > 0 then
    31     40   				ch = quote
    32     41   					[self].[o] = argv+(idx+1+optstack)
    33     42   					optstack = optstack + consume
    34     43   				end
    35     44   				verifiers[#verifiers+1] = quote
    36     45   					var terminus = argv + argc
    37     46   					if [self].[o] ~= nil and [self].[o] >= terminus then
    38     47   						lib.bail(['missing argument for command line option ' .. sanitize(o)])
    39     48   					end
    40     49   				end
           50  +			elseif incr > 0 then
           51  +				ch = quote [self].[o] = [self].[o] + incr end
    41     52   			else ch = quote
    42     53   				[self].[o] = true
    43     54   			end end
    44     55   			shortcases[#shortcases + 1] = quote
    45     56   				case [int8]([string.byte(flag)]) then [ch] end
    46     57   			end
    47     58   			longcases[#longcases + 1] = quote
................................................................................
    49     60   			end
    50     61   		end
    51     62   		terra options:free() self.arglist:free() end
    52     63   		options.methods.parse = terra([self], [argc], [argv])
    53     64   			[init]
    54     65   			var parseopts = true
    55     66   			var [optstack] = 0
           67  +			var [subcmd] = [ opts.subcmd or 0 ]
    56     68   			self.arglist = lib.mem.heapa(rawstring, argc)
    57     69   			var finalargc = 0
    58     70   			for [idx]=1,argc do
    59     71   				var [arg] = argv[idx]
    60     72   				if optstack > 0 then optstack = optstack - 1 goto [skip] end
    61     73   				if arg[0] == @'-' and parseopts then
    62     74   					if arg[1] == @'-' then -- long option
................................................................................
    68     80   							switch arg[j] do [shortcases] end
    69     81   							j = j + 1
    70     82   						end
    71     83   					end
    72     84   				else
    73     85   					self.arglist.ptr[finalargc] = arg
    74     86   					finalargc = finalargc + 1
           87  +					if subcmd > 0 then
           88  +						subcmd = subcmd - 1
           89  +						if subcmd == 0 then parseopts = false end
           90  +					end
    75     91   				end
    76     92   				::[skip]::
    77     93   			end
    78     94   			[verifiers]
    79     95   			if finalargc == 0 then self.arglist:free()
    80     96   							  else self.arglist:resize(finalargc) end
    81     97   		end
    82         -		options.helptxt = helpstr
           98  +		options.helptxt = { opts = helpstr, flags = flagstr }
    83     99   	end
    84    100   	return options
    85    101   end

Modified common.lua from [e762fc8997] to [c72e0a0971].

   101    101   		local kt = {}
   102    102   		for k,v in pairs(ary) do kt[#kt+1] = k end
   103    103   		return kt
   104    104   	end;
   105    105   	ingest = function(f)
   106    106   		local h = io.open(f, 'r')
   107    107   		if h == nil then return nil end
   108         -		local txt = f:read('*a') f:close()
          108  +		local txt = h:read('*a') h:close()
   109    109   		return chomp(txt)
   110    110   	end;
   111    111   	parseargs = function(a)
   112    112   		local raw = false
   113    113   		local opts, args = {}, {}
   114    114   		for i,v in ipairs(a) do
   115    115   			if v == '--' then

Modified config.lua from [29834379ec] to [0c09bec0e6].

    33     33   	tgthf     = u.tobool(default('parsav_arch_armhf',true)); 
    34     34   	doc       = {
    35     35   		online  = u.tobool(default('parsav_online_documentation',true)); 
    36     36   		offline = u.tobool(default('parsav_offline_documentation',true)); 
    37     37   	};
    38     38   	outform   = default('parsav_emit_type', 'o');
    39     39   	endian    = default('parsav_arch_endian', 'little');
           40  +	prefix    = default('parsav_install_prefix', './');
    40     41   	build     = {
    41     42   		id = u.rndstr(6);
    42     43   		release = u.ingest('release');
    43     44   		when = os.date();
    44     45   	};
    45     46   	feat = {};
           47  +	debug = u.tobool(default('parsav_enable_debug',true)); 
    46     48   	backends = defaultlist('parsav_backends', 'pgsql');
    47     49   	braingeniousmode = false;
    48     50   	embeds = {
    49     51   		{'style.css', 'text/css'};
    50     52   		{'default-avatar.webp', 'image/webp'};
    51     53   		{'padlock.webp', 'image/webp'};
    52     54   		{'warn.webp', 'image/webp'};
................................................................................
    58     60   if u.ping '.fslckout' or u.ping '_FOSSIL_' then
    59     61   	if u.ping '_FOSSIL_' then default_os = 'windows' end
    60     62   	conf.build.branch = u.exec { 'fossil', 'branch', 'current' }
    61     63   	conf.build.checkout = (u.exec { 'fossil', 'sql',
    62     64   		[[select value from localdb.vvar where name = 'checkout-hash']]
    63     65   	}):gsub("^'(.*)'$", '%1')
    64     66   end
    65         -conf.os    = default('parsav_host_os', default_os);
    66         -conf.tgtos = default('parsav_target_os', default_os);
           67  +conf.os    = default('parsav_host_os', default_os)
           68  +conf.tgtos = default('parsav_target_os', default_os)
    67     69   conf.posix = posixes[conf.os]
    68         -conf.exe   = u.tobool(default('parsav_link',not conf.tgttrip)); -- turn off for partial builds
           70  +conf.exe   = u.tobool(default('parsav_link',not conf.tgttrip)) -- turn off for partial builds
           71  +conf.prefix_conf = default('parsav_install_prefix_cfg', conf.prefix)
           72  +conf.prefix_static = default('parsav_install_prefix_static', nil)
    69     73   conf.build.origin = coalesce(
    70     74   	os.getenv('parsav_builder'),
    71     75   	string.format('%s@%s', coalesce (
    72     76   		os.getenv('USER'),
    73     77   		u.exec{'whoami'}
    74     78   	), u.exec{'hostname'}) -- whoami and hostname are present on both windows & unix
    75     79   )

Modified crypt.t from [bf3957f4f4] to [9b6529621c].

    34     34   	sha384 = `hashalg {id = lib.md.MBEDTLS_MD_SHA384; bytes = m.algsz.sha384};
    35     35   	sha224 = `hashalg {id = lib.md.MBEDTLS_MD_SHA224; bytes = m.algsz.sha224};
    36     36   	-- md5 = {id = lib.md.MBEDTLS_MD_MD5};-- !!!
    37     37   };
    38     38   local callbacks = {}
    39     39   if config.feat.randomizer == 'kern' then
    40     40   	local rnd = terralib.externfunction('getrandom', {&opaque, intptr, uint} -> ptrdiff);
    41         -	terra callbacks.randomize(ctx: &opaque, dest: &uint8, sz: intptr): int
           41  +	terra m.spray(dest: &uint8, sz: intptr): int
    42     42   		return rnd(dest, sz, 0)
    43     43   	end
    44     44   elseif config.feat.randomizer == 'devfs' then
    45         -	terra callbacks.randomize(ctx: &opaque, dest: &uint8, sz: intptr): int
           45  +	terra m.spray(dest: &uint8, sz: intptr): int
    46     46   		var gen = lib.io.open("/dev/urandom",0)
    47     47   		lib.io.read(gen, dest, sz)
    48     48   		lib.io.close(gen)
    49     49   		return sz
    50     50   	end
    51     51   elseif config.feat.randomizer == 'libc' then
    52     52   	local rnd = terralib.externfunction('rand', {} -> int);
    53     53   	local srnd = terralib.externfunction('srand', uint -> int);
    54     54   	local time = terralib.includec 'time.h'
    55     55   	lib.init[#lib.init + 1] = quote srnd(time.time(nil)) end
    56     56   	print '(warn) using libc soft-rand function for cryptographic purposes, this is very bad!'
    57         -	terra callbacks.randomize(ctx: &opaque, dest: &uint8, sz: intptr): int
           57  +	terra m.spray(dest: &uint8, sz: intptr): int
    58     58   		for i=0,sz do dest[i] = [uint8](rnd()) end
    59     59   		return sz
    60     60   	end
    61     61   end
           62  +
           63  +m.random = macro(function(typ, from, to)
           64  +	local ty = typ:astype()
           65  +	return quote
           66  +		var v: ty
           67  +		m.spray([&uint8](&v), sizeof(ty))
           68  +		v = v % (to - from) + from -- only works with unsigned!!
           69  +	in v end
           70  +end)
           71  +
           72  +terra callbacks.randomize(ctx: &opaque, dest: &uint8, sz: intptr)
           73  +	return m.spray(dest,sz) end
    62     74   
    63     75   terra m.pem(pub: bool, key: &ctx, buf: &uint8): bool
    64     76   	if pub then
    65     77   		return lib.pk.mbedtls_pk_write_pubkey_pem(key, buf, const.maxpemsz) == 0
    66     78   	else
    67     79   		return lib.pk.mbedtls_pk_write_key_pem(key, buf, const.maxpemsz) == 0
    68     80   	end

Added html.t version [ee4d50abb4].

            1  +-- vim: ft=terra
            2  +local m={}
            3  +local pstr = lib.mem.ptr(int8)
            4  +
            5  +terra m.sanitize(txt: pstr, quo: bool)
            6  +	var a: lib.str.acc a:init(txt.ct*1.3)
            7  +	for i=0,txt.ct do
            8  +		if txt(i) == @'<' then a:lpush('&lt;')
            9  +		elseif txt(i) == @'>' then a:lpush('&gt;')
           10  +		elseif txt(i) == @'&' then a:lpush('&amp;')
           11  +		elseif quo and txt(i) == @'"' then a:lpush('&quot;')
           12  +		else a:push(&txt(i),1) end
           13  +	end
           14  +	return a:finalize()
           15  +end
           16  +
           17  +return m

Modified makefile from [3210eb684d] to [8946539e56].

     1      1   dl = git
     2      2   dbg-flags = $(if $(dbg),-g)
     3      3   
     4      4   images = $(addsuffix .webp, $(basename $(wildcard static/*.svg)))
     5      5   styles = $(addsuffix .css, $(basename $(wildcard static/*.scss)))
     6      6   
     7         -parsav: parsav.t config.lua pkgdata.lua $(images) $(styles)
            7  +parsav parsavd: parsav.t config.lua pkgdata.lua $(images) $(styles)
     8      8   	terra $(dbg-flags) $<
     9         -parsav.o: parsav.t config.lua pkgdata.lua $(images) $(styles)
            9  +parsav.o parsavd.o: parsav.t config.lua pkgdata.lua $(images) $(styles)
    10     10   	env parsav_link=no terra $(dbg-flags) $<
    11         -parsav.ll: parsav.t config.lua pkgdata.lua $(images) $(styles)
           11  +parsav.ll parsavd.ll: parsav.t config.lua pkgdata.lua $(images) $(styles)
    12     12   	env parsav_emit_type=ll parsav_link=no terra $(dbg-flags) $<
    13         -parsav.s: parsav.ll
           13  +parsav.s parsavd.ss: parsav.ll
    14     14   	llc --march=$(target) $<
    15     15   
    16     16   static/%.webp: static/%.png
    17     17   	cwebp -q 90 $< -o $@
    18     18   static/%.png: static/%.svg
    19     19   	inkscape -f $< -C -d 180 -e $@
    20     20   static/%.css: static/%.scss

Added mgtool.t version [293667feb7].

            1  +-- vim: ft=terra
            2  +-- provides the functionality of the `parsav` utility that controls `parsavd`
            3  +local pstr = lib.mem.ptr(int8)
            4  +local ctloptions = lib.cmdparse({
            5  +	version = {'V', 'display information about the binary build and exit'};
            6  +	verbose = {'v', 'increase logging verbosity', inc=1};
            7  +	quiet = {'q', 'do not print to standard out'};
            8  +	help = {'h', 'display this list'};
            9  +	backend_file = {'B', 'init from specified backend file', consume=1};
           10  +	backend = {'b', 'operate on only the selected backend'};
           11  +	instance = {'i', 'specify the instance to control by name', consume=1};
           12  +	all = {'A', 'affect all running instances'};
           13  +}, { subcmd = 1 })
           14  +
           15  +local pbasic = lib.cmdparse {
           16  +	help = {'h', 'display this list'}
           17  +}
           18  +local subcmds = {
           19  +}
           20  +
           21  +local ctlcmds = {
           22  +	{ 'start', 'start a new instance of the server' };
           23  +	{ 'stop', 'stop a running instance' };
           24  +	{ 'attach', 'capture log output from a running instance' };
           25  +	{ 'db init <domain>', 'initialize backend databases (or a single specified database) with the necessary schema and structures for the given FQDN' };
           26  +	{ 'db vacuum', 'delete old remote content from the database' };
           27  +	{ 'db extract (<artifact>|<post>/<attachment number>)', 'extracts an attachment artifact from the database and prints it to standard out' };
           28  +	{ 'db excise <artifact>', 'extracts an attachment artifact from the database and prints it to standard out' };
           29  +	{ 'db obliterate', 'completely purge all parsav-related content and structure from the database, destroying all user content (requires confirmation)' };
           30  +	{ 'db insert', 'reads a file from standard in and inserts it into the attachment database, printing the resulting ID' };
           31  +	{ 'mkroot <handle>', 'establish a new root user with the given handle' };
           32  +	{ 'user <handle> auth <type> new', '(where applicable, managed auth only) create a new authentication token of the given type for a user' };
           33  +	{ 'user <handle> auth <type> reset', '(where applicable, managed auth only) delete all of a user\'s authentication tokens of the given type and issue a new one' };
           34  +	{ 'user <handle> auth purge-credentials [<type>]', 'delete all credentials that would allow this user to log in (where possible)' };
           35  +	{ 'user <handle> (grant|revoke) (<priv>|all)', 'grant or revoke a specific power to or from a user' };
           36  +	{ 'user <handle> emasculate', 'strip all administrative powers from a user' };
           37  +	{ 'user <handle> suspend [<timespec>]', '(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'};
           38  +	{ 'actor <xid> 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)' };
           39  +	{ 'actor <xid> create', 'instantiate a new actor' };
           40  +	{ 'actor <xid> bestow <epithet>', 'bestow an epithet upon an actor' };
           41  +	{ 'conf set <setting> <value>', 'add or a change a server configuration parameter to the database' };
           42  +	{ 'conf get <setting>', 'report the value of a server setting' };
           43  +	{ 'conf reset <setting>', 'reset a server setting to its default value' };
           44  +	{ 'conf refresh', 'instruct an instance to refresh its configuration cache' };
           45  +	{ 'conf chsec', 'reset the server secret, invalidating all authentication cookies' };
           46  +	{ 'serv dl', 'initiate an update cycle over foreign actors' };
           47  +	{ 'tl', 'print the current local timeline to standard out' };
           48  +	{ 'be pgsql setup-auth (managed|unmanaged)', '(PGSQL backends) select the authentication strategy to use' };
           49  +}
           50  +
           51  +local ctlcmdhelp = 'commands:\n'
           52  +for _, v in ipairs(ctlcmds) do
           53  +	ctlcmdhelp = ctlcmdhelp .. string.format (
           54  +		'    \27[1m%s\27[m: %s\n', v[1]:gsub('(<%w+>)','\27[36m%1\27[;1m'), v[2]
           55  +	)
           56  +end
           57  +
           58  +local struct idelegate {
           59  +	all: bool
           60  +	src: &lib.store.source
           61  +	srv: &lib.srv.overlord
           62  +}
           63  +idelegate.metamethods.__methodmissing = macro(function(meth, self, ...)
           64  +	local expr = {...}
           65  +	local rt
           66  +	for _,f in pairs(lib.store.backend.entries) do
           67  +		local fn = f.field or f[1]
           68  +		local ft = f.type or f[2]
           69  +		if fn == meth then rt = ft.type.returntype break end
           70  +	end
           71  +
           72  +	return quote
           73  +		var r: rt
           74  +		if self.all
           75  +			then r=self.srv:[meth]([expr])
           76  +			elseif self.src ~= nil then r=self.src:[meth]([expr])
           77  +			else lib.bail('no data source specified')
           78  +		end
           79  +	in r end
           80  +end)
           81  +
           82  +local terra gensec(sdest: rawstring)
           83  +	var dest = [&uint8](sdest)
           84  +	lib.crypt.spray(dest,64)
           85  +	for i=0,64 do dest[i] = dest[i] % (0x7e - 0x20) + 0x20 end
           86  +	dest[64] = 0
           87  +end
           88  +
           89  +local terra entry_mgtool(argc: int, argv: &rawstring): int
           90  +	if argc < 1 then lib.bail('bad invocation!') end
           91  +
           92  +	lib.noise_init(2)
           93  +	[lib.init]
           94  +
           95  +	var srv: lib.srv.overlord
           96  +	var dlg = idelegate { srv = &srv, src = nil }
           97  +
           98  +	var mode: ctloptions
           99  +	mode:parse(argc,argv) defer mode:free()
          100  +	if mode.version then version() return 0 end
          101  +	if mode.help then
          102  +		[ lib.emit(false, 1, 'usage: ', `argv[0], ' ', ctloptions.helptxt.flags, ' <cmd> [<args>…]', ctloptions.helptxt.opts, ctlcmdhelp) ]
          103  +		return 0
          104  +	end
          105  +	var cnf: rawstring
          106  +	if mode.backend_file ~= nil
          107  +		then cnf = @mode.backend_file
          108  +		else cnf = lib.proc.getenv('parsav_backend_file')
          109  +	end
          110  +	if cnf == nil then cnf = "backend.conf" end
          111  +	if mode.all then dlg.all = true else
          112  +		-- iterate through and pick the right backend
          113  +	end
          114  +
          115  +	if mode.arglist.ct == 0 then lib.bail('no command') return 1 end
          116  +	if lib.str.cmp(mode.arglist(0),'attach') == 0 then
          117  +	elseif lib.str.cmp(mode.arglist(0),'start') == 0 then
          118  +	elseif lib.str.cmp(mode.arglist(0),'stop') == 0 then
          119  +	else
          120  +		if lib.str.cmp(mode.arglist(0),'db') == 0 then
          121  +			var dbmode: pbasic dbmode:parse(mode.arglist.ct, &mode.arglist(0))
          122  +			if dbmode.help then
          123  +				[ lib.emit(false, 1, 'usage: ', `argv[0], ' db ', dbmode.type.helptxt.flags, ' <cmd> [<args>…]', dbmode.type.helptxt.opts) ]
          124  +				return 1
          125  +			end
          126  +			if dbmode.arglist.ct < 1 then goto cmderr end
          127  +
          128  +			srv:setup(cnf) 
          129  +			if lib.str.cmp(dbmode.arglist(0),'init') == 0 and dbmode.arglist.ct == 2 then
          130  +				lib.report('initializing new database structure for domain ', dbmode.arglist(1))
          131  +				dlg:dbsetup()
          132  +				srv:conprep(lib.store.prepmode.conf)
          133  +				dlg:conf_set('instance-name', dbmode.arglist(1))
          134  +				do var sec: int8[65] gensec(&sec[0])
          135  +					dlg:conf_set('server-secret', &sec[0])
          136  +				end
          137  +				lib.report('database setup complete; use mkroot to create an administrative user')
          138  +			elseif lib.str.cmp(dbmode.arglist(0),'obliterate') == 0 then
          139  +				var confirmstrs = array(
          140  +					'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'eta', 'nu', 'kappa'
          141  +				)
          142  +				var cfmstr: int8[64] cfmstr[0] = 0
          143  +				var tdx = lib.osclock.time(nil) / 60
          144  +				for i=0,3 do
          145  +					if i ~= 0 then lib.str.cat(&cfmstr[0], '-') end
          146  +					lib.str.cat(&cfmstr[0], confirmstrs[(tdx + 49*i) % [confirmstrs.type.N]])
          147  +				end
          148  +
          149  +				if dbmode.arglist.ct == 1 then
          150  +					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])
          151  +				elseif dbmode.arglist.ct == 2 then
          152  +					if lib.str.cmp(dbmode.arglist(1), cfmstr) == 0 then
          153  +						lib.warn('completely obliterating all data!')
          154  +						dlg:obliterate_everything()
          155  +					else
          156  +						lib.bail('you passed an incorrect confirmation string; pass ', &cfmstr[0], ' if you really want to destroy everything')
          157  +					end
          158  +				else goto cmderr end
          159  +			else goto cmderr end
          160  +		elseif lib.str.cmp(mode.arglist(0),'be') == 0 then
          161  +			srv:setup(cnf) 
          162  +		elseif lib.str.cmp(mode.arglist(0),'conf') == 0 then
          163  +			srv:setup(cnf) 
          164  +			srv:conprep(lib.store.prepmode.conf)
          165  +			var cfmode: lib.cmdparse {
          166  +				help = {'h','display this list'};
          167  +				no_notify = {'n', "don't instruct the server to refresh its configuration cache after making changes; useful for \"transactional\" configuration changes."};
          168  +			}
          169  +			cfmode:parse(mode.arglist.ct, &mode.arglist(0))
          170  +			if cfmode.help then
          171  +				[ lib.emit(false, 1, 'usage: ', `argv[0], ' conf ', cfmode.type.helptxt.flags, ' <cmd> [<args>…]', cfmode.type.helptxt.opts) ]
          172  +				return 1
          173  +			end
          174  +			if cfmode.arglist.ct < 1 then goto cmderr end
          175  +
          176  +			if cfmode.arglist.ct == 1 then
          177  +				if lib.str.cmp(cfmode.arglist(0),'chsec') == 0 then
          178  +					var sec: int8[65] gensec(&sec[0])
          179  +					dlg:conf_set('server-secret', &sec[0])
          180  +					lib.report('server secret reset')
          181  +					-- FIXME notify server to reload its config
          182  +				elseif lib.str.cmp(cfmode.arglist(0),'refresh') == 0 then
          183  +					-- TODO notify server to reload config
          184  +				else goto cmderr end
          185  +			elseif cfmode.arglist.ct == 3 and
          186  +				lib.str.cmp(cfmode.arglist(0),'set') == 0 then
          187  +				dlg:conf_set(cfmode.arglist(1),cfmode.arglist(2))
          188  +				lib.report('parameter set')
          189  +			else goto cmderr end
          190  +		else
          191  +			srv:setup(cnf) 
          192  +			srv:conprep(lib.store.prepmode.full)
          193  +			if lib.str.cmp(mode.arglist(0),'mkroot') == 0 then
          194  +				var cfmode: pbasic cfmode:parse(mode.arglist.ct, &mode.arglist(0))
          195  +				if cfmode.help then
          196  +					[ lib.emit(false, 1, 'usage: ', `argv[0], ' mkroot ', cfmode.type.helptxt.flags, ' <handle>', cfmode.type.helptxt.opts) ]
          197  +					return 1
          198  +				end
          199  +				if cfmode.arglist.ct == 1 then
          200  +					var am = dlg:conf_get('credential-store')
          201  +					var mg: bool
          202  +					if (not am) or am:cmp(lib.str.plit 'managed') then
          203  +						mg = true
          204  +					elseif am:cmp(lib.str.plit 'unmanaged') then
          205  +						lib.warn('credential store is unmanaged; you will need to create credentials for the new root user manually!')
          206  +						mg = false
          207  +					else lib.bail('unknown credential store mode "',{am.ptr,am.ct},'"; should be either "managed" or "unmanaged"') end
          208  +					var kbuf: uint8[lib.crypt.const.maxdersz]
          209  +					var root = lib.store.actor.mk(&kbuf[0])
          210  +					root.handle = cfmode.arglist(0)
          211  +					var epithets = array(
          212  +						'root', 'god', 'regional jehovah', 'titan king',
          213  +						'king of olympus', 'cyberpharaoh', 'electric ellimist',
          214  +						"rampaging c'tan", 'deathless tweetlord', 'postmaster',
          215  +						'faerie queene', 'lord of the posts', 'ruthless cybercrat',
          216  +						'general secretary', 'commissar', 'kwisatz haderach'
          217  +						-- feel free to add more
          218  +					)
          219  +					root.epithet = epithets[lib.crypt.random(intptr,0,[epithets.type.N])]
          220  +					root.rights.powers:fill() -- grant omnipotence
          221  +					root.rights.rank = 1
          222  +					var ruid = dlg:actor_create(&root)
          223  +					dlg:conf_set('master',root.handle)
          224  +					lib.report('created new administrator')
          225  +					if mg then
          226  +						lib.dbg('generating temporary password')
          227  +						var tmppw: uint8[33]
          228  +						lib.crypt.spray(&tmppw[0],32) tmppw[32] = 0
          229  +						for i=0,32 do
          230  +							tmppw[i] = tmppw[i] % (10 + 26*2)
          231  +							if tmppw[i] >= 36 then
          232  +								tmppw[i] = tmppw[i] + (0x61 - 36)
          233  +							elseif tmppw[i] >= 10 then
          234  +								tmppw[i] = tmppw[i] + (0x41 - 10)
          235  +							else tmppw[i] = tmppw[i] + 0x30 end
          236  +						end
          237  +						lib.dbg('assigning temporary password')
          238  +						dlg:auth_create_pw(ruid, false, pstr {
          239  +							ptr = [rawstring](&tmppw[0]), ct = 32
          240  +						})
          241  +						lib.report('temporary root pw: ', {[rawstring](&tmppw[0]), 32})
          242  +					end
          243  +				else goto cmderr end
          244  +			elseif lib.str.cmp(mode.arglist(0),'user') == 0 then
          245  +			elseif lib.str.cmp(mode.arglist(0),'actor') == 0 then
          246  +			elseif lib.str.cmp(mode.arglist(0),'tl') == 0 then
          247  +			elseif lib.str.cmp(mode.arglist(0),'serv') == 0 then
          248  +			else goto cmderr end
          249  +		end
          250  +	end
          251  +
          252  +	do return 0 end
          253  +	::cmderr:: lib.bail('invalid command') return 2
          254  +end
          255  +
          256  +return entry_mgtool

Modified parsav.md from [ae23203c4b] to [4b27db126a].

    39     39   
    40     40   if you use nixos and wish to build the pdf documentation, you're going to have to do a bit of extra work (but you're used to that, aren't you). for some incomprehensible reason, the groff package on nix is split up, seemingly randomly, with many crucial output devices relegated to the "perl" output of the package, which is not installed by default (and `nix-env -iA nixos.groff.perl` doesn't work either; i don't know why either). you'll have to instantiate and install the outputs directly by path, e.g. `nix-env -i /nix/store/*groff*/` to get everything you need into your profile. alas, the battle is not over: you also need to change the environment variables `GROFF_FONT_PATH` and `GROFF_TMAC_PATH` to point at the `font` and `tmac` subdirs of `~/.nix-profile/share/groff/$groff_version/`. once this is done, invoking `groff -Tpdf` will work as expected.
    41     41   
    42     42   ## configuring
    43     43   
    44     44   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.
    45     45   
    46         -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.
           46  +you can directly modify the store from the command line with the `parsav conf` command; see `parsav conf -h` for more information.
    47     47   
    48     48   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
    49     49   
    50     50       master   pgsql   host=localhost dbname=parsav
    51     51   	tweets   pgsql   host=420.69.dread.cloud dbname=content
    52     52   
    53     53   the form the configuration string takes depends on the specific backend.
    54     54   
           55  +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 <domain>`, where `<domain>` 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 <handle>` command, where `<handle>` 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.
           56  +
           57  +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 <address>`, 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)
           58  +
    55     59   ### postgresql backend
    56     60   
    57         -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).
           61  +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.
    58     62   
    59         -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.
           63  +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`.
           64  +
           65  +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!
    60     66   
    61     67   `parsav_auth` has the following schema:
    62     68   
    63     69       create table parsav_auth (
    64     70   		aid bigint primary key,
    65     71   		uid bigint,
    66     72   		newname text,
................................................................................
    69     75   		restrict text[],
    70     76   		netmask cidr,
    71     77   		blacklist bool
    72     78       )
    73     79   
    74     80   `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.
    75     81   
    76         -## authentication 
    77     82   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.
    78     83   
    79         -`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`.
           84  +`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`.
           85  +
           86  +## invoking
           87  +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.
           88  +
           89  +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.
           90  +
           91  +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. 
    80     92   
           93  +## authentication 
    81     94   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.
    82     95   
    83     96   * ☑ pw-sha{512,384,256,224}: an ordinary password, hashed with the appropriate algorithm
    84     97   * ☐ 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"`)
    85     98   * ☐ pw-pbkdf2-hmac-sha{…}: a password hashed with the Password-Based Key Derivation Function 2 instead of plain SHA2
    86     99   * ☐ pw-extern-ldap: try to authenticate by binding against an LDAP server
    87    100   * ☐ pw-extern-cyrus: try to authenticate against saslauthd
................................................................................
    92    105   * ☐ api-digest-sha{…}: a value that can be hashed with the current epoch to derive a temporary access key without logging in. these are used for API calls, sent in the header `X-API-Key`.
    93    106   * ☐ otp-time-sha1: a TOTP PSK: the first two bytes represent the step, the third byte the OTP length, and the remaining ten bytes the secret key
    94    107   * ☐ tls-cert-fp: a fingerprint of a client certificate
    95    108   * ☐ 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
    96    109   * ☐ 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.
    97    110   * ☐ 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.
    98    111   * ☐ 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.
    99         -* ☑ trust: authentication always succeeds. only use in combination with netmask!!!
          112  +* ☑ trust: authentication always succeeds (or fails, if blacklisted). only use in combination with netmask!!!
          113  +
          114  +## legal
          115  +
          116  +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.
   100    117   
   101         -## license
          118  +## code of conduct
   102    119   
   103         -parsav is released under the terms of the EUPL v1.2. copies of this license are included in the repository. dependencies are produced
          120  +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.
   104    121   
   105    122   ## future direction
   106    123   
   107    124   parsav needs more storage backends, as it currently supports only postgres. some possibilities, in order of priority, are:
   108    125   
   109    126   * plain text/filesystem storage
   110    127   * lmdb

Modified parsav.t from [1c10e6f8f8] to [d1470e4b10].

    11     11   			local path = {}
    12     12   			for m in l:gmatch('([^:]+)') do path[#path+1]=m end
    13     13   			local tgt = lib
    14     14   			for i=1,#path-1 do
    15     15   				if tgt[path[i]] == nil then tgt[path[i]] = {} end
    16     16   				tgt = tgt[path[i]]
    17     17   			end
    18         -			tgt[path[#path]] = terralib.loadfile(l:gsub(':','/') .. '.t')()
           18  +			tgt[path[#path]:gsub('-','_')] = terralib.loadfile(l:gsub(':','/') .. '.t')()
    19     19   		end
    20     20   	end;
    21     21   	loadlib = function(name,hdr)
    22     22   		local p = config.pkg[name]
    23     23   		-- for _,v in pairs(p.dylibs) do
    24     24   		-- 	terralib.linklibrary(p.libdir .. '/' .. v)
    25     25   		-- end
................................................................................
   175    175   	else -- print time since last msg
   176    176   		var dfs = arrayof(int8, 0x30 + diff/10, 0x30 + diff%10, 0x20, 0)
   177    177   		[ lib.emit(false, 2, ' \27[36m+', `&dfs[0]) ]
   178    178   	end
   179    179   end
   180    180   
   181    181   local defrep = function(level,n,code)
          182  +	if level >= 3 and config.debug == false then
          183  +		return macro(function(...) return {} end)
          184  +	end
   182    185   	return macro(function(...)
   183    186   		local fn = (...).filename
   184    187   		local ln = tostring((...).linenumber)
   185    188   		local dbgtag = string.format('\27[35m · \27[34m%s:\27[1m%s\27[m\n', fn,ln)
   186    189   		local q = lib.emit(level < 3 and true or dbgtag, 2, noise_header(code,n), ...)
   187         -		return quote if noise >= level then timehdr(); [q] end end
          190  +		return quote
          191  +		--lib.io.fmt(['attempting to emit at ' .. fn..':'..ln.. '\n'])
          192  +		if noise >= level then timehdr(); [q] end end
   188    193   	end);
   189    194   end
   190    195   lib.dbg = defrep(3,'debug', '32')
   191    196   lib.report = defrep(2,'info', '35')
   192    197   lib.warn = defrep(1,'warn', '33')
   193    198   lib.bail = macro(function(...)
   194    199   	local q = lib.emit(true, 2, noise_header('31','fatal'), ...)
................................................................................
   328    333   lib.md = lib.loadlib('mbedtls','mbedtls/md.h')
   329    334   lib.b64 = lib.loadlib('mbedtls','mbedtls/base64.h')
   330    335   lib.net = lib.loadlib('mongoose','mongoose.h')
   331    336   lib.pq = lib.loadlib('libpq','libpq-fe.h')
   332    337   
   333    338   lib.load {
   334    339   	'mem',  'math', 'str', 'file', 'crypt';
   335         -	'http', 'session', 'tpl', 'store';
          340  +	'http', 'html', 'session', 'tpl', 'store';
   336    341   
   337    342   	'smackdown'; -- md-alike parser
   338    343   }
   339    344   
   340    345   local be = {}
   341    346   for _, b in pairs(config.backends) do
   342    347   	be[#be+1] = terralib.loadfile('backend/' .. b .. '.t')()
................................................................................
   367    372   	local t = lib.tpl.mk { body = v, id = 'view/'..k }
   368    373   	data.view[k] = t
   369    374   end
   370    375   
   371    376   lib.load {
   372    377   	'srv';
   373    378   	'render:nav';
          379  +	'render:nym';
   374    380   	'render:login';
   375    381   	'render:profile';
          382  +
   376    383   	'render:compose';
   377    384   	'render:tweet';
   378    385   	'render:userpage';
   379    386   	'render:timeline';
          387  +
   380    388   	'render:docpage';
          389  +
          390  +	'render:conf:profile';
          391  +	'render:conf';
   381    392   	'route';
   382    393   }
   383    394   
   384    395   do
   385    396   	local p = string.format('parsav: %s\nbuilt on %s\n', config.build.str, config.build.when)
   386    397   	terra version() lib.io.send(1, p, [#p]) end
   387    398   end
   388    399   
   389         -terra noise_init()
          400  +terra lib.noise_init(default_level: uint)
   390    401   	starttime = lib.osclock.time(nil)
   391    402   	lastnoisetime = 0
   392    403   	var n = lib.proc.getenv('parsav_noise')
   393    404   	if n ~= nil then
   394    405   		if n[0] >= 0x30 and n[0] <= 0x39 and n[1] == 0 then
   395    406   			noise = n[0] - 0x30
   396    407   			return
   397    408   		end
   398    409   	end
   399         -	noise = 1
          410  +	noise = default_level
   400    411   end
          412  +lib.load{'mgtool'}
   401    413   
   402    414   local options = lib.cmdparse {
   403    415   	version = {'V', 'display information about the binary build and exit'};
          416  +	verbose = {'v', 'increase logging verbosity', inc=1};
   404    417   	quiet = {'q', 'do not print to standard out'};
   405    418   	help = {'h', 'display this list'};
   406         -	backend_file = {'b', 'init from specified backend file', 1};
   407         -	static_dir = {'S', 'directory with overrides for static content', 1};
   408         -	builtin_data = {'B', 'do not load static content overrides at runtime under any circumstances'};
          419  +	backend_file = {'B', 'init from specified backend file', consume=1};
          420  +	static_dir = {'S', 'directory with overrides for static content', consume=1};
          421  +	builtin_data = {'D', 'do not load static content overrides at runtime under any circumstances'};
          422  +	instance = {'i', 'set an instance name to make it easier to control multiple daemons', consume = 1};
   409    423   }
   410    424   
   411    425   
   412    426   local static_setup = quote end
   413    427   local mapin = quote end
   414    428   local odir = symbol(rawstring)
   415    429   local pathbuf = symbol(lib.str.acc)
................................................................................
   437    451   	[static_setup]
   438    452   	if mode.builtin_data then return end
   439    453   
   440    454   	var [odir] = lib.proc.getenv('parsav_override_dir')
   441    455   	if mode.static_dir ~= nil then
   442    456   		odir=@mode.static_dir
   443    457   	end
   444         -	if odir == nil then return end
          458  +	if odir == nil then [
          459  +		config.prefix_static and quote
          460  +			odir = [config.prefix_static]
          461  +		end or quote return end
          462  +	] end
   445    463   
   446    464   	var [pathbuf] defer pathbuf:free()
   447    465   	pathbuf:compose(odir,'/')
   448    466   	[mapin]
   449    467   end
   450    468   
   451         -terra entry(argc: int, argv: &rawstring): int
          469  +local terra entry_daemon(argc: int, argv: &rawstring): int
   452    470   	if argc < 1 then lib.bail('bad invocation!') end
   453    471   
   454         -	noise_init()
          472  +	lib.noise_init(1)
   455    473   	[lib.init]
   456    474   
   457    475   	-- shut mongoose the fuck up
   458    476   	lib.net.mg_log_set_callback([terra(msg: &opaque, sz: int, u: &opaque) end], nil)
   459    477   	var srv: lib.srv.overlord
   460    478   
   461    479   	do var mode: options
   462    480   		mode:parse(argc,argv) defer mode:free()
   463    481   		static_init(&mode)
   464    482   		if mode.version then version() return 0 end
   465    483   		if mode.help then
   466         -			lib.io.send(1,  [options.helptxt], [#options.helptxt])
          484  +			[ lib.emit(true, 1, 'usage: ',`argv[0],' ', options.helptxt.flags, ' [<args>…]', options.helptxt.opts) ]
   467    485   			return 0
   468    486   		end
   469    487   		var cnf: rawstring
   470    488   		if mode.backend_file ~= nil
   471    489   			then cnf = @mode.backend_file
   472    490   			else cnf = lib.proc.getenv('parsav_backend_file')
   473    491   		end
   474         -		if cnf == nil then cnf = "backend.conf" end
          492  +		if cnf == nil then cnf = [config.prefix_conf .. "backend.conf"] end
   475    493   
   476         -		srv:start(cnf)
          494  +		srv:setup(cnf)
          495  +		srv:start(lib.trn(mode.instance ~= nil, @mode.instance, nil))
   477    496   	end
   478    497   
   479    498   	lib.report('listening for requests')
   480    499   	while true do
   481    500   		srv:poll()
   482    501   	end
   483    502   	srv:shutdown()
   484    503   
   485    504   	return 0
   486    505   end
          506  +
   487    507   
   488    508   local bflag = function(long,short)
   489    509   	if short and util.has(buildopts, short) then return true end
   490    510   	if long and util.has(buildopts, long) then return true end
   491    511   	return false
   492    512   end
   493    513   
   494    514   if bflag('dump-config','C') then
   495    515   	print(util.dump(config))
   496    516   	os.exit(0)
   497    517   end
   498    518   
   499    519   local holler = print
   500         -local out = config.exe and 'parsav' or ('parsav.' .. config.outform)
          520  +local suffix = config.exe and '' or ('.'..config.outform)
          521  +local out = 'parsavd' .. suffix
   501    522   local linkargs = {}
          523  +local target = config.tgttrip and terralib.newtarget {
          524  +	Triple = config.tgttrip;
          525  +	CPU = config.tgtcpu;
          526  +	FloatABIHard = config.tgthf;
          527  +} or nil
   502    528   
   503    529   if bflag('quiet','q') then holler = function() end end
   504    530   if bflag('asan','s') then linkargs[#linkargs+1] = '-fsanitize=address' end
   505    531   if bflag('lsan','S') then linkargs[#linkargs+1] = '-fsanitize=leak' end
   506    532   
          533  +for _,p in pairs(config.pkg) do util.append(linkargs, p.linkargs) end
          534  +local linkargs_d = linkargs -- controller is not multithreaded
   507    535   if config.posix then
   508         -	linkargs[#linkargs+1] = '-pthread'
          536  +	linkargs_d[#linkargs_d+1] = '-pthread'
   509    537   end
   510         -for _,p in pairs(config.pkg) do util.append(linkargs, p.linkargs) end
   511    538   holler('linking with args',util.dump(linkargs))
   512         -terralib.saveobj(out, {
   513         -		main = entry
   514         -	},
   515         -	linkargs,
   516         -	config.tgttrip and terralib.newtarget {
   517         -		Triple = config.tgttrip;
   518         -		CPU = config.tgtcpu;
   519         -		FloatABIHard = config.tgthf;
   520         -	} or nil)
          539  +
          540  +terralib.saveobj('parsavd'..suffix, { main = entry_daemon }, linkargs_d, target)
          541  +terralib.saveobj('parsav' ..suffix, { main = lib.mgtool }, linkargs, target)

Modified render/compose.t from [0685338a7e] to [cb3a66bab9].

     4      4   	var target, tgtlen = co:getv('to')
     5      5   	var form: data.view.compose
     6      6   	if edit == nil then
     7      7   		form = data.view.compose {
     8      8   			content = lib.coalesce(target, '');
     9      9   			acl = lib.trn(target == nil, 'all', 'mentioned'); -- TODO default acl setting?
    10     10   			handle = co.who.handle;
           11  +			circles = ''; -- TODO: list user's circles, rooms, and saved aclexps
    11     12   		}
    12     13   	end
    13     14   	var cotxt = form:tostr() defer cotxt:free()
    14     15   
    15     16   	var doc = data.view.docskel {
    16     17   		instance = co.srv.cfg.instance;
    17     18   		title = lib.str.plit 'compose';

Added render/conf.t version [6e08f785f6].

            1  +-- vim: ft=terra
            2  +local pstr = lib.mem.ptr(int8)
            3  +local pref = lib.mem.ref(int8)
            4  +
            5  +local mappings = {
            6  +	{url = 'profile', title = 'account profile', render = 'profile'};
            7  +	{url = 'avi', title = 'avatar', render = 'avatar'};
            8  +	{url = 'sec', title = 'security', render = 'sec'};
            9  +	{url = 'rel', title = 'relationships', render = 'rel'};
           10  +	{url = 'qnt', title = 'quarantine', render = 'quarantine'};
           11  +	{url = 'acl', title = 'access control shortcuts', render = 'acl'};
           12  +	{url = 'rooms', title = 'chatrooms', render = 'rooms'};
           13  +	{url = 'circles', title = 'circles', render = 'circles'};
           14  +
           15  +	{url = 'srv', title = 'server settings', render = 'srv'};
           16  +	{url = 'brand', title = 'instance branding', render = 'rebrand'};
           17  +	{url = 'censor', title = 'censorship &amp; badthink suppression', render = 'rebrand'};
           18  +	{url = 'users', title = 'user accounting', render = 'users'};
           19  +
           20  +}
           21  +
           22  +local path = symbol(lib.mem.ptr(pref))
           23  +local co = symbol(&lib.srv.convo)
           24  +local panel = symbol(pstr)
           25  +local invoker = quote co:complain(404,'not found','no such control panel is available in this version of parsav') end
           26  +
           27  +for i, m in ipairs(mappings) do
           28  +	if lib.render.conf[m.render] then
           29  +		invoker = quote
           30  +			if path(1):cmp(lib.str.lit([m.url])) then
           31  +				var body = [lib.render.conf[m.render]] (co, path)
           32  +				var a: lib.str.acc a:init(body.ct+48)
           33  +				a:lpush(['<h1>' .. m.title .. '</h1>']):ppush(body)
           34  +				panel = a:finalize()
           35  +				body:free()
           36  +			else [invoker] end
           37  +		end
           38  +	end
           39  +end
           40  +
           41  +local terra 
           42  +render_conf([co], [path])
           43  +	var menu: lib.str.acc menu:init(64):lpush('<hr>') defer menu:free()
           44  +
           45  +	-- build menu
           46  +	do var p = co.who.rights.powers
           47  +		if p.config() then menu:lpush '<a href="/conf/srv">server settings</a>' end
           48  +		if p.rebrand() then menu:lpush '<a href="/conf/brand">instance branding</a>' end
           49  +		if p.censor() then menu:lpush '<a href="/conf/censor">badthink alerts</a>' end
           50  +		if p:affect_users() then menu:lpush '<a href="/conf/users">users</a>' end
           51  +	end
           52  +
           53  +	-- select the appropriate panel
           54  +	var [panel] = pstr { ptr = ''; ct = 0 }
           55  +	if path.ct >= 2 then [invoker] end
           56  +
           57  +	-- avoid the hr if we didn't add any elements
           58  +	var mptr = pstr { ptr = menu.buf, ct = menu.sz }
           59  +	if menu.sz <= 4 then mptr.ct = 0 end -- 🙄
           60  +	var pg = data.view.conf {
           61  +		menu = mptr;
           62  +		panel = panel;
           63  +	}
           64  +
           65  +	var pgt = pg:tostr() defer pgt:free()
           66  +	co:stdpage([lib.srv.convo.page] {
           67  +		title = 'configure'; body = pgt;
           68  +		class = lib.str.plit 'conf';
           69  +	})
           70  +
           71  +	if panel.ct ~= 0 then panel:free() end
           72  +end
           73  +
           74  +return render_conf

Added render/conf/profile.t version [248ab207d4].

            1  +-- vim: ft=terra
            2  +local pstr = lib.mem.ptr(int8)
            3  +local pref = lib.mem.ref(int8)
            4  +
            5  +local terra cs(s: rawstring)
            6  +	return pstr { ptr = s, ct = lib.str.sz(s) }
            7  +end
            8  +
            9  +local terra 
           10  +render_conf_profile(co: &lib.srv.convo, path: lib.mem.ptr(pref)): pstr
           11  +
           12  +	var c = data.view.conf_profile {
           13  +		handle = cs(co.who.handle);
           14  +		nym = cs(lib.coalesce(co.who.nym,''));
           15  +		bio = cs(lib.coalesce(co.who.bio,''));
           16  +	}
           17  +	return c:tostr()
           18  +end
           19  +
           20  +return render_conf_profile

Modified render/login.t from [671b92715b] to [dd5c50c3e9].

    31     31   			handle = user.handle;
    32     32   			name = lib.coalesce(user.nym, user.handle);
    33     33   		}
    34     34   		if creds.pw() then
    35     35   			ch.challenge = P'enter the password associated with your account'
    36     36   			ch.label = P'password'
    37     37   			ch.method = P'pw'
           38  +			ch.auto = P'current-password';
    38     39   		elseif creds.otp() then
    39     40   			ch.challenge = P'enter a valid one-time password for your account'
    40     41   			ch.label = P'OTP code'
    41     42   			ch.method = P'otp'
           43  +			ch.auto = P'one-time-code';
    42     44   		elseif creds.challenge() then
    43     45   			ch.challenge = P'sign the challenge token: <code>...</code>'
    44     46   			ch.label = P'digest'
    45     47   			ch.method = P'challenge'
           48  +			ch.auto = P'one-time-code';
    46     49   		else
    47     50   			co:complain(500,'login failure','unknown login method')
    48     51   			return
    49     52   		end
    50     53   
    51     54   		doc.body = ch:tostr()
    52     55   	else

Added render/nym.t version [89e574dd98].

            1  +-- vim: ft=terra
            2  +local pstr = lib.mem.ptr(int8)
            3  +
            4  +local terra 
            5  +render_nym(who: &lib.store.actor, scope: uint64)
            6  +	var n: lib.str.acc n:init(128)
            7  +	if who.nym ~= nil and who.nym[0] ~= 0 then
            8  +		n:compose('<span class="nym">',who.nym,'</span> [<span class="handle">',
            9  +			who.xid,'</span>]')
           10  +	else n:compose('<span class="handle">',who.xid,'</span>') end
           11  +
           12  +	if who.epithet ~= nil then
           13  +		n:lpush(' <span class="epithet">'):push(who.epithet,0):lpush('</span>')
           14  +	end
           15  +	
           16  +	-- TODO: if scope == chat room then lookup titles in room member db
           17  +
           18  +	return n:finalize()
           19  +end
           20  +
           21  +return render_nym

Modified render/profile.t from [efe49adad0] to [03b39adc21].

    28     28   
    29     29   	var strfbuf: int8[28*4]
    30     30   	var stats = co.srv:actor_stats(actor.id)
    31     31   		var sn_posts     = cs(lib.math.decstr_friendly(stats.posts, &strfbuf[ [strfbuf.type.N - 1] ]))
    32     32   		var sn_follows   = cs(lib.math.decstr_friendly(stats.follows, sn_posts.ptr - 1))
    33     33   		var sn_followers = cs(lib.math.decstr_friendly(stats.followers, sn_follows.ptr - 1))
    34     34   		var sn_mutuals   = cs(lib.math.decstr_friendly(stats.mutuals, sn_followers.ptr - 1))
    35         -	
           35  +	var bio = lib.str.plit "<em>tall, dark, and mysterious</em>"
           36  +	if actor.bio ~= nil then
           37  +		bio = lib.html.sanitize(cs(actor.bio), false)
           38  +	end
           39  +	var fullname = lib.render.nym(actor,0) defer fullname:free()
    36     40   	var profile = data.view.profile {
    37         -		nym = cs(lib.coalesce(actor.nym, actor.handle));
    38         -		bio = cs(lib.coalesce(actor.bio, "<em>tall, dark, and mysterious</em>"));
           41  +		nym = fullname;
           42  +		bio = bio;
    39     43   		xid = cs(actor.xid);
    40     44   		avatar = lib.trn(actor.origin == 0, pstr{ptr=avistr.buf,ct=avistr.sz},
    41     45   			cs(lib.coalesce(actor.avatar, '/s/default-avatar.webp')));
    42     46   
    43     47   		nposts = sn_posts, nfollows = sn_follows;
    44     48   		nfollowers = sn_followers, nmutuals = sn_mutuals;
    45     49   		tweetday = cs(timestr);
................................................................................
    47     51   
    48     52   		auxbtn = auxp;
    49     53   	}
    50     54   
    51     55   	var ret = profile:tostr()
    52     56   	if actor.origin == 0 then avistr:free() end
    53     57   	if not (co.aid ~= 0 and co.who.id == actor.id) then auxp:free() end
           58  +	if actor.bio ~= nil then bio:free() end
    54     59   	return ret
    55     60   end
    56     61   
    57     62   return render_profile

Modified render/tweet.t from [00c7b6fd89] to [ac0f8e680f].

    22     22   	var timestr: int8[26] lib.osclock.ctime_r(&p.posted, &timestr[0])
    23     23   
    24     24   	var bhtml = lib.smackdown.html([lib.mem.ptr(int8)] {ptr=p.body,ct=0}) defer bhtml:free()
    25     25   
    26     26   	var idbuf: int8[lib.math.shorthand.maxlen]
    27     27   	var idlen = lib.math.shorthand.gen(p.id, idbuf)
    28     28   	var permalink: lib.str.acc permalink:compose('/post/',{idbuf,idlen})
    29         -
           29  +	var fullname = lib.render.nym(author,0) defer fullname:free()
    30     30   	var tpl = data.view.tweet {
    31     31   		text = bhtml;
    32     32   		subject = cs(lib.coalesce(p.subject,''));
    33         -		nym = cs(lib.coalesce(author.nym, author.handle));
    34         -		xid = cs(author.xid);
           33  +		nym = fullname;
    35     34   		when = cs(&timestr[0]);
    36     35   		avatar = cs(lib.trn(author.origin == 0, avistr.buf,
    37     36   			lib.coalesce(author.avatar, '/s/default-avatar.webp')));
    38     37   		acctlink = cs(author.xid);
    39     38   		permalink = permalink:finalize();
    40     39   	}
    41     40   	defer tpl.permalink:free()
    42     41   	if acc ~= nil then tpl:append(acc) return [lib.mem.ptr(int8)]{ptr=nil,ct=0} end
    43     42   	var txt = tpl:tostr()
    44     43   	return txt
    45     44   end
    46     45   return render_tweet

Modified route.t from [d6af263481] to [4fbd6ed0a5].

    81     81   		var fakeactor: lib.store.actor
    82     82   		if act.ptr == nil then
    83     83   			-- the user is known to us but has not yet claimed an
    84     84   			-- account on the server. create a template for the
    85     85   			-- account that will be created once they log in
    86     86   			fakeact = true
    87     87   			fakeactor = lib.store.actor {
    88         -				id = 0, handle = usn, nym = usn;
           88  +				id = 0, handle = usn, nym = nil;
    89     89   				origin = 0, bio = nil;
    90         -				key = [lib.mem.ptr(uint8)] {ptr=nil, ct=0}
           90  +				key = [lib.mem.ptr(uint8)] {ptr=nil, ct=0};
           91  +				epithet = nil;
    91     92   			}
    92     93   			act.ct = 1
    93     94   			act.ptr = &fakeactor
    94     95   			act.ptr.rights = lib.store.rights_default()
    95     96   		end
    96     97   		if am == nil then
    97     98   			-- pick an auth method
................................................................................
   169    170   		lib.render.docpage(co,path(1))
   170    171   	elseif path.ct == 1 then
   171    172   		lib.render.docpage(co, rstring.null())
   172    173   	else
   173    174   		co:complain(404, 'no such documentation', 'invalid documentation URL')
   174    175   	end
   175    176   end
          177  +
          178  +terra http.configure(co: &lib.srv.convo, path: hpath)
          179  +	lib.render.conf(co,path)
          180  +end
   176    181   
   177    182   do local branches = quote end
   178    183   	local filename, flen = symbol(&int8), symbol(intptr)
   179    184   	local page = symbol(lib.http.page)
   180    185   	local send = label()
   181    186   	local storage = data.stmap
   182    187   	for i,e in ipairs(config.embeds) do local id,mime = e[1],e[2]
................................................................................
   262    267   		if path.ptr[0]:cmp(lib.str.lit('user')) then
   263    268   			http.actor_profile_uid(co, path, meth)
   264    269   		elseif path.ptr[0]:cmp(lib.str.lit('tl')) then
   265    270   			http.timeline(co, path)
   266    271   		elseif path.ptr[0]:cmp(lib.str.lit('doc')) then
   267    272   			if meth ~= method.get and meth ~= method.head then goto wrongmeth end
   268    273   			http.documentation(co, path)
          274  +		elseif path.ptr[0]:cmp(lib.str.lit('conf')) then
          275  +			if co.aid == 0 then goto unauth end
          276  +			http.configure(co,path)
   269    277   		else goto notfound end
   270    278   		return
   271    279   	end
   272    280   
   273    281   	::wrongmeth:: co:complain(405, 'method not allowed', 'that method is not meaningful for this endpoint') do return end
   274    282   	::notfound:: co:complain(404, 'not found', 'no such resource available') do return end
          283  +	::unauth:: co:complain(401, 'unauthorized', 'this content is not available at your clearance level') do return end
   275    284   end

Modified smackdown.t from [896438e021] to [c51eb8a6b2].

    52     52   local terra scanline_wordend(l: rawstring, max: intptr, n: rawstring, nc: intptr)
    53     53   	var sl = scanline(l,max,n,nc)
    54     54   	if sl == nil then return nil else sl = sl + nc end
    55     55   	if sl >= l+max or isws(@sl) then return sl-nc end
    56     56   	return nil
    57     57   end
    58     58   
    59         -terra m.html(md: pstr)
    60         -	if md.ct == 0 then md.ct = lib.str.sz(md.ptr) end
           59  +terra m.html(input: pstr)
           60  +	if input.ct == 0 then input.ct = lib.str.sz(input.ptr) end
           61  +
           62  +	var md = lib.html.sanitize(input,false)
           63  +
    61     64   	var styled: lib.str.acc styled:init(md.ct)
    62     65   
    63     66   	do var i = 0 while i < md.ct do
    64     67   		var wordstart = (i == 0 or isws(md.ptr[i-1]))
    65     68   		var wordend = (i == md.ct - 1 or isws(md.ptr[i+1]))
    66     69   
    67     70   		var here = md.ptr + i
................................................................................
   112    115   				goto skip
   113    116   			end
   114    117   		end
   115    118   
   116    119   		::fallback::styled:push(here,1) -- :/
   117    120   		i = i + 1
   118    121   	::skip::end end
          122  +	md:free()
   119    123   
   120    124   	-- we make two passes: the first detects and transforms inline elements,
   121    125   	-- the second carries out block-level organization
   122    126   
   123    127   	var html: lib.str.acc html:init(styled.sz)
   124    128   	var s = state {
   125    129   		segt = segt.none;

Modified srv.t from [8808dbd7a5] to [7234d58b59].

    11     11   	pol_reg: bool
    12     12   }
    13     13   local struct srv {
    14     14   	sources: lib.mem.ptr(lib.store.source)
    15     15   	webmgr: lib.net.mg_mgr
    16     16   	webcon: &lib.net.mg_connection
    17     17   	cfg: cfgcache
           18  +	id: rawstring
    18     19   }
    19     20   
    20     21   terra cfgcache:free() -- :/
    21     22   	self.secret:free()
    22     23   	self.instance:free()
    23     24   end
    24     25   
................................................................................
   539    540   		if self.sources(i).backend ~= nil and
   540    541   		   self.sources(i).backend.actor_auth_pw ~= nil then
   541    542   			var aid,uid,newhnd = self.sources(i):actor_auth_pw(ip,user,pw)
   542    543   			if aid ~= 0 then
   543    544   				if uid == 0 then
   544    545   					lib.dbg('new user just logged in, creating account entry')
   545    546   					var kbuf: uint8[lib.crypt.const.maxdersz]
   546         -					var newkp = lib.crypt.genkp()
   547         -					var privsz = lib.crypt.der(false,&newkp,&kbuf[0])
   548         -					var na = lib.store.actor {
   549         -						id = 0; nym = nil; handle = newhnd.ptr;
   550         -						origin = 0; bio = nil; avatar = nil;
   551         -						knownsince = lib.osclock.time(nil);
   552         -						rights = lib.store.rights_default();
   553         -						title = nil, key = [lib.mem.ptr(uint8)] {
   554         -							ptr = &kbuf[0], ct = privsz
   555         -						};
   556         -					}
          547  +					var na = lib.store.actor.mk(&kbuf[0])
   557    548   					var newuid: uint64
   558    549   					if self.sources(i).backend.actor_create ~= nil then
   559    550   						newuid = self.sources(i):actor_create(&na)
   560    551   					else newuid = self:actor_create(&na) end
   561    552   
   562    553   					if self.sources(i).backend.actor_auth_register_uid ~= nil then
   563    554   						self.sources(i):actor_auth_register_uid(aid,newuid)
................................................................................
   574    565   --9twh8y94i5c1qqr7hxu20fyd
   575    566   terra cfgcache.methods.load :: {&cfgcache} -> {}
   576    567   terra cfgcache:init(o: &srv)
   577    568   	self.overlord = o
   578    569   	self:load()
   579    570   end
   580    571   
   581         -srv.methods.start = terra(self: &srv, befile: rawstring)
          572  +terra srv:setup(befile: rawstring)
   582    573   	cfg(self, befile)
   583    574   	var success = false
   584    575   	if self.sources.ct == 0 then lib.bail('no data sources specified') end
   585    576   	for i=0,self.sources.ct do var src = self.sources.ptr + i
   586    577   		lib.report('opening data source ', src.id.ptr, '(', src.backend.id, ')')
   587    578   		src.handle = src.backend.open(src)
   588    579   		if src.handle ~= nil then success = true end
   589    580   	end
   590    581   	if not success then
   591    582   		lib.bail('could not connect to any data sources!')
   592    583   	end
          584  +end
   593    585   
          586  +terra srv:start(iname: rawstring)
          587  +	self:conprep(lib.store.prepmode.full)
   594    588   	self.cfg:init(self)
   595         -
   596    589   	var dbbind = self:conf_get('bind')
          590  +	if iname == nil then iname = lib.proc.getenv('parsav_instance') end
          591  +	if iname == nil then
          592  +		self.id = self.cfg.instance.ptr;
          593  +		-- let this leak -- it'll be needed for the lifetime of the process anyway
          594  +	else self.id = iname end 
          595  +
          596  +	if iname ~= nil then
          597  +		lib.report('parsav instance "',iname,'" starting')
          598  +	end
          599  +
   597    600   	var envbind = lib.proc.getenv('parsav_bind')
   598    601   	var bind: rawstring
   599    602   	if envbind ~= nil then
   600    603   		bind = envbind
   601    604   	elseif dbbind.ptr ~= nil then
   602    605   		bind = dbbind.ptr
   603         -	else bind = '[::]:10917' end
          606  +	else bind = '[::1]:10917' end
   604    607   
   605    608   	lib.report('binding to ', bind)
   606    609   	lib.net.mg_mgr_init(&self.webmgr)
   607    610   	self.webcon = lib.net.mg_http_listen(&self.webmgr, bind, handle.http, self)
   608    611   
   609    612   	if dbbind.ptr ~= nil then dbbind:free() end
   610    613   end
   611    614   
   612         -srv.methods.poll = terra(self: &srv)
          615  +terra srv:poll()
   613    616   	lib.net.mg_mgr_poll(&self.webmgr,1000)
   614    617   end
   615    618   
   616         -srv.methods.shutdown = terra(self: &srv)
          619  +terra srv:shutdown()
   617    620   	lib.net.mg_mgr_free(&self.webmgr)
   618    621   	for i=0,self.sources.ct do var src = self.sources.ptr + i
   619    622   		lib.report('closing data source ', src.id.ptr, '(', src.backend.id, ')')
   620    623   		src:close()
   621    624   	end
   622    625   	self.sources:free()
   623    626   end

Modified static/style.scss from [b0a8082b0c] to [1f479ddac7].

    11     11   	color: tone(25%);
    12     12   	font-size: 14pt;
    13     13   	margin: 0;
    14     14   	padding: 0;
    15     15   }
    16     16   a[href] {
    17     17   	color: tone(10%);
    18         -	text-decoration-color: adjust-color($color, $lightness: 10%, $alpha: -0.5);
           18  +	text-decoration-color: tone(10%,-0.5);
    19     19   	&:hover {
    20     20   		color: white;
    21     21   		text-shadow: 0 0 15px tone(20%);
    22         -		text-decoration-color: adjust-color($color, $lightness: 10%, $alpha: -0.1);
           22  +		text-decoration-color: tone(10%,-0.1);
    23     23   	}
    24     24   }
    25     25   a[href^="//"],
    26     26   a[href^="http://"],
    27     27   a[href^="https://"] { // external link
    28     28   	&:hover::after {
    29     29   		color: black;
................................................................................
    30     30   		background-color: white;
    31     31   	}
    32     32   	&::after {
    33     33   		content: "↗";
    34     34   		display: inline-block;
    35     35   		color: black;
    36     36   		margin-left: 4pt;
    37         -		background-color: adjust-color($color, $lightness: 10%);
           37  +		background-color: tone(10%);
    38     38   		padding: 0 4px;
    39     39   		text-shadow: none;
    40     40   		padding-right: 5px;
    41     41   		vertical-align: baseline;
    42     42   		font-size: 80%;
    43     43   	}
    44     44   }
................................................................................
    45     45   
    46     46   %content {
    47     47   	width: 8in;
    48     48   	margin: auto;
    49     49   }
    50     50   
    51     51   %glow {
    52         -	box-shadow: 0 0 20px adjust-color($color, $alpha: -0.8);
           52  +	box-shadow: 0 0 20px tone(0%,-0.8);
    53     53   }
    54     54   
    55     55   %button {
    56     56   	@extend %sans;
    57     57   	font-size: 14pt;
    58     58   	padding: 0.1in 0.2in;
    59     59   	border: 1px solid black;
    60         -	color: adjust-color($color, $lightness: 25%);
           60  +	color: tone(25%);
    61     61   	text-shadow: 1px 1px black;
    62     62   	text-decoration: none;
    63     63   	text-align: center;
           64  +	cursor: default;
    64     65   	background: linear-gradient(to bottom,
    65         -		adjust-color($color, $lightness: -45%),
    66         -		adjust-color($color, $lightness: -50%) 15%,
    67         -		adjust-color($color, $lightness: -50%) 75%,
    68         -		adjust-color($color, $lightness: -55%)
           66  +		tone(-47%),
           67  +		tone(-50%) 15%,
           68  +		tone(-50%) 75%,
           69  +		tone(-53%)
    69     70   	);
    70     71   	&:hover, &:focus {
    71     72   		@extend %glow;
    72     73   		outline: none;
    73         -		color: adjust-color($color, $lightness: -55%);
           74  +		color: tone(-55%);
    74     75   		text-shadow: none;
    75     76   		background: linear-gradient(to bottom,
    76         -			adjust-color($color, $lightness: -25%),
    77         -			adjust-color($color, $lightness: -30%) 15%,
    78         -			adjust-color($color, $lightness: -30%) 75%,
    79         -			adjust-color($color, $lightness: -35%)
           77  +			tone(-27%),
           78  +			tone(-30%) 15%,
           79  +			tone(-30%) 75%,
           80  +			tone(-35%)
    80     81   		);
    81     82   	}
    82     83   	&:active {
    83     84   		color: black;
    84     85   		padding-bottom: calc(0.1in - 2px);
    85     86   		padding-top: calc(0.1in + 2px);
    86     87   		background: linear-gradient(to top,
    87         -			adjust-color($color, $lightness: -25%),
    88         -			adjust-color($color, $lightness: -30%) 15%,
    89         -			adjust-color($color, $lightness: -30%) 75%,
    90         -			adjust-color($color, $lightness: -35%)
           88  +			tone(-25%),
           89  +			tone(-30%) 15%,
           90  +			tone(-30%) 75%,
           91  +			tone(-35%)
    91     92   		);
    92     93   	}
    93     94   }
    94     95   
    95     96   button { @extend %button;
    96     97   	&:first-of-type {
    97     98   		@extend %button;
    98     99   		color: white;
    99         -		box-shadow: inset 0 1px  adjust-color($color, $lightness: -25%),
   100         -		            inset 0 -1px adjust-color($color, $lightness: -50%);
          100  +		box-shadow: inset 0 1px  tone(-25%),
          101  +		            inset 0 -1px tone(-50%);
   101    102   		background: linear-gradient(to bottom,
   102         -			adjust-color($color, $lightness: -35%),
   103         -			adjust-color($color, $lightness: -40%) 15%,
   104         -			adjust-color($color, $lightness: -40%) 75%,
   105         -			adjust-color($color, $lightness: -45%)
          103  +			tone(-35%),
          104  +			tone(-40%) 15%,
          105  +			tone(-40%) 75%,
          106  +			tone(-45%)
   106    107   		);
   107    108   		&:hover, &:focus {
   108         -			box-shadow: inset 0 1px  adjust-color($color, $lightness: -15%),
   109         -						inset 0 -1px adjust-color($color, $lightness: -40%);
          109  +			box-shadow: inset 0 1px  tone(-15%),
          110  +						inset 0 -1px tone(-40%);
   110    111   		}
   111    112   		&:active {
   112         -			box-shadow: inset 0 1px  adjust-color($color, $lightness: -50%),
   113         -						inset 0 -1px adjust-color($color, $lightness: -25%);
          113  +			box-shadow: inset 0 1px  tone(-50%),
          114  +						inset 0 -1px tone(-25%);
   114    115   			background: linear-gradient(to top,
   115         -				adjust-color($color, $lightness: -30%),
   116         -				adjust-color($color, $lightness: -35%) 15%,
   117         -				adjust-color($color, $lightness: -35%) 75%,
   118         -				adjust-color($color, $lightness: -40%)
          116  +				tone(-30%),
          117  +				tone(-35%) 15%,
          118  +				tone(-35%) 75%,
          119  +				tone(-40%)
   119    120   			);
   120    121   		}
   121    122   	}
   122    123   	&:hover { font-weight: bold; }
   123    124   }
   124    125   
   125    126   $grad-ui-focus: linear-gradient(to bottom,
   126         -	adjust-color($color, $lightness: -50%),
   127         -	adjust-color($color, $lightness: -35%)
          127  +	tone(-50%),
          128  +	tone(-35%)
   128    129   );
   129    130   
   130    131   input[type='text'], input[type='password'], textarea {
   131    132   	@extend %serif;
   132    133   	padding: 0.08in 0.1in;
   133    134   	border: 1px solid black;
   134         -	background: linear-gradient(to bottom,
   135         -		adjust-color($color, $lightness: -55%),
   136         -		adjust-color($color, $lightness: -40%)
   137         -	);
          135  +	background: linear-gradient(to bottom, tone(-55%), tone(-40%));
   138    136   	font-size: 16pt;
   139         -	color: adjust-color($color, $lightness: 25%);
   140         -	box-shadow: inset 0 0 20px -3px adjust-color($color, $lightness: -55%);
          137  +	color: tone(25%);
          138  +	box-shadow: inset 0 0 20px -3px tone(-55%);
   141    139   	&:focus {
   142    140   		color: white;
   143         -		border-image: linear-gradient(to bottom,
   144         -			adjust-color($color, $lightness: -10%),
   145         -			adjust-color($color, $lightness: -30%)
   146         -		) 1 / 1px;
          141  +		border-image: linear-gradient(to bottom, tone(-10%), tone(-30%)) 1 / 1px;
   147    142   		background: $grad-ui-focus;
   148    143   		outline: none;
   149    144   		@extend %glow;
   150    145   	}
   151    146   }
   152    147   
   153    148   @mixin glass {
   154    149   	@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
   155    150   		backdrop-filter: blur(40px);
   156    151   		-webkit-backdrop-filter: blur(40px);
   157         -		background-color: adjust-color($color, $lightness: -53%, $alpha: -0.7);
          152  +		background-color: tone(-53%, -0.7);
   158    153   	}
   159    154   	@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
   160         -		background-color: adjust-color($color, $lightness: -53%, $alpha: -0.1);
          155  +		background-color: tone(-53%, -0.1);
   161    156   	}
   162    157   }
   163    158   
   164    159   h1 { margin-top: 0 }
   165    160   
   166    161   header {
   167    162   	position: fixed;
................................................................................
   215    210   	position: relative;
   216    211   	min-height: calc(100vh - 1.1in);
   217    212   	margin-top: 0;
   218    213   	margin-bottom: 0;
   219    214   	padding: 0 0.4in;
   220    215   	padding-top: 1.1in;
   221    216   	padding-bottom: 0.1in;
   222         -	background-color: adjust-color($color, $lightness: -45%, $alpha: 0.4);
          217  +	background-color: tone(-45%,-0.3);
   223    218   	border: {
   224    219   		left: 1px solid black;
   225    220   		right: 1px solid black;
   226    221   	}
   227    222   }
   228    223   
   229    224   div.profile {
................................................................................
   246    241   			grid-column: 1 / 2;
   247    242   			grid-row: 1 / 3;
   248    243   			border: 1px solid black;
   249    244   		}
   250    245   		> .id {
   251    246   			grid-column: 2 / 3;
   252    247   			grid-row: 1 / 2;
   253         -			color: adjust-color($color, $lightness: 25%, $alpha: -0.4);
          248  +			color: tone(25%,-0.4);
   254    249   			> .nym {
   255    250   				font-weight: bold;
   256         -				color: adjust-color($color, $lightness: 25%);
          251  +				color: tone(25%);
   257    252   			}
   258    253   			> .xid {
   259         -				color: adjust-color($color, $lightness: 20%, $alpha: -0.1);
          254  +				color: tone(20%,-0.1);
   260    255   				font-size: 80%;
   261    256   				vertical-align: text-top;
   262    257   			}
   263    258   		}
   264    259   		> .bio {
   265    260   			grid-column: 2 / 3;
   266    261   			grid-row: 2 / 3;
................................................................................
   287    282   			display: block;
   288    283   			height: 0.3in;
   289    284   			width: 1px;
   290    285   			border-left: 1px solid rgba(0,0,0,0.6);
   291    286   		}
   292    287   	}
   293    288   }
          289  +
          290  +.epithet {
          291  +	display: inline-block;
          292  +	background: tone(20%);
          293  +	color: tone(-45%);
          294  +	text-shadow: 0 0 3px tone(-30%, -0.4);
          295  +	border-radius: 3px;
          296  +	padding: 6px;
          297  +	padding-top: 2px;
          298  +	padding-bottom: 4px;
          299  +	font-size: 80%;
          300  +	vertical-align: top;
          301  +	font-weight: 300;
          302  +	letter-spacing: 0.5px;
          303  +	margin: 0 5pt;
          304  +	// transform: scale(80%) translateX(-10pt); // cheating!
          305  +}
   294    306   
   295    307   %box {
   296    308   	margin: auto;
   297         -	border: 1px solid adjust-color($color, $lightness: -55%);
          309  +	border: 1px solid tone(-55%);
   298    310   	border-bottom: 3px solid black;
   299    311   	box-shadow: 0 0 1px black;
   300    312   	border-image: linear-gradient(to bottom,
   301         -		adjust-color($color, $lightness: -40%),
   302         -		adjust-color($color, $lightness: -52%) 10%,
   303         -		adjust-color($color, $lightness: -55%) 90%,
   304         -		adjust-color($color, $lightness: -60%)
          313  +		tone(-40%),
          314  +		tone(-52%) 10%,
          315  +		tone(-55%) 90%,
          316  +		tone(-60%)
   305    317   	) 1 / 1px;
   306    318   	background: linear-gradient(to bottom,
   307         -		adjust-color($color, $lightness: -58%),
   308         -		adjust-color($color, $lightness: -55%) 10%,
   309         -		adjust-color($color, $lightness: -50%) 80%,
   310         -		adjust-color($color, $lightness: -45%)
          319  +		tone(-58%),
          320  +		tone(-55%) 10%,
          321  +		tone(-50%) 80%,
          322  +		tone(-45%)
   311    323   	);
   312    324   	// outline: 1px solid black;
   313    325   }
   314    326   
   315    327   body.error .message {
   316    328   	@extend %box;
   317    329   	width: 4in;
................................................................................
   327    339   	> .msg {
   328    340   		text-align: center;
   329    341   		padding: 0.3in;
   330    342   	}
   331    343   	> .msg:first-child { padding-top: 0; }
   332    344   	> .user {
   333    345   		width: min-content; margin: auto;
   334         -		background: adjust-color($color, $lightness: -20%, $alpha: -0.3);
          346  +		background: tone(-20%,-0.3);
   335    347   		border: 1px solid black;
   336         -		color: adjust-color($color, $lightness: -50%);
          348  +		color: tone(-50%);
   337    349   		padding: 0.1in;
   338    350   		> img { width: 1in; height: 1in; border: 1px solid black; }
   339    351   		> .name { @extend %serif; text-align: center; font-size: 130%; font-weight: bold; margin-top: 0.08in; }
   340    352   	}
   341    353   	>form {
   342    354   		display: grid;
   343    355   		grid-template-columns: 1fr 1fr;
................................................................................
   355    367   	@extend %box;
   356    368   	display: grid;
   357    369   	grid-template-columns: 1.1in 2fr min-content 1fr;
   358    370   	grid-template-rows: 1fr min-content;
   359    371   	grid-gap: 2px;
   360    372   	padding: 0.1in;
   361    373   	> img { grid-column: 1/2; grid-row: 1/3; width: 1in; height: 1in;}
   362         -	> textarea { grid-column: 2/5; grid-row: 1/2; height: 3in;}
          374  +	> textarea {
          375  +		grid-column: 2/5; grid-row: 1/2; height: 3in;
          376  +		resize: vertical;
          377  +		margin-bottom: 0.08in;
          378  +	}
   363    379   	> input[name="acl"] { grid-column: 2/3; grid-row: 2/3; }
   364    380   	> button { grid-column: 4/5; grid-row: 2/3; }
   365    381   	a.help[href] { margin-right: 0.05in }
   366    382   }
   367    383   
   368    384   a.help[href] {
   369    385   	display: block;
................................................................................
   457    473   		background: linear-gradient(to right, tone(-55%), transparent);
   458    474   	}
   459    475   	>.content {
   460    476   		grid-column: 2/4; grid-row: 1/2;
   461    477   		padding: 0.2in;
   462    478   		@extend %serif;
   463    479   		font-size: 110%;
          480  +		text-align: justify;
   464    481   	}
   465    482   	> a[href].permalink {
   466    483   		display: block;
   467    484   		grid-column: 3/4; grid-row: 2/3;
   468    485   		font-size: 80%;
   469    486   		text-align: right;
   470    487   		padding: 0.1in;
................................................................................
   476    493   
   477    494   a[href].rawlink {
   478    495   	@extend %teletype;
   479    496   }
   480    497   
   481    498   body.doc main {
   482    499   	@extend %serif;
          500  +	text-align: justify;
   483    501   	li { margin-top: 0.05in; }
   484    502   	li:first-child { margin-top: 0; }
   485    503   	h1, h2, h3, h4, h5, h6 {
   486    504   		background: linear-gradient(to right, tone(-50%), transparent);
   487    505   		margin-left: -0.4in;
   488    506   		padding-left: 0.2in;
   489    507   		text-shadow: 0 2px 0 black;
   490    508   	}
   491    509   }
          510  +
          511  +body.conf main {
          512  +	display: grid;
          513  +	grid-template-columns: 2in 1fr;
          514  +	grid-template-rows: max-content 1fr;
          515  +	> .menu {
          516  +		margin-left: -0.25in;
          517  +		grid-column: 1/2; grid-row: 1/2;
          518  +		background: linear-gradient(to bottom, tone(-45%),tone(-55%));
          519  +		border: 1px solid black;
          520  +		padding: 0.1in;
          521  +		> a[href] {
          522  +			@extend %button;
          523  +			display: block;
          524  +			text-align: left;
          525  +		}
          526  +		> a[href] + a[href] {
          527  +			border-top: none;
          528  +		}
          529  +		hr {
          530  +			border: none;
          531  +		}
          532  +	}
          533  +	> .panel {
          534  +		grid-column: 2/3; grid-row: 1/3;
          535  +		padding-left: 0.15in;
          536  +		> h1 {
          537  +			padding-bottom: 0.1in;
          538  +			margin-bottom: 0.1in;
          539  +			margin-left: -0.15in;
          540  +			padding-left: 0.15in;
          541  +			padding-top: 0.12in;
          542  +			background: linear-gradient(to right, tone(-50%), tone(-50%,-0.7));
          543  +			border: 1px solid tone(-55%);
          544  +			border-left: none;
          545  +			text-shadow: 1px 1px 0 black;
          546  +		}
          547  +	}
          548  +}

Modified store.t from [4959208545] to [71684bc451].

     4      4   	scope = lib.enum {
     5      5   		'public', 'private', 'local';
     6      6   		'personal', 'direct', 'circle';
     7      7   	};
     8      8   	notiftype = lib.enum {
     9      9   		'mention', 'like', 'rt', 'react'
    10     10   	};
           11  +
    11     12   	relation = lib.enum {
    12     13   		'follow', 'mute', 'block'
    13     14   	};
    14     15   	credset = lib.set {
    15     16   		'pw', 'otp', 'challenge', 'trust'
    16     17   	};
    17     18   	privset = lib.set {
................................................................................
    20     21   	powerset = lib.set {
    21     22   		-- user powers -- default on
    22     23   		'login', 'visible', 'post', 'shout',
    23     24   		'propagate', 'upload', 'acct', 'edit';
    24     25   
    25     26   		-- admin powers -- default off
    26     27   		'purge', 'config', 'censor', 'suspend',
    27         -		'cred', 'elevate', 'demote', 'rebrand' -- modify site's brand identity
           28  +		'cred', 'elevate', 'demote', 'rebrand', -- modify site's brand identity
           29  +		'herald' -- grant serverwide epithets
           30  +	};
           31  +	prepmode = lib.enum {
           32  +		'full','conf','admin'
    28     33   	}
    29     34   }
    30     35   
    31     36   terra m.powerset:affect_users()
    32     37   	return self.purge() or self.censor() or self.suspend() or
    33         -	       self.elevate() or self.demote() or self.rebrand() or
    34         -		   self.cred()
           38  +	       self.elevate() or self.demote() or self.cred()
    35     39   end
    36     40   
    37     41   local str = rawstring
    38     42   local pstr = lib.mem.ptr(int8)
    39     43   
    40     44   struct m.source
    41     45   
................................................................................
    62     66   
    63     67   struct m.actor {
    64     68   	id: uint64
    65     69   	nym: str
    66     70   	handle: str
    67     71   	origin: uint64
    68     72   	bio: str
    69         -	title: str
           73  +	epithet: str
    70     74   	avatar: str
    71     75   	knownsince: m.timepoint
    72     76   	rights: m.rights
    73     77   	key: lib.mem.ptr(uint8)
    74     78   
    75     79   -- ephemera
    76     80   	xid: str
    77     81   	source: &m.source
    78     82   }
           83  +
           84  +terra m.actor.methods.mk(kbuf: &uint8)
           85  +	var newkp = lib.crypt.genkp()
           86  +	var privsz = lib.crypt.der(false,&newkp,kbuf)
           87  +	return m.actor {
           88  +		id = 0; nym = nil; handle = nil;
           89  +		origin = 0; bio = nil; avatar = nil;
           90  +		knownsince = lib.osclock.time(nil);
           91  +		rights = m.rights_default();
           92  +		epithet = nil, key = [lib.mem.ptr(uint8)] {
           93  +			ptr = &kbuf[0], ct = privsz
           94  +		};
           95  +	}
           96  +end
    79     97   
    80     98   struct m.actor_stats {
    81     99   	posts: intptr
    82    100   	follows: intptr
    83    101   	followers: intptr
    84    102   	mutuals: intptr
    85    103   }
................................................................................
   178    196   	blacklist: bool
   179    197   }
   180    198   
   181    199   -- backends only handle content on the local server
   182    200   struct m.backend { id: rawstring
   183    201   	open: &m.source -> &opaque
   184    202   	close: &m.source -> {}
          203  +	dbsetup: &m.source -> bool -- creates the schema needed to call conprep (called only once per database e.g. with `parsav db init`)
          204  +	conprep: {&m.source, m.prepmode.t} -> {} -- prepares queries and similar tasks that require the schema to already be in place
          205  +	obliterate_everything: &m.source -> bool -- wipes everything parsav-related out of the database
   185    206   
   186    207   	conf_get: {&m.source, rawstring} -> lib.mem.ptr(int8)
   187    208   	conf_set: {&m.source, rawstring, rawstring} -> {}
   188    209   	conf_reset: {&m.source, rawstring} -> {}
   189    210   
   190    211   	actor_save: {&m.source, &m.actor} -> bool
   191    212   	actor_create: {&m.source, &m.actor} -> uint64
................................................................................
   231    252   		-- notifies the backend module of the UID that has been assigned for
   232    253   		-- an authentication ID
   233    254   			-- aid: uint64
   234    255   			-- uid: uint64
   235    256   
   236    257   	actor_conf_str: cnf(rawstring, lib.mem.ptr(int8))
   237    258   	actor_conf_int: cnf(intptr, lib.stat(intptr))
          259  +
          260  +	auth_create_pw: {&m.source, uint64, bool, lib.mem.ptr(int8)} -> {}
          261  +		-- uid: uint64
          262  +		-- reset: bool (delete other passwords?)
          263  +		-- pw: pstring
   238    264   
   239    265   	post_save: {&m.source, &m.post} -> {}
   240    266   	post_create: {&m.source, &m.post} -> uint64
   241    267   	post_enum_author_uid: {&m.source, uint64, m.range} -> lib.mem.ptr(lib.mem.ptr(m.post))
   242    268   	convo_fetch_xid: {&m.source,rawstring} -> lib.mem.ptr(m.post)
   243    269   	convo_fetch_uid: {&m.source,uint64} -> lib.mem.ptr(m.post)
   244    270   

Modified str.t from [5af1afba76] to [f2457b558f].

     6      6   
     7      7   local m = {
     8      8   	sz = terralib.externfunction('strlen', rawstring -> intptr);
     9      9   	cmp = terralib.externfunction('strcmp', {rawstring, rawstring} -> int);
    10     10   	ncmp = terralib.externfunction('strncmp', {rawstring, rawstring, intptr} -> int);
    11     11   	cpy = terralib.externfunction('stpcpy',{rawstring, rawstring} -> rawstring);
    12     12   	ncpy = terralib.externfunction('stpncpy',{rawstring, rawstring, intptr} -> rawstring);
           13  +	cat = terralib.externfunction('strcat',{rawstring, rawstring} -> rawstring);
           14  +	ncat = terralib.externfunction('strncat',{rawstring, rawstring, intptr} -> rawstring);
    13     15   	dup = terralib.externfunction('strdup',rawstring -> rawstring);
    14     16   	ndup = terralib.externfunction('strndup',{rawstring, intptr} -> rawstring);
    15     17   	fmt = terralib.externfunction('asprintf',
    16     18   		terralib.types.funcpointer({&rawstring,rawstring},{int},true));
    17     19   	bfmt = terralib.externfunction('sprintf',
    18     20   		terralib.types.funcpointer({rawstring,rawstring},{int},true));
    19     21   	span = terralib.externfunction('strspn',{rawstring, rawstring} -> rawstring);

Modified tpl.t from [ba911a8ebc] to [682e534236].

     1      1   -- vim: ft=terra
     2      2   -- string template generator:
     3      3   -- returns a function that fills out a template
     4      4   -- with the strings given
     5      5   
     6      6   local util = lib.util
            7  +local pstr = lib.mem.ptr(int8)
     7      8   local m = {}
     8      9   function m.mk(tplspec)
     9     10   	local str
    10     11   	if type(tplspec) == 'string'
    11     12   		then str = tplspec tplspec = {}
    12     13   		else str = tplspec.body
    13     14   	end
................................................................................
    33     34   	str = str:gsub('%s+[\n$]','')
    34     35   	str = str:gsub('\n','')
    35     36   	str = str:gsub('</a><a ','</a> <a ') -- keep nav links from getting smooshed
    36     37   	str = str:gsub(tplchar .. '%?([-%w]+)', function(file)
    37     38   		if not docs[file] then docs[file] = data.doc[file] end
    38     39   		return string.format('<a href="#help-%s" class="help">?</a>', file)
    39     40   	end)
    40         -	for start, key, stop in string.gmatch(str,'()'..tplchar..'(%w+)()') do
           41  +	for start, mode, key, stop in string.gmatch(str,'()'..tplchar..'([:!]?)(%w+)()') do
    41     42   		if string.sub(str,start-1,start-1) ~= '\\' then
    42     43   			segs[#segs+1] = string.sub(str,last,start-1)
    43         -			fields[#segs] = key
           44  +			fields[#segs] = { key = key, mode = (mode ~= '' and mode or nil) }
    44     45   			last = stop
    45     46   		end
    46     47   	end
    47     48   	segs[#segs+1] = string.sub(str,last)
    48     49   
    49     50   	for i, s in ipairs(segs) do
    50     51   		segs[i] = string.gsub(s, '\\'..tplchar, tplchar_o)
................................................................................
    63     64   	local runningtally = symbol(intptr)
    64     65   	local tallyup = {quote
    65     66   		var [runningtally] = 1 + constlen
    66     67   	end}
    67     68   	local rec = terralib.types.newstruct(string.format('template<%s>',tplspec.id or ''))
    68     69   	local symself = symbol(&rec)
    69     70   	do local kfac = {}
    70         -		for afterseg,key in pairs(fields) do
    71         -			if not kfac[key] then
           71  +		local sanmode = {}
           72  +		for afterseg,fld in ipairs(fields) do
           73  +			if not kfac[fld.key] then
    72     74   				rec.entries[#rec.entries + 1] = {
    73         -					field = key;
           75  +					field = fld.key;
    74     76   					type = lib.mem.ptr(int8);
    75     77   				}
    76     78   			end
    77         -			kfac[key] = (kfac[key] or 0) + 1
           79  +			kfac[fld.key] = (kfac[fld.key] or 0) + 1
           80  +			sanmode[fld.key] = fld.mode == ':' and 6 or fld.mode == '!' and 5 or 1
    78     81   		end
    79     82   		for key, fac in pairs(kfac) do
           83  +			local sanfac = sanmode[key]
           84  +			
    80     85   			tallyup[#tallyup + 1] = quote
    81         -				[runningtally] = [runningtally] + ([symself].[key].ct)*fac
           86  +				[runningtally] = [runningtally] + ([symself].[key].ct)*fac*sanfac
    82     87   			end
    83     88   		end
    84     89   	end
    85     90   
    86     91   	local copiers = {}
    87     92   	local senders = {}
    88     93   	local appenders = {}
................................................................................
    90     95   	local cpypos = symbol(&opaque)
    91     96   	local accumulator = symbol(&lib.str.acc)
    92     97   	local destcon = symbol(&lib.net.mg_connection)
    93     98   	for idx, seg in ipairs(segs) do
    94     99   		copiers[#copiers+1] = quote [cpypos] = lib.mem.cpy([cpypos], [&opaque]([seg]), [#seg]) end
    95    100   		senders[#senders+1] = quote lib.net.mg_send([destcon], [seg], [#seg]) end
    96    101   		appenders[#appenders+1] = quote [accumulator]:push([seg], [#seg]) end
    97         -		if fields[idx] then
    98         -			--local fsz = `lib.str.sz(symself.[fields[idx]])
    99         -			local fval = `symself.[fields[idx]].ptr
   100         -			local fsz = `symself.[fields[idx]].ct
   101         -			copiers[#copiers+1] = quote
   102         -				[cpypos] = lib.mem.cpy([cpypos], [&opaque]([fval]), [fsz])
          102  +		if fields[idx] and fields[idx].mode then
          103  +			local f = fields[idx]
          104  +			local fp = `symself.[f.key]
          105  +			copiers[#copiers+1] = quote 
          106  +				if fp.ct > 0 then
          107  +					var san = lib.html.sanitize(fp, [f.mode == ':'])
          108  +					[cpypos] = lib.mem.cpy([cpypos], [&opaque](san.ptr), san.ct)
          109  +					--san:free()
          110  +				end
          111  +			end
          112  +			senders[#senders+1] = quote
          113  +				if fp.ct > 0 then
          114  +					var san = lib.html.sanitize(fp, [f.mode == ':'])
          115  +					lib.net.mg_send([destcon], san.ptr, san.ct)
          116  +					--san:free()
          117  +				end
          118  +			end
          119  +			appenders[#appenders+1] = quote
          120  +				if fp.ct > 0 then
          121  +					var san = lib.html.sanitize(fp, [f.mode == ':'])
          122  +					[accumulator]:ppush(san)
          123  +					--san:free()
          124  +				end
          125  +			end
          126  +		elseif fields[idx] then
          127  +			local f = fields[idx]
          128  +			local fp = `symself.[f.key]
          129  +			copiers[#copiers+1] = quote 
          130  +				if fp.ct > 0 then
          131  +					[cpypos] = lib.mem.cpy([cpypos], [&opaque](fp.ptr), fp.ct)
          132  +				end
   103    133   			end
   104    134   			senders[#senders+1] = quote
   105         -				lib.net.mg_send([destcon], [fval], [fsz])
          135  +				if fp.ct > 0 then
          136  +					lib.net.mg_send([destcon], fp.ptr, fp.ct)
          137  +				end
   106    138   			end
   107    139   			appenders[#appenders+1] = quote
   108         -				[accumulator]:push([fval], [fsz])
          140  +				if fp.ct > 0 then [accumulator]:ppush(fp) end
   109    141   			end
   110    142   		end
   111    143   	end
   112    144   
   113    145   	local tid = tplspec.id or '<anonymous>'
   114    146   	rec.methods.tostr = terra([symself])
   115    147   		lib.dbg(['compiling template ' .. tid])
   116    148   		[tallyup]
   117    149   		var [symtxt] = lib.mem.heapa(int8, [runningtally])
   118    150   		var [cpypos] = [&opaque](symtxt.ptr)
   119    151   		[copiers]
   120    152   		@[&int8](cpypos) = 0
          153  +		symtxt.ct = [&int8](cpypos) - symtxt.ptr
   121    154   		return symtxt
   122    155   	end
   123    156   	rec.methods.append = terra([symself], [accumulator])
   124    157   		lib.dbg(['appending template ' .. tid])
   125    158   		[tallyup]
   126    159   		accumulator:cue([runningtally])
   127    160   		[appenders]

Modified view/compose.tpl from [bb642e2999] to [5ccb8d92d6].

     1      1   <form class="compose" method="post">
     2      2   	<img src="/avi/@handle">
     3         -	<textarea autofocus name="post" placeholder="it was a dark and stormy night…">@content</textarea>
     4         -	<input required type="text" name="acl" class="acl" value="@acl"> @?acl
            3  +	<textarea autofocus name="post" placeholder="it was a dark and stormy night…">@!content</textarea>
            4  +	<input required autocomplete="on" type="text" name="acl" class="acl" value="@acl" list="scopes" placeholder="access control"> @?acl
     5      5   	<button type="submit">commit</button>
     6      6   </form>
            7  +
            8  +<datalist id="scopes">
            9  +	<option>all</option>
           10  +	<option>mentioned</option>
           11  +	<option>local</option>
           12  +	<option>mutual</option>
           13  +	<option>followers</option>
           14  +	<option>followed</option>
           15  +	<option>groupies</option>
           16  +	<option>staff</option>
           17  +	<option>admin</option>
           18  +	@circles
           19  +</datalist>

Added view/conf-profile.tpl version [746111dd26].

            1  +<form method="post">
            2  +	<label>handle <div class="txtbox">@!handle</div></label>
            3  +	<label>display name <input type="text" name="nym" value="@:nym"></label>
            4  +	<label>bio <textarea name="bio">@!bio</textarea></label>
            5  +	<input type="submit" value="commit">
            6  +</form>

Added view/conf-sec.tpl version [7ba95a81c5].

            1  +<form method="post">
            2  +	<p>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!</p>
            3  +	<label>
            4  +		sessions valid from
            5  +		<div class="txtbox">@lastreset</div>
            6  +	</label>
            7  +	<button type="submit" name="act" value="invalidate">
            8  +		invalidate other sessions
            9  +	</button>
           10  +</form>

Added view/conf.tpl version [bd130ad9d3].

            1  +<div class="menu">
            2  +	<a href="/conf/profile">profile</a>
            3  +	<a href="/conf/avi">avatar</a>
            4  +	<a href="/conf/sec">security</a>
            5  +	<a href="/conf/rel">relationships</a>
            6  +	<a href="/conf/qnt">quarantine</a>
            7  +	<a href="/conf/acl">ACL shortcuts</a>
            8  +	<a href="/conf/rooms">chatrooms</a>
            9  +	<a href="/conf/circles">circles</a>
           10  +	@menu
           11  +</div>
           12  +
           13  +<div class="panel">
           14  +	@panel
           15  +</div>

Modified view/load.lua from [1503c625ad] to [f63ef60595].

     4      4   -- create templates from when we return to terra
     5      5   local path = ...
     6      6   local sources = {
     7      7   	'docskel';
     8      8   	'tweet';
     9      9   	'profile';
    10     10   	'compose';
           11  +
    11     12   	'login-username';
    12     13   	'login-challenge';
           14  +
           15  +	'conf';
           16  +	'conf-profile';
    13     17   }
    14     18   
    15     19   local ingest = function(filename)
    16     20   	local hnd = io.open(path..'/'..filename)
    17     21   	local txt = hnd:read('*a')
    18     22   	io.close(hnd)
    19     23   	txt = txt:gsub('([^\\])!%b[]', '%1')

Modified view/login-challenge.tpl from [c8511de2b7] to [84fccbb367].

     1      1   <div class="login">
     2      2   	<div class="user">
     3      3   		<img src="/avi/@handle">
     4         -		<div class="name">@name</div>
            4  +		<div class="name">@!name</div>
     5      5   	</div>
     6      6   	<div class="msg">@challenge</div>
     7      7   	<form action="/login" method="post">
     8      8   		<label for="response">@label</label>
     9         -		<input type="hidden" name="user" value="@handle">
    10         -		<input type="password" name="response" id="response" autofocus required>
            9  +		<input type="hidden" name="user" value="@:handle">
           10  +		<input type="password" autocomplete="@auto" name="response" id="response" autofocus required>
    11     11   		<button type="submit" name="authmethod" value="@method">authenticate</button>
    12     12   		<a href="/login">cancel</a>
    13     13   	</form>
    14     14   </div>

Modified view/login-username.tpl from [4dc628d5ef] to [8c165f8ae9].

     1      1   <div class="login">
     2      2   	<div class="msg">@loginmsg</div>
     3      3   	<form action="/login" method="post">
     4      4   		<label for="user">local handle</label>
     5         -		<input type="text" name="user" id="user" autofocus required>
            5  +		<input type="text" name="user" id="user" autocomplete="username" autofocus required>
     6      6   		<button type="submit">log on</button>
     7      7   	</form>
     8      8   </div>

Modified view/profile.tpl from [6c61b2a3c1] to [d21ccabbe8].

     1      1   <div class="profile">
     2      2   	<div class="banner">
     3         -		<img class="avatar" src="@avatar">
     4         -		<div class="id"><span class="nym">@nym</span> [<span class="xid">@xid</span>]</div>
            3  +		<img class="avatar" src="@:avatar">
            4  +		<div class="id">@nym</div>
     5      5   		<div class="bio">
     6      6   			@bio
     7      7   		</div>
     8      8   	</div>
     9      9   	<table class="stats">
    10     10   		<tr><th>posts</th> <td>@nposts</td></tr>
    11     11   		<tr><th>following</th> <td>@nfollows</td></tr>
    12     12   		<tr><th>followers</th> <td>@nfollowers</td></tr>
    13     13   		<tr><th>mutuals</th> <td>@nmutuals</td></tr>
    14     14   		<tr><th>@timephrase</th> <td>@tweetday</td></tr>
    15     15   	</table>
    16     16   	<div class="menu">
    17         -		<a href="/@xid">posts</a>
    18         -		<a href="/@xid/media">media</a>
    19         -		<a href="/@xid/social">associates</a>
           17  +		<a href="/@:xid">posts</a>
           18  +		<a href="/@:xid/media">media</a>
           19  +		<a href="/@:xid/social">associates</a>
    20     20   		<hr>
    21     21   		@auxbtn
    22     22   	</div>
    23     23   </div>

Modified view/tweet.tpl from [43ed0b36e9] to [806c88c01c].

     1      1   <div class="post">
     2         -	<div class="avatar"><img src="@avatar"></div>
     3         -	<a class="username" href="/@acctlink">
     4         -		<span class="nym">@nym</span> [<span class="handle">@xid</span>]
     5         -	</a>
            2  +	<div class="avatar"><img src="@:avatar"></div>
            3  +	<a class="username" href="/@:acctlink">@nym</a>
     6      4   	<div class="content">
     7         -		<div class="subject">@subject</div>
            5  +		<div class="subject">@!subject</div>
     8      6   		<div class="text">@text</div>
     9      7   	</div>
    10      8   	<a class="permalink" href="@permalink">@when</a>
    11      9   </div>