From ba423f32d8f4b163101040ce9a1051e58b687375 Mon Sep 17 00:00:00 2001 From: dhkatz Date: Thu, 5 Nov 2026 14:32:08 -0800 Subject: [PATCH] feat: file blocks Moderators block a file network-wide by its sha256, from a product's files or the console's new Blocks tab, which first shows the products using it. Blocking removes those products (their creators are told and may appeal), puts a `block` change in the changes feed of every catalog hosting it, and refuses uploads with it; moderators see who tried. Unblocking lets it in again; the products it removed stay removed until restored. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 74 +- apps/api/src/blocks.ts | 113 + apps/api/src/products.ts | 22 +- apps/api/test/blocks.test.ts | 203 + apps/api/test/catalogs.test.ts | 2 +- apps/web/app/console/audit-table.tsx | 9 +- apps/web/app/console/blocks/[sha256]/page.tsx | 106 + apps/web/app/console/blocks/actions.ts | 31 + apps/web/app/console/blocks/page.tsx | 88 + apps/web/app/console/console-nav.tsx | 1 + apps/web/app/products/[id]/page.tsx | 9 + docs/console.md | 2 + docs/design.md | 3 +- docs/products.md | 7 +- packages/db/drizzle/0013_file_blocks.sql | 9 + packages/db/drizzle/meta/0013_snapshot.json | 3387 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema.ts | 11 +- 18 files changed, 4074 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/blocks.ts create mode 100644 apps/api/test/blocks.test.ts create mode 100644 apps/web/app/console/blocks/[sha256]/page.tsx create mode 100644 apps/web/app/console/blocks/actions.ts create mode 100644 apps/web/app/console/blocks/page.tsx create mode 100644 packages/db/drizzle/0013_file_blocks.sql create mode 100644 packages/db/drizzle/meta/0013_snapshot.json diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 6512fc0..13ab8ec 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -26,7 +26,8 @@ import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu"; import { Realtime } from "./realtime"; import { isAdultRoom, Rooms } from "./rooms"; import { Signer } from "./keys"; -import { availableTo, ProductError, Products, type EditRights, type ProductViewer } from "./products"; +import { Blocks, isSha256 } from "./blocks"; +import { availableTo, BlockedUpload, ProductError, Products, type EditRights, type ProductViewer } from "./products"; import { chatId, HostedError, @@ -182,6 +183,7 @@ export function createApp({ }); const limiter = new Limiter(); const cases = new Cases(db, perms); + const blocks = new Blocks(db); const notifications = new Notifications(db, (userId, message) => realtime.notify(userId, message)); /** Tells a product's creator that someone else changed it. */ @@ -407,7 +409,15 @@ export function createApp({ url: `${(webURL ?? "").replace(/\/+$/, "")}/catalogs/${i.catalogId}?invite=${i.code}`, }); - type ConsoleSection = "overview" | "products" | "catalogs" | "cases" | "people" | "audit" | "settings"; + type ConsoleSection = "overview" | "products" | "catalogs" | "cases" | "people" | "blocks" | "audit" | "settings"; + + const blockParam = t.Object({ sha256: t.String({ pattern: "^[0-9a-f]{64}$" }) }); + const blockView = (b: { block: typeof schema.fileBlock.$inferSelect; by: string | null }) => ({ + sha256: b.block.sha256, + reason: b.block.reason, + by: b.by, + at: b.block.createdAt.toISOString(), + }); const caseParam = t.Object({ id: t.Numeric({ minimum: 1, multipleOf: 1 }) }); @@ -1719,7 +1729,16 @@ export function createApp({ async ({ request, body, set }) => { const c = await products.catalogFor(request.headers.get("authorization")); if (!c) throw new Problem(401, "forbidden", "Not an approved catalog."); - const r = await productCall(() => products.registerRevision(c, body)); + const r = await productCall(async () => { + try { + return await products.registerRevision(c, body); + } catch (e) { + // Someone tried a blocked file again: moderators see who, and from which catalog. + if (e instanceof BlockedUpload) + await audited(e.personId, "file.blocked_upload", c.id, { files: e.files }, { catalog: c.id, person: e.personId }); + throw e; + } + }); set.status = 201; return r; }, @@ -1976,6 +1995,54 @@ export function createApp({ }, { moderator: true, params: productParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) }, ) + // File blocks: moderation blocks a file network-wide (docs/products.md). + .get( + "/blocks", + async ({ me, query }) => { + await need(me, "product:remove", hubScope); + const limit = 50; + const rows = await blocks.list({ before: query.before ? new Date(query.before) : undefined, limit }); + return { blocks: rows.map(blockView), before: rows.length === limit ? rows.at(-1)!.block.createdAt.toISOString() : null }; + }, + { signedIn: true, query: t.Object({ before: t.Optional(t.String({ format: "date-time" })) }) }, + ) + // A file's block, if any, and the products using it (what blocking it removes). + .get( + "/blocks/:sha256", + async ({ me, params }) => { + await need(me, "product:remove", hubScope); + const b = await blocks.get(params.sha256); + return { block: b ? blockView(b) : null, products: await blocks.usedBy(params.sha256) }; + }, + { signedIn: true, params: blockParam }, + ) + .put( + "/blocks/:sha256", + async ({ me, params, body }) => { + await need(me, "product:remove", hubScope); + const reason = body.reason.trim(); + if (!reason || !isSha256(params.sha256)) throw new Problem(400, "invalid", "Give a reason."); + const removed = await productCall(() => blocks.block(params.sha256, reason, me.id)); + await audited(me.id, "file.block", params.sha256, { reason, removed: removed.map((p) => p.id) }); + for (const p of removed) { + await audited(me.id, "product.remove", p.id, { reason, file: params.sha256 }, { product: p.id, person: p.creatorId }); + await notifications.add(p.creatorId, "product_removed", { product: p.id, name: p.name, reason }, { product: p.id }, me.id); + } + return { removed: removed.map((p) => ({ id: p.id, name: p.name })) }; + }, + { signedIn: true, params: blockParam, body: t.Object({ reason: t.String({ maxLength: 500 }) }) }, + ) + .delete( + "/blocks/:sha256", + async ({ me, params, body, set }) => { + await need(me, "product:remove", hubScope); + await productCall(() => blocks.unblock(params.sha256)); + const reason = body?.reason?.trim() ?? ""; + await audited(me.id, "file.unblock", params.sha256, reason ? { reason } : {}); + set.status = 204; + }, + { signedIn: true, params: blockParam, body: t.Optional(t.Object({ reason: t.Optional(t.String({ maxLength: 500 })) })) }, + ) // Moderation restores a removed product (an upheld appeal, a mistake). .post( "/products/:id/unremove", @@ -2458,6 +2525,7 @@ export function createApp({ if (mod || scope.catalogs.length || scope.products.length || (await cases.list(me, { limit: 1 })).length) sections.push("cases"); if (mod) sections.push("people"); + if (await perms.can(me, "product:remove", hubScope)) sections.push("blocks"); if (sections.length > 1 || scope.rooms.length) sections.push("audit"); if (me.role === "admin") sections.push("settings"); if (sections.length === 1) diff --git a/apps/api/src/blocks.ts b/apps/api/src/blocks.ts new file mode 100644 index 0000000..32baae8 --- /dev/null +++ b/apps/api/src/blocks.ts @@ -0,0 +1,113 @@ +// File blocks (docs/products.md): moderation blocks a file network-wide by its sha256, so a removed product can't +// come back under another name. Revisions with a blocked file can't be registered, products using it are removed, +// and the catalogs hosting it are told to delete it. The routes in app.ts check permissions, audit and notify. +import { and, desc, eq, inArray, isNull, lt, sql, schema, type Database } from "@t5de/db"; +import { ProductError } from "./products"; + +const { catalogChange, fileBlock, hosting, product, productRevision, user } = schema; + +export const isSha256 = (s: string) => /^[0-9a-f]{64}$/.test(s); + +/** A product with a revision using a file. */ +export type FileUse = { + id: string; + name: string; + creatorId: string; + creatorUsername: string | null; + removed: boolean; + revisions: number[]; +}; + +export class Blocks { + constructor(private db: Database) {} + + async get(sha256: string) { + const [b] = await this.db + .select({ block: fileBlock, by: user.username }) + .from(fileBlock) + .leftJoin(user, eq(user.id, fileBlock.byId)) + .where(eq(fileBlock.sha256, sha256)); + return b; + } + + /** Which of these files are blocked. */ + async blocked(sha256s: string[]): Promise { + if (sha256s.length === 0) return []; + const rows = await this.db.select({ sha256: fileBlock.sha256 }).from(fileBlock).where(inArray(fileBlock.sha256, sha256s)); + return rows.map((r) => r.sha256); + } + + /** The products with a revision using a file (what blocking it would remove), newest first. */ + async usedBy(sha256: string): Promise { + const rows = await this.db + .select({ p: product, creatorUsername: user.username, number: productRevision.number }) + .from(productRevision) + .innerJoin(product, eq(product.id, productRevision.productId)) + .leftJoin(user, eq(user.id, product.creatorId)) + .where(sql`${productRevision.files} @> ${JSON.stringify([{ sha256 }])}::jsonb`) + .orderBy(desc(product.createdAt), productRevision.number); + const out = new Map(); + for (const r of rows) { + if (r.p.deletedAt && !r.p.removedAt) continue; // deleted by its creator: nothing to remove + const u = out.get(r.p.id) ?? { + id: r.p.id, + name: r.p.name, + creatorId: r.p.creatorId, + creatorUsername: r.creatorUsername, + removed: r.p.removedAt !== null, + revisions: [], + }; + u.revisions.push(r.number); + out.set(r.p.id, u); + } + return [...out.values()]; + } + + /** + * Blocks a file: removes the products using it (those not removed already, which are returned) and tells the + * catalogs hosting it to delete it (a `block` change). + */ + async block(sha256: string, reason: string, byId: string): Promise { + if (!isSha256(sha256)) throw new ProductError(400, "invalid", "That isn't a file's sha256 (64 hex digits)."); + const uses = await this.usedBy(sha256); + const ids = uses.map((u) => u.id); + const removed = uses.filter((u) => !u.removed); + await this.db.transaction(async (tx) => { + const [made] = await tx.insert(fileBlock).values({ sha256, reason, byId }).onConflictDoNothing().returning(); + if (!made) throw new ProductError(409, "blocked", "That file is blocked already."); + if (removed.length) + await tx + .update(product) + .set({ removedAt: new Date(), removedReason: reason }) + .where(and(inArray(product.id, removed.map((u) => u.id)), isNull(product.removedAt))); + if (ids.length) { + const hosts = await tx + .selectDistinct({ catalogId: hosting.catalogId }) + .from(hosting) + .innerJoin(productRevision, and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number))) + .where(and(inArray(hosting.productId, ids), sql`${productRevision.files} @> ${JSON.stringify([{ sha256 }])}::jsonb`)); + if (hosts.length) await tx.insert(catalogChange).values(hosts.map((h) => ({ catalogId: h.catalogId, data: { type: "block", sha256 } }))); + } + }); + return removed; + } + + /** Unblocks a file. Products it removed stay removed (moderators restore them one by one); catalogs aren't told, + * since they deleted the file and get it again only with a new upload. */ + async unblock(sha256: string) { + const [gone] = await this.db.delete(fileBlock).where(eq(fileBlock.sha256, sha256)).returning(); + if (!gone) throw new ProductError(404, "not_found", "That file isn't blocked."); + return gone; + } + + /** Blocks, newest first. */ + async list(opts: { before?: Date; limit?: number } = {}) { + return this.db + .select({ block: fileBlock, by: user.username }) + .from(fileBlock) + .leftJoin(user, eq(user.id, fileBlock.byId)) + .where(opts.before ? lt(fileBlock.createdAt, opts.before) : undefined) + .orderBy(desc(fileBlock.createdAt)) + .limit(opts.limit ?? 50); + } +} diff --git a/apps/api/src/products.ts b/apps/api/src/products.ts index c0afc63..a7644c5 100644 --- a/apps/api/src/products.ts +++ b/apps/api/src/products.ts @@ -20,7 +20,7 @@ import { cleanHTML, maxDescription } from "./html"; import type { ImvuClient } from "./imvu"; import type { Signer } from "./keys"; -const { catalog, catalogChange, catalogMember, product, productInfoChange, productRevision, hosting, library, roleAssignment, user } = +const { catalog, catalogChange, catalogMember, fileBlock, product, productInfoChange, productRevision, hosting, library, roleAssignment, user } = schema; export type Catalog = typeof catalog.$inferSelect; @@ -63,6 +63,17 @@ export class ProductError extends Error { } } +/** An upload with a blocked file (docs/products.md): refused, and the route audits who tried. */ +export class BlockedUpload extends ProductError { + constructor( + readonly personId: string, + readonly catalogId: string, + readonly files: FileEntry[], + ) { + super(409, "blocked", "This upload has a file T5DE blocks."); + } +} + const base32 = "abcdefghijklmnopqrstuvwxyz234567"; export const newProductID = () => [...crypto.getRandomValues(new Uint8Array(10))].map((b) => base32[b & 31]).join(""); @@ -260,6 +271,15 @@ export class Products { const parent = Number.isSafeInteger(body.parent) && body.parent > 0 ? body.parent : 0; const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000); const personId = claims.sub; + const blocked = new Set( + ( + await this.db + .select({ sha256: fileBlock.sha256 }) + .from(fileBlock) + .where(inArray(fileBlock.sha256, [...new Set(files.map((f) => f.sha256))])) + ).map((b) => b.sha256), + ); + if (blocked.size) throw new BlockedUpload(personId, c.id, files.filter((f) => blocked.has(f.sha256))); const made = await this.db.transaction(async (tx) => { let p: Product; diff --git a/apps/api/test/blocks.test.ts b/apps/api/test/blocks.test.ts new file mode 100644 index 0000000..ef82c35 --- /dev/null +++ b/apps/api/test/blocks.test.ts @@ -0,0 +1,203 @@ +// File blocks (docs/products.md): moderation blocks a file network-wide; products using it are removed, catalogs told +// to delete it, and uploads with it refused. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { openDatabase, type Database } from "@t5de/db"; +import { createApp } from "../src/app"; +import { createAuth } from "../src/auth"; +import { ImvuClient, type ImvuUser } from "../src/imvu"; + +const webURL = "http://web.test"; +const apiURL = "http://api.test"; +const accounts = new Map(); +const fakeIMVU: typeof fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)); + if (url.pathname.startsWith("/product/")) return Response.json({ status: "failure" }, { status: 404 }); + const u = [...accounts.values()].find( + (u) => url.pathname === `/user/user-${u.id}` || url.searchParams.get("username")?.toLowerCase() === u.username.toLowerCase(), + ); + return Response.json({ + denormalized: u + ? { [`https://api.imvu.com/user/user-${u.id}`]: { data: { legacy_cid: u.id, username: u.username, tagline: u.tagline, is_ageverified: u.ageVerified, is_ap: u.accessPass } } } + : {}, + }); +}) as typeof fetch; + +// Catalog addresses that answer /health (for address changes). +const healthy = new Set(["https://moved.example.test"]); + +let db: Database; +let close: () => Promise; +let base: string; +let stop: () => void; + +beforeAll(async () => { + ({ db, close } = await openDatabase("memory")); + const auth = createAuth(db, { apiURL, webURL, secret: "test-secret-test-secret-test-secret-1234", rateLimit: false }); + const hub = createApp({ + db, + auth, + imvu: new ImvuClient("http://imvu.test", fakeIMVU), + apiURL, + webURL, + catalogHealthy: async (url) => healthy.has(url), + }); + hub.app.listen({ port: 0, hostname: "127.0.0.1" }); + const server = hub.app.server as unknown as { port: number; stop: () => void }; + stop = () => server.stop(); + base = `http://127.0.0.1:${server.port}`; +}); + +afterAll(async () => { + stop(); + await close(); +}); + +type Person = { cookie: string; name: string }; +async function call(p: Person | undefined, method: string, path: string, body?: unknown, headers: Record = {}) { + const res = await fetch(base + path, { + method, + headers: { Origin: webURL, ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(p ? { Cookie: p.cookie } : {}), ...headers }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json: any; + try { + json = JSON.parse(text); + } catch { + json = text; + } + return { status: res.status, json }; +} + +async function person(name: string): Promise { + const res = await fetch(base + "/api/auth/sign-up/email", { + method: "POST", + headers: { Origin: webURL, "Content-Type": "application/json" }, + body: JSON.stringify({ email: `${name}@example.test`, password: "a long password", name, username: name }), + }); + return { cookie: res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "), name }; +} + +const file = (name: string, n: number) => ({ name, sha256: n.toString(16).padStart(64, "0"), size: 100 * n }); + +/** A person's catalog, registered and approved; its key's Authorization header. */ +async function catalogOf(owner: Person, admin: Person, name: string, membership = "open") { + const reg = await call(owner, "POST", "/api/v1/catalogs", { + name, + url: `https://${name.toLowerCase().replace(/\W/g, "")}.example.test`, + contact: "takedowns@example.test", + visibility: "public", + membership, + }); + expect(reg.status).toBe(201); + const id = reg.json.catalog.id as string; + expect((await call(admin, "POST", `/api/v1/console/catalogs/${id}/approve`)).status).toBe(200); + return { id, auth: { Authorization: `Catalog ${id}:${reg.json.key}` } }; +} + +/** A product uploaded by a person to a catalog; its ID. */ +async function upload(maker: Person, c: { id: string; auth: Record }, name: string, n: number, rating = "general") { + const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id }); + expect(tok.status).toBe(200); + const r = await call( + undefined, + "POST", + "/api/v1/catalog/revisions", + { uploadToken: tok.json.token, files: [file("index.xml", n)], rating, parent: 0, name, notes: "" }, + c.auth, + ); + expect(r.status).toBe(201); + expect((await call(maker, "POST", `/api/v1/products/${r.json.product}/publish`)).status).toBe(200); + return r.json.product as string; +} + + +/** A revision with these files, by a person, to a catalog: the answer. */ +async function uploadFiles(maker: Person, c: { id: string; auth: Record }, name: string, files: unknown[]) { + const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id }); + return call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: tok.json.token, files, rating: "general", parent: 0, name, notes: "" }, c.auth); +} + +let admin: Person; +let mod: Person; + +test("the first account is the admin; a moderator", async () => { + admin = await person("admin1"); + mod = await person("warden"); + const id = (await call(mod, "GET", "/api/v1/me")).json.person.id; + expect((await call(admin, "POST", `/api/v1/console/people/${id}/role`, { role: "moderator" })).status).toBe(200); +}); + +test("blocking a file removes the products using it, tells their catalogs, and refuses it in uploads", async () => { + const owner = await person("shelfowner"); + const maker = await person("maker"); + const copier = await person("copier"); + const c = await catalogOf(owner, admin, "Block Shelf"); + const other = await catalogOf(copier, admin, "Copy Shelf"); + const logo = file("logo.png", 2).sha256; + + const tee = await upload(maker, c, "Logo Tee", 1); + const second = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id, product: tee }); + expect( + (await call(undefined, "POST", "/api/v1/catalog/revisions", { uploadToken: second.json.token, files: [file("index.xml", 1), file("logo.png", 2)], rating: "general", parent: 0, name: "", notes: "Added the logo" }, c.auth)).status, + ).toBe(201); + const copy = (await uploadFiles(copier, other, "Copy Tee", [file("index.xml", 5), file("logo.png", 2)])).json.product as string; + const plain = await upload(maker, c, "Plain Tee", 3); + + // Only those who can remove products hub-wide. + for (const p of [owner, maker]) { + expect((await call(p, "GET", "/api/v1/blocks")).status).toBe(403); + expect((await call(p, "GET", `/api/v1/blocks/${logo}`)).status).toBe(403); + expect((await call(p, "PUT", `/api/v1/blocks/${logo}`, { reason: "x" })).status).toBe(403); + } + expect((await call(owner, "GET", "/api/v1/console")).json.sections).not.toContain("blocks"); + expect((await call(mod, "GET", "/api/v1/console")).json.sections).toContain("blocks"); + expect((await call(mod, "GET", "/api/v1/blocks/not-a-hash")).status).toBe(400); + + // What blocking it would remove. + const preview = (await call(mod, "GET", `/api/v1/blocks/${logo}`)).json; + expect(preview.block).toBeNull(); + expect(preview.products.map((p: any) => [p.name, p.creatorUsername, p.revisions, p.removed]).sort()).toEqual([ + ["Copy Tee", "copier", [1], false], + ["Logo Tee", "maker", [2], false], + ]); + + const blocked = await call(mod, "PUT", `/api/v1/blocks/${logo}`, { reason: "Trademarked logo" }); + expect(blocked.status).toBe(200); + expect(blocked.json.removed.map((p: any) => p.id).sort()).toEqual([copy, tee].sort()); + expect((await call(mod, "PUT", `/api/v1/blocks/${logo}`, { reason: "Again" })).json.error).toBe("blocked"); + expect((await call(undefined, "GET", `/api/v1/products/${tee}`)).status).toBe(410); + expect((await call(undefined, "GET", `/api/v1/products/${copy}`)).status).toBe(410); + expect((await call(undefined, "GET", `/api/v1/products/${plain}`)).status).toBe(200); + expect((await call(mod, "GET", `/api/v1/blocks/${logo}`)).json.block).toMatchObject({ sha256: logo, reason: "Trademarked logo", by: "warden" }); + expect((await call(mod, "GET", "/api/v1/blocks")).json.blocks.map((b: any) => b.sha256)).toEqual([logo]); + + // Each catalog that hosts it is told to delete it. + for (const k of [c, other]) { + const feed = (await call(undefined, "GET", "/api/v1/catalog/changes", undefined, k.auth)).json.changes; + expect(feed.filter((x: any) => x.type === "block")).toEqual([{ type: "block", sha256: logo }]); + } + + // Creators are told, and may appeal the removal. + const told = (await call(maker, "GET", "/api/v1/notifications")).json.notifications[0]; + expect(told).toMatchObject({ type: "product_removed", data: { product: tee, reason: "Trademarked logo" } }); + const removal = (await call(maker, "GET", `/api/v1/console/audit?action=product.remove`)).json.entries[0]; + expect(removal).toMatchObject({ target: tee, appealable: true, detail: { file: logo } }); + + // Uploads with it are refused, and moderators see who tried. + const again = await uploadFiles(copier, other, "Copy Tee 2", [file("index.xml", 6), file("logo2.png", 2)]); + expect(again.status).toBe(409); + expect(again.json.error).toBe("blocked"); + const tried = (await call(mod, "GET", "/api/v1/console/audit?action=file.blocked_upload")).json.entries[0]; + expect(tried).toMatchObject({ actor: "copier", tags: { catalog: other.id }, detail: { files: [{ name: "logo2.png", sha256: logo }] } }); + + // Unblocking lets it in again; the products it removed stay removed until restored. + expect((await call(owner, "DELETE", `/api/v1/blocks/${logo}`)).status).toBe(403); + expect((await call(mod, "DELETE", `/api/v1/blocks/${logo}`, { reason: "Licensed after all" })).status).toBe(204); + expect((await call(mod, "DELETE", `/api/v1/blocks/${logo}`)).status).toBe(404); + expect((await uploadFiles(copier, other, "Copy Tee 2", [file("index.xml", 6), file("logo2.png", 2)])).status).toBe(201); + expect((await call(undefined, "GET", `/api/v1/products/${tee}`)).status).toBe(410); + expect((await call(mod, "POST", `/api/v1/products/${tee}/unremove`, { reason: "Licensed" })).status).toBe(200); + expect((await call(undefined, "GET", `/api/v1/products/${tee}`)).status).toBe(200); + expect((await call(mod, "GET", "/api/v1/console/audit?action=file.unblock")).json.entries[0]).toMatchObject({ target: logo, detail: { reason: "Licensed after all" } }); +}); diff --git a/apps/api/test/catalogs.test.ts b/apps/api/test/catalogs.test.ts index 6ecfc53..cf1920b 100644 --- a/apps/api/test/catalogs.test.ts +++ b/apps/api/test/catalogs.test.ts @@ -300,7 +300,7 @@ test("the console is for everyone who controls something; its audit log shows on expect(ownerConsole.sections).toEqual(["overview", "catalogs", "cases", "audit"]); expect(ownerConsole.moderation).toBeNull(); expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "cases", "audit"]); - expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "cases", "people", "audit", "settings"]); + expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "cases", "people", "blocks", "audit", "settings"]); // The owner sees their catalog's entries, not the other catalog's; the maker sees their product's. const ownerLog = (await call(owner, "GET", "/api/v1/console/audit")).json.entries; diff --git a/apps/web/app/console/audit-table.tsx b/apps/web/app/console/audit-table.tsx index 9337eae..2a42833 100644 --- a/apps/web/app/console/audit-table.tsx +++ b/apps/web/app/console/audit-table.tsx @@ -60,7 +60,14 @@ export function AuditTable({ entries, empty = "Nothing yet." }: { entries: Audit ); })} - {Object.keys(e.tags).length === 0 && e.target} + {Object.keys(e.tags).length === 0 && + (e.action.startsWith("file.") && /^[0-9a-f]{64}$/.test(e.target) ? ( + + file {e.target.slice(0, 12)}… + + ) : ( + e.target + ))} {detailText(e.detail)} diff --git a/apps/web/app/console/blocks/[sha256]/page.tsx b/apps/web/app/console/blocks/[sha256]/page.tsx new file mode 100644 index 0000000..88b4342 --- /dev/null +++ b/apps/web/app/console/blocks/[sha256]/page.tsx @@ -0,0 +1,106 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ActionForm, SubmitButton } from "@/components/forms"; +import { PageHeader, Tag } from "@/components/status"; +import { DateTime } from "@/components/time"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { api, unwrap } from "@/lib/api"; +import { ConsoleNav, requireConsole } from "../../console-nav"; +import { blockFile, unblockFile } from "../actions"; + +export const metadata: Metadata = { title: "Block" }; + +/** A file: whether it's blocked, the products using it, and blocking or unblocking it. */ +export default async function BlockPage({ params }: { params: Promise<{ sha256: string }> }) { + const { sha256 } = await params; + if (!/^[0-9a-f]{64}$/.test(sha256)) notFound(); + const { console: c } = await requireConsole(`/console/blocks/${sha256}`); + if (!c.sections.includes("blocks")) notFound(); + const { block, products } = unwrap(await api.blocks({ sha256 }).get()); + const live = products.filter((p) => !p.removed); + const removeCount = live.length === 1 ? "1 product" : `${live.length} products`; + + return ( + <> + + {sha256} + + +
+ + + {block ? "Blocked" : "Not Blocked"} + + {block ? ( + <> + By {block.by ?? "a former moderator"}, : {block.reason} + + ) : ( + "Blocking it removes the products below, tells their catalogs to delete it, and refuses uploads with it." + )} + + + + {block ? ( + +
+ + + Unblock + +
+
+ ) : ( + +
+ + + {live.length ? `Block and remove ${removeCount}` : "Block"} + +
+
+ )} +
+
+ + + Used By + Products with a revision that has this file. + + + {products.length === 0 ? ( +

No product uses it.

+ ) : ( +
    + {products.map((p) => ( +
  • + + {p.name} + + + by {p.creatorUsername ?? "someone"} · {p.revisions.length === 1 ? "revision" : "revisions"} {p.revisions.join(", ")} + + {p.removed && removed} +
  • + ))} +
+ )} +
+
+
+ + ); +} diff --git a/apps/web/app/console/blocks/actions.ts b/apps/web/app/console/blocks/actions.ts new file mode 100644 index 0000000..ded5442 --- /dev/null +++ b/apps/web/app/console/blocks/actions.ts @@ -0,0 +1,31 @@ +"use server"; +// File blocks, from the console. +import { revalidatePath } from "next/cache"; +import { api, unwrap } from "@/lib/api"; +import { attempt, field, type ActionState } from "@/lib/actions"; + +const done = (sha256: string) => { + revalidatePath("/console/blocks"); + revalidatePath(`/console/blocks/${sha256}`); + revalidatePath("/console/products"); +}; + +/** Blocks a file network-wide, removing the products using it. */ +export async function blockFile(_: ActionState, form: FormData): Promise { + const sha256 = field(form, "sha256"); + return attempt(async () => { + const { removed } = unwrap(await api.blocks({ sha256 }).put({ reason: field(form, "reason") })); + done(sha256); + return removed.length ? `Blocked. Removed ${removed.length === 1 ? "1 product" : `${removed.length} products`}.` : "Blocked."; + }); +} + +/** Unblocks a file. The products it removed stay removed. */ +export async function unblockFile(_: ActionState, form: FormData): Promise { + const sha256 = field(form, "sha256"); + return attempt(async () => { + unwrap(await api.blocks({ sha256 }).delete({ reason: field(form, "reason") })); + done(sha256); + return "Unblocked."; + }); +} diff --git a/apps/web/app/console/blocks/page.tsx b/apps/web/app/console/blocks/page.tsx new file mode 100644 index 0000000..1731ca9 --- /dev/null +++ b/apps/web/app/console/blocks/page.tsx @@ -0,0 +1,88 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound, redirect } from "next/navigation"; +import { PageHeader } from "@/components/status"; +import { DateTime } from "@/components/time"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { api, unwrap } from "@/lib/api"; +import { ConsoleNav, requireConsole } from "../console-nav"; + +export const metadata: Metadata = { title: "Blocks" }; + +export default async function BlocksPage({ searchParams }: { searchParams: Promise> }) { + const { console: c } = await requireConsole("/console/blocks"); + if (!c.sections.includes("blocks")) notFound(); + const params = await searchParams; + // Looking a file up goes to its page. + if (typeof params.sha256 === "string") { + const sha256 = params.sha256.trim().toLowerCase(); + if (/^[0-9a-f]{64}$/.test(sha256)) redirect(`/console/blocks/${sha256}`); + } + const before = typeof params.before === "string" && !Number.isNaN(Date.parse(params.before)) ? params.before : undefined; + const data = unwrap(await api.blocks.get({ query: before ? { before } : {} })); + + return ( + <> + Blocked files: uploads with them are refused, and catalogs delete them. + +
+ + +
+ + + {data.blocks.length === 0 ? ( +

+ Nothing blocked. Block a file from a product's files, or look it up above. +

+ ) : ( + + + + File + Reason + By + When + + + + {data.blocks.map((b) => ( + + + + {b.sha256.slice(0, 16)}… + + + {b.reason} + {b.by ?? ""} + + + + + ))} + +
+ )} +
+
+ {data.before && ( +
+ + Older + +
+ )} + + ); +} diff --git a/apps/web/app/console/console-nav.tsx b/apps/web/app/console/console-nav.tsx index 2d3a60c..14f4517 100644 --- a/apps/web/app/console/console-nav.tsx +++ b/apps/web/app/console/console-nav.tsx @@ -39,6 +39,7 @@ const tabs: { section: Section; href: string; label: string }[] = [ { section: "catalogs", href: "/console/catalogs", label: "Catalogs" }, { section: "cases", href: "/console/cases", label: "Cases" }, { section: "people", href: "/console/people", label: "People" }, + { section: "blocks", href: "/console/blocks", label: "Blocks" }, { section: "audit", href: "/console/audit", label: "Audit" }, ]; diff --git a/apps/web/app/products/[id]/page.tsx b/apps/web/app/products/[id]/page.tsx index d3011d7..d4b7fd9 100644 --- a/apps/web/app/products/[id]/page.tsx +++ b/apps/web/app/products/[id]/page.tsx @@ -189,6 +189,7 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st stayOn={stayOn} using={using === r.number} mine={mine} + canBlock={permissions.includes("product:remove")} /> ))} @@ -212,6 +213,7 @@ function RevisionRow({ stayOn, using, mine, + canBlock, }: { id: string; revision: Revision; @@ -220,6 +222,8 @@ function RevisionRow({ stayOn: number | null; using: boolean; mine: boolean; + /** Moderators: each file links to blocking it. */ + canBlock: boolean; }) { const facts = [ formatSize(r.size), @@ -247,6 +251,11 @@ function RevisionRow({
  • {f.name} {formatSize(f.size)} + {canBlock && ( + + Block + + )}
  • ))} diff --git a/docs/console.md b/docs/console.md index c673586..48eeac9 100644 --- a/docs/console.md +++ b/docs/console.md @@ -93,6 +93,8 @@ Sections (each shown only to those with something in it): - **Rooms**: their hosted rooms. - **Cases**: reports, appeals and reviews about what they control. - **People**: moderators and admins. +- **Blocks**: those who remove products hub-wide (`product:remove`): blocked files, a file's page (the products using it; + block, unblock). Each file in a product's revisions links to its page for them. - **Audit**: scoped (below). - **Settings**: admins (registration, announcements, roles). diff --git a/docs/design.md b/docs/design.md index 58bad31..4bdc2e6 100644 --- a/docs/design.md +++ b/docs/design.md @@ -478,7 +478,8 @@ Phases for products, catalogs and imports (products.md, console.md, imports.md): (to look into with the importer spike). 3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens; two-factor sign-in for sensitive permissions. Cases and notifications **done** (2026-11-05), without email, canned - reasons, moderator notes or second approval; file blocks, API tokens and two-factor sign-in next. + reasons, moderator notes or second approval. File blocks **done** (2026-11-05); API tokens and two-factor sign-in + next. 4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike). 5. Charts and stats; mirroring between catalogs. diff --git a/docs/products.md b/docs/products.md index 7effcfa..242628f 100644 --- a/docs/products.md +++ b/docs/products.md @@ -71,7 +71,7 @@ registering on the hub), `WEB_ORIGIN` (the hub website, for CORS). | | | | --- | --- | | `POST /api/v1/catalog/revisions` | `{uploadToken, files, rating ("general" or "adult"), parent (IMVU product ID or 0), name (from index.xml, for a new product), notes}` → 201 `{product, revision, draft}`. The upload token must be for this catalog. Without `product` in the token, a new product is made as a **draft** (owner: the token's person, name from `name`; the creator publishes it on the website). With it, a new revision of that product (the person must own it; `notes` required). A file list identical to the latest revision's is refused (409 `unchanged`). Files the hub blocks are refused (409 `blocked`). The catalog's upload policy (`anyone`, `members`, `team`) is checked when the upload token is made (403 `forbidden`). With `importToken` instead of `uploadToken`: an imported revision (imports.md); the hub checks the job and that a grant still covers it, makes the product (published, owned by the grant's creator) or adds the revision, and marks the job done. | -| `GET /api/v1/catalog/changes?after=` | `{changes, cursor}`: what the catalog must act on, oldest first: `{type: "delete", product, revision, files}` (withdrawn, removed by moderation, blocked: delete those files unless another hosted revision uses them), `{type: "mirror", product, revision, files, from: [catalog URLs]}` (fetch each file from one of `from`, check its sha256, then confirm below), `{type: "status", status}` (the catalog was suspended or reinstated). Start with no `after`; keep the cursor. | +| `GET /api/v1/catalog/changes?after=` | `{changes, cursor}`: what the catalog must act on, oldest first: `{type: "delete", product, revision, files}` (withdrawn, removed by moderation, blocked: delete those files unless another hosted revision uses them), `{type: "mirror", product, revision, files, from: [catalog URLs]}` (fetch each file from one of `from`, check its sha256, then confirm below), `{type: "status", status}` (the catalog was suspended or reinstated), `{type: "block", sha256}` (moderation blocked the file: delete it whatever uses it, and refuse it in uploads). Start with no `after`; keep the cursor. | | `POST /api/v1/catalog/hosting` | `{product, revision}`: the catalog now hosts a revision it was asked to mirror (it has every file) → 204 | | `POST /api/v1/catalog/stats` | optional: `{day, downloads, bytes}` for a past day | @@ -121,7 +121,10 @@ catalog's). Only approved catalogs with imports on and not paused. | `POST /api/v1/products/:id/revisions/:n/retract` | (creator) | | `DELETE /api/v1/products/:id` | (creator) deletes it: no longer listed, and nobody new can get it; people who have it keep it (its page, files and library entries stay, and rooms keep showing it). Its creator can't edit it or add revisions after (until restored). 204 | | `POST /api/v1/products/:id/remove` | (`product:remove`) `{reason}`: removes it everywhere: its page and files answer 410 `removed`, it leaves libraries and rooms, and catalogs delete its files. `.../unremove` (`product:unremove`) `{reason?}` undoes it (for now removal only hides it, so its files and library entries come back). Its creator is told both. | -| `PUT /api/v1/blocks/:sha256` | (`product:remove`) `{reason}`: blocks a file network-wide: revisions with it can't be registered, and products using it are removed. `DELETE` unblocks. | +| `GET /api/v1/blocks?before=` | (`product:remove` hub-wide) `{blocks: [{sha256, reason, by, at}], before}`, newest first, 50 a page | +| `GET /api/v1/blocks/:sha256` | (`product:remove`) `{block (or null), products}`: the products with a revision using the file (`{id, name, creatorId, creatorUsername, removed, revisions}`), what blocking it removes | +| `PUT /api/v1/blocks/:sha256` | (`product:remove`) `{reason}` → `{removed: [{id, name}]}`: blocks a file network-wide: revisions with it can't be registered (409 `blocked`; the attempt is audited as `file.blocked_upload`, with the person and catalog), products using it are removed (each audited as `product.remove` with the `file`, so its creator is told and may appeal), and every catalog hosting it gets a `block` change. 409 `blocked` if it is already. | +| `DELETE /api/v1/blocks/:sha256` | (`product:remove`) `{reason?}` → 204: unblocks it; the products it removed stay removed until restored (`.../unremove`). | | `GET /api/v1/library` | `{library: [{product, revision, stayOn}]}`: the person's products that are available to them, each with the revision to use (products no longer available, e.g. after leaving a catalog, stay in the table but aren't listed until they are again) | | `PUT /api/v1/library/:id` | get a product (add it): it must be published and available to the person (403 `members_only`, with the catalogs they could join) | | `DELETE /api/v1/library/:id` | remove it | diff --git a/packages/db/drizzle/0013_file_blocks.sql b/packages/db/drizzle/0013_file_blocks.sql new file mode 100644 index 0000000..7d3c4e9 --- /dev/null +++ b/packages/db/drizzle/0013_file_blocks.sql @@ -0,0 +1,9 @@ +CREATE TABLE "file_block" ( + "sha256" text PRIMARY KEY NOT NULL, + "reason" text NOT NULL, + "by_id" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "file_block" ADD CONSTRAINT "file_block_by_id_user_id_fk" FOREIGN KEY ("by_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "product_revision_files" ON "product_revision" USING gin ("files"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0013_snapshot.json b/packages/db/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..8adc956 --- /dev/null +++ b/packages/db/drizzle/meta/0013_snapshot.json @@ -0,0 +1,3387 @@ +{ + "id": "f9562d67-64e9-4bf9-965c-b78989e08cfa", + "prevId": "00353b8d-c368-4030-ae98-f1d4aa560b01", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user": { + "name": "account_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.age_check": { + "name": "age_check", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "over_eighteen": { + "name": "over_eighteen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "age_check_user": { + "name": "age_check_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_imvu": { + "name": "age_check_imvu", + "columns": [ + { + "expression": "imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "age_check_provider_ref": { + "name": "age_check_provider_ref", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "age_check_user_id_user_id_fk": { + "name": "age_check_user_id_user_id_fk", + "tableFrom": "age_check", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit": { + "name": "audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "detail": { + "name": "detail", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "audit_at": { + "name": "audit_at", + "columns": [ + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_tags": { + "name": "audit_tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "audit_actor_id_user_id_fk": { + "name": "audit_actor_id_user_id_fk", + "tableFrom": "audit", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.case_note": { + "name": "case_note", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "case_id": { + "name": "case_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "by_id": { + "name": "by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal": { + "name": "internal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "case_note_case": { + "name": "case_note_case", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "case_note_case_id_moderation_case_id_fk": { + "name": "case_note_case_id_moderation_case_id_fk", + "tableFrom": "case_note", + "tableTo": "moderation_case", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "case_note_by_id_user_id_fk": { + "name": "case_note_by_id_user_id_fk", + "tableFrom": "case_note", + "tableTo": "user", + "columnsFrom": [ + "by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog": { + "name": "catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "contact": { + "name": "contact", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "membership": { + "name": "membership", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "max_rating": { + "name": "max_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'adult'" + }, + "uploads": { + "name": "uploads", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'anyone'" + }, + "mirrors": { + "name": "mirrors", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'approve'" + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_url": { + "name": "catalog_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_owner_id_user_id_fk": { + "name": "catalog_owner_id_user_id_fk", + "tableFrom": "catalog", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_change": { + "name": "catalog_change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_change_catalog": { + "name": "catalog_change_catalog", + "columns": [ + { + "expression": "catalog_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_change_catalog_id_catalog_id_fk": { + "name": "catalog_change_catalog_id_catalog_id_fk", + "tableFrom": "catalog_change", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_invite": { + "name": "catalog_invite", + "schema": "", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uses": { + "name": "uses", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_invite_catalog": { + "name": "catalog_invite_catalog", + "columns": [ + { + "expression": "catalog_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_invite_catalog_id_catalog_id_fk": { + "name": "catalog_invite_catalog_id_catalog_id_fk", + "tableFrom": "catalog_invite", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_invite_created_by_user_id_fk": { + "name": "catalog_invite_created_by_user_id_fk", + "tableFrom": "catalog_invite", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.catalog_member": { + "name": "catalog_member", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invite": { + "name": "invite", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_member_user": { + "name": "catalog_member_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "catalog_member_catalog_id_catalog_id_fk": { + "name": "catalog_member_catalog_id_catalog_id_fk", + "tableFrom": "catalog_member", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_member_user_id_user_id_fk": { + "name": "catalog_member_user_id_user_id_fk", + "tableFrom": "catalog_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "catalog_member_decided_by_user_id_fk": { + "name": "catalog_member_decided_by_user_id_fk", + "tableFrom": "catalog_member", + "tableTo": "user", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "catalog_member_catalog_id_user_id_pk": { + "name": "catalog_member_catalog_id_user_id_pk", + "columns": [ + "catalog_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file_block": { + "name": "file_block", + "schema": "", + "columns": { + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "by_id": { + "name": "by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "file_block_by_id_user_id_fk": { + "name": "file_block_by_id_user_id_fk", + "tableFrom": "file_block", + "tableTo": "user", + "columnsFrom": [ + "by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room": { + "name": "hosted_room", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "room_pid": { + "name": "room_pid", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "scene": { + "name": "scene", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "scene_hash": { + "name": "scene_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "closed": { + "name": "closed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "moderators": { + "name": "moderators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "image_hash": { + "name": "image_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_owner": { + "name": "hosted_room_owner", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "hosted_room_created": { + "name": "hosted_room_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_owner_id_user_id_fk": { + "name": "hosted_room_owner_id_user_id_fk", + "tableFrom": "hosted_room", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_ban": { + "name": "hosted_room_ban", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "by_imvu_id": { + "name": "by_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "until": { + "name": "until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_ban_n_hosted_room_n_fk": { + "name": "hosted_room_ban_n_hosted_room_n_fk", + "tableFrom": "hosted_room_ban", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_ban_n_imvu_id_pk": { + "name": "hosted_room_ban_n_imvu_id_pk", + "columns": [ + "n", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_favorite": { + "name": "hosted_room_favorite", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_favorite_user_id_user_id_fk": { + "name": "hosted_room_favorite_user_id_user_id_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_favorite_n_hosted_room_n_fk": { + "name": "hosted_room_favorite_n_hosted_room_n_fk", + "tableFrom": "hosted_room_favorite", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_favorite_user_id_n_pk": { + "name": "hosted_room_favorite_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_image": { + "name": "hosted_room_image", + "schema": "", + "columns": { + "n": { + "name": "n", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hosted_room_image_n_hosted_room_n_fk": { + "name": "hosted_room_image_n_hosted_room_n_fk", + "tableFrom": "hosted_room_image", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_rating": { + "name": "hosted_room_rating", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_rating_room": { + "name": "hosted_room_rating_room", + "columns": [ + { + "expression": "n", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_rating_user_id_user_id_fk": { + "name": "hosted_room_rating_user_id_user_id_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_rating_n_hosted_room_n_fk": { + "name": "hosted_room_rating_n_hosted_room_n_fk", + "tableFrom": "hosted_room_rating", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_rating_user_id_n_pk": { + "name": "hosted_room_rating_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosted_room_visit": { + "name": "hosted_room_visit", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n": { + "name": "n", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "at": { + "name": "at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosted_room_visit_at": { + "name": "hosted_room_visit_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosted_room_visit_user_id_user_id_fk": { + "name": "hosted_room_visit_user_id_user_id_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hosted_room_visit_n_hosted_room_n_fk": { + "name": "hosted_room_visit_n_hosted_room_n_fk", + "tableFrom": "hosted_room_visit", + "tableTo": "hosted_room", + "columnsFrom": [ + "n" + ], + "columnsTo": [ + "n" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosted_room_visit_user_id_n_pk": { + "name": "hosted_room_visit_user_id_n_pk", + "columns": [ + "user_id", + "n" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hosting": { + "name": "hosting", + "schema": "", + "columns": { + "catalog_id": { + "name": "catalog_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hosting_product": { + "name": "hosting_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hosting_catalog_id_catalog_id_fk": { + "name": "hosting_catalog_id_catalog_id_fk", + "tableFrom": "hosting", + "tableTo": "catalog", + "columnsFrom": [ + "catalog_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "hosting_catalog_id_product_id_number_pk": { + "name": "hosting_catalog_id_product_id_number_pk", + "columns": [ + "catalog_id", + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hub_setting": { + "name": "hub_setting", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.imvu_link": { + "name": "imvu_link", + "schema": "", + "columns": { + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pairwise_id": { + "name": "pairwise_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "linked_at": { + "name": "linked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "show_badge": { + "name": "show_badge", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "show_presence": { + "name": "show_presence", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "list_rooms": { + "name": "list_rooms", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "imvu_link_user": { + "name": "imvu_link_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "imvu_link_user_id_user_id_fk": { + "name": "imvu_link_user_id_user_id_fk", + "tableFrom": "imvu_link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "imvu_link_pairwise_id_unique": { + "name": "imvu_link_pairwise_id_unique", + "nullsNotDistinct": false, + "columns": [ + "pairwise_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.library": { + "name": "library", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stay_on": { + "name": "stay_on", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "library_user_id_user_id_fk": { + "name": "library_user_id_user_id_fk", + "tableFrom": "library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "library_product_id_product_id_fk": { + "name": "library_product_id_product_id_fk", + "tableFrom": "library", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "library_user_id_product_id_pk": { + "name": "library_user_id_product_id_pk", + "columns": [ + "user_id", + "product_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_challenge": { + "name": "link_challenge", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "imvu_id": { + "name": "imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "link_challenge_user_id_user_id_fk": { + "name": "link_challenge_user_id_user_id_fk", + "tableFrom": "link_challenge", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "link_challenge_user_id_imvu_id_pk": { + "name": "link_challenge_user_id_imvu_id_pk", + "columns": [ + "user_id", + "imvu_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.moderation_case": { + "name": "moderation_case", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "opened_by": { + "name": "opened_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assignee": { + "name": "assignee", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "resolution": { + "name": "resolution", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "moderation_case_status": { + "name": "moderation_case_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "moderation_case_opened_by": { + "name": "moderation_case_opened_by", + "columns": [ + { + "expression": "opened_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "moderation_case_tags": { + "name": "moderation_case_tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "moderation_case_opened_by_user_id_fk": { + "name": "moderation_case_opened_by_user_id_fk", + "tableFrom": "moderation_case", + "tableTo": "user", + "columnsFrom": [ + "opened_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "moderation_case_assignee_user_id_fk": { + "name": "moderation_case_assignee_user_id_fk", + "tableFrom": "moderation_case", + "tableTo": "user", + "columnsFrom": [ + "assignee" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "moderation_case_decided_by_user_id_fk": { + "name": "moderation_case_decided_by_user_id_fk", + "tableFrom": "moderation_case", + "tableTo": "user", + "columnsFrom": [ + "decided_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "notification_user": { + "name": "notification_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_user_id_user_id_fk": { + "name": "notification_user_id_user_id_fk", + "tableFrom": "notification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product": { + "name": "product", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "latest": { + "name": "latest", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'published'" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "listed": { + "name": "listed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "category": { + "name": "category", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_reason": { + "name": "removed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_creator": { + "name": "product_creator", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_created": { + "name": "product_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_tags": { + "name": "product_tags", + "columns": [ + { + "expression": "tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "product_creator_id_user_id_fk": { + "name": "product_creator_id_user_id_fk", + "tableFrom": "product", + "tableTo": "user", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_info_change": { + "name": "product_info_change", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "by_id": { + "name": "by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "revert_of": { + "name": "revert_of", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_info_change_product": { + "name": "product_info_change_product", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_info_change_product_id_product_id_fk": { + "name": "product_info_change_product_id_product_id_fk", + "tableFrom": "product_info_change", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_info_change_by_id_user_id_fk": { + "name": "product_info_change_by_id_user_id_fk", + "tableFrom": "product_info_change", + "tableTo": "user", + "columnsFrom": [ + "by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_revision": { + "name": "product_revision", + "schema": "", + "columns": { + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent": { + "name": "parent", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "parent_info": { + "name": "parent_info", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retracted": { + "name": "retracted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_revision_files": { + "name": "product_revision_files", + "columns": [ + { + "expression": "files", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "product_revision_product_id_product_id_fk": { + "name": "product_revision_product_id_product_id_fk", + "tableFrom": "product_revision", + "tableTo": "product", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "product_revision_uploaded_by_user_id_fk": { + "name": "product_revision_uploaded_by_user_id_fk", + "tableFrom": "product_revision", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "product_revision_product_id_number_pk": { + "name": "product_revision_product_id_number_pk", + "columns": [ + "product_id", + "number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_assignment": { + "name": "role_assignment", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_type": { + "name": "scope_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "role_assignment_scope": { + "name": "role_assignment_scope", + "columns": [ + { + "expression": "scope_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "role_assignment_user_id_user_id_fk": { + "name": "role_assignment_user_id_user_id_fk", + "tableFrom": "role_assignment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "role_assignment_granted_by_user_id_fk": { + "name": "role_assignment_granted_by_user_id_fk", + "tableFrom": "role_assignment", + "tableTo": "user", + "columnsFrom": [ + "granted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "role_assignment_user_id_role_scope_type_scope_id_pk": { + "name": "role_assignment_user_id_role_scope_type_scope_id_pk", + "columns": [ + "user_id", + "role", + "scope_type", + "scope_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room": { + "name": "room", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "privacy": { + "name": "privacy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_rating": { + "name": "content_rating", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "access_pass": { + "name": "access_pass", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "occupancy": { + "name": "occupancy", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "capacity": { + "name": "capacity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expired": { + "name": "expired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "checked_at": { + "name": "checked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "seen_at": { + "name": "seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_owner": { + "name": "room_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.room_decoration": { + "name": "room_decoration", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_imvu_id": { + "name": "owner_imvu_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "contents": { + "name": "contents", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "room_decoration_owner": { + "name": "room_decoration_owner", + "columns": [ + { + "expression": "owner_imvu_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user": { + "name": "session_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signing_key": { + "name": "signing_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "link_cap": { + "name": "link_cap", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "approved": { + "name": "approved", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "user_username_lower": { + "name": "user_username_lower", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_username_unique": { + "name": "user_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier": { + "name": "verification_identifier", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 6fcc80b..a7f22f2 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1793888724185, "tag": "0012_cases", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1793917069600, + "tag": "0013_file_blocks", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index c819659..745a861 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -423,9 +423,18 @@ export const productRevision = pgTable( retracted: boolean("retracted").notNull().default(false), createdAt: createdAt(), }, - (t) => [primaryKey({ columns: [t.productId, t.number] })], + (t) => [primaryKey({ columns: [t.productId, t.number] }), index("product_revision_files").using("gin", t.files)], ); +// A file blocked network-wide by its sha256: revisions with it can't be registered, products using it are removed, +// and catalogs are told to delete it (docs/products.md). +export const fileBlock = pgTable("file_block", { + sha256: text("sha256").primaryKey(), + reason: text("reason").notNull(), + byId: text("by_id").references(() => user.id, { onDelete: "set null" }), + createdAt: createdAt(), +}); + // Which catalogs host which revisions. export const hosting = pgTable( "hosting", -- 2.54.0