DRY pass #2, batch 4, F13: the server's remaining pairs (#5372)

- common.detach: the hold-a-reference-until-done task start that mailer and
  unfurl_queue each wrote.
- common.expired_before: the retention window, now shared by trash and the
  audit log (it moves out of retention.py, which imports audit).
- responses.too_many: the 429 with Retry-After from the credential throttle
  and the client-download throttle.
- share_sync.revoke_lost: revoke whoever could see a note before and no
  longer can, after a share or a group goes.
- serialize.serialize_person: a member as the directory, a share and a
  group listing show them.
- groups_api._get_group: the path-id lookup four group routes wrote.
- settings.apply_session_ttl: the session lifetime set at boot and on save.

Kept: the attachment-id claim check (one query; each caller answers an id it
already holds differently), the strict UUID-list parses in reorder and
set_note_labels (distinct error messages), and the checklist-items loops
(one line each).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:39:38 -04:00
co-authored by Claude Opus 5.5
parent 627c5e43bc
commit 38b94bd57e
16 changed files with 92 additions and 61 deletions
+2 -3
View File
@@ -25,7 +25,7 @@ from .notes import bp as notes_bp
from .proxy import is_https from .proxy import is_https
from .responses import json_error, not_found from .responses import json_error, not_found
from .retention import run_sweeper from .retention import run_sweeper
from .settings import MAX_BODY_MB, get_public_config, get_setting, load_or_create_secret_key, refresh_live from .settings import MAX_BODY_MB, apply_session_ttl, get_public_config, get_setting, load_or_create_secret_key, refresh_live
from .settings_api import bp as settings_bp from .settings_api import bp as settings_bp
from .shares_api import bp as shares_bp from .shares_api import bp as shares_bp
from .sync import bp as sync_bp, protocol_advertisement from .sync import bp as sync_bp, protocol_advertisement
@@ -114,8 +114,7 @@ def create_app() -> Quart:
async with session_scope() as db: async with session_scope() as db:
app.secret_key = await load_or_create_secret_key(db) app.secret_key = await load_or_create_secret_key(db)
try: try:
days = int(await get_setting(db, "session_ttl_days")) apply_session_ttl(app, await get_setting(db, "session_ttl_days"))
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days)
except (ValueError, TypeError, KeyError): except (ValueError, TypeError, KeyError):
pass pass
# The security settings the throttle and the proxy trust read on hot # The security settings the throttle and the proxy trust read on hot
+4 -4
View File
@@ -18,11 +18,11 @@ from __future__ import annotations
import logging import logging
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from sqlalchemy import delete, select from sqlalchemy import delete, select
from .common import iso from .common import expired_before, iso
from .db import session_scope from .db import session_scope
from .models.audit_event import AuditEvent from .models.audit_event import AuditEvent
from .proxy import client_address from .proxy import client_address
@@ -99,9 +99,9 @@ async def sweep_once(*, now: datetime | None = None) -> int:
"""Delete events older than `audit_retention_days`; 0 keeps them forever.""" """Delete events older than `audit_retention_days`; 0 keeps them forever."""
async with session_scope() as db: async with session_scope() as db:
days = int(await get_setting(db, "audit_retention_days")) days = int(await get_setting(db, "audit_retention_days"))
if days <= 0: cutoff = expired_before(now or datetime.now(timezone.utc), days)
if cutoff is None:
return 0 return 0
cutoff = (now or datetime.now(timezone.utc)) - timedelta(days=days)
deleted = (await db.execute(delete(AuditEvent).where(AuditEvent.at < cutoff))).rowcount deleted = (await db.execute(delete(AuditEvent).where(AuditEvent.at < cutoff))).rowcount
await db.commit() await db.commit()
return deleted return deleted
+2 -2
View File
@@ -18,7 +18,7 @@ from .password_resets import INVALID as INVALID_RESET, claim as claim_reset, mai
from .models.device_token import DeviceToken from .models.device_token import DeviceToken
from .models.user import User from .models.user import User
from .proxy import client_address from .proxy import client_address
from .responses import json_error, not_found, parse_uuid from .responses import json_error, not_found, parse_uuid, too_many
from .ratelimit import ( from .ratelimit import (
register_by_address, register_by_address,
reset_mail_by_account, reset_mail_by_account,
@@ -196,7 +196,7 @@ def _throttled(retry_after: int):
genuinely needs. genuinely needs.
""" """
logger.warning("throttled credential attempt from=%s retry_after=%ss", client_address(), retry_after) logger.warning("throttled credential attempt from=%s retry_after=%ss", client_address(), retry_after)
return (*json_error("too many attempts — try again shortly", 429), {"Retry-After": str(retry_after)}) return too_many("too many attempts — try again shortly", retry_after)
def _sign_in_block(email: str) -> int | None: def _sign_in_block(email: str) -> int | None:
+2 -5
View File
@@ -82,7 +82,7 @@ from .auth import login_required
from .config import Config from .config import Config
from .proxy import is_https from .proxy import is_https
from .ratelimit import downloads_by_account from .ratelimit import downloads_by_account
from .responses import json_error from .responses import json_error, too_many
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -394,10 +394,7 @@ async def client_download(platform_id: str):
account = str(g.user_id) account = str(g.user_id)
wait = downloads_by_account.retry_after(account) wait = downloads_by_account.retry_after(account)
if wait is not None: if wait is not None:
response = jsonify({"error": "too many downloads from this account; try again later"}) return too_many("too many downloads from this account; try again later", wait)
response.status_code = 429
response.headers["Retry-After"] = str(wait)
return response
downloads_by_account.record(account) downloads_by_account.record(account)
# From the SAME directory the metadata came from, or a drop-in appearing between # From the SAME directory the metadata came from, or a drop-in appearing between
+27 -1
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime import asyncio
from datetime import datetime, timedelta
# Small, dependency-free value coercions shared across the blueprints. Kept in one # Small, dependency-free value coercions shared across the blueprints. Kept in one
# place so the "parse an ISO date" / "is this flag truthy" logic has a single # place so the "parse an ISO date" / "is this flag truthy" logic has a single
@@ -47,3 +48,28 @@ def normalize_email(raw: str | None) -> str:
one from a request goes through here, so "Ana@Example.com " and "ana@example.com" one from a request goes through here, so "Ana@Example.com " and "ana@example.com"
are one account at sign-up, sign-in, reset and invite alike.""" are one account at sign-up, sign-in, reset and invite alike."""
return (raw or "").strip().lower() return (raw or "").strip().lower()
def detach(running: set[asyncio.Task], coro) -> asyncio.Task:
"""Start `coro` without waiting for it, held in `running` until it finishes.
The event loop keeps only a weak reference to a task, so one nobody holds can be
collected mid-flight. Each caller keeps its own set, which is what its `drain()`
awaits in tests. Raises RuntimeError when there is no running loop."""
task = asyncio.create_task(coro)
running.add(task)
task.add_done_callback(running.discard)
return task
def expired_before(now: datetime, retention_days: int) -> datetime | None:
"""The cutoff: anything older than this has expired. `None` = retention is off.
Kept separate from the query so the window arithmetic — including the two ways
to say "never" (0 and negative, the latter reachable by typing a stray minus in
Settings) — is testable without a database. Trash and the audit log both expire
through it.
"""
if retention_days <= 0:
return None
return now - timedelta(days=retention_days)
+14 -11
View File
@@ -18,7 +18,8 @@ from .db import session_scope
from .models.group import Group, GroupMember from .models.group import Group, GroupMember
from .models.user import User from .models.user import User
from .responses import json_error, not_found, parse_uuid from .responses import json_error, not_found, parse_uuid
from .share_sync import bump_note, group_notes, joined_group, left_group, recipients, revoke from .serialize import serialize_person
from .share_sync import bump_note, group_notes, joined_group, left_group, recipients, revoke_lost
bp = Blueprint("groups", __name__, url_prefix="/api/groups") bp = Blueprint("groups", __name__, url_prefix="/api/groups")
@@ -49,11 +50,17 @@ async def _serialize_groups(db, groups: list[Group]) -> list[dict]:
members: dict = {} members: dict = {}
for group_id, user in rows: for group_id, user in rows:
members.setdefault(group_id, []).append( members.setdefault(group_id, []).append(
{"id": str(user.id), "display_name": user.display_name, "email": user.email} serialize_person(user)
) )
return [{"id": str(gr.id), "name": gr.name, "members": members.get(gr.id, [])} for gr in groups] return [{"id": str(gr.id), "name": gr.name, "members": members.get(gr.id, [])} for gr in groups]
async def _get_group(db, group_id: str) -> Group | None:
"""The group a route's path names, or None for a malformed or unknown id."""
gid = parse_uuid(group_id)
return await db.get(Group, gid) if gid else None
async def _answer(db, group: Group, status: int = 200): async def _answer(db, group: Group, status: int = 200):
return jsonify((await _serialize_groups(db, [group]))[0]), status return jsonify((await _serialize_groups(db, [group]))[0]), status
@@ -87,9 +94,8 @@ async def rename_group(group_id: str):
name = _name(await request.get_json(silent=True) or {}) name = _name(await request.get_json(silent=True) or {})
if name is None: if name is None:
return json_error("give the group a name", 400) return json_error("give the group a name", 400)
gid = parse_uuid(group_id)
async with session_scope() as db: async with session_scope() as db:
group = await db.get(Group, gid) if gid else None group = await _get_group(db, group_id)
if group is None: if group is None:
return not_found() return not_found()
if await _name_taken(db, name, but=group.id): if await _name_taken(db, name, but=group.id):
@@ -104,9 +110,8 @@ async def rename_group(group_id: str):
async def delete_group(group_id: str): async def delete_group(group_id: str):
"""Delete the group and every share made to it. Whoever could see a note only """Delete the group and every share made to it. Whoever could see a note only
through it loses the note, and their devices are told.""" through it loses the note, and their devices are told."""
gid = parse_uuid(group_id)
async with session_scope() as db: async with session_scope() as db:
group = await db.get(Group, gid) if gid else None group = await _get_group(db, group_id)
if group is None: if group is None:
return not_found() return not_found()
note_ids = await group_notes(db, group.id) note_ids = await group_notes(db, group.id)
@@ -114,7 +119,7 @@ async def delete_group(group_id: str):
await db.delete(group) # members and shares go with it (ON DELETE CASCADE) await db.delete(group) # members and shares go with it (ON DELETE CASCADE)
await db.flush() await db.flush()
for nid in note_ids: for nid in note_ids:
await revoke(db, nid, before[nid] - await recipients(db, nid)) await revoke_lost(db, nid, before[nid])
# The owner's devices learn whether the note is still shared at all. # The owner's devices learn whether the note is still shared at all.
await bump_note(db, nid) await bump_note(db, nid)
await db.commit() await db.commit()
@@ -125,10 +130,9 @@ async def delete_group(group_id: str):
@require_admin @require_admin
async def add_member(group_id: str): async def add_member(group_id: str):
data = await request.get_json(silent=True) or {} data = await request.get_json(silent=True) or {}
gid = parse_uuid(group_id)
uid = parse_uuid(str(data.get("user_id") or "")) uid = parse_uuid(str(data.get("user_id") or ""))
async with session_scope() as db: async with session_scope() as db:
group = await db.get(Group, gid) if gid else None group = await _get_group(db, group_id)
if group is None: if group is None:
return not_found() return not_found()
if uid is None or await db.get(User, uid) is None: if uid is None or await db.get(User, uid) is None:
@@ -147,10 +151,9 @@ async def add_member(group_id: str):
@bp.delete("/<group_id>/members/<user_id>") @bp.delete("/<group_id>/members/<user_id>")
@require_admin @require_admin
async def remove_member(group_id: str, user_id: str): async def remove_member(group_id: str, user_id: str):
gid = parse_uuid(group_id)
uid = parse_uuid(user_id) uid = parse_uuid(user_id)
async with session_scope() as db: async with session_scope() as db:
group = await db.get(Group, gid) if gid else None group = await _get_group(db, group_id)
if group is None or uid is None: if group is None or uid is None:
return not_found() return not_found()
membership = await db.scalar( membership = await db.scalar(
+2 -3
View File
@@ -17,6 +17,7 @@ import ssl
from dataclasses import dataclass from dataclasses import dataclass
from email.message import EmailMessage from email.message import EmailMessage
from .common import detach
from .settings import get_setting, mail_configured from .settings import get_setting, mail_configured
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -91,9 +92,7 @@ def send_later(job) -> None:
For the forgot-password route, where waiting would also be an oracle: an address For the forgot-password route, where waiting would also be an oracle: an address
with an account would answer as slowly as a mail server, and one without, at once. with an account would answer as slowly as a mail server, and one without, at once.
`job` handles and logs its own failures.""" `job` handles and logs its own failures."""
task = asyncio.create_task(job) detach(_running, job)
_running.add(task)
task.add_done_callback(_running.discard)
async def drain() -> None: async def drain() -> None:
+6
View File
@@ -19,6 +19,12 @@ def not_found():
return json_error("not found", 404) return json_error("not found", 404)
def too_many(message: str, retry_after: int):
"""429 with `Retry-After`, the one thing a client that was slowed down needs to
know: how long to wait. Never which limit it hit."""
return jsonify({"error": message}), 429, {"Retry-After": str(retry_after)}
def parse_uuid(raw: object) -> uuid.UUID | None: def parse_uuid(raw: object) -> uuid.UUID | None:
"""Parse a path/body UUID, returning None on anything malformed. Pair with """Parse a path/body UUID, returning None on anything malformed. Pair with
not_found() for the ubiquitous 'bad id in the URL → 404' guard.""" not_found() for the ubiquitous 'bad id in the URL → 404' guard."""
+2 -13
View File
@@ -22,12 +22,13 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timezone
from sqlalchemy import delete as sa_delete from sqlalchemy import delete as sa_delete
from sqlalchemy import select from sqlalchemy import select
from . import audit from . import audit
from .common import expired_before
from .config import Config from .config import Config
from .db import session_scope from .db import session_scope
from .models.label import NoteLabel from .models.label import NoteLabel
@@ -56,18 +57,6 @@ SWEEP_STARTUP_DELAY_SECONDS = 60
SWEEP_BATCH = 200 SWEEP_BATCH = 200
def expired_before(now: datetime, retention_days: int) -> datetime | None:
"""The cutoff: trash older than this has expired. `None` = retention is off.
Kept separate from the query so the window arithmetic — including the two ways
to say "never" (0 and negative, the latter reachable by typing a stray minus in
Settings) — is testable without a database.
"""
if retention_days <= 0:
return None
return now - timedelta(days=retention_days)
async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None: async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
"""Turn a note into a content-less tombstone: delete its children (and the """Turn a note into a content-less tombstone: delete its children (and the
attachment files on disk), clear its content, stamp `purged_at`. attachment files on disk), clear its content, stamp `purged_at`.
+8
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
from .common import iso from .common import iso
from .models.label import Label from .models.label import Label
from .models.user import User
def serialize_label(label: Label) -> dict: def serialize_label(label: Label) -> dict:
@@ -24,3 +25,10 @@ def serialize_label_sync(label: Label) -> dict:
"purged_at": iso(label.purged_at), "purged_at": iso(label.purged_at),
"created_at": iso(label.created_at), "created_at": iso(label.created_at),
} }
def serialize_person(user: User) -> dict:
"""Someone as another member sees them: a name and an email, which is what
choosing them takes and all a fellow member needs to know. The share directory,
a share's recipient and a group's members all show people this way."""
return {"id": str(user.id), "display_name": user.display_name, "email": user.email}
+7 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json import json
import secrets import secrets
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Literal from typing import Any, Literal
from .common import coerce_bool from .common import coerce_bool
@@ -394,6 +394,12 @@ def live(key: str) -> Any:
return _live[key] return _live[key]
def apply_session_ttl(app, days) -> None:
"""Sessions signed from now on last `days` days. At boot and on every admin save,
so a changed lifetime applies without a restart (rule 25)."""
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=int(days))
async def refresh_live(db) -> None: async def refresh_live(db) -> None:
"""Re-read the hot settings into the cache. Called at boot and after every save.""" """Re-read the hot settings into the cache. Called at boot and after every save."""
for key in _LIVE_KEYS: for key in _LIVE_KEYS:
+2 -4
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import logging import logging
from quart import Blueprint, current_app, g, jsonify, request from quart import Blueprint, current_app, g, jsonify, request
@@ -11,7 +9,7 @@ from .db import session_scope
from .mailer import mail_settings, send from .mailer import mail_settings, send
from .responses import json_error from .responses import json_error
from .models.user import User from .models.user import User
from .settings import get_admin_settings, get_setting, refresh_live, set_settings, validate_updates from .settings import apply_session_ttl, get_admin_settings, get_setting, refresh_live, set_settings, validate_updates
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -50,7 +48,7 @@ async def update_settings():
# Apply the live-tunable knob without a restart (rule 25). # Apply the live-tunable knob without a restart (rule 25).
if "session_ttl_days" in clean: if "session_ttl_days" in clean:
current_app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=int(clean["session_ttl_days"])) apply_session_ttl(current_app, clean["session_ttl_days"])
return jsonify({"settings": result}) return jsonify({"settings": result})
+6
View File
@@ -80,6 +80,12 @@ async def granted(db, note_id: uuid.UUID, user_id: uuid.UUID) -> None:
# cursor, and losing it leaves a revocation. # cursor, and losing it leaves a revocation.
async def revoke_lost(db, note_id: uuid.UUID, before: set[uuid.UUID]) -> None:
"""After a share or a group goes: tell the devices of whoever could see the note
`before` and no longer can. Someone still reached another way keeps it."""
await revoke(db, note_id, before - await recipients(db, note_id))
async def group_notes(db, group_id: uuid.UUID) -> list[uuid.UUID]: async def group_notes(db, group_id: uuid.UUID) -> list[uuid.UUID]:
"""The notes shared with this group.""" """The notes shared with this group."""
rows = await db.scalars( rows = await db.scalars(
+5 -10
View File
@@ -25,15 +25,12 @@ from .models.share import Share
from .models.user import User from .models.user import User
from .notes.helpers import _get_owned from .notes.helpers import _get_owned
from .responses import json_error, not_found, parse_uuid from .responses import json_error, not_found, parse_uuid
from .share_sync import bump_note, granted, recipients, revoke from .serialize import serialize_person
from .share_sync import bump_note, granted, recipients, revoke_lost
bp = Blueprint("shares", __name__, url_prefix="/api") bp = Blueprint("shares", __name__, url_prefix="/api")
def _member(user: User) -> dict:
return {"id": str(user.id), "display_name": user.display_name, "email": user.email}
def _group(group: Group, counts: dict) -> dict: def _group(group: Group, counts: dict) -> dict:
return {"id": str(group.id), "name": group.name, "member_count": counts.get(group.id, 0)} return {"id": str(group.id), "name": group.name, "member_count": counts.get(group.id, 0)}
@@ -64,7 +61,7 @@ async def directory():
).all() ).all()
groups = (await db.scalars(select(Group).order_by(func.lower(Group.name)))).all() groups = (await db.scalars(select(Group).order_by(func.lower(Group.name)))).all()
counts = await _member_counts(db, [gr.id for gr in groups]) counts = await _member_counts(db, [gr.id for gr in groups])
return jsonify({"members": [_member(u) for u in users], "groups": [_group(gr, counts) for gr in groups]}) return jsonify({"members": [serialize_person(u) for u in users], "groups": [_group(gr, counts) for gr in groups]})
async def _shares_of(db, note_id: uuid.UUID) -> list[dict]: async def _shares_of(db, note_id: uuid.UUID) -> list[dict]:
@@ -83,7 +80,7 @@ async def _shares_of(db, note_id: uuid.UUID) -> list[dict]:
return [ return [
{ {
"id": str(share.id), "id": str(share.id),
"member": _member(user) if user is not None else None, "member": serialize_person(user) if user is not None else None,
"group": _group(group, counts) if group is not None else None, "group": _group(group, counts) if group is not None else None,
"permission": share.permission, "permission": share.permission,
"created_at": iso(share.created_at), "created_at": iso(share.created_at),
@@ -167,9 +164,7 @@ async def unshare_note(note_id: str, share_id: str):
before = await recipients(db, note.id) before = await recipients(db, note.id)
await db.delete(share) await db.delete(share)
await db.flush() await db.flush()
# Only the people who can no longer see it: someone still reached through a await revoke_lost(db, note.id, before)
# group keeps the note on their devices.
await revoke(db, note.id, before - await recipients(db, note.id))
# The owner's devices learn the note is no longer shared. # The owner's devices learn the note is no longer shared.
await bump_note(db, note.id) await bump_note(db, note.id)
await db.commit() await db.commit()
+2 -3
View File
@@ -30,6 +30,7 @@ import uuid
from sqlalchemy import select from sqlalchemy import select
from .common import detach
from .db import session_scope from .db import session_scope
from .models.note import Note from .models.note import Note
from .models.note_link_preview import NoteLinkPreview from .models.note_link_preview import NoteLinkPreview
@@ -118,10 +119,8 @@ def schedule(note_id: uuid.UUID, body: str | None) -> None:
if not body or not detect_urls(body): if not body or not detect_urls(body):
return return
try: try:
task = asyncio.create_task(_unfurl_new_urls(note_id, body)) detach(_running, _unfurl_new_urls(note_id, body))
except RuntimeError: except RuntimeError:
# No running loop — a script or a test calling the write path directly. The # No running loop — a script or a test calling the write path directly. The
# note is saved either way; only the preview is skipped. # note is saved either way; only the preview is skipped.
return return
_running.add(task)
task.add_done_callback(_running.discard)
+1 -1
View File
@@ -2,11 +2,11 @@ from datetime import datetime, timedelta, timezone
import pytest import pytest
from inkwell.common import expired_before
from inkwell.retention import ( from inkwell.retention import (
SWEEP_BATCH, SWEEP_BATCH,
SWEEP_INTERVAL_SECONDS, SWEEP_INTERVAL_SECONDS,
SWEEP_STARTUP_DELAY_SECONDS, SWEEP_STARTUP_DELAY_SECONDS,
expired_before,
sweep_expired_trash, sweep_expired_trash,
) )
from inkwell.settings import REGISTRY, get_public_config, validate_updates from inkwell.settings import REGISTRY, get_public_config, validate_updates