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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user