From b5c3b7d126df83bdc09b9c02c66640c9cc19f50b Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Fri, 23 Oct 2026 06:01:34 +0100 Subject: [PATCH] Share every picture with everyone signed in A profile photo, a bot's picture and a workspace's picture all come from one condition now (sharedPictureMediaSql), so a face is never blank just because its owner isn't in the viewer's rail. Anything that isn't a picture stays behind its own check. --- .../db/access/grab-shared-picture-media.ts | 43 ++++----- .../access/shared-picture-media-sql.test.ts | 88 +++++++++++++++++++ .../db/access/shared-picture-media-sql.ts | 41 +++++++++ .../db/bot-files/grab-bot-file-media.ts | 7 +- .../backend/db/media/grab-user-media.ts | 6 +- .../backend/db/tasks/grab-task-media-file.ts | 5 +- .../server/media/handle-media-server.ts | 6 +- 7 files changed, 162 insertions(+), 34 deletions(-) create mode 100644 src/functions/backend/db/access/shared-picture-media-sql.test.ts create mode 100644 src/functions/backend/db/access/shared-picture-media-sql.ts diff --git a/src/functions/backend/db/access/grab-shared-picture-media.ts b/src/functions/backend/db/access/grab-shared-picture-media.ts index 4e99862..ac16bf3 100644 --- a/src/functions/backend/db/access/grab-shared-picture-media.ts +++ b/src/functions/backend/db/access/grab-shared-picture-media.ts @@ -1,40 +1,31 @@ import BunSQLite from "@moduletrace/bun-sqlite"; -import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_MEDIA, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db"; -import grabUserBot from "../bots/grab-user-bot"; -import grabUserWorkspace from "../workspaces/grab-user-workspace"; +import type { BUN_SQLITE_COBBLE_MEDIA } from "@/db/types/db"; +import sharedPictureMediaSql from "./shared-picture-media-sql"; type Params = { media_id: number | string; - user_id: number | string; }; /** - * A bot's or workspace's picture (private media, uploaded by whoever set - * it) for a user who reaches that bot or workspace, or null. Bots and - * workspaces belong to the install, so their pictures show to everyone - * who sees them. + * A picture every signed-in user may see - a member's current profile photo, + * a bot's picture, a workspace's picture (`sharedPictureMediaSql`) - or + * null when this media is something else. + * + * Which crew the viewer reaches plays no part: bots and workspaces belong to + * the install, and people know each other by their photo, so a picture is + * shared with everyone signed in. Everything a picture must not expose (a + * bot's files, a task's deliverable, someone's chat upload) stays behind + * its own check, and a removed picture stops being shared as soon as no + * user, bot or workspace points at it. */ -export default async function grabSharedPictureMedia({ media_id, user_id }: Params) { - const [bot, workspace] = await Promise.all([ - BunSQLite.sql({ - sql: `SELECT id FROM bots WHERE image_media_id = ? LIMIT 1`, - values: [media_id], - }).then((res) => res.payload?.[0]), - BunSQLite.sql({ - sql: `SELECT id FROM workspaces WHERE image_media_id = ? LIMIT 1`, - values: [media_id], - }).then((res) => res.payload?.[0]), - ]); +export default async function grabSharedPictureMedia({ media_id }: Params) { + if (!media_id) return null; - const allowed = - (bot?.id && (await grabUserBot({ bot_id: bot.id, user_id }))?.id) || - (workspace?.id && (await grabUserWorkspace({ workspace_id: workspace.id, user_id }))?.id); - - if (!allowed) return null; + const condition = sharedPictureMediaSql({ alias: "m" }); const res = await BunSQLite.sql({ - sql: `SELECT * FROM media WHERE id = ? LIMIT 1`, - values: [media_id], + sql: `SELECT m.* FROM media m WHERE m.id = ? AND COALESCE(m.archived, 0) <> 1 AND ${condition.sql} LIMIT 1`, + values: [media_id, ...condition.values], }); return res.payload?.[0] || null; diff --git a/src/functions/backend/db/access/shared-picture-media-sql.test.ts b/src/functions/backend/db/access/shared-picture-media-sql.test.ts new file mode 100644 index 0000000..187cdb5 --- /dev/null +++ b/src/functions/backend/db/access/shared-picture-media-sql.test.ts @@ -0,0 +1,88 @@ +import { Database } from "bun:sqlite"; +import { beforeAll, describe, expect, test } from "bun:test"; +import sharedPictureMediaSql from "./shared-picture-media-sql"; + +const db = new Database(":memory:"); + +beforeAll(() => { + db.run(`CREATE TABLE media (id INTEGER PRIMARY KEY, user_id INTEGER, is_primary INTEGER, archived INTEGER)`); + db.run(`CREATE TABLE media_paradigms (media_id INTEGER, media_paradigm TEXT)`); + db.run(`CREATE TABLE bots (id INTEGER PRIMARY KEY, image_media_id INTEGER)`); + db.run(`CREATE TABLE workspaces (id INTEGER PRIMARY KEY, image_media_id INTEGER)`); + + db.run(`INSERT INTO media VALUES + (1, 7, 1, 0), + (2, 8, 1, 0), + (3, 8, NULL, 0), + (4, 9, NULL, 0), + (5, 7, NULL, 0), + (6, 7, NULL, 0), + (7, 7, NULL, 0), + (8, 7, NULL, 0)`); + + // 1: user 7's current photo, 2: user 8's, 3: an old photo of user 8's (no longer primary) + db.run(`INSERT INTO media_paradigms VALUES + (1, 'user-profile-image'), + (2, 'user-profile-image'), + (3, 'user-profile-image'), + (4, 'bot-image'), + (5, 'bot-image'), + (6, 'workspace-image'), + (7, 'workspace-image'), + (8, 'bot-file')`); + + // 4: still bot 2's picture, 5: a bot picture no bot points at any more + db.run(`INSERT INTO bots VALUES (2, 4), (3, NULL)`); + // 6: still workspace 10's picture, 7: a workspace picture nothing points at + db.run(`INSERT INTO workspaces VALUES (10, 6), (11, NULL)`); +}); + +function visible({ archived }: { archived?: number } = {}) { + const condition = sharedPictureMediaSql(); + + return db + .query( + `SELECT m.id FROM media m WHERE ${condition.sql} ${ + archived === undefined ? `` : `AND COALESCE(m.archived, 0) <> 1` + } ORDER BY m.id`, + ) + .all(...condition.values) + .map((row) => (row as { id: number }).id); +} + +describe("sharedPictureMediaSql", () => { + test("every user's current photo, and every bot's and workspace's picture", () => { + expect(visible()).toEqual([1, 2, 4, 6]); + }); + + test("archived media is not a picture anyone gets", () => { + db.run(`UPDATE media SET archived = 1 WHERE id = 2`); + expect(visible({ archived: 0 })).toEqual([1, 4, 6]); + db.run(`UPDATE media SET archived = 0 WHERE id = 2`); + }); + + test("a photo that is no longer the user's primary is not shared", () => { + expect(visible()).not.toContain(3); + }); + + test("a picture no bot or workspace points at any more is not shared", () => { + expect(visible()).not.toContain(5); + expect(visible()).not.toContain(7); + }); + + test("anything else stays private, a bot's file among them", () => { + expect(visible()).not.toContain(8); + }); + + test("removing a picture row closes it at once", () => { + db.run(`INSERT INTO media VALUES (9, 7, NULL, 0)`); + db.run(`INSERT INTO media_paradigms VALUES (9, 'bot-image')`); + db.run(`UPDATE bots SET image_media_id = 9 WHERE id = 3`); + + expect(visible()).toContain(9); + + db.run(`UPDATE bots SET image_media_id = NULL WHERE id = 3`); + + expect(visible()).not.toContain(9); + }); +}); diff --git a/src/functions/backend/db/access/shared-picture-media-sql.ts b/src/functions/backend/db/access/shared-picture-media-sql.ts new file mode 100644 index 0000000..f39bf3b --- /dev/null +++ b/src/functions/backend/db/access/shared-picture-media-sql.ts @@ -0,0 +1,41 @@ +import type { SqlCondition } from "@/src/utils/crew-access/types"; + +type Params = { + /** + * The `media` table alias the condition reads. + */ + alias?: string; +}; + +/** + * The pictures every signed-in user may load, whoever they are and whatever + * crew they reach: + * + * - a member's current profile photo (their primary `user-profile-image`, + * the one `grabUsers` reports as `profile_media_id`), + * - a bot's picture (`bots.image_media_id`), + * - a workspace's picture (`workspaces.image_media_id`). + * + * Bots and workspaces belong to the install, and people know each other by + * their photo, so a picture is never limited to whoever happens to reach + * its owner. It does have to be one still in use: the paradigm alone is not + * enough, since a removed picture leaves its paradigm row behind. + */ +export default function sharedPictureMediaSql({ alias = "m" }: Params = {}): SqlCondition { + return { + sql: `EXISTS ( + SELECT 1 FROM media_paradigms mp + WHERE mp.media_id = ${alias}.id + AND ( + (mp.media_paradigm = 'user-profile-image' AND ${alias}.is_primary = 1) + OR (mp.media_paradigm = 'bot-image' AND EXISTS ( + SELECT 1 FROM bots b WHERE b.image_media_id = ${alias}.id + )) + OR (mp.media_paradigm = 'workspace-image' AND EXISTS ( + SELECT 1 FROM workspaces w WHERE w.image_media_id = ${alias}.id + )) + ) + )`, + values: [], + }; +} diff --git a/src/functions/backend/db/bot-files/grab-bot-file-media.ts b/src/functions/backend/db/bot-files/grab-bot-file-media.ts index 6f2dfed..210fc64 100644 --- a/src/functions/backend/db/bot-files/grab-bot-file-media.ts +++ b/src/functions/backend/db/bot-files/grab-bot-file-media.ts @@ -35,7 +35,12 @@ export default async function grabBotFileMedia({ const role = await grabCrewRole({ user_id }); const reaches = file.scope == "workspace" - ? Boolean(await grabUserWorkspace({ workspace_id: file.owner_id!, user_id })) + ? Boolean( + await grabUserWorkspace({ + workspace_id: file.owner_id!, + user_id, + }), + ) : role == "super_admin" ? true : Boolean(await grabUserBot({ bot_id: file.owner_id!, user_id })); diff --git a/src/functions/backend/db/media/grab-user-media.ts b/src/functions/backend/db/media/grab-user-media.ts index 5054910..5f20a59 100644 --- a/src/functions/backend/db/media/grab-user-media.ts +++ b/src/functions/backend/db/media/grab-user-media.ts @@ -15,8 +15,8 @@ type Params = { * gate in front of the file on disk: * * - the user's own uploads (`media.user_id`), - * - a bot's or workspace's picture, to whoever reaches that bot or - * workspace (`grabSharedPictureMedia`), + * - a picture every signed-in user may see: a member's current profile + * photo, a bot's picture, a workspace's picture (`grabSharedPictureMedia`), * - a bot's or workspace's file, to whoever reaches the folder it lives in * (`grabBotFileMedia`), * - a file attached to a task, to whoever reaches the task @@ -48,7 +48,7 @@ export default async function grabUserMedia({ return ( own.singleRes || - (await grabSharedPictureMedia({ media_id, user_id })) || + (await grabSharedPictureMedia({ media_id })) || (await grabBotFileMedia({ media_id, user_id })) || (await grabTaskMediaFile({ media_id, user_id })) || undefined diff --git a/src/functions/backend/db/tasks/grab-task-media-file.ts b/src/functions/backend/db/tasks/grab-task-media-file.ts index 7dfe59a..e244e4c 100644 --- a/src/functions/backend/db/tasks/grab-task-media-file.ts +++ b/src/functions/backend/db/tasks/grab-task-media-file.ts @@ -30,7 +30,10 @@ export default async function grabTaskMediaFile({ if (!link?.task_id) return null; - const access = taskScopesSql({ role: await grabCrewRole({ user_id }), user_id }); + const access = taskScopesSql({ + role: await grabCrewRole({ user_id }), + user_id, + }); const task = ( await BunSQLite.sql<{ id: number }>({ diff --git a/src/functions/server/media/handle-media-server.ts b/src/functions/server/media/handle-media-server.ts index 4836b34..5b72b7b 100644 --- a/src/functions/server/media/handle-media-server.ts +++ b/src/functions/server/media/handle-media-server.ts @@ -16,9 +16,9 @@ type Params = { * * There is no public media. Every URL is private, so the request has to be * signed in and the user has to reach the file (`grabUserMedia` decides - * that: their own upload, a bot's or workspace's file or picture they can - * chat with, a file on one of their tasks). Anything else is answered with - * a 404, never with the file. + * that: their own upload, any profile photo, bot picture or workspace + * picture, a bot's or workspace's file they can reach, a file on one of + * their tasks). Anything else is answered with a 404, never with the file. */ export default async function handleMediaServer({ req,