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:
2026-10-15 04:58:16 +02:00
parent 86712b6b0e
commit 827df7494c
23 changed files with 399 additions and 96 deletions
+40
View File
@@ -185,3 +185,43 @@ def test_the_downgrade_writes_the_tokens_back(db, key, owner_job):
assert db.execute(select(DownloadLink.token_hash)).scalars().all() == [
security.hash_token(token)
]
def test_without_a_key_the_migration_keeps_the_address_opening_but_not_shown(db, owner_job, client):
"""No key to seal with: the hash alone is kept, the link still opens, its address is gone."""
assert not security.can_encrypt()
owner, job_id = owner_job
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
cfg = Config("alembic.ini")
command.downgrade(cfg, "0106_api_tokens")
try:
with engine.begin() as conn:
conn.execute(
text("INSERT INTO download_links (job_id, token) VALUES (:j, 'an-old-token-sent')"),
{"j": job_id},
)
finally:
command.upgrade(cfg, "head")
(row,) = db.execute(text("SELECT token_hash, token_enc FROM download_links")).mappings().all()
assert row["token_hash"] == security.hash_token("an-old-token-sent")
assert row["token_enc"] is None
assert client.get("/api/public/watch/an-old-token-sent").status_code == 200
assert [e["url"] for e in owner.get(f"/api/downloads/{job_id}/links").json()] == [None]
def test_a_downgrade_with_no_readable_copy_writes_a_fresh_token(db, key, owner_job, monkeypatch):
"""A key since changed reads no copy: the old column gets a random token, so the link is
revoked rather than left with no token (the column is NOT NULL there)."""
owner, job_id = owner_job
token = owner.post(f"/api/downloads/{job_id}/links", json={}).json()["token"]
db.commit() # an open transaction's locks would hold the downgrade's ALTER TABLE forever
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
cfg = Config("alembic.ini")
command.downgrade(cfg, "0106_api_tokens")
try:
with engine.connect() as conn:
back = conn.execute(text("SELECT token FROM download_links")).scalars().all()
finally:
command.upgrade(cfg, "head")
assert len(back) == 1 and back[0] and back[0] != token