From 3accb1c1079a2831542b1b827207b114d8a44a39 Mon Sep 17 00:00:00 2001 From: phil Date: Wed, 16 Jul 2025 15:49:47 -0400 Subject: [PATCH] secret password and admin --- .../src/components/Fetch.tsx | 12 +- .../src/components/SecretPassword.jsx | 3 + atproto-notifications/src/main.tsx | 4 +- atproto-notifications/src/pages/Admin.css | 18 +++ atproto-notifications/src/pages/Admin.tsx | 153 +++++++++++++++++- server/api.js | 102 ++++++++++-- server/db.js | 63 +++++++- server/schema.sql | 12 +- 8 files changed, 332 insertions(+), 35 deletions(-) create mode 100644 atproto-notifications/src/pages/Admin.css diff --git a/atproto-notifications/src/components/Fetch.tsx b/atproto-notifications/src/components/Fetch.tsx index c528a81..d71478d 100644 --- a/atproto-notifications/src/components/Fetch.tsx +++ b/atproto-notifications/src/components/Fetch.tsx @@ -77,9 +77,19 @@ async function postJson(url, body, credentials) { const res = await fetch(url, opts); if (!res.ok) { const m = await res.text(); + let reason + try { + reason = JSON.parse(m)?.reason; + } catch (err) {}; + if (reason) throw reason; throw new Error(`Failed to fetch: ${m}`); } - return await res.json(); + try { + return await res.json(); + } catch (e) { + if ([201, 204].includes(res.status)) return null; + throw e; + } } export function PostJson({ endpoint, data, credentials, ...forFetch }) { diff --git a/atproto-notifications/src/components/SecretPassword.jsx b/atproto-notifications/src/components/SecretPassword.jsx index a86b4e1..3e1463d 100644 --- a/atproto-notifications/src/components/SecretPassword.jsx +++ b/atproto-notifications/src/components/SecretPassword.jsx @@ -4,10 +4,12 @@ import { PostJson } from './Fetch'; export function SecretPassword({ did, role }) { const [begun, setBegun] = useState(false); const [pw, setPw] = useState(''); + const [submission, setSubmission] = useState(0); const [submitting, setSubmitting] = useState(false); const handleSubmit = useCallback(e => { e.preventDefault(); + setSubmission(n => n + 1); setSubmitting(true); }) @@ -18,6 +20,7 @@ export function SecretPassword({ did, role }) { {submitting ? ( + // @@ -16,7 +16,7 @@ createRoot(document.getElementById('root')!).render( - , + // , ); import TimeAgo from 'javascript-time-ago' diff --git a/atproto-notifications/src/pages/Admin.css b/atproto-notifications/src/pages/Admin.css new file mode 100644 index 0000000..dfacda4 --- /dev/null +++ b/atproto-notifications/src/pages/Admin.css @@ -0,0 +1,18 @@ +.admin-new-pw-p { + margin-bottom: 0.25rem; +} +.admin-error-message { + font-size: 0.8rem; + color: #f90; + margin-top: 0.25rem; +} + +.admin-secret { + border-left: 2px solid #555; + padding: 0.25rem 0.5rem; + margin: 0.75rem 0; + text-align: left; +} +.admin-secret-secret { + margin: 0 0 0.5rem; +} diff --git a/atproto-notifications/src/pages/Admin.tsx b/atproto-notifications/src/pages/Admin.tsx index 7c3aa2d..6c59c8e 100644 --- a/atproto-notifications/src/pages/Admin.tsx +++ b/atproto-notifications/src/pages/Admin.tsx @@ -1,11 +1,154 @@ -import { useContext } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { RoleContext } from '../context'; +import ReactTimeAgo from 'react-time-ago'; +import { GetJson, PostJson } from '../components/Fetch'; +import { Handle } from '../components/User'; + +import './Admin.css' + +// yeah this is horrible, i don't care +function OnMount({ callback }) { + useEffect(() => { + callback(); + }); +} + +function AddSecretForm({ onAdded }) { + const [active, setActive] = useState(false); + const [value, setValue] = useState(''); + const [submit, setSubmit] = useState(false); + + const handleFocus = () => { + setSubmit(false); + setActive(true); + }; + + const handleChange = e => { + setSubmit(false); + setValue(e.target.value); + }; + + const handleSubmit = e => { + e.preventDefault(); + setSubmit(true); + }; + + return ( +
+

+ + {active && ( + <>{' '} + )} +

+ {submit && ( + (

added. onAdded(value)}/>

)} + error={e => { + if (e === 'conflict') { + return

rejected (likely exists or constraint failed)

+ } + return

adding secret failed: {e.toString()}

; + }} + /> + )} + + ); +} export function Admin({}) { - const role = useContext(RoleContext); - if (role !== 'admin') { + const [listKey, setListKey] = useState(''); + if (useContext(RoleContext) !== 'admin') { return

sorry, this page is admin-only

} - return 'sup'; -} \ No newline at end of file + return ( + <> +

Top secret(s)

+ + secrets.map(s => )} + /> + + ); +} + +function Secret({ password, added, expired }) { + const [expiring, setExpiring] = useState(false); + const [reallyExpired, setReallyExpired] = useState(expired); + return ( +
+

+ "{password}" + {' '} + (added + {expired && ( + <>, expired + )}) + {' '} + {!reallyExpired && ( + expiring ? ( + <>…} + ok={() => setReallyExpired(true)} />} + /> + ) : ( + + ) + )} +

