fix(files): ending every session ends every API token, and the share links hold their folder
- A password reset or change, "log out everywhere" and a revoked untrusted password revoke all of the account's API tokens too (audited): one minted from a stolen session no longer outlives the answer. - A bearer token or link grant with a non-ASCII character is refused (401/403), not a 500. - Making a token's grants or a folder link holds the folder, so a racing delete cannot fail the foreign key; a link's view count is one atomic UPDATE. - A folder link's card counts exactly the files its page lists (one `links.folder_files`). - A file with no extension is served without a trailing dot. - 0106's downgrade names a Hub folder so it never collides with one of the user's folders. - The speed baseline reads share tokens through the app (the database keeps them hashed/encrypted). - Tests: the token scope on the real session stack, the folder poster, the Hub refused for a link, 0106's downgrade data step, 0107 without a key; docs/comments follow (three folder kinds, folder links, key change, the cleanup job's token warnings).
This commit is contained in:
@@ -17,7 +17,7 @@ import { retryConnect } from "./retryConnect";
|
||||
|
||||
const RUNS = 5;
|
||||
const OUT = "e2e/.speed/latest.json";
|
||||
const CONTAINER = process.env.E2E_DB_CONTAINER || "siftlode-db-1";
|
||||
const CONTAINER = process.env.E2E_API_CONTAINER || "siftlode-api-1";
|
||||
|
||||
const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? NaN;
|
||||
|
||||
@@ -32,20 +32,33 @@ const scriptBytes = (page: Page) =>
|
||||
|
||||
/** Public share links on the local stack that could play: no password, not expired, asset ready.
|
||||
* The page itself is the judge of the last step (its file must be on THIS disk — a localdev DB
|
||||
* seeded from prod lists assets whose files never came along), so the caller probes them. */
|
||||
* seeded from prod lists assets whose files never came along), so the caller probes them. Read
|
||||
* through the API container: the database keeps a link's token only hashed and encrypted, and only
|
||||
* the app holds the key to read it back (`links.recover`). */
|
||||
const SHARE_TOKENS_PY = `
|
||||
from sqlalchemy import select
|
||||
from app.db import SessionLocal
|
||||
from app.downloads import links
|
||||
from app.models import DownloadJob, DownloadLink, MediaAsset
|
||||
with SessionLocal() as db:
|
||||
for link in db.execute(
|
||||
select(DownloadLink)
|
||||
.join(DownloadJob, DownloadJob.id == DownloadLink.job_id)
|
||||
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
|
||||
.where(DownloadLink.password_hash.is_(None), MediaAsset.status == "ready")
|
||||
.order_by(DownloadLink.id)
|
||||
).scalars():
|
||||
token = None if links.is_expired(link) else links.recover(link)
|
||||
if token:
|
||||
print(token)
|
||||
`;
|
||||
|
||||
function shareTokens(): string[] {
|
||||
const sql =
|
||||
"select l.token from download_links l join download_jobs j on j.id = l.job_id" +
|
||||
" join media_assets a on a.id = j.asset_id" +
|
||||
" where l.password_hash is null and (l.expires_at is null or l.expires_at > now())" +
|
||||
" and a.status = 'ready' order by l.id";
|
||||
// Not caught: a database this cannot reach (no docker, a renamed container) is a failed
|
||||
// Not caught: an API container this cannot reach (no docker, a renamed container) is a failed
|
||||
// measurement, not "no playable share link on the local stack".
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
["exec", CONTAINER, "psql", "-U", "siftlode", "-d", "siftlode", "-tAc", sql],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
const out = execFileSync("docker", ["exec", CONTAINER, "python", "-c", SHARE_TOKENS_PY], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return out
|
||||
.split("\n")
|
||||
.map((t) => t.trim())
|
||||
|
||||
Reference in New Issue
Block a user