Record what may be shared, ahead of federation

The bot-side half of TODO #4. Nothing federates yet; this establishes
what would be eligible to, and proves the boundary before there is a
hub that could leak across it.

- Schema v5 adds visibility, defaulting to private. Upgrading an
  existing library therefore opts nothing into sharing, which a test
  on the legacy upgrade path pins down.
- list_syncable() is the only function that will ever feed a hub, and
  it requires both public and approved. Tests force rows into states
  set_visibility refuses, so the filter is what protects the boundary
  rather than the caller being careful.
- set_visibility refuses anything not approved: media still in the
  moderation queue cannot be marked shared.
- /share lists memes with their current state and asks before making
  one public, since un-sharing stops future copies but cannot recall
  those already taken. The wording describes what sharing will mean
  once a hub exists, because by then warning is too late.
- /list shows the same markers.

Vault posts have no path into the sync set at all; being in a separate
table is what guarantees it, and there is a test saying so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-09-01 22:09:37 +00:00
parent a35abb0541
commit 0732f2c34b
7 changed files with 272 additions and 7 deletions
+53 -2
View File
@@ -4,7 +4,7 @@ import os
DB_PATH = os.getenv("DB_PATH", "/data/media.db")
# Bump this and add a matching _migrate_to_vN() when the schema changes.
SCHEMA_VERSION = 4
SCHEMA_VERSION = 5
# ── schema / migrations ──────────────────────────────────────────────────────
@@ -87,10 +87,18 @@ async def _migrate_to_v4(db) -> None:
await db.execute("CREATE INDEX IF NOT EXISTS idx_vault_owner ON vault_items(owner_id)")
async def _migrate_to_v5(db) -> None:
"""Mark what may leave this instance. Everything defaults to private, so an
upgrade never opts an existing library into sharing."""
await db.execute("ALTER TABLE media ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'")
await db.execute("CREATE INDEX IF NOT EXISTS idx_visibility ON media(visibility)")
MIGRATIONS = {
2: _migrate_to_v2,
3: _migrate_to_v3,
4: _migrate_to_v4,
5: _migrate_to_v5,
}
@@ -160,7 +168,7 @@ async def delete_media(media_id: int) -> bool:
# ── reads ────────────────────────────────────────────────────────────────────
COLUMNS = "id, owner_id, file_id, file_unique_id, media_type, keywords, status"
COLUMNS = "id, owner_id, file_id, file_unique_id, media_type, keywords, status, visibility"
async def search_media(query: str, owner_id: int | None = None,
@@ -267,6 +275,49 @@ async def set_status(media_id: int, status: str, reviewer_id: int) -> bool:
return cur.rowcount > 0
# ── visibility / federation ──────────────────────────────────────────────────
async def set_visibility(media_id: int, visibility: str) -> bool:
"""Only approved media can be shared; something still awaiting moderation
or already rejected must never become publishable."""
async with aiosqlite.connect(DB_PATH) as db:
cur = await db.execute(
"UPDATE media SET visibility = ? WHERE id = ? AND status = 'approved'",
(visibility, media_id),
)
await db.commit()
return cur.rowcount > 0
async def list_syncable(limit: int = 500) -> list[dict]:
"""The only rows permitted to leave this instance.
Both conditions matter: private media is nobody else's business, and
unreviewed or rejected media must not be pushed to peers. Everything that
ever talks to the hub goes through this function.
"""
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
cursor = await db.execute(
f"""
SELECT {COLUMNS} FROM media
WHERE visibility = 'public' AND status = 'approved'
ORDER BY added_at ASC, id ASC
LIMIT ?
""",
(limit,),
)
return [dict(r) for r in await cursor.fetchall()]
async def count_public() -> int:
async with aiosqlite.connect(DB_PATH) as db:
cursor = await db.execute(
"SELECT COUNT(*) FROM media WHERE visibility = 'public' AND status = 'approved'"
)
return (await cursor.fetchone())[0]
# ── vault ────────────────────────────────────────────────────────────────────
#
# Every query here takes an owner_id and filters on it. A vault item belongs to