+ accounts.length > 0 ? ( +
    + {accounts.map(({ did, first_seen, role }) => ( +
  • + +
  • + ))} +
+ ) : ( +

no accounts

+ )} + /> +
+ ); +} + +function Account({ did, firstSeen, role }) { + return ( +

+ + {', '} + "{role}" + {', '} + +

+ ); +} diff --git a/server/api.js b/server/api.js index 7b97e81..80fa4a8 100644 --- a/server/api.js +++ b/server/api.js @@ -8,13 +8,16 @@ import { v4 as uuidv4 } from 'uuid'; const replyJson = (res, code) => res.setHeader('Content-Type', 'application/json').writeHead(code); const errJson = (code, reason) => res => replyJson(res, code).end(JSON.stringify({ reason })); +const ok = (res, data) => replyJson(res, 200).end(JSON.stringify(data)); +const gotIt = res => res.writeHead(201).end(); +const okBye = res => res.writeHead(204).end(); +const notModified = res => res.writeHead(304).end(); const badRequest = (res, reason) => errJson(400, reason)(res); const forbidden = errJson(401, 'forbidden'); const unauthorized = errJson(403, 'unauthorized'); const notFound = errJson(404, 'not found'); +const conflict = errJson(409, 'conflict'); const serverError = errJson(500, 'internal server error'); -const okBye = res => res.writeHead(204).end(); -const ok = (res, data) => replyJson(res, 200).end(JSON.stringify(data)); const getRequesBody = async req => new Promise((resolve, reject) => { let body = ''; @@ -66,6 +69,7 @@ const getUser = (req, res, db, appSecret, adminDid) => { return { did, session, role }; }; +/////// handlers // never EVER allow user-controllable input into fname (or just fix the path joining) const handleFile = (fname, ftype) => async (req, res, replace = {}) => { @@ -126,7 +130,7 @@ const handleSubscribe = async (db, user, req, res, updateSubs) => { return serverError(res); } updateSubs(db); - return okBye(res); + return gotIt(res); }; const handleLogout = async (db, user, req, res, appSecret, updateSubs) => { @@ -139,21 +143,67 @@ const handleLogout = async (db, user, req, res, appSecret, updateSubs) => { updateSubs(db); clearAccountCookie(res); return okBye(res); -} +}; const handleTopSecret = async (db, user, req, res) => { + console.log('ts'); + // TODO: succeed early if they're already in? const body = await getRequesBody(req); const { secret_password } = JSON.parse(body); - console.log({ secret_password }); + const { did } = user; const role = 'early'; - db.setRole(user.did, role, secret_password); - return okBye(res); -} + console.log('going with', {did, role, secret_password}); + const updated = db.setRole({ did, role, secret_password }); + console.log('updated?', updated); + if (updated) { + return okBye(res); + } else { + return forbidden(res); + } +}; + +const handleListSecrets = async (db, res) => { + const secrets = db.getSecrets(); + return ok(res, secrets); +}; + +const handleAddSecret = async (db, req, res) => { + const body = await getRequesBody(req); + const { secret_password } = JSON.parse(body); + try { + db.addTopSecret(secret_password); + } catch (e) { + if (['SQLITE_CONSTRAINT_PRIMARYKEY', 'SQLITE_CONSTRAINT_CHECK'].includes(e.code)) { + return conflict(res); + } + throw e; + } + return gotIt(res); +}; + +const handleExpireSecret = async (db, req, res) => { + const body = await getRequesBody(req); + const { secret_password } = JSON.parse(body); + if (db.expireTopSecret(secret_password)) { + return gotIt(res); + } else { + return notModified(res); + } +}; + +const handleTopSecretAccounts = async (db, req, res, searchParams) => { + const accounts = db.getSecretAccounts(searchParams.get('secret_password')); + return ok(res, accounts); +}; + + +/////// end handlers const attempt = listener => async (req, res) => { console.log(`-> ${req.method} ${req.url}`); try { - return await listener(req, res); + await listener(req, res); + console.log(` <-${req.method} ${req.url} (${res.statusCode})`); } catch (e) { console.error('listener errored:', e); return serverError(res); @@ -178,37 +228,55 @@ const withCors = (allowedOrigin, listener) => { export const server = (secrets, jwks, allowedOrigin, whoamiHost, db, updateSubs, adminDid) => { const handler = (req, res) => { + // don't love this but whatever + const { pathname, searchParams } = new URL(`http://localhost${req.url}`); + const { method } = req; + // public (we're doing fall-through auth, what could go wrong) - if (req.method === 'GET' && req.url === '/') { + if (method === 'GET' && pathname === '/') { return handleIndex(req, res, {}); } - if (req.method === 'POST' && req.url === '/verify') { + if (method === 'POST' && pathname === '/verify') { return handleVerify(db, req, res, secrets, jwks, adminDid); } // semi-public const user = getUser(req, res, db, secrets.appSecret, adminDid); - if (req.method === 'GET' && req.url === '/hello') { + if (method === 'GET' && pathname === '/hello') { return handleHello(user, req, res, secrets.pushKeys.publicKey, whoamiHost); } // login required - if (req.method === 'POST' && req.url === '/logout') { + if (method === 'POST' && pathname === '/logout') { if (!user) return unauthorized(res); return handleLogout(db, user, req, res, secrets.appSecret, updateSubs); } - if (req.method === 'POST' && req.url === '/super-top-secret-access') { + if (method === 'POST' && pathname === '/super-top-secret-access') { if (!user) return unauthorized(res); - return handleTopSecret(db, req, res, secrets.appSecret); + return handleTopSecret(db, user, req, res); } // non-public access required - if (req.method === 'POST' && req.url === '/subscribe') { + if (method === 'POST' && pathname === '/subscribe') { if (!user || user.role === 'public') return forbidden(res); return handleSubscribe(db, user, req, res, updateSubs); } - // admin required + // admin required (just 404 for non-admin) + if (user?.role === 'admin') { + if (method === 'GET' && pathname === '/top-secrets') { + return handleListSecrets(db, res); + } + if (method === 'POST' && pathname === '/top-secret') { + return handleAddSecret(db, req, res); + } + if (method === 'POST' && pathname === '/expire-top-secret') { + return handleExpireSecret(db, req, res); + } + if (method === 'GET' && pathname === '/top-secret-accounts') { + return handleTopSecretAccounts(db, req, res, searchParams); + } + } // sigh return notFound(res); diff --git a/server/db.js b/server/db.js index c16739a..ed3105b 100644 --- a/server/db.js +++ b/server/db.js @@ -15,6 +15,11 @@ export class DB { #stmt_delete_push_sub; #stmt_get_push_info; #stmt_set_role; + #stmt_admin_add_secret; + #stmt_admin_expire_secret; + #stmt_admin_get_secrets; + #stmt_admin_secret_accounts; + #transactionally; #db; @@ -91,9 +96,36 @@ export class DB { this.#stmt_set_role = db.prepare( `update accounts - set role = ?, - secret_password = ? - where did = ?`); + set role = :role, + secret_password = :secret_password + where did = :did + and :secret_password in (select password + from top_secret_passwords)`); + + this.#stmt_admin_add_secret = db.prepare( + `insert into top_secret_passwords (password) + values (?)`); + + this.#stmt_admin_expire_secret = db.prepare( + `update top_secret_passwords + set expired = CURRENT_TIMESTAMP + where expired is null + and password = ?`); + + this.#stmt_admin_get_secrets = db.prepare( + `select password, + unixepoch(added) * 1000 as 'added', + unixepoch(expired) * 1000 as 'expired' + from top_secret_passwords + order by expired, added desc`); + + this.#stmt_admin_secret_accounts = db.prepare( + `select did, + unixepoch(first_seen) * 1000 as 'first_seen', + role + from accounts + where secret_password = ? + order by first_seen desc`); this.#transactionally = t => db.transaction(t).immediate(); } @@ -135,10 +167,25 @@ export class DB { this.#stmt_delete_push_sub.run(session); } - setRole(did, role, secret_password) { - let res = this.#stmt_set_role.run(role, secret_password, did); - if (res.changes === 0) { - console.warn('set role: no changes'); - } + setRole(params) { + let res = this.#stmt_set_role.run(params); + return res.changes > 0; + } + + addTopSecret(secretPassword) { + this.#stmt_admin_add_secret.run(secretPassword); + } + + expireTopSecret(secretPassword) { + let res = this.#stmt_admin_expire_secret.run(secretPassword); + return res.changes > 0; + } + + getSecrets() { + return this.#stmt_admin_get_secrets.all(); + } + + getSecretAccounts(secretPassword) { + return this.#stmt_admin_secret_accounts.all(secretPassword); } } diff --git a/server/schema.sql b/server/schema.sql index fd9d59d..39fe624 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -1,4 +1,4 @@ -create table accounts ( +create table if not exists accounts ( did text primary key, first_seen text not null default CURRENT_TIMESTAMP, role text null, @@ -7,7 +7,7 @@ create table accounts ( check(did like 'did:%') ) strict; -create table push_subs ( +create table if not exists push_subs ( session text primary key, -- uuidv4, bound to signed browser cookie account_did text not null, subscription text not null, -- from browser, treat as opaque blob @@ -20,3 +20,11 @@ create table push_subs ( foreign key(account_did) references accounts(did) on delete cascade on update cascade ) strict; + +create table if not exists top_secret_passwords ( + password text primary key, + added text not null default CURRENT_TIMESTAMP, + expired text null, -- timestamp + + check(length(password) >= 3) +) strict; -- 2.51.2