feat: file blocks #10
No files matched your search
+71
-3
@@ -26,7 +26,8 @@ import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
|
|||||||
import { Realtime } from "./realtime";
|
import { Realtime } from "./realtime";
|
||||||
import { isAdultRoom, Rooms } from "./rooms";
|
import { isAdultRoom, Rooms } from "./rooms";
|
||||||
import { Signer } from "./keys";
|
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 {
|
import {
|
||||||
chatId,
|
chatId,
|
||||||
HostedError,
|
HostedError,
|
||||||
@@ -182,6 +183,7 @@ export function createApp({
|
|||||||
});
|
});
|
||||||
const limiter = new Limiter();
|
const limiter = new Limiter();
|
||||||
const cases = new Cases(db, perms);
|
const cases = new Cases(db, perms);
|
||||||
|
const blocks = new Blocks(db);
|
||||||
const notifications = new Notifications(db, (userId, message) => realtime.notify(userId, message));
|
const notifications = new Notifications(db, (userId, message) => realtime.notify(userId, message));
|
||||||
|
|
||||||
/** Tells a product's creator that someone else changed it. */
|
/** 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}`,
|
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 }) });
|
const caseParam = t.Object({ id: t.Numeric({ minimum: 1, multipleOf: 1 }) });
|
||||||
|
|
||||||
@@ -1719,7 +1729,16 @@ export function createApp({
|
|||||||
async ({ request, body, set }) => {
|
async ({ request, body, set }) => {
|
||||||
const c = await products.catalogFor(request.headers.get("authorization"));
|
const c = await products.catalogFor(request.headers.get("authorization"));
|
||||||
if (!c) throw new Problem(401, "forbidden", "Not an approved catalog.");
|
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;
|
set.status = 201;
|
||||||
return r;
|
return r;
|
||||||
},
|
},
|
||||||
@@ -1976,6 +1995,54 @@ export function createApp({
|
|||||||
},
|
},
|
||||||
{ moderator: true, params: productParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
|
{ 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).
|
// Moderation restores a removed product (an upheld appeal, a mistake).
|
||||||
.post(
|
.post(
|
||||||
"/products/:id/unremove",
|
"/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)
|
if (mod || scope.catalogs.length || scope.products.length || (await cases.list(me, { limit: 1 })).length)
|
||||||
sections.push("cases");
|
sections.push("cases");
|
||||||
if (mod) sections.push("people");
|
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 (sections.length > 1 || scope.rooms.length) sections.push("audit");
|
||||||
if (me.role === "admin") sections.push("settings");
|
if (me.role === "admin") sections.push("settings");
|
||||||
if (sections.length === 1)
|
if (sections.length === 1)
|
||||||
|
|||||||
@@ -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<string[]> {
|
||||||
|
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<FileUse[]> {
|
||||||
|
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<string, FileUse>();
|
||||||
|
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<FileUse[]> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import { cleanHTML, maxDescription } from "./html";
|
|||||||
import type { ImvuClient } from "./imvu";
|
import type { ImvuClient } from "./imvu";
|
||||||
import type { Signer } from "./keys";
|
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;
|
schema;
|
||||||
|
|
||||||
export type Catalog = typeof catalog.$inferSelect;
|
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";
|
const base32 = "abcdefghijklmnopqrstuvwxyz234567";
|
||||||
export const newProductID = () =>
|
export const newProductID = () =>
|
||||||
[...crypto.getRandomValues(new Uint8Array(10))].map((b) => base32[b & 31]).join("");
|
[...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 parent = Number.isSafeInteger(body.parent) && body.parent > 0 ? body.parent : 0;
|
||||||
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
|
const notes = (typeof body.notes === "string" ? body.notes.trim() : "").slice(0, 2000);
|
||||||
const personId = claims.sub;
|
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) => {
|
const made = await this.db.transaction(async (tx) => {
|
||||||
let p: Product;
|
let p: Product;
|
||||||
|
|||||||
@@ -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<number, ImvuUser>();
|
||||||
|
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<string>(["https://moved.example.test"]);
|
||||||
|
|
||||||
|
let db: Database;
|
||||||
|
let close: () => Promise<void>;
|
||||||
|
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<string, string> = {}) {
|
||||||
|
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<Person> {
|
||||||
|
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: "[email protected]",
|
||||||
|
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<string, string> }, 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<string, string> }, 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" } });
|
||||||
|
});
|
||||||
@@ -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.sections).toEqual(["overview", "catalogs", "cases", "audit"]);
|
||||||
expect(ownerConsole.moderation).toBeNull();
|
expect(ownerConsole.moderation).toBeNull();
|
||||||
expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "cases", "audit"]);
|
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.
|
// 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;
|
const ownerLog = (await call(owner, "GET", "/api/v1/console/audit")).json.entries;
|
||||||
|
|||||||
@@ -60,7 +60,14 @@ export function AuditTable({ entries, empty = "Nothing yet." }: { entries: Audit
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{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) ? (
|
||||||
|
<Link href={`/console/blocks/${e.target}`} className="font-mono text-xs underline-offset-4 hover:underline">
|
||||||
|
file {e.target.slice(0, 12)}…
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
e.target
|
||||||
|
))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs whitespace-normal text-muted-foreground">
|
<TableCell className="text-xs whitespace-normal text-muted-foreground">
|
||||||
{detailText(e.detail)}
|
{detailText(e.detail)}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title="File">
|
||||||
|
<span className="font-mono text-xs break-all">{sha256}</span>
|
||||||
|
</PageHeader>
|
||||||
|
<ConsoleNav />
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{block ? "Blocked" : "Not Blocked"}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{block ? (
|
||||||
|
<>
|
||||||
|
By {block.by ?? "a former moderator"}, <DateTime iso={block.at} />: {block.reason}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Blocking it removes the products below, tells their catalogs to delete it, and refuses uploads with it."
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{block ? (
|
||||||
|
<ActionForm action={unblockFile} fields={{ sha256 }}>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Input name="reason" maxLength={500} placeholder="Reason" aria-label="Reason" className="max-w-sm" />
|
||||||
|
<SubmitButton
|
||||||
|
variant="outline"
|
||||||
|
pendingLabel="Unblocking…"
|
||||||
|
confirm="Unblock this file? The products it removed stay removed until restored."
|
||||||
|
>
|
||||||
|
Unblock
|
||||||
|
</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
) : (
|
||||||
|
<ActionForm action={blockFile} fields={{ sha256 }}>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Input name="reason" required maxLength={500} placeholder="Reason (creators see it)" aria-label="Reason" className="max-w-sm" />
|
||||||
|
<SubmitButton
|
||||||
|
variant="destructive"
|
||||||
|
pendingLabel="Blocking…"
|
||||||
|
confirm={live.length ? `Block this file and remove ${removeCount}?` : "Block this file?"}
|
||||||
|
>
|
||||||
|
{live.length ? `Block and remove ${removeCount}` : "Block"}
|
||||||
|
</SubmitButton>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Used By</CardTitle>
|
||||||
|
<CardDescription>Products with a revision that has this file.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{products.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No product uses it.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{products.map((p) => (
|
||||||
|
<li key={p.id} className="flex flex-wrap items-center gap-x-3 gap-y-1 py-3 first:pt-0 last:pb-0">
|
||||||
|
<Link
|
||||||
|
href={p.removed ? `/console/products?q=${encodeURIComponent(p.name)}&status=removed&all=1` : `/products/${p.id}`}
|
||||||
|
className="font-medium underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{p.name}
|
||||||
|
</Link>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
by {p.creatorUsername ?? "someone"} · {p.revisions.length === 1 ? "revision" : "revisions"} {p.revisions.join(", ")}
|
||||||
|
</span>
|
||||||
|
{p.removed && <Tag tone="danger">removed</Tag>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<ActionState> {
|
||||||
|
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<ActionState> {
|
||||||
|
const sha256 = field(form, "sha256");
|
||||||
|
return attempt(async () => {
|
||||||
|
unwrap(await api.blocks({ sha256 }).delete({ reason: field(form, "reason") }));
|
||||||
|
done(sha256);
|
||||||
|
return "Unblocked.";
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Record<string, string | string[]>> }) {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Blocks">Blocked files: uploads with them are refused, and catalogs delete them.</PageHeader>
|
||||||
|
<ConsoleNav />
|
||||||
|
<form action="/console/blocks" className="mb-6 flex flex-wrap gap-2">
|
||||||
|
<Input
|
||||||
|
name="sha256"
|
||||||
|
required
|
||||||
|
pattern="\s*[0-9a-fA-F]{64}\s*"
|
||||||
|
defaultValue={typeof params.sha256 === "string" ? params.sha256 : ""}
|
||||||
|
placeholder="A file's sha256"
|
||||||
|
aria-label="sha256"
|
||||||
|
className="max-w-xl font-mono"
|
||||||
|
/>
|
||||||
|
<Button type="submit">Look up</Button>
|
||||||
|
</form>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
{data.blocks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nothing blocked. Block a file from a product's files, or look it up above.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>File</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
<TableHead>By</TableHead>
|
||||||
|
<TableHead>When</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.blocks.map((b) => (
|
||||||
|
<TableRow key={b.sha256}>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
<Link href={`/console/blocks/${b.sha256}`} className="underline-offset-4 hover:underline">
|
||||||
|
{b.sha256.slice(0, 16)}…
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="whitespace-normal">{b.reason}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{b.by ?? ""}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
<DateTime iso={b.at} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{data.before && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<Link href={`/console/blocks?before=${encodeURIComponent(data.before)}`} className={buttonVariants({ variant: "outline" })}>
|
||||||
|
Older
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ const tabs: { section: Section; href: string; label: string }[] = [
|
|||||||
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
|
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
|
||||||
{ section: "cases", href: "/console/cases", label: "Cases" },
|
{ section: "cases", href: "/console/cases", label: "Cases" },
|
||||||
{ section: "people", href: "/console/people", label: "People" },
|
{ section: "people", href: "/console/people", label: "People" },
|
||||||
|
{ section: "blocks", href: "/console/blocks", label: "Blocks" },
|
||||||
{ section: "audit", href: "/console/audit", label: "Audit" },
|
{ section: "audit", href: "/console/audit", label: "Audit" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
|||||||
stayOn={stayOn}
|
stayOn={stayOn}
|
||||||
using={using === r.number}
|
using={using === r.number}
|
||||||
mine={mine}
|
mine={mine}
|
||||||
|
canBlock={permissions.includes("product:remove")}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -212,6 +213,7 @@ function RevisionRow({
|
|||||||
stayOn,
|
stayOn,
|
||||||
using,
|
using,
|
||||||
mine,
|
mine,
|
||||||
|
canBlock,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
revision: Revision;
|
revision: Revision;
|
||||||
@@ -220,6 +222,8 @@ function RevisionRow({
|
|||||||
stayOn: number | null;
|
stayOn: number | null;
|
||||||
using: boolean;
|
using: boolean;
|
||||||
mine: boolean;
|
mine: boolean;
|
||||||
|
/** Moderators: each file links to blocking it. */
|
||||||
|
canBlock: boolean;
|
||||||
}) {
|
}) {
|
||||||
const facts = [
|
const facts = [
|
||||||
formatSize(r.size),
|
formatSize(r.size),
|
||||||
@@ -247,6 +251,11 @@ function RevisionRow({
|
|||||||
<li key={f.name} className="flex gap-3">
|
<li key={f.name} className="flex gap-3">
|
||||||
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
<span className="min-w-0 flex-1 truncate">{f.name}</span>
|
||||||
<span className="text-muted-foreground tabular-nums">{formatSize(f.size)}</span>
|
<span className="text-muted-foreground tabular-nums">{formatSize(f.size)}</span>
|
||||||
|
{canBlock && (
|
||||||
|
<Link href={`/console/blocks/${f.sha256}`} className="text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
|
||||||
|
Block
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ Sections (each shown only to those with something in it):
|
|||||||
- **Rooms**: their hosted rooms.
|
- **Rooms**: their hosted rooms.
|
||||||
- **Cases**: reports, appeals and reviews about what they control.
|
- **Cases**: reports, appeals and reviews about what they control.
|
||||||
- **People**: moderators and admins.
|
- **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).
|
- **Audit**: scoped (below).
|
||||||
- **Settings**: admins (registration, announcements, roles).
|
- **Settings**: admins (registration, announcements, roles).
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -478,7 +478,8 @@ Phases for products, catalogs and imports (products.md, console.md, imports.md):
|
|||||||
(to look into with the importer spike).
|
(to look into with the importer spike).
|
||||||
3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens;
|
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
|
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).
|
4. Imports: grants, recorded grants, requests, unclaimed creators, the job queue, `t5de-importer` (after its spike).
|
||||||
5. Charts and stats; mirroring between catalogs.
|
5. Charts and stats; mirroring between catalogs.
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -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. |
|
| `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=<cursor>` | `{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=<cursor>` | `{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/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 |
|
| `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) |
|
| `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 |
|
| `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. |
|
| `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) |
|
| `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) |
|
| `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 |
|
| `DELETE /api/v1/library/:id` | remove it |
|
||||||
|
|||||||
@@ -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");
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -92,6 +92,13 @@
|
|||||||
"when": 1793888724185,
|
"when": 1793888724185,
|
||||||
"tag": "0012_cases",
|
"tag": "0012_cases",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1793917069600,
|
||||||
|
"tag": "0013_file_blocks",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -423,9 +423,18 @@ export const productRevision = pgTable(
|
|||||||
retracted: boolean("retracted").notNull().default(false),
|
retracted: boolean("retracted").notNull().default(false),
|
||||||
createdAt: createdAt(),
|
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.
|
// Which catalogs host which revisions.
|
||||||
export const hosting = pgTable(
|
export const hosting = pgTable(
|
||||||
"hosting",
|
"hosting",
|
||||||
|
|||||||
Reference in new issue
Block a user