feat: permissions, a scoped console, and catalog management #7
No files matched your search
+580
-28
@@ -15,6 +15,7 @@ import {
|
||||
count,
|
||||
schema,
|
||||
type Database,
|
||||
type AuditTags,
|
||||
type ParentInfo,
|
||||
type RoomModerator,
|
||||
} from "@t5de/db";
|
||||
@@ -25,7 +26,7 @@ import { ImvuClient, ImvuUnavailable, NoSuchImvuUser } from "./imvu";
|
||||
import { Realtime } from "./realtime";
|
||||
import { isAdultRoom, Rooms } from "./rooms";
|
||||
import { Signer } from "./keys";
|
||||
import { ProductError, Products } from "./products";
|
||||
import { availableTo, ProductError, Products } from "./products";
|
||||
import {
|
||||
chatId,
|
||||
HostedError,
|
||||
@@ -41,8 +42,10 @@ import {
|
||||
} from "./hosted";
|
||||
import { DecorationError, Decorations, maxBatch, maxDecorationPlacement, maxDecorations } from "./decorations";
|
||||
import { Settings } from "./settings";
|
||||
import { Catalogs } from "./catalogs";
|
||||
import { assignableCatalogRoles, Forbidden, hubScope, Permissions, roles as roleBundles, type Scope } from "./permissions";
|
||||
|
||||
const { user, session, imvuLink, linkChallenge, audit } = schema;
|
||||
const { user, session, imvuLink, linkChallenge, audit, hostedRoom, catalog, product } = schema;
|
||||
|
||||
export type Deps = {
|
||||
db: Database;
|
||||
@@ -55,6 +58,8 @@ export type Deps = {
|
||||
apiURL?: string;
|
||||
/** The website's address: providers send people back only to it. */
|
||||
webURL?: string;
|
||||
/** Whether a catalog answers at an address (its /health), checked when a catalog's address changes. */
|
||||
catalogHealthy?: (url: string) => Promise<boolean>;
|
||||
/** The hub's settings (default: kept in the database, with no environment defaults). */
|
||||
settings?: Settings;
|
||||
};
|
||||
@@ -65,11 +70,19 @@ export class Problem extends Error {
|
||||
readonly status: number,
|
||||
readonly slug: string,
|
||||
message: string,
|
||||
/** More for the website, e.g. the permission that was missing. */
|
||||
readonly extra: Record<string, unknown> = {},
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** A missing permission as the API answers it: 403 forbidden, naming the permission and its scope. */
|
||||
const forbiddenProblem = (e: Forbidden) =>
|
||||
new Problem(403, "forbidden", e.message, {
|
||||
missing: { permission: e.permission, scope: e.scope.type === "hub" ? { hub: true } : { [e.scope.type]: e.scope.id } },
|
||||
});
|
||||
|
||||
type Person = typeof user.$inferSelect;
|
||||
type Link = typeof imvuLink.$inferSelect;
|
||||
|
||||
@@ -117,11 +130,14 @@ export function createApp({
|
||||
apiURL,
|
||||
webURL,
|
||||
settings: hubSettings,
|
||||
catalogHealthy,
|
||||
}: Deps) {
|
||||
const age = ageService ?? new AgeService(db, [imvuAgeProvider(imvu)]);
|
||||
const settings = hubSettings ?? new Settings(db);
|
||||
const signer = new Signer(db, apiURL ?? "http://localhost:4101");
|
||||
const products = new Products(db, signer, imvu);
|
||||
const perms = new Permissions(db);
|
||||
const catalogs = new Catalogs(db, catalogHealthy);
|
||||
const rooms = roomsService ?? new Rooms(db, imvu);
|
||||
const hosted = new HostedRooms(db);
|
||||
const decorations = new Decorations(db);
|
||||
@@ -154,6 +170,13 @@ export function createApp({
|
||||
hostedVisit: (userId, n, imvuId) => {
|
||||
hosted.visit(userId, n, imvuId).catch((e) => console.error("hosted visit:", e));
|
||||
},
|
||||
wearable: async (userId, ids) => {
|
||||
const rows = await db
|
||||
.select({ id: product.id })
|
||||
.from(product)
|
||||
.where(and(inArray(product.id, ids), availableTo(userId)));
|
||||
return new Set(rows.map((r) => r.id));
|
||||
},
|
||||
});
|
||||
const limiter = new Limiter();
|
||||
|
||||
@@ -220,8 +243,33 @@ export function createApp({
|
||||
};
|
||||
const linkView = async (l: Link) => (await linkViews(l.userId, [l]))[0]!;
|
||||
|
||||
const audited = (actorId: string | null, action: string, target: string, detail: Record<string, unknown> = {}) =>
|
||||
db.insert(audit).values({ actorId, action, target, detail });
|
||||
/** Writes an audit entry. Tags (who may see it, docs/console.md) default from the action: catalog.*, product.*
|
||||
* and hosted_room.* entries are about their target. */
|
||||
const audited = (
|
||||
actorId: string | null,
|
||||
action: string,
|
||||
target: string,
|
||||
detail: Record<string, unknown> = {},
|
||||
tags?: AuditTags,
|
||||
) => {
|
||||
const t: AuditTags = tags ?? {};
|
||||
if (!tags && target) {
|
||||
if (action.startsWith("catalog.")) t.catalog = target;
|
||||
else if (action.startsWith("product.")) t.product = target;
|
||||
else if (action.startsWith("hosted_room.")) t.room = target;
|
||||
}
|
||||
return db.insert(audit).values({ actorId, action, target, detail, tags: t });
|
||||
};
|
||||
|
||||
/** Runs a check, turning a missing permission into a Problem. */
|
||||
const need = async (who: Person, permission: Parameters<Permissions["require"]>[1], scope: Scope) => {
|
||||
try {
|
||||
await perms.require(who, permission, scope);
|
||||
} catch (e) {
|
||||
if (e instanceof Forbidden) throw forbiddenProblem(e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
/** Who's online that viewer may see: accounts showing their presence (moderators see all). */
|
||||
const visibleOnline = async (viewer: Person | undefined) => {
|
||||
@@ -298,9 +346,124 @@ export function createApp({
|
||||
visibility: c.visibility,
|
||||
membership: c.membership,
|
||||
status: c.status,
|
||||
statusReason: c.statusReason,
|
||||
maxRating: c.maxRating,
|
||||
uploads: c.uploads,
|
||||
mirrors: c.mirrors,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
});
|
||||
|
||||
const catalogParam = t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }) });
|
||||
const catalogScope = (id: string): Scope => ({ type: "catalog", id });
|
||||
|
||||
const inviteView = (i: typeof schema.catalogInvite.$inferSelect) => ({
|
||||
code: i.code,
|
||||
maxUses: i.maxUses,
|
||||
uses: i.uses,
|
||||
expiresAt: i.expiresAt?.toISOString() ?? null,
|
||||
createdAt: i.createdAt.toISOString(),
|
||||
url: `${(webURL ?? "").replace(/\/+$/, "")}/catalogs/${i.catalogId}?invite=${i.code}`,
|
||||
});
|
||||
|
||||
type ConsoleSection = "overview" | "products" | "catalogs" | "people" | "audit" | "settings";
|
||||
|
||||
/** What a person controls: the catalogs they're on the team of, their products, their hosted rooms. */
|
||||
const consoleScope = async (me: Person) => ({
|
||||
catalogs: await perms.teamCatalogs(me.id),
|
||||
products: await perms.ownProducts(me.id),
|
||||
rooms: (await db.select({ n: hostedRoom.n }).from(hostedRoom).where(eq(hostedRoom.ownerId, me.id))).map((r) => String(r.n)),
|
||||
});
|
||||
|
||||
type AuditQuery = {
|
||||
catalog?: string;
|
||||
product?: string;
|
||||
room?: string;
|
||||
person?: string;
|
||||
actor?: string;
|
||||
action?: string;
|
||||
q?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
before?: number;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Audit entries a person may see (docs/console.md): moderators all; others those they made, and those tagged
|
||||
* with a catalog they're on the team of, a product of theirs or a hosted room of theirs. Newest first.
|
||||
*/
|
||||
const auditFor = async (me: Person, scope: Awaited<ReturnType<typeof consoleScope>>, f: AuditQuery) => {
|
||||
const conds = [];
|
||||
if (!isModerator(me)) {
|
||||
const visible = [eq(audit.actorId, me.id)];
|
||||
if (scope.catalogs.length) visible.push(inArray(sql`${audit.tags}->>'catalog'`, scope.catalogs));
|
||||
if (scope.products.length) visible.push(inArray(sql`${audit.tags}->>'product'`, scope.products));
|
||||
if (scope.rooms.length) visible.push(inArray(sql`${audit.tags}->>'room'`, scope.rooms));
|
||||
conds.push(or(...visible)!);
|
||||
}
|
||||
for (const k of ["catalog", "product", "room"] as const) if (f[k]) conds.push(sql`${audit.tags}->>${k} = ${f[k]}`);
|
||||
if (f.person) {
|
||||
const [p] = await db.select({ id: user.id }).from(user).where(sql`lower(${user.username}) = lower(${f.person})`);
|
||||
conds.push(p ? or(sql`${audit.tags}->>'person' = ${p.id}`, eq(audit.actorId, p.id))! : sql`false`);
|
||||
}
|
||||
if (f.actor) {
|
||||
const [p] = await db.select({ id: user.id }).from(user).where(sql`lower(${user.username}) = lower(${f.actor})`);
|
||||
conds.push(p ? eq(audit.actorId, p.id) : sql`false`);
|
||||
}
|
||||
const esc = (v: string) => v.replace(/[%_\\]/g, (c) => "\\" + c);
|
||||
if (f.action) conds.push(ilike(audit.action, `${esc(f.action)}%`));
|
||||
if (f.q) {
|
||||
const like = `%${esc(f.q)}%`;
|
||||
conds.push(or(ilike(audit.action, like), ilike(audit.target, like), sql`${audit.detail}::text ilike ${like}`)!);
|
||||
}
|
||||
const day = (v: string | undefined) => (v && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined);
|
||||
if (day(f.from)) conds.push(sql`${audit.at} >= ${day(f.from)}::date`);
|
||||
if (day(f.to)) conds.push(sql`${audit.at} < ${day(f.to)}::date + 1`);
|
||||
if (f.before) conds.push(sql`${audit.id} < ${f.before}`);
|
||||
const rows = await db
|
||||
.select({
|
||||
id: audit.id,
|
||||
at: audit.at,
|
||||
actor: user.username,
|
||||
action: audit.action,
|
||||
target: audit.target,
|
||||
tags: audit.tags,
|
||||
detail: audit.detail,
|
||||
})
|
||||
.from(audit)
|
||||
.leftJoin(user, eq(user.id, audit.actorId))
|
||||
.where(conds.length ? and(...conds) : undefined)
|
||||
.orderBy(desc(audit.id))
|
||||
.limit(Math.min(f.limit ?? 100, 200));
|
||||
// Names for the tags, so the console can show and link them.
|
||||
const ids = (k: keyof AuditTags) => [...new Set(rows.map((r) => r.tags[k]).filter((v): v is string => !!v))];
|
||||
const [cs, ps, people] = await Promise.all([
|
||||
ids("catalog").length ? db.select({ id: catalog.id, name: catalog.name }).from(catalog).where(inArray(catalog.id, ids("catalog"))) : [],
|
||||
ids("product").length ? db.select({ id: product.id, name: product.name }).from(product).where(inArray(product.id, ids("product"))) : [],
|
||||
ids("person").length
|
||||
? db.select({ id: user.id, name: user.username }).from(user).where(inArray(user.id, ids("person")))
|
||||
: [],
|
||||
]);
|
||||
const names: Record<string, string> = {};
|
||||
for (const x of [...cs, ...ps, ...people]) names[x.id] = x.name ?? "";
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
at: r.at.toISOString(),
|
||||
actor: r.actor ?? "",
|
||||
action: r.action,
|
||||
target: r.target,
|
||||
tags: r.tags,
|
||||
tagNames: Object.fromEntries(Object.values(r.tags).filter((v): v is string => !!v && names[v] !== undefined).map((v) => [v, names[v]!])),
|
||||
detail: r.detail,
|
||||
}));
|
||||
};
|
||||
|
||||
const personByUsername = async (username: string) => {
|
||||
const [p] = await db.select().from(user).where(sql`lower(${user.username}) = lower(${username.trim()})`);
|
||||
if (!p) throw new Problem(404, "not_found", `Nobody on T5DE is called ${username.trim()}.`);
|
||||
return p;
|
||||
};
|
||||
|
||||
/** Runs a hosted-room operation, turning its errors into Problems. */
|
||||
const hostedCall = async <T>(f: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
@@ -452,7 +615,7 @@ export function createApp({
|
||||
.onError(({ code, error, set }) => {
|
||||
if (code === "Problem") {
|
||||
set.status = error.status;
|
||||
return { error: error.slug, message: error.message };
|
||||
return { error: error.slug, message: error.message, ...error.extra };
|
||||
}
|
||||
if (code === "VALIDATION") {
|
||||
set.status = 400;
|
||||
@@ -501,7 +664,19 @@ export function createApp({
|
||||
})
|
||||
|
||||
// Who's signed in, and the home page.
|
||||
.get("/me", async ({ me }) => ({ person: { ...personView(me), picture: await picture(me.id) } }), { account: true })
|
||||
.get(
|
||||
"/me",
|
||||
async ({ me, person }) => {
|
||||
// Whether the console is for them: moderators, and anyone controlling a catalog, product or room.
|
||||
let console = false;
|
||||
if (person) {
|
||||
const scope = await consoleScope(person);
|
||||
console = isModerator(person) || scope.catalogs.length + scope.products.length + scope.rooms.length > 0;
|
||||
}
|
||||
return { person: { ...personView(me), picture: await picture(me.id), console } };
|
||||
},
|
||||
{ account: true },
|
||||
)
|
||||
// The hub's public settings: whether new accounts wait for approval.
|
||||
.get("/hub", async () => ({ registration: await settings.registration() }))
|
||||
.get("/home", async ({ person }) => {
|
||||
@@ -1104,6 +1279,298 @@ export function createApp({
|
||||
catalogs: (await products.catalogs(person?.id)).map((c) => ({ ...catalogView(c), mine: c.ownerId === person?.id })),
|
||||
}))
|
||||
|
||||
// Managing a catalog (docs/products.md; permissions in docs/console.md).
|
||||
.get(
|
||||
"/catalogs/:id",
|
||||
async ({ person, params }) => {
|
||||
const c = await productCall(() => catalogs.get(params.id));
|
||||
const held = [...(await perms.at(person, catalogScope(c.id)))];
|
||||
const team = held.includes("audit:read");
|
||||
if (c.status !== "approved" && !team) throw new Problem(404, "not_found", "No such catalog.");
|
||||
return {
|
||||
catalog: { ...catalogView(c), mine: c.ownerId === person?.id },
|
||||
membership: person ? ((await catalogs.membership(c.id, person.id)) ?? null) : null,
|
||||
permissions: held,
|
||||
roles: await perms.rolesAt(person, catalogScope(c.id)),
|
||||
hosted: await catalogs.hostedCounts(c.id),
|
||||
members: team ? await catalogs.memberCounts(c.id) : null,
|
||||
};
|
||||
},
|
||||
{ params: catalogParam },
|
||||
)
|
||||
.patch(
|
||||
"/catalogs/:id",
|
||||
async ({ me, params, body }) => {
|
||||
await need(me, "catalog:edit", catalogScope(params.id));
|
||||
const r = await productCall(() => catalogs.update(params.id, body));
|
||||
if (Object.keys(r.changed).length) await audited(me.id, "catalog.edit", params.id, { changed: r.changed });
|
||||
return { catalog: catalogView(r.catalog) };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: catalogParam,
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String({ maxLength: 64 })),
|
||||
url: t.Optional(t.String({ maxLength: 300 })),
|
||||
contact: t.Optional(t.String({ maxLength: 200 })),
|
||||
visibility: t.Optional(t.Union([t.Literal("public"), t.Literal("private")])),
|
||||
membership: t.Optional(t.Union([t.Literal("open"), t.Literal("approval"), t.Literal("invite")])),
|
||||
maxRating: t.Optional(t.Union([t.Literal("general"), t.Literal("adult")])),
|
||||
uploads: t.Optional(t.Union([t.Literal("anyone"), t.Literal("members"), t.Literal("team")])),
|
||||
mirrors: t.Optional(t.Union([t.Literal("auto"), t.Literal("approve"), t.Literal("off")])),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/catalogs/:id/key",
|
||||
async ({ me, params }) => {
|
||||
await need(me, "catalog:rotate-key", catalogScope(params.id));
|
||||
const key = await productCall(() => catalogs.rotateKey(params.id));
|
||||
await audited(me.id, "catalog.rotate_key", params.id);
|
||||
return { key };
|
||||
},
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
.post(
|
||||
"/catalogs/:id/suspend",
|
||||
async ({ me, params, body }) => {
|
||||
await need(me, "catalog:suspend", catalogScope(params.id));
|
||||
const c = await productCall(() => catalogs.setSuspended(params.id, true, body.reason.trim()));
|
||||
await audited(me.id, "catalog.suspend", c.id, { name: c.name, reason: body.reason.trim() });
|
||||
return { catalog: catalogView(c) };
|
||||
},
|
||||
{ signedIn: true, params: catalogParam, body: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }) },
|
||||
)
|
||||
.post(
|
||||
"/catalogs/:id/reinstate",
|
||||
async ({ me, params }) => {
|
||||
await need(me, "catalog:suspend", catalogScope(params.id));
|
||||
const c = await productCall(() => catalogs.setSuspended(params.id, false, ""));
|
||||
await audited(me.id, "catalog.reinstate", c.id, { name: c.name });
|
||||
return { catalog: catalogView(c) };
|
||||
},
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
|
||||
// A catalog's team: the owner, and the roles it gave.
|
||||
.get(
|
||||
"/catalogs/:id/team",
|
||||
async ({ me, params }) => {
|
||||
await need(me, "audit:read", catalogScope(params.id));
|
||||
const team = await perms.team(params.id);
|
||||
return {
|
||||
...team,
|
||||
assignable: assignableCatalogRoles.map((r) => ({
|
||||
id: r,
|
||||
name: roleBundles[r].name,
|
||||
permissions: [...roleBundles[r].permissions],
|
||||
})),
|
||||
};
|
||||
},
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
.put(
|
||||
"/catalogs/:id/team/:username",
|
||||
async ({ me, params, body }) => {
|
||||
const scope = catalogScope(params.id);
|
||||
await need(me, "team:manage", scope);
|
||||
if (!(await perms.mayGive(me, body.role, scope)))
|
||||
throw new Problem(403, "forbidden", "You can only give roles whose permissions you have.");
|
||||
const p = await personByUsername(params.username);
|
||||
await productCall(() => catalogs.addRole(params.id, p.id, body.role, me.id));
|
||||
await audited(me.id, "catalog.team_add", params.id, { person: p.username, role: body.role }, { catalog: params.id, person: p.id });
|
||||
return { team: await perms.team(params.id) };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
|
||||
body: t.Object({ role: t.Literal("catalog-operator") }),
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/catalogs/:id/team/:username",
|
||||
async ({ me, params, query }) => {
|
||||
const scope = catalogScope(params.id);
|
||||
const p = await personByUsername(params.username);
|
||||
// Anyone may leave a team; taking others off needs team:manage.
|
||||
if (p.id !== me.id) await need(me, "team:manage", scope);
|
||||
await productCall(() => catalogs.removeRole(params.id, p.id, query.role));
|
||||
await audited(me.id, "catalog.team_remove", params.id, { person: p.username, role: query.role }, { catalog: params.id, person: p.id });
|
||||
return { team: await perms.team(params.id) };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
|
||||
query: t.Object({ role: t.Literal("catalog-operator") }),
|
||||
},
|
||||
)
|
||||
|
||||
// Membership: joining and leaving; the team's view and decisions; invitations.
|
||||
.post(
|
||||
"/catalogs/:id/join",
|
||||
async ({ me, params, body }) => {
|
||||
const invite = body?.invite || undefined;
|
||||
const status = await productCall(() => catalogs.join(params.id, me.id, invite));
|
||||
await audited(me.id, status === "member" ? "catalog.join" : "catalog.request", params.id, invite ? { invite: true } : {}, {
|
||||
catalog: params.id,
|
||||
person: me.id,
|
||||
});
|
||||
if (status === "member") realtime.notify(me.id, { type: "library" });
|
||||
return { membership: status };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: catalogParam,
|
||||
body: t.Optional(t.Object({ invite: t.Optional(t.String({ maxLength: 32 })) })),
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/catalogs/:id/join",
|
||||
async ({ me, params, set }) => {
|
||||
await catalogs.leave(params.id, me.id);
|
||||
await audited(me.id, "catalog.leave", params.id, {}, { catalog: params.id, person: me.id });
|
||||
realtime.notify(me.id, { type: "library" });
|
||||
set.status = 204;
|
||||
},
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
.get(
|
||||
"/catalogs/:id/members",
|
||||
async ({ me, params, query }) => {
|
||||
await need(me, "member:approve", catalogScope(params.id));
|
||||
return { members: await catalogs.members(params.id, query.status), counts: await catalogs.memberCounts(params.id) };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: catalogParam,
|
||||
query: t.Object({
|
||||
status: t.Optional(t.Union([t.Literal("requested"), t.Literal("member"), t.Literal("banned")])),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.put(
|
||||
"/catalogs/:id/members/:username",
|
||||
async ({ me, params, body }) => {
|
||||
await need(me, body.status === "banned" ? "member:ban" : "member:approve", catalogScope(params.id));
|
||||
const p = await personByUsername(params.username);
|
||||
const before = await catalogs.membership(params.id, p.id);
|
||||
await productCall(() => catalogs.setMember(params.id, p.id, body.status, me.id));
|
||||
const action =
|
||||
body.status === "banned" ? "catalog.member_ban" : before === "requested" ? "catalog.member_approve" : "catalog.member_add";
|
||||
await audited(me.id, action, params.id, { person: p.username, ...(before ? { from: before } : {}) }, {
|
||||
catalog: params.id,
|
||||
person: p.id,
|
||||
});
|
||||
realtime.notify(p.id, { type: "library" });
|
||||
return { membership: body.status };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
|
||||
body: t.Object({ status: t.Union([t.Literal("member"), t.Literal("banned")]) }),
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/catalogs/:id/members/:username",
|
||||
async ({ me, params, set }) => {
|
||||
await need(me, "member:remove", catalogScope(params.id));
|
||||
const p = await personByUsername(params.username);
|
||||
const before = await catalogs.membership(params.id, p.id);
|
||||
await productCall(() => catalogs.removeMember(params.id, p.id));
|
||||
const action =
|
||||
before === "requested" ? "catalog.member_decline" : before === "banned" ? "catalog.member_unban" : "catalog.member_remove";
|
||||
await audited(me.id, action, params.id, { person: p.username }, { catalog: params.id, person: p.id });
|
||||
realtime.notify(p.id, { type: "library" });
|
||||
set.status = 204;
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), username: t.String({ maxLength: 64 }) }),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/catalogs/:id/invites",
|
||||
async ({ me, params }) => {
|
||||
await need(me, "member:invite", catalogScope(params.id));
|
||||
return { invites: (await catalogs.invites(params.id)).map(inviteView) };
|
||||
},
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
.post(
|
||||
"/catalogs/:id/invites",
|
||||
async ({ me, params, body, set }) => {
|
||||
await need(me, "member:invite", catalogScope(params.id));
|
||||
const inv = await productCall(() => catalogs.createInvite(params.id, me.id, body));
|
||||
await audited(me.id, "catalog.invite_create", params.id, {
|
||||
maxUses: inv.maxUses,
|
||||
expiresAt: inv.expiresAt?.toISOString() ?? null,
|
||||
});
|
||||
set.status = 201;
|
||||
return { invite: inviteView(inv) };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: catalogParam,
|
||||
body: t.Object({
|
||||
maxUses: t.Optional(t.Integer({ minimum: 1, maximum: 10_000 })),
|
||||
expiresInDays: t.Optional(t.Integer({ minimum: 1, maximum: 365 })),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.delete(
|
||||
"/catalogs/:id/invites/:code",
|
||||
async ({ me, params, set }) => {
|
||||
await need(me, "member:invite", catalogScope(params.id));
|
||||
await productCall(() => catalogs.revokeInvite(params.id, params.code));
|
||||
await audited(me.id, "catalog.invite_revoke", params.id, { code: params.code.slice(0, 3) + "…" });
|
||||
set.status = 204;
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), code: t.String({ maxLength: 32 }) }),
|
||||
},
|
||||
)
|
||||
|
||||
// What a catalog hosts, and delisting.
|
||||
.get(
|
||||
"/catalogs/:id/products",
|
||||
async ({ me, params }) => {
|
||||
await need(me, "audit:read", catalogScope(params.id));
|
||||
return { products: await catalogs.hosted(params.id) };
|
||||
},
|
||||
{ signedIn: true, params: catalogParam },
|
||||
)
|
||||
.delete(
|
||||
"/catalogs/:id/products/:product",
|
||||
async ({ me, params, query }) => {
|
||||
await need(me, "hosting:delist", catalogScope(params.id));
|
||||
const reason = query.reason.trim();
|
||||
const revisions = await productCall(() => catalogs.delist(params.id, params.product));
|
||||
await audited(me.id, "catalog.delist", params.id, { product: params.product, revisions, reason }, {
|
||||
catalog: params.id,
|
||||
product: params.product,
|
||||
});
|
||||
return { revisions };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
params: t.Object({ id: t.String({ pattern: "^[a-z2-7]{10}$" }), product: t.String({ pattern: "^[a-z2-7]{10}$" }) }),
|
||||
query: t.Object({ reason: t.String({ minLength: 1, maxLength: 500 }) }),
|
||||
},
|
||||
)
|
||||
|
||||
// For catalogs: what to act on (files to delete, revisions to mirror, its status). A suspended catalog still
|
||||
// reads it, to learn that it's suspended.
|
||||
.get(
|
||||
"/catalog/changes",
|
||||
async ({ request, query }) => {
|
||||
const c = await products.catalogFor(request.headers.get("authorization"), true);
|
||||
if (!c) throw new Problem(401, "forbidden", "Not an approved catalog.");
|
||||
return catalogs.changes(c.id, query.after ?? 0);
|
||||
},
|
||||
{ query: t.Object({ after: t.Optional(t.Numeric({ minimum: 0, multipleOf: 1 })) }) },
|
||||
)
|
||||
|
||||
// For catalogs: registering an uploaded revision.
|
||||
.post(
|
||||
"/catalog/revisions",
|
||||
@@ -1170,6 +1637,14 @@ export function createApp({
|
||||
inLibrary: lib !== undefined,
|
||||
stayOn: lib?.stayOn ?? null,
|
||||
mine: person?.id === p.product.creatorId,
|
||||
available: person ? await products.available(person.id, p.product.id) : false,
|
||||
catalogs: await Promise.all(
|
||||
(await products.hostingCatalogs(p.product.id)).map(async (c) => ({
|
||||
...c,
|
||||
membership: c.membership as "open" | "approval" | "invite",
|
||||
joined: person ? ((await catalogs.membership(c.id, person.id)) ?? null) : null,
|
||||
})),
|
||||
),
|
||||
};
|
||||
},
|
||||
{ params: productParam },
|
||||
@@ -1262,7 +1737,14 @@ export function createApp({
|
||||
.put(
|
||||
"/library/:id",
|
||||
async ({ me, params }) => {
|
||||
await productCall(async () => products.add(me.id, params.id, await personAdult(me)));
|
||||
try {
|
||||
await products.add(me.id, params.id, await personAdult(me));
|
||||
} catch (e) {
|
||||
if (e instanceof ProductError && e.slug === "members_only")
|
||||
throw new Problem(e.status, e.slug, e.message, { catalogs: await products.hostingCatalogs(params.id) });
|
||||
if (e instanceof ProductError) throw new Problem(e.status, e.slug, e.message);
|
||||
throw e;
|
||||
}
|
||||
realtime.notify(me.id, { type: "library" });
|
||||
return { ok: true };
|
||||
},
|
||||
@@ -1388,8 +1870,17 @@ export function createApp({
|
||||
// The console.
|
||||
.get(
|
||||
"/console/catalogs",
|
||||
async () => ({ catalogs: (await products.allCatalogs()).map(catalogView) }),
|
||||
{ moderator: true },
|
||||
async ({ me }) => {
|
||||
const mod = isModerator(me);
|
||||
const mine = await perms.teamCatalogs(me.id);
|
||||
if (!mod && mine.length === 0) throw new Problem(403, "forbidden", "You're not on any catalog's team.");
|
||||
const rows = mod ? await products.allCatalogs() : await db.select().from(catalog).where(inArray(catalog.id, mine));
|
||||
return {
|
||||
catalogs: rows.map((c) => ({ ...catalogView(c), mine: c.ownerId === me.id, team: mine.includes(c.id) })),
|
||||
moderator: mod,
|
||||
};
|
||||
},
|
||||
{ signedIn: true },
|
||||
)
|
||||
.post(
|
||||
"/console/catalogs/:id/:decision",
|
||||
@@ -1405,28 +1896,89 @@ export function createApp({
|
||||
.get(
|
||||
"/console",
|
||||
async ({ me }) => {
|
||||
const [counts] = await db
|
||||
.select({
|
||||
people: sql<number>`(select count(*) from ${user})::int`,
|
||||
waiting: sql<number>`(select count(*) from ${user} where not ${user.approved} and coalesce(${user.banned}, false) = false)::int`,
|
||||
links: sql<number>`(select count(*) from ${imvuLink})::int`,
|
||||
computers: sql<number>`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`,
|
||||
})
|
||||
.from(sql`(select 1) as one`);
|
||||
const log = await db
|
||||
.select({ at: audit.at, actor: user.username, action: audit.action, target: audit.target, detail: audit.detail })
|
||||
.from(audit)
|
||||
.leftJoin(user, eq(user.id, audit.actorId))
|
||||
.orderBy(desc(audit.id))
|
||||
.limit(50);
|
||||
const mod = isModerator(me);
|
||||
const scope = await consoleScope(me);
|
||||
const sections: ConsoleSection[] = ["overview"];
|
||||
if (mod || scope.products.length) sections.push("products");
|
||||
if (mod || scope.catalogs.length) sections.push("catalogs");
|
||||
if (mod) sections.push("people");
|
||||
if (sections.length > 1 || scope.rooms.length) sections.push("audit");
|
||||
if (me.role === "admin") sections.push("settings");
|
||||
if (sections.length === 1)
|
||||
throw new Problem(403, "forbidden", "The console is for people who manage something on T5DE: a product, a catalog or a room.");
|
||||
const [requests] = scope.catalogs.length
|
||||
? await db
|
||||
.select({ n: count() })
|
||||
.from(schema.catalogMember)
|
||||
.where(and(inArray(schema.catalogMember.catalogId, scope.catalogs), eq(schema.catalogMember.status, "requested")))
|
||||
: [{ n: 0 }];
|
||||
let moderation = null;
|
||||
if (mod) {
|
||||
const [counts] = await db
|
||||
.select({
|
||||
people: sql<number>`(select count(*) from ${user})::int`,
|
||||
waiting: sql<number>`(select count(*) from ${user} where not ${user.approved} and coalesce(${user.banned}, false) = false)::int`,
|
||||
links: sql<number>`(select count(*) from ${imvuLink})::int`,
|
||||
computers: sql<number>`(select count(*) from ${session} where ${session.userAgent} ilike 't5de-proxy%' and ${session.expiresAt} > now())::int`,
|
||||
pendingCatalogs: sql<number>`(select count(*) from ${catalog} where ${catalog.status} = 'pending')::int`,
|
||||
})
|
||||
.from(sql`(select 1) as one`);
|
||||
moderation = { counts: counts!, connections: realtime.connections(), online: await visibleOnline(me) };
|
||||
}
|
||||
return {
|
||||
counts: counts!,
|
||||
connections: realtime.connections(),
|
||||
online: await visibleOnline(me),
|
||||
audit: log.map((e) => ({ ...e, at: e.at.toISOString(), actor: e.actor ?? "" })),
|
||||
sections,
|
||||
mine: { products: scope.products.length, catalogs: scope.catalogs.length, rooms: scope.rooms.length },
|
||||
todo: {
|
||||
memberRequests: requests?.n ?? 0,
|
||||
pendingCatalogs: moderation?.counts.pendingCatalogs ?? 0,
|
||||
waitingPeople: moderation?.counts.waiting ?? 0,
|
||||
},
|
||||
moderation,
|
||||
audit: await auditFor(me, scope, { limit: 20 }),
|
||||
};
|
||||
},
|
||||
{ moderator: true },
|
||||
{ signedIn: true },
|
||||
)
|
||||
.get(
|
||||
"/console/audit",
|
||||
async ({ me, query }) => {
|
||||
const scope = await consoleScope(me);
|
||||
if (!isModerator(me) && !scope.catalogs.length && !scope.products.length && !scope.rooms.length)
|
||||
throw new Problem(403, "forbidden", "The console is for people who manage something on T5DE.");
|
||||
const entries = await auditFor(me, scope, query);
|
||||
return { entries, before: entries.length ? entries[entries.length - 1]!.id : null };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
query: t.Object({
|
||||
catalog: t.Optional(t.String({ maxLength: 32 })),
|
||||
product: t.Optional(t.String({ maxLength: 32 })),
|
||||
room: t.Optional(t.String({ maxLength: 32 })),
|
||||
person: t.Optional(t.String({ maxLength: 64 })),
|
||||
actor: t.Optional(t.String({ maxLength: 64 })),
|
||||
action: t.Optional(t.String({ maxLength: 64 })),
|
||||
q: t.Optional(t.String({ maxLength: 100 })),
|
||||
from: t.Optional(t.String({ maxLength: 32 })),
|
||||
to: t.Optional(t.String({ maxLength: 32 })),
|
||||
before: t.Optional(t.Numeric({ minimum: 1, multipleOf: 1 })),
|
||||
limit: t.Optional(t.Numeric({ minimum: 1, maximum: 200, multipleOf: 1 })),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/console/permissions",
|
||||
async ({ me, query }) => {
|
||||
const scope: Scope =
|
||||
query.catalog ? { type: "catalog", id: query.catalog } : query.product ? { type: "product", id: query.product } : hubScope;
|
||||
return { roles: await perms.rolesAt(me, scope), permissions: [...(await perms.at(me, scope))] };
|
||||
},
|
||||
{
|
||||
signedIn: true,
|
||||
query: t.Object({
|
||||
catalog: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })),
|
||||
product: t.Optional(t.String({ pattern: "^[a-z2-7]{10}$" })),
|
||||
}),
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/console/people",
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
// Managing catalogs (docs/products.md, docs/console.md): settings, keys, teams, members, invites, what they host,
|
||||
// suspension, and the changes feed catalogs poll. Permission checks are the routes'; this is what they do.
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, asc, count, desc, eq, gt, inArray, isNull, or, sql, schema, type Database } from "@t5de/db";
|
||||
import { ProductError } from "./products";
|
||||
|
||||
const { catalog, catalogChange, catalogInvite, catalogMember, hosting, product, productRevision, roleAssignment, user } =
|
||||
schema;
|
||||
|
||||
export type Catalog = typeof catalog.$inferSelect;
|
||||
export type MemberStatus = "requested" | "member" | "banned";
|
||||
|
||||
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
|
||||
const randomKey = () => Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString("base64url");
|
||||
// Invite codes: 10 letters and digits without look-alikes.
|
||||
const inviteCode = () => {
|
||||
const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
|
||||
return [...crypto.getRandomValues(new Uint8Array(10))].map((b) => alphabet[b % alphabet.length]).join("");
|
||||
};
|
||||
|
||||
const plain = (s: unknown, max: number) => {
|
||||
const v = typeof s === "string" ? s.trim() : "";
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (!v || v.length > max || /[\u0000-\u001f\u007f]/.test(v)) return undefined;
|
||||
return v;
|
||||
};
|
||||
|
||||
/** A catalog's address, checked and without a trailing slash (https, or localhost for development). */
|
||||
export function catalogURL(input: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(input);
|
||||
} catch {
|
||||
throw new ProductError(400, "invalid", "That address isn't a URL.");
|
||||
}
|
||||
if (url.protocol !== "https:" && url.hostname !== "localhost" && url.hostname !== "127.0.0.1")
|
||||
throw new ProductError(400, "invalid", "Catalogs need an https address.");
|
||||
return url.origin + url.pathname.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export type CatalogSettings = {
|
||||
name?: string;
|
||||
url?: string;
|
||||
contact?: string;
|
||||
visibility?: "public" | "private";
|
||||
membership?: "open" | "approval" | "invite";
|
||||
maxRating?: "general" | "adult";
|
||||
uploads?: "anyone" | "members" | "team";
|
||||
mirrors?: "auto" | "approve" | "off";
|
||||
};
|
||||
|
||||
export class Catalogs {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
/** Whether a catalog answers at an address (its /health); checked when its address changes. */
|
||||
private readonly healthy: (url: string) => Promise<boolean> = defaultHealthy,
|
||||
) {}
|
||||
|
||||
async get(id: string): Promise<Catalog> {
|
||||
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, id));
|
||||
if (!c) throw new ProductError(404, "not_found", "No such catalog.");
|
||||
return c;
|
||||
}
|
||||
|
||||
/** Changes a catalog's settings; returns the catalog and the fields that changed (old and new values). */
|
||||
async update(id: string, input: CatalogSettings) {
|
||||
const c = await this.get(id);
|
||||
const set: Partial<Catalog> = {};
|
||||
if (input.name !== undefined) {
|
||||
const name = plain(input.name, 64);
|
||||
if (!name) throw new ProductError(400, "invalid", "Names are 1-64 characters.");
|
||||
set.name = name;
|
||||
}
|
||||
if (input.contact !== undefined) {
|
||||
const contact = plain(input.contact, 200);
|
||||
if (!contact) throw new ProductError(400, "invalid", "A takedown contact is needed.");
|
||||
set.contact = contact;
|
||||
}
|
||||
if (input.url !== undefined) {
|
||||
const url = catalogURL(input.url);
|
||||
if (url !== c.url) {
|
||||
const [taken] = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.url, url));
|
||||
if (taken) throw new ProductError(409, "taken", "A catalog at that address is already registered.");
|
||||
if (!(await this.healthy(url)))
|
||||
throw new ProductError(400, "unreachable", "Nothing answered at that address's /health. Is the catalog running there?");
|
||||
set.url = url;
|
||||
}
|
||||
}
|
||||
for (const k of ["visibility", "membership", "maxRating", "uploads", "mirrors"] as const)
|
||||
if (input[k] !== undefined) (set as Record<string, unknown>)[k] = input[k];
|
||||
const changed: Record<string, { from: unknown; to: unknown }> = {};
|
||||
for (const [k, v] of Object.entries(set)) {
|
||||
const before = (c as Record<string, unknown>)[k];
|
||||
if (before !== v) changed[k] = { from: before, to: v };
|
||||
}
|
||||
if (Object.keys(changed).length === 0) return { catalog: c, changed };
|
||||
const [updated] = await this.db.update(catalog).set(set).where(eq(catalog.id, id)).returning();
|
||||
return { catalog: updated!, changed };
|
||||
}
|
||||
|
||||
/** A new key for the catalog; the old one stops working at once. */
|
||||
async rotateKey(id: string): Promise<string> {
|
||||
await this.get(id);
|
||||
const key = randomKey();
|
||||
await this.db.update(catalog).set({ keyHash: sha256(key) }).where(eq(catalog.id, id));
|
||||
return key;
|
||||
}
|
||||
|
||||
/** Suspends or reinstates an approved catalog; tells it through its changes feed. */
|
||||
async setSuspended(id: string, suspended: boolean, reason: string) {
|
||||
const c = await this.get(id);
|
||||
if (suspended && c.status !== "approved")
|
||||
throw new ProductError(409, "not_approved", "Only an approved catalog can be suspended.");
|
||||
if (!suspended && c.status !== "suspended") throw new ProductError(409, "not_suspended", "That catalog isn't suspended.");
|
||||
const status = suspended ? "suspended" : "approved";
|
||||
const [updated] = await this.db
|
||||
.update(catalog)
|
||||
.set({ status, statusReason: suspended ? reason : "" })
|
||||
.where(eq(catalog.id, id))
|
||||
.returning();
|
||||
await this.change(id, { type: "status", status });
|
||||
return updated!;
|
||||
}
|
||||
|
||||
// ---- teams ----
|
||||
|
||||
async addRole(catalogId: string, personId: string, role: string, by: string) {
|
||||
const c = await this.get(catalogId);
|
||||
if (c.ownerId === personId) throw new ProductError(409, "owner", "That's the catalog's owner.");
|
||||
await this.db
|
||||
.insert(roleAssignment)
|
||||
.values({ userId: personId, role, scopeType: "catalog", scopeId: catalogId, grantedBy: by })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeRole(catalogId: string, personId: string, role: string) {
|
||||
const gone = await this.db
|
||||
.delete(roleAssignment)
|
||||
.where(
|
||||
and(
|
||||
eq(roleAssignment.userId, personId),
|
||||
eq(roleAssignment.role, role),
|
||||
eq(roleAssignment.scopeType, "catalog"),
|
||||
eq(roleAssignment.scopeId, catalogId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (gone.length === 0) throw new ProductError(404, "not_found", "They don't have that role here.");
|
||||
}
|
||||
|
||||
// ---- members ----
|
||||
|
||||
async membership(catalogId: string, personId: string): Promise<MemberStatus | undefined> {
|
||||
const [m] = await this.db
|
||||
.select({ status: catalogMember.status })
|
||||
.from(catalogMember)
|
||||
.where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId)));
|
||||
return m?.status as MemberStatus | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A person joins: an open catalog at once, an approval one as a request, an invite one only with an invite (which
|
||||
* also lets them into approval catalogs at once). Returns their status.
|
||||
*/
|
||||
async join(catalogId: string, personId: string, invite?: string): Promise<MemberStatus> {
|
||||
const c = await this.get(catalogId);
|
||||
if (c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
|
||||
const now = await this.membership(catalogId, personId);
|
||||
if (now === "banned") throw new ProductError(403, "banned", "This catalog's team banned you from it.");
|
||||
if (now === "member") return "member";
|
||||
let status: MemberStatus;
|
||||
let usedInvite: string | null = null;
|
||||
if (invite) {
|
||||
const updated = await this.db
|
||||
.update(catalogInvite)
|
||||
.set({ uses: sql`${catalogInvite.uses} + 1` })
|
||||
.where(
|
||||
and(
|
||||
eq(catalogInvite.code, invite),
|
||||
eq(catalogInvite.catalogId, catalogId),
|
||||
isNull(catalogInvite.revokedAt),
|
||||
or(isNull(catalogInvite.expiresAt), gt(catalogInvite.expiresAt, new Date())),
|
||||
or(isNull(catalogInvite.maxUses), sql`${catalogInvite.uses} < ${catalogInvite.maxUses}`),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (updated.length === 0) throw new ProductError(410, "invite_invalid", "That invitation is used up, expired or revoked.");
|
||||
status = "member";
|
||||
usedInvite = invite;
|
||||
} else if (c.membership === "open") status = "member";
|
||||
else if (c.membership === "approval") status = "requested";
|
||||
else throw new ProductError(403, "invite_only", "This catalog takes members by invitation only.");
|
||||
await this.db
|
||||
.insert(catalogMember)
|
||||
.values({ catalogId, userId: personId, status, invite: usedInvite })
|
||||
.onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, invite: usedInvite } });
|
||||
return status;
|
||||
}
|
||||
|
||||
/** A person leaves (or withdraws their request). A ban stays. */
|
||||
async leave(catalogId: string, personId: string) {
|
||||
await this.db
|
||||
.delete(catalogMember)
|
||||
.where(
|
||||
and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId), sql`${catalogMember.status} <> 'banned'`),
|
||||
);
|
||||
}
|
||||
|
||||
async members(catalogId: string, status?: MemberStatus) {
|
||||
const conds = [eq(catalogMember.catalogId, catalogId)];
|
||||
if (status) conds.push(eq(catalogMember.status, status));
|
||||
const rows = await this.db
|
||||
.select({
|
||||
personId: catalogMember.userId,
|
||||
username: user.username,
|
||||
name: user.displayUsername,
|
||||
status: catalogMember.status,
|
||||
invite: catalogMember.invite,
|
||||
since: catalogMember.updatedAt,
|
||||
})
|
||||
.from(catalogMember)
|
||||
.innerJoin(user, eq(user.id, catalogMember.userId))
|
||||
.where(and(...conds))
|
||||
.orderBy(desc(catalogMember.updatedAt))
|
||||
.limit(500);
|
||||
return rows.map((r) => ({
|
||||
personId: r.personId,
|
||||
username: r.name ?? r.username ?? "",
|
||||
status: r.status as MemberStatus,
|
||||
invited: r.invite !== null,
|
||||
since: r.since.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async memberCounts(catalogId: string) {
|
||||
const rows = await this.db
|
||||
.select({ status: catalogMember.status, n: count() })
|
||||
.from(catalogMember)
|
||||
.where(eq(catalogMember.catalogId, catalogId))
|
||||
.groupBy(catalogMember.status);
|
||||
const get = (s: string) => rows.find((r) => r.status === s)?.n ?? 0;
|
||||
return { members: get("member"), requests: get("requested"), banned: get("banned") };
|
||||
}
|
||||
|
||||
/** The team sets a person's membership: let them in, or ban them. */
|
||||
async setMember(catalogId: string, personId: string, status: "member" | "banned", by: string) {
|
||||
await this.get(catalogId);
|
||||
await this.db
|
||||
.insert(catalogMember)
|
||||
.values({ catalogId, userId: personId, status, decidedBy: by })
|
||||
.onConflictDoUpdate({ target: [catalogMember.catalogId, catalogMember.userId], set: { status, decidedBy: by } });
|
||||
}
|
||||
|
||||
async removeMember(catalogId: string, personId: string) {
|
||||
const gone = await this.db
|
||||
.delete(catalogMember)
|
||||
.where(and(eq(catalogMember.catalogId, catalogId), eq(catalogMember.userId, personId)))
|
||||
.returning();
|
||||
if (gone.length === 0) throw new ProductError(404, "not_found", "They aren't a member, and haven't asked to be.");
|
||||
}
|
||||
|
||||
// ---- invites ----
|
||||
|
||||
async createInvite(catalogId: string, by: string, opts: { maxUses?: number; expiresInDays?: number }) {
|
||||
await this.get(catalogId);
|
||||
const [inv] = await this.db
|
||||
.insert(catalogInvite)
|
||||
.values({
|
||||
code: inviteCode(),
|
||||
catalogId,
|
||||
createdBy: by,
|
||||
maxUses: opts.maxUses ?? null,
|
||||
expiresAt: opts.expiresInDays ? new Date(Date.now() + opts.expiresInDays * 86_400_000) : null,
|
||||
})
|
||||
.returning();
|
||||
return inv!;
|
||||
}
|
||||
|
||||
async invites(catalogId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(catalogInvite)
|
||||
.where(and(eq(catalogInvite.catalogId, catalogId), isNull(catalogInvite.revokedAt)))
|
||||
.orderBy(desc(catalogInvite.createdAt));
|
||||
}
|
||||
|
||||
async revokeInvite(catalogId: string, code: string) {
|
||||
const r = await this.db
|
||||
.update(catalogInvite)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(catalogInvite.catalogId, catalogId), eq(catalogInvite.code, code), isNull(catalogInvite.revokedAt)))
|
||||
.returning();
|
||||
if (r.length === 0) throw new ProductError(404, "not_found", "No such invitation.");
|
||||
}
|
||||
|
||||
// ---- what it hosts ----
|
||||
|
||||
/** The products a catalog hosts, newest first, with the revisions it hosts of each. */
|
||||
async hosted(catalogId: string) {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
rating: product.rating,
|
||||
creator: user.displayUsername,
|
||||
creatorUsername: user.username,
|
||||
removed: product.removedAt,
|
||||
deleted: product.deletedAt,
|
||||
revisions: sql<number[]>`array_agg(${hosting.number} order by ${hosting.number})`,
|
||||
since: sql<string>`min(${hosting.createdAt})`,
|
||||
})
|
||||
.from(hosting)
|
||||
.innerJoin(product, eq(product.id, hosting.productId))
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.where(eq(hosting.catalogId, catalogId))
|
||||
.groupBy(product.id, user.id)
|
||||
.orderBy(desc(sql`min(${hosting.createdAt})`))
|
||||
.limit(500);
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
rating: r.rating,
|
||||
creator: r.creator ?? r.creatorUsername ?? "",
|
||||
removed: r.removed !== null,
|
||||
deleted: r.deleted !== null,
|
||||
revisions: r.revisions,
|
||||
since: new Date(r.since).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async hostedCounts(catalogId: string) {
|
||||
const [r] = await this.db
|
||||
.select({
|
||||
products: sql<number>`count(distinct ${hosting.productId})::int`,
|
||||
revisions: sql<number>`count(*)::int`,
|
||||
bytes: sql<number>`coalesce(sum((select sum((f->>'size')::bigint) from jsonb_array_elements(${productRevision.files}) f)), 0)::bigint`,
|
||||
})
|
||||
.from(hosting)
|
||||
.innerJoin(
|
||||
productRevision,
|
||||
and(eq(productRevision.productId, hosting.productId), eq(productRevision.number, hosting.number)),
|
||||
)
|
||||
.where(eq(hosting.catalogId, catalogId));
|
||||
return { products: r?.products ?? 0, revisions: r?.revisions ?? 0, bytes: Number(r?.bytes ?? 0) };
|
||||
}
|
||||
|
||||
/** The catalog stops hosting every revision of a product; its files go in the changes feed for deletion. */
|
||||
async delist(catalogId: string, productId: string) {
|
||||
const gone = await this.db
|
||||
.delete(hosting)
|
||||
.where(and(eq(hosting.catalogId, catalogId), eq(hosting.productId, productId)))
|
||||
.returning();
|
||||
if (gone.length === 0) throw new ProductError(404, "not_found", "This catalog doesn't host that product.");
|
||||
const revs = await this.db
|
||||
.select({ number: productRevision.number, files: productRevision.files })
|
||||
.from(productRevision)
|
||||
.where(and(eq(productRevision.productId, productId), inArray(productRevision.number, gone.map((g) => g.number))));
|
||||
for (const r of revs) await this.change(catalogId, { type: "delete", product: productId, revision: r.number, files: r.files });
|
||||
return gone.map((g) => g.number);
|
||||
}
|
||||
|
||||
// ---- the changes feed ----
|
||||
|
||||
async change(catalogId: string, data: Record<string, unknown>) {
|
||||
await this.db.insert(catalogChange).values({ catalogId, data });
|
||||
}
|
||||
|
||||
/** Changes after a cursor, oldest first (at most 500 at a time). */
|
||||
async changes(catalogId: string, after: number) {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(catalogChange)
|
||||
.where(and(eq(catalogChange.catalogId, catalogId), gt(catalogChange.id, after)))
|
||||
.orderBy(asc(catalogChange.id))
|
||||
.limit(500);
|
||||
return { changes: rows.map((r) => r.data), cursor: rows.length ? rows[rows.length - 1]!.id : after };
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultHealthy(url: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
// Permissions (docs/console.md): code checks `resource:action` permissions, never roles. A role is a bundle of
|
||||
// permissions given at a scope; hub-wide roles are user.role, owners are derived (a catalog's owner, a product's
|
||||
// creator), and other roles are role_assignment rows.
|
||||
import { and, eq, schema, type Database } from "@t5de/db";
|
||||
|
||||
const { catalog, product, roleAssignment, user } = schema;
|
||||
|
||||
export const permissions = [
|
||||
"product:edit",
|
||||
"product:edit-locked",
|
||||
"product:lock",
|
||||
"product:rate-lower",
|
||||
"product:publish",
|
||||
"product:delete",
|
||||
"product:restore",
|
||||
"product:remove",
|
||||
"product:unremove",
|
||||
"product:transfer",
|
||||
"revision:upload",
|
||||
"revision:retract",
|
||||
"catalog:approve",
|
||||
"catalog:edit",
|
||||
"catalog:rotate-key",
|
||||
"catalog:suspend",
|
||||
"catalog:delete-request",
|
||||
"catalog:delete-review",
|
||||
"catalog:transfer",
|
||||
"team:manage",
|
||||
"member:approve",
|
||||
"member:invite",
|
||||
"member:remove",
|
||||
"member:ban",
|
||||
"hosting:choose",
|
||||
"hosting:delist",
|
||||
"import:grant",
|
||||
"import:grant-on-behalf",
|
||||
"import:accept",
|
||||
"import:run",
|
||||
"import:pause",
|
||||
"import:withdraw",
|
||||
"case:read",
|
||||
"case:resolve",
|
||||
"case:assign",
|
||||
"audit:read",
|
||||
"audit:read-sensitive",
|
||||
"stats:read",
|
||||
"person:approve",
|
||||
"person:ban",
|
||||
"person:link-cap",
|
||||
"person:notes",
|
||||
"person:role",
|
||||
"person:view-as",
|
||||
"room:edit",
|
||||
"room:close",
|
||||
"room:kick",
|
||||
"room:ban",
|
||||
"room:mute",
|
||||
"settings:edit",
|
||||
] as const;
|
||||
|
||||
export type Permission = (typeof permissions)[number];
|
||||
|
||||
/** Where a permission applies. The hub covers everything. */
|
||||
export type Scope = { type: "hub" } | { type: "catalog"; id: string } | { type: "product"; id: string };
|
||||
|
||||
export const hubScope: Scope = { type: "hub" };
|
||||
|
||||
const all = (prefix: string) => permissions.filter((p) => p.startsWith(prefix + ":"));
|
||||
const without = (list: readonly Permission[], ...drop: Permission[]) => list.filter((p) => !drop.includes(p));
|
||||
|
||||
const catalogOwner: Permission[] = [
|
||||
"catalog:edit",
|
||||
"catalog:rotate-key",
|
||||
"catalog:delete-request",
|
||||
"catalog:transfer",
|
||||
"team:manage",
|
||||
...all("member"),
|
||||
"hosting:delist",
|
||||
"import:grant",
|
||||
"import:accept",
|
||||
"import:run",
|
||||
"import:pause",
|
||||
"import:withdraw",
|
||||
"case:read",
|
||||
"case:resolve",
|
||||
"audit:read",
|
||||
"stats:read",
|
||||
];
|
||||
|
||||
/** The built-in roles: name, the scope they're given at, and their permissions. */
|
||||
export const roles = {
|
||||
admin: { name: "Admin", scope: "hub", permissions: [...permissions] as Permission[] },
|
||||
moderator: {
|
||||
name: "Moderator",
|
||||
scope: "hub",
|
||||
permissions: [
|
||||
...without(all("product"), "product:transfer"),
|
||||
"revision:retract",
|
||||
"catalog:approve",
|
||||
"catalog:suspend",
|
||||
"catalog:delete-review",
|
||||
"hosting:delist",
|
||||
"import:grant-on-behalf",
|
||||
"import:withdraw",
|
||||
"import:pause",
|
||||
...all("case"),
|
||||
"person:approve",
|
||||
"person:ban",
|
||||
"person:link-cap",
|
||||
"person:notes",
|
||||
"audit:read",
|
||||
"stats:read",
|
||||
...all("room"),
|
||||
] as Permission[],
|
||||
},
|
||||
"catalog-owner": { name: "Owner", scope: "catalog", permissions: catalogOwner },
|
||||
"catalog-operator": {
|
||||
name: "Operator",
|
||||
scope: "catalog",
|
||||
permissions: without(catalogOwner, "catalog:delete-request", "catalog:transfer", "team:manage"),
|
||||
},
|
||||
creator: {
|
||||
name: "Creator",
|
||||
scope: "product",
|
||||
permissions: [
|
||||
"product:edit",
|
||||
"product:publish",
|
||||
"product:delete",
|
||||
"product:restore",
|
||||
"product:transfer",
|
||||
"revision:upload",
|
||||
"revision:retract",
|
||||
"hosting:choose",
|
||||
"case:read",
|
||||
"audit:read",
|
||||
"stats:read",
|
||||
] as Permission[],
|
||||
},
|
||||
} as const satisfies Record<string, { name: string; scope: string; permissions: Permission[] }>;
|
||||
|
||||
export type RoleId = keyof typeof roles;
|
||||
|
||||
/** Roles that can be given at a catalog (the owner is derived, never given). */
|
||||
export const assignableCatalogRoles = ["catalog-operator"] as const;
|
||||
|
||||
/** A person, as the check needs them. */
|
||||
export type Who = { id: string; role: string };
|
||||
|
||||
/** Thrown when a permission is missing; the API answers 403 with what was missing. */
|
||||
export class Forbidden extends Error {
|
||||
constructor(
|
||||
readonly permission: Permission,
|
||||
readonly scope: Scope,
|
||||
) {
|
||||
super("You don't have permission to do that.");
|
||||
}
|
||||
}
|
||||
|
||||
export class Permissions {
|
||||
constructor(private readonly db: Database) {}
|
||||
|
||||
/** A person's hub-wide permissions (their user.role). */
|
||||
hub(who: Who | undefined): Set<Permission> {
|
||||
if (who?.role === "admin") return new Set(roles.admin.permissions);
|
||||
if (who?.role === "moderator") return new Set(roles.moderator.permissions);
|
||||
return new Set();
|
||||
}
|
||||
|
||||
/** The roles a person holds at a scope, broader scopes' included ("admin", "catalog-owner", ...). */
|
||||
async rolesAt(who: Who | undefined, scope: Scope): Promise<RoleId[]> {
|
||||
if (!who) return [];
|
||||
const out: RoleId[] = [];
|
||||
if (who.role === "admin") out.push("admin");
|
||||
else if (who.role === "moderator") out.push("moderator");
|
||||
if (scope.type === "catalog") {
|
||||
const [c] = await this.db.select({ ownerId: catalog.ownerId }).from(catalog).where(eq(catalog.id, scope.id));
|
||||
if (c?.ownerId === who.id) out.push("catalog-owner");
|
||||
const given = await this.db
|
||||
.select({ role: roleAssignment.role })
|
||||
.from(roleAssignment)
|
||||
.where(
|
||||
and(eq(roleAssignment.userId, who.id), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, scope.id)),
|
||||
);
|
||||
for (const g of given) if (g.role in roles) out.push(g.role as RoleId);
|
||||
} else if (scope.type === "product") {
|
||||
const [p] = await this.db.select({ creatorId: product.creatorId }).from(product).where(eq(product.id, scope.id));
|
||||
if (p?.creatorId === who.id) out.push("creator");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Every permission a person holds at a scope. */
|
||||
async at(who: Who | undefined, scope: Scope): Promise<Set<Permission>> {
|
||||
const out = new Set<Permission>();
|
||||
for (const r of await this.rolesAt(who, scope)) for (const p of roles[r].permissions) out.add(p);
|
||||
return out;
|
||||
}
|
||||
|
||||
async can(who: Who | undefined, permission: Permission, scope: Scope): Promise<boolean> {
|
||||
if (this.hub(who).has(permission)) return true;
|
||||
if (scope.type === "hub") return false;
|
||||
return (await this.at(who, scope)).has(permission);
|
||||
}
|
||||
|
||||
/** Throws Forbidden unless the person holds the permission at the scope. */
|
||||
async require(who: Who | undefined, permission: Permission, scope: Scope): Promise<void> {
|
||||
if (!(await this.can(who, permission, scope))) throw new Forbidden(permission, scope);
|
||||
}
|
||||
|
||||
/** May `who` give (or take away) `role` at `scope`? Only if they hold every permission in it there. */
|
||||
async mayGive(who: Who | undefined, role: RoleId, scope: Scope): Promise<boolean> {
|
||||
const held = await this.at(who, scope);
|
||||
return roles[role].permissions.every((p) => held.has(p));
|
||||
}
|
||||
|
||||
/** The catalogs a person is on the team of (owner or a role there). */
|
||||
async teamCatalogs(personId: string): Promise<string[]> {
|
||||
const owned = await this.db.select({ id: catalog.id }).from(catalog).where(eq(catalog.ownerId, personId));
|
||||
const given = await this.db
|
||||
.select({ id: roleAssignment.scopeId })
|
||||
.from(roleAssignment)
|
||||
.where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog")));
|
||||
return [...new Set([...owned.map((c) => c.id), ...given.map((g) => g.id)])];
|
||||
}
|
||||
|
||||
/** The products a person created. */
|
||||
async ownProducts(personId: string): Promise<string[]> {
|
||||
return (await this.db.select({ id: product.id }).from(product).where(eq(product.creatorId, personId))).map((p) => p.id);
|
||||
}
|
||||
|
||||
/** A catalog's team: the owner and everyone given a role there, with their names. */
|
||||
async team(catalogId: string) {
|
||||
const [c] = await this.db
|
||||
.select({ ownerId: catalog.ownerId, name: user.displayUsername, username: user.username })
|
||||
.from(catalog)
|
||||
.innerJoin(user, eq(user.id, catalog.ownerId))
|
||||
.where(eq(catalog.id, catalogId));
|
||||
const given = await this.db
|
||||
.select({
|
||||
personId: roleAssignment.userId,
|
||||
role: roleAssignment.role,
|
||||
name: user.displayUsername,
|
||||
username: user.username,
|
||||
since: roleAssignment.createdAt,
|
||||
})
|
||||
.from(roleAssignment)
|
||||
.innerJoin(user, eq(user.id, roleAssignment.userId))
|
||||
.where(and(eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, catalogId)));
|
||||
return {
|
||||
owner: c ? { personId: c.ownerId, username: c.name ?? c.username ?? "" } : undefined,
|
||||
roles: given.map((g) => ({
|
||||
personId: g.personId,
|
||||
role: g.role,
|
||||
username: g.name ?? g.username ?? "",
|
||||
since: g.since.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { cleanHTML, maxDescription } from "./html";
|
||||
import type { ImvuClient } from "./imvu";
|
||||
import type { Signer } from "./keys";
|
||||
|
||||
const { catalog, product, productRevision, hosting, library, user } = schema;
|
||||
const { catalog, catalogMember, product, productRevision, hosting, library, roleAssignment, user } = schema;
|
||||
|
||||
export type Catalog = typeof catalog.$inferSelect;
|
||||
export type Product = typeof product.$inferSelect;
|
||||
@@ -74,6 +74,21 @@ const sameFiles = (a: FileEntry[], b: FileEntry[]) =>
|
||||
// A parent's info another revision looked up lately is reused rather than asking IMVU again.
|
||||
const parentInfoFresh = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Whether a product is available to a person (docs/products.md, "Member"): they made it, or a catalog hosting it is
|
||||
* approved, hasn't banned them, and is open or has them as a member or on its team.
|
||||
*/
|
||||
export const availableTo = (personId: string) => sql`(
|
||||
${product.creatorId} = ${personId} or exists (
|
||||
select 1 from ${hosting} h
|
||||
join ${catalog} c on c.id = h.catalog_id
|
||||
left join ${catalogMember} m on m.catalog_id = c.id and m.user_id = ${personId}
|
||||
where h.product_id = ${product.id} and c.status = 'approved' and coalesce(m.status, '') <> 'banned'
|
||||
and (c.membership = 'open' or m.status = 'member' or c.owner_id = ${personId}
|
||||
or exists (select 1 from ${roleAssignment} r
|
||||
where r.user_id = ${personId} and r.scope_type = 'catalog' and r.scope_id = c.id))
|
||||
))`;
|
||||
|
||||
/** Each product's latest revision (joined on product.latest, for lists). */
|
||||
const latestJoin = and(eq(productRevision.productId, product.id), eq(productRevision.number, product.latest));
|
||||
|
||||
@@ -123,12 +138,13 @@ export class Products {
|
||||
return { catalog: c!, key };
|
||||
}
|
||||
|
||||
/** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved and the key is right. */
|
||||
async catalogFor(header: string | null): Promise<Catalog | undefined> {
|
||||
/** The catalog an `Authorization: Catalog <id>:<key>` header names, if it's approved (or suspended, when asked)
|
||||
* and the key is right. */
|
||||
async catalogFor(header: string | null, suspendedToo = false): Promise<Catalog | undefined> {
|
||||
const m = header?.match(/^Catalog ([a-z2-7]{10}):(\S+)$/);
|
||||
if (!m) return undefined;
|
||||
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, m[1]!));
|
||||
if (!c || c.status !== "approved") return undefined;
|
||||
if (!c || !(c.status === "approved" || (suspendedToo && c.status === "suspended"))) return undefined;
|
||||
return timingSafeEqual(Buffer.from(sha256(m[2]!)), Buffer.from(c.keyHash)) ? c : undefined;
|
||||
}
|
||||
|
||||
@@ -163,6 +179,23 @@ export class Products {
|
||||
const [c] = await this.db.select().from(catalog).where(eq(catalog.id, catalogId));
|
||||
if (!c || c.status !== "approved") throw new ProductError(404, "not_found", "No such catalog.");
|
||||
if (productId) await this.own(personId, productId);
|
||||
if (c.uploads !== "anyone" && c.ownerId !== personId) {
|
||||
const [team] = await this.db
|
||||
.select({ role: roleAssignment.role })
|
||||
.from(roleAssignment)
|
||||
.where(and(eq(roleAssignment.userId, personId), eq(roleAssignment.scopeType, "catalog"), eq(roleAssignment.scopeId, c.id)));
|
||||
const [m] = await this.db
|
||||
.select({ status: catalogMember.status })
|
||||
.from(catalogMember)
|
||||
.where(and(eq(catalogMember.catalogId, c.id), eq(catalogMember.userId, personId)));
|
||||
const ok = team !== undefined || (c.uploads === "members" && m?.status === "member");
|
||||
if (!ok)
|
||||
throw new ProductError(
|
||||
403,
|
||||
"forbidden",
|
||||
c.uploads === "members" ? "Only this catalog's members can upload to it." : "Only this catalog's team can upload to it.",
|
||||
);
|
||||
}
|
||||
const token = await this.signer.sign(
|
||||
"t5de-upload",
|
||||
{ sub: personId, catalog: c.id, ...(productId ? { product: productId } : {}) },
|
||||
@@ -183,6 +216,8 @@ export class Products {
|
||||
if (!files) throw new ProductError(400, "invalid", "The file list isn't valid.");
|
||||
if (body.rating !== "general" && body.rating !== "adult")
|
||||
throw new ProductError(400, "invalid", "The rating must be general or adult.");
|
||||
if (body.rating === "adult" && c.maxRating === "general")
|
||||
throw new ProductError(400, "rating_ceiling", "This catalog hosts general-audience products only.");
|
||||
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;
|
||||
@@ -427,7 +462,7 @@ export class Products {
|
||||
.innerJoin(product, eq(product.id, library.productId))
|
||||
.innerJoin(user, eq(user.id, product.creatorId))
|
||||
.leftJoin(productRevision, latestJoin)
|
||||
.where(and(eq(library.userId, personId), isNull(product.removedAt)))
|
||||
.where(and(eq(library.userId, personId), isNull(product.removedAt), availableTo(personId)))
|
||||
.orderBy(desc(library.addedAt));
|
||||
}
|
||||
|
||||
@@ -443,9 +478,28 @@ export class Products {
|
||||
const p = await this.get(productId, adult);
|
||||
if (p.product.deletedAt && !(await this.inLibrary(personId, productId)))
|
||||
throw new ProductError(410, "deleted", "Its creator deleted this product.");
|
||||
if (!(await this.available(personId, productId)))
|
||||
throw new ProductError(403, "members_only", "Join a catalog hosting this product to get it.");
|
||||
await this.db.insert(library).values({ userId: personId, productId }).onConflictDoNothing();
|
||||
}
|
||||
|
||||
async available(personId: string, productId: string): Promise<boolean> {
|
||||
const [row] = await this.db
|
||||
.select({ id: product.id })
|
||||
.from(product)
|
||||
.where(and(eq(product.id, productId), availableTo(personId)));
|
||||
return row !== undefined;
|
||||
}
|
||||
|
||||
/** The approved catalogs hosting a product, with their membership rule (for "join to get it"). */
|
||||
async hostingCatalogs(productId: string) {
|
||||
return this.db
|
||||
.selectDistinct({ id: catalog.id, name: catalog.name, membership: catalog.membership, visibility: catalog.visibility })
|
||||
.from(hosting)
|
||||
.innerJoin(catalog, eq(catalog.id, hosting.catalogId))
|
||||
.where(and(eq(hosting.productId, productId), eq(catalog.status, "approved")));
|
||||
}
|
||||
|
||||
async remove(personId: string, productId: string) {
|
||||
await this.db.delete(library).where(and(eq(library.userId, personId), eq(library.productId, productId)));
|
||||
}
|
||||
|
||||
@@ -121,8 +121,15 @@ export type RealtimeDeps = {
|
||||
hostedVisit?: (userId: string, n: number, imvuId: number) => void;
|
||||
/** The IMVU username of a linked account, from its verified link ("" if unknown). */
|
||||
accountName?: (imvuId: number) => Promise<string>;
|
||||
/**
|
||||
* Of these hub product IDs, the ones a person may wear (available to them: docs/products.md, "Member"). Outfit
|
||||
* items with other hub IDs aren't relayed; items that aren't hub IDs (local packs) are.
|
||||
*/
|
||||
wearable?: (userId: string, productIds: string[]) => Promise<Set<string>>;
|
||||
};
|
||||
|
||||
const hubProductID = /^[a-z2-7]{10}$/;
|
||||
|
||||
const maxChats = 16;
|
||||
const chatPattern = /^[0-9]{1,20}$/;
|
||||
const roomPattern = /^[0-9]{1,12}-[0-9]{1,12}$/;
|
||||
@@ -179,6 +186,7 @@ export class Realtime {
|
||||
hostedBoot: async () => {},
|
||||
hostedVisit: () => {},
|
||||
accountName: async () => "",
|
||||
wearable: async (_, ids) => new Set(ids),
|
||||
...d,
|
||||
};
|
||||
}
|
||||
@@ -644,10 +652,16 @@ export class Realtime {
|
||||
const items = m.items ?? [];
|
||||
if (!Array.isArray(items)) return { type: "error", message: "items must be a list" };
|
||||
if (items.length > 64) return { type: "error", message: "too many items" };
|
||||
s.outfit = items
|
||||
let outfit = items
|
||||
.filter((it): it is Item => typeof it?.id === "string" && typeof it?.name === "string")
|
||||
.filter((it) => it.id && it.id.length <= 128 && it.name.length <= 128)
|
||||
.map((it) => ({ id: it.id, name: it.name, adult: it.adult === true }));
|
||||
const hubIds = outfit.filter((it) => hubProductID.test(it.id)).map((it) => it.id);
|
||||
if (hubIds.length > 0) {
|
||||
const ok = await this.deps.wearable(s.userId, hubIds);
|
||||
outfit = outfit.filter((it) => !hubProductID.test(it.id) || ok.has(it.id));
|
||||
}
|
||||
s.outfit = outfit;
|
||||
this.changed(s.chats.keys());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
// Managing catalogs (docs/products.md) with permissions (docs/console.md): settings, keys, teams, membership and
|
||||
// what it gates, invites, delisting, suspension, the changes feed, and the console's scoped audit log.
|
||||
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);
|
||||
return r.json.product as string;
|
||||
}
|
||||
|
||||
let admin: Person;
|
||||
|
||||
test("the first account is the admin", async () => {
|
||||
admin = await person("admin1");
|
||||
expect((await call(admin, "GET", "/api/v1/me")).json.person.role).toBe("admin");
|
||||
});
|
||||
|
||||
test("a catalog's owner changes its settings and key; others can't, and are told which permission they lack", async () => {
|
||||
const owner = await person("owner1");
|
||||
const stranger = await person("stranger1");
|
||||
const c = await catalogOf(owner, admin, "Owner Shelf");
|
||||
|
||||
// Anyone can see an approved catalog; the owner sees their permissions there.
|
||||
const seen = await call(owner, "GET", `/api/v1/catalogs/${c.id}`);
|
||||
expect(seen.status).toBe(200);
|
||||
expect(seen.json.roles).toContain("catalog-owner");
|
||||
expect(seen.json.permissions).toContain("catalog:edit");
|
||||
expect((await call(stranger, "GET", `/api/v1/catalogs/${c.id}`)).json.permissions).toEqual([]);
|
||||
|
||||
// A stranger is refused, naming what's missing.
|
||||
const refused = await call(stranger, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Mine now" });
|
||||
expect(refused.status).toBe(403);
|
||||
expect(refused.json).toMatchObject({ error: "forbidden", missing: { permission: "catalog:edit", scope: { catalog: c.id } } });
|
||||
|
||||
// The owner changes settings; an address must answer /health.
|
||||
const edited = await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { name: "Owner's Shelf", maxRating: "general", uploads: "members" });
|
||||
expect(edited.status).toBe(200);
|
||||
expect(edited.json.catalog).toMatchObject({ name: "Owner's Shelf", maxRating: "general", uploads: "members" });
|
||||
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://nowhere.example.test" })).json.error).toBe("unreachable");
|
||||
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { url: "https://moved.example.test/" })).json.catalog.url).toBe(
|
||||
"https://moved.example.test",
|
||||
);
|
||||
|
||||
// A new key: the old one stops working at once.
|
||||
const rotated = await call(owner, "POST", `/api/v1/catalogs/${c.id}/key`);
|
||||
expect(rotated.status).toBe(200);
|
||||
expect((await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth)).status).toBe(401);
|
||||
expect(
|
||||
(await call(undefined, "GET", "/api/v1/catalog/changes", undefined, { Authorization: `Catalog ${c.id}:${rotated.json.key}` })).status,
|
||||
).toBe(200);
|
||||
});
|
||||
|
||||
test("teams: the owner gives the operator role; operators manage members but not the team", async () => {
|
||||
const owner = await person("owner2");
|
||||
const op = await person("op2");
|
||||
const other = await person("other2");
|
||||
const c = await catalogOf(owner, admin, "Team Shelf", "approval");
|
||||
|
||||
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).status).toBe(403);
|
||||
const added = await call(owner, "PUT", `/api/v1/catalogs/${c.id}/team/op2`, { role: "catalog-operator" });
|
||||
expect(added.status).toBe(200);
|
||||
expect(added.json.team.roles).toMatchObject([{ username: "op2", role: "catalog-operator" }]);
|
||||
|
||||
// The operator holds member:approve, not team:manage.
|
||||
const perms = (await call(op, "GET", `/api/v1/console/permissions?catalog=${c.id}`)).json;
|
||||
expect(perms.roles).toEqual(["catalog-operator"]);
|
||||
expect(perms.permissions).toContain("member:approve");
|
||||
expect(perms.permissions).not.toContain("team:manage");
|
||||
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/team/other2`, { role: "catalog-operator" })).json.missing.permission).toBe(
|
||||
"team:manage",
|
||||
);
|
||||
|
||||
// An approval catalog: other asks, the operator approves.
|
||||
expect((await call(other, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("requested");
|
||||
const requests = await call(op, "GET", `/api/v1/catalogs/${c.id}/members?status=requested`);
|
||||
expect(requests.json.members).toMatchObject([{ username: "other2", status: "requested" }]);
|
||||
expect((await call(op, "PUT", `/api/v1/catalogs/${c.id}/members/other2`, { status: "member" })).status).toBe(200);
|
||||
expect((await call(other, "GET", `/api/v1/catalogs/${c.id}`)).json.membership).toBe("member");
|
||||
|
||||
// Operators leave on their own; the console lists the catalog for them while they're on the team.
|
||||
expect((await call(op, "GET", "/api/v1/console/catalogs")).json.catalogs.map((x: any) => x.id)).toEqual([c.id]);
|
||||
expect((await call(op, "DELETE", `/api/v1/catalogs/${c.id}/team/op2?role=catalog-operator`)).status).toBe(200);
|
||||
expect((await call(op, "GET", "/api/v1/console/catalogs")).status).toBe(403);
|
||||
});
|
||||
|
||||
test("membership gates getting and wearing products; seeing them doesn't need it", async () => {
|
||||
const owner = await person("owner3");
|
||||
const maker = await person("maker3");
|
||||
const fan = await person("fan3");
|
||||
const c = await catalogOf(owner, admin, "Invite Shelf", "invite");
|
||||
// Uploads to an invite catalog: anyone may upload by default.
|
||||
const pid = await upload(maker, c, "Hat", 31);
|
||||
|
||||
// Seeing works; getting doesn't, and says which catalogs host it.
|
||||
const page = await call(fan, "GET", `/api/v1/products/${pid}`);
|
||||
expect(page.json).toMatchObject({ available: false, catalogs: [{ id: c.id, membership: "invite", joined: null }] });
|
||||
expect((await call(fan, "GET", `/api/v1/products/${pid}/files`)).status).toBe(200);
|
||||
const refused = await call(fan, "PUT", `/api/v1/library/${pid}`);
|
||||
expect(refused.status).toBe(403);
|
||||
expect(refused.json).toMatchObject({ error: "members_only", catalogs: [{ id: c.id }] });
|
||||
|
||||
// Invite only: joining without an invite is refused; with one, they're in.
|
||||
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("invite_only");
|
||||
const inv = await call(owner, "POST", `/api/v1/catalogs/${c.id}/invites`, { maxUses: 1 });
|
||||
expect(inv.status).toBe(201);
|
||||
expect(inv.json.invite.url).toBe(`${webURL}/catalogs/${c.id}?invite=${inv.json.invite.code}`);
|
||||
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.membership).toBe("member");
|
||||
const late = await person("late3");
|
||||
expect((await call(late, "POST", `/api/v1/catalogs/${c.id}/join`, { invite: inv.json.invite.code })).json.error).toBe("invite_invalid");
|
||||
|
||||
expect((await call(fan, "PUT", `/api/v1/library/${pid}`)).status).toBe(200);
|
||||
expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]);
|
||||
|
||||
// Banned: the product stays in the table but isn't listed (or wearable) until they're back.
|
||||
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "banned" })).status).toBe(200);
|
||||
expect((await call(fan, "GET", "/api/v1/library")).json.library).toEqual([]);
|
||||
expect((await call(fan, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.error).toBe("banned");
|
||||
expect((await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/members/fan3`)).status).toBe(204);
|
||||
expect((await call(owner, "PUT", `/api/v1/catalogs/${c.id}/members/fan3`, { status: "member" })).status).toBe(200);
|
||||
expect((await call(fan, "GET", "/api/v1/library")).json.library.map((l: any) => l.product.id)).toEqual([pid]);
|
||||
|
||||
// Its creator always has their own product.
|
||||
expect((await call(maker, "PUT", `/api/v1/library/${pid}`)).status).toBe(200);
|
||||
});
|
||||
|
||||
test("a catalog's upload policy and rating ceiling apply to uploads", async () => {
|
||||
const owner = await person("owner4");
|
||||
const maker = await person("maker4");
|
||||
const c = await catalogOf(owner, admin, "Strict Shelf");
|
||||
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "members", maxRating: "general" })).status).toBe(200);
|
||||
|
||||
const refused = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
||||
expect(refused).toMatchObject({ status: 403, json: { error: "forbidden" } });
|
||||
expect((await call(maker, "POST", `/api/v1/catalogs/${c.id}/join`, {})).json.membership).toBe("member");
|
||||
const tok = await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id });
|
||||
expect(tok.status).toBe(200);
|
||||
const adult = await call(
|
||||
undefined,
|
||||
"POST",
|
||||
"/api/v1/catalog/revisions",
|
||||
{ uploadToken: tok.json.token, files: [file("index.xml", 41)], rating: "adult", parent: 0, name: "Spicy", notes: "" },
|
||||
c.auth,
|
||||
);
|
||||
expect(adult).toMatchObject({ status: 400, json: { error: "rating_ceiling" } });
|
||||
|
||||
// Team only: members can't either.
|
||||
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { uploads: "team" })).status).toBe(200);
|
||||
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(403);
|
||||
expect((await call(owner, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(200);
|
||||
});
|
||||
|
||||
test("delisting and suspension reach the catalog through its changes feed", async () => {
|
||||
const owner = await person("owner5");
|
||||
const maker = await person("maker5");
|
||||
const c = await catalogOf(owner, admin, "Feed Shelf");
|
||||
const pid = await upload(maker, c, "Scarf", 51);
|
||||
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toMatchObject([{ id: pid, revisions: [1] }]);
|
||||
|
||||
expect((await call(maker, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=no`)).status).toBe(403);
|
||||
const delisted = await call(owner, "DELETE", `/api/v1/catalogs/${c.id}/products/${pid}?reason=${encodeURIComponent("off-topic")}`);
|
||||
expect(delisted.json.revisions).toEqual([1]);
|
||||
expect((await call(owner, "GET", `/api/v1/catalogs/${c.id}/products`)).json.products).toEqual([]);
|
||||
|
||||
const feed = await call(undefined, "GET", "/api/v1/catalog/changes", undefined, c.auth);
|
||||
expect(feed.json.changes).toEqual([{ type: "delete", product: pid, revision: 1, files: [file("index.xml", 51)] }]);
|
||||
const cursor = feed.json.cursor;
|
||||
expect((await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth)).json.changes).toEqual([]);
|
||||
|
||||
// Owners can't suspend their own catalog; moderators can. A suspended catalog can't register revisions, still
|
||||
// reads its feed, and is told.
|
||||
expect((await call(owner, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "x" })).status).toBe(403);
|
||||
expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/suspend`, { reason: "spam" })).json.catalog).toMatchObject({
|
||||
status: "suspended",
|
||||
statusReason: "spam",
|
||||
});
|
||||
expect((await call(maker, "POST", "/api/v1/products/upload", { catalog: c.id })).status).toBe(404);
|
||||
const told = await call(undefined, "GET", `/api/v1/catalog/changes?after=${cursor}`, undefined, c.auth);
|
||||
expect(told.json.changes).toEqual([{ type: "status", status: "suspended" }]);
|
||||
expect((await call(admin, "POST", `/api/v1/catalogs/${c.id}/reinstate`)).json.catalog.status).toBe("approved");
|
||||
});
|
||||
|
||||
test("the console is for everyone who controls something; its audit log shows only what they may see", async () => {
|
||||
const owner = await person("owner6");
|
||||
const maker = await person("maker6");
|
||||
const nobody = await person("nobody6");
|
||||
const c = await catalogOf(owner, admin, "Audit Shelf");
|
||||
const other = await catalogOf(await person("owner6b"), admin, "Other Shelf");
|
||||
const pid = await upload(maker, c, "Gloves", 61);
|
||||
expect((await call(owner, "PATCH", `/api/v1/catalogs/${c.id}`, { contact: "[email protected]" })).status).toBe(200);
|
||||
expect((await call(maker, "PATCH", `/api/v1/products/${pid}`, { name: "Warm Gloves" })).status).toBe(200);
|
||||
expect((await call(admin, "PATCH", `/api/v1/catalogs/${other.id}`, { name: "Other Shelf 2" })).status).toBe(200);
|
||||
|
||||
// Sections by what they control.
|
||||
expect((await call(nobody, "GET", "/api/v1/console")).status).toBe(403);
|
||||
expect((await call(nobody, "GET", "/api/v1/console/audit")).status).toBe(403);
|
||||
const ownerConsole = (await call(owner, "GET", "/api/v1/console")).json;
|
||||
expect(ownerConsole.sections).toEqual(["overview", "catalogs", "audit"]);
|
||||
expect(ownerConsole.moderation).toBeNull();
|
||||
expect((await call(maker, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "audit"]);
|
||||
expect((await call(admin, "GET", "/api/v1/console")).json.sections).toEqual(["overview", "products", "catalogs", "people", "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;
|
||||
expect(ownerLog.some((e: any) => e.action === "catalog.edit" && e.tags.catalog === c.id)).toBe(true);
|
||||
expect(ownerLog.some((e: any) => e.tags.catalog === other.id)).toBe(false);
|
||||
expect(ownerLog.find((e: any) => e.tags.catalog === c.id).tagNames[c.id]).toBe("Audit Shelf");
|
||||
const makerLog = (await call(maker, "GET", "/api/v1/console/audit")).json.entries;
|
||||
expect(makerLog.map((e: any) => e.action)).toContain("product.edit");
|
||||
expect(makerLog.some((e: any) => e.tags.catalog === c.id && e.action === "catalog.edit")).toBe(false);
|
||||
|
||||
// Admins see everything, and filter.
|
||||
const filtered = (await call(admin, "GET", `/api/v1/console/audit?catalog=${other.id}&action=catalog.edit`)).json.entries;
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0]).toMatchObject({ actor: "admin1", action: "catalog.edit" });
|
||||
expect((await call(admin, "GET", "/api/v1/console/audit?actor=maker6")).json.entries.every((e: any) => e.actor === "maker6")).toBe(true);
|
||||
expect((await call(admin, "GET", "/api/v1/console/audit?q=legal@example")).json.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("outfits relay only the hub products the wearer may wear; local packs pass", async () => {
|
||||
const { Realtime } = await import("../src/realtime");
|
||||
const rt = new Realtime({
|
||||
owner: async () => "u1",
|
||||
wearable: async (_, ids) => new Set(ids.filter((id) => id === "aaaaaaaaaa")),
|
||||
});
|
||||
rt.open("s1", "u1", () => {});
|
||||
await rt.message("s1", { type: "session", imvu_id: 7 });
|
||||
await rt.message("s1", {
|
||||
type: "outfit",
|
||||
items: [
|
||||
{ id: "aaaaaaaaaa", name: "Mine", adult: false },
|
||||
{ id: "bbbbbbbbbb", name: "Members only", adult: false },
|
||||
{ id: "local-pack-1", name: "A pack", adult: false },
|
||||
],
|
||||
});
|
||||
expect(rt.online()[0]!.outfit.map((i) => i.id)).toEqual(["aaaaaaaaaa", "local-pack-1"]);
|
||||
});
|
||||
@@ -229,8 +229,8 @@ describe("T5DE's proxy", () => {
|
||||
const admin = new Browser();
|
||||
await admin.call("POST", "/api/auth/sign-in/username", { username: "alice", password: "a long password" });
|
||||
const consoleView = (await admin.call("GET", "/api/v1/console")).json;
|
||||
expect(consoleView.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
|
||||
expect(consoleView.counts.computers).toBe(1);
|
||||
expect(consoleView.moderation.online[0]).toMatchObject({ username: "ErinAv", hidden: true });
|
||||
expect(consoleView.moderation.counts.computers).toBe(1);
|
||||
await erin.call("PATCH", "/api/v1/account/links/77", { showPresence: true, showBadge: true });
|
||||
const online = (await visitor.call("GET", "/api/v1/online")).json.online;
|
||||
expect(online).toEqual([
|
||||
|
||||
@@ -63,7 +63,7 @@ test("closed: new accounts wait for approval; the first one runs the hub", async
|
||||
expect((await call(bo, "GET", "/api/v1/console")).status).toBe(403);
|
||||
|
||||
// Moderators see who's waiting, and approve them.
|
||||
expect((await call(admin, "GET", "/api/v1/console")).json.counts.waiting).toBe(1);
|
||||
expect((await call(admin, "GET", "/api/v1/console")).json.moderation.counts.waiting).toBe(1);
|
||||
const waiting = (await call(admin, "GET", "/api/v1/console/people?waiting=true")).json.people;
|
||||
expect(waiting.map((p: any) => p.username)).toEqual(["bo_"]);
|
||||
expect((await call(admin, "POST", `/api/v1/console/people/${waiting[0].id}/approve`)).json.person.approved).toBe(true);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { membership as membershipLabel } from "@/components/catalogs";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { api, getMe, unwrap } from "@/lib/api";
|
||||
import { membership } from "../../console/catalogs/actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Catalog" };
|
||||
|
||||
export default async function CatalogPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ invite?: string | string[] }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const raw = (await searchParams).invite;
|
||||
const invite = typeof raw === "string" && /^[a-z0-9]{1,32}$/.test(raw) ? raw : "";
|
||||
const [me, answer] = await Promise.all([getMe(), api.catalogs({ id }).get()]);
|
||||
if (answer.error) notFound();
|
||||
const data = unwrap(answer);
|
||||
const c = data.catalog;
|
||||
const manage = data.permissions.includes("audit:read");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={c.name}>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span>{membershipLabel[c.membership] ?? c.membership}</span>
|
||||
{c.maxRating === "general" && <Tag>General audiences</Tag>}
|
||||
{data.membership === "member" && <Tag tone="strong">Member</Tag>}
|
||||
{data.membership === "requested" && <Tag>Asked to join</Tag>}
|
||||
{data.membership === "banned" && <Tag tone="danger">Banned</Tag>}
|
||||
</span>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Products</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">{data.hosted.products}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>Takedown Contact</CardDescription>
|
||||
<CardTitle className="text-base font-normal break-all">{c.contact}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Membership</CardTitle>
|
||||
<CardDescription>
|
||||
{c.membership === "open"
|
||||
? "Anyone on T5DE can get its products."
|
||||
: c.membership === "approval"
|
||||
? "Ask to join; its team decides. Anyone can see its products on others."
|
||||
: "Join with an invitation. Anyone can see its products on others."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!me ? (
|
||||
<Link href={`/signin?next=${encodeURIComponent(`/catalogs/${id}${invite ? `?invite=${invite}` : ""}`)}`} className={buttonVariants()}>
|
||||
Sign in to join
|
||||
</Link>
|
||||
) : data.membership === "banned" ? (
|
||||
<p className="text-sm text-muted-foreground">This catalog's team banned you from it.</p>
|
||||
) : data.membership === "member" || data.membership === "requested" ? (
|
||||
<ActionForm action={membership} fields={{ id, intent: "leave" }}>
|
||||
<SubmitButton variant="outline" pendingLabel="Leaving…" confirm={data.membership === "member" ? `Leave ${c.name}?` : undefined}>
|
||||
{data.membership === "member" ? "Leave" : "Withdraw request"}
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
) : c.membership === "open" ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing to join: get its products from their pages.</p>
|
||||
) : c.membership === "invite" && !invite ? (
|
||||
<p className="text-sm text-muted-foreground">You need an invitation link from its team.</p>
|
||||
) : (
|
||||
<ActionForm action={membership} fields={{ id, intent: "join", invite }}>
|
||||
<SubmitButton pendingLabel="Joining…">{invite ? "Accept invitation" : "Ask to join"}</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{manage && (
|
||||
<Link href={`/console/catalogs/${id}`} className={buttonVariants({ variant: "outline" })}>
|
||||
Manage
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CatalogStatus, membership } from "@/components/catalogs";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
@@ -71,7 +72,11 @@ function CatalogTable({ catalogs, showStatus = false }: { catalogs: Catalog[]; s
|
||||
<TableBody>
|
||||
{catalogs.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={showStatus ? `/console/catalogs/${c.id}` : `/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">{c.url}</TableCell>
|
||||
<TableCell>{membership[c.membership] ?? c.membership}</TableCell>
|
||||
{showStatus && <TableCell className="capitalize">{c.visibility}</TableCell>}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from "next/link";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import type { AuditEntry } from "@/lib/api";
|
||||
|
||||
const tagLinks: Record<string, (id: string) => string> = {
|
||||
catalog: (id) => `/console/catalogs/${id}`,
|
||||
product: (id) => `/products/${id}`,
|
||||
room: (id) => `/rooms/${id}/edit`,
|
||||
};
|
||||
|
||||
/** Audit entries: when, who, what, which resources (linked), and the details. */
|
||||
export function AuditTable({ entries, empty = "Nothing yet." }: { entries: AuditEntry[]; empty?: string }) {
|
||||
if (entries.length === 0) return <p className="text-sm text-muted-foreground">{empty}</p>;
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Who</TableHead>
|
||||
<TableHead>What</TableHead>
|
||||
<TableHead>About</TableHead>
|
||||
<TableHead>Detail</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((e) => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={e.at} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{e.actor ? (
|
||||
<Link href={`/console/audit?actor=${encodeURIComponent(e.actor)}`} className="underline-offset-4 hover:underline">
|
||||
{e.actor}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">hub</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.action}</TableCell>
|
||||
<TableCell className="space-x-2 text-sm">
|
||||
{Object.entries(e.tags as Record<string, string | undefined>).map(([kind, id]) => {
|
||||
if (!id) return null;
|
||||
const name = (e.tagNames as Record<string, string>)[id] ?? id;
|
||||
const href = tagLinks[kind]?.(id);
|
||||
return (
|
||||
<span key={kind}>
|
||||
<span className="text-muted-foreground">{kind} </span>
|
||||
{href ? (
|
||||
<Link href={href} className="underline-offset-4 hover:underline">
|
||||
{name}
|
||||
</Link>
|
||||
) : (
|
||||
name
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{Object.keys(e.tags).length === 0 && e.target}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs whitespace-normal text-muted-foreground">{detailText(e.detail)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function detailText(detail: unknown): string {
|
||||
return Object.entries((detail ?? {}) as Record<string, unknown>)
|
||||
.map(([k, v]) => {
|
||||
if (k === "changed" && v && typeof v === "object")
|
||||
return Object.entries(v as Record<string, { from: unknown; to: unknown }>)
|
||||
.map(([f, c]) => `${f}: ${String(c?.from)} → ${String(c?.to)}`)
|
||||
.join(", ");
|
||||
return `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`;
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { AuditTable } from "../audit-table";
|
||||
import { ConsoleNav, requireConsole } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Audit Log" };
|
||||
|
||||
const filters = ["action", "actor", "person", "catalog", "product", "room", "q", "from", "to"] as const;
|
||||
type Filters = Partial<Record<(typeof filters)[number], string>>;
|
||||
|
||||
export default async function AuditPage({ searchParams }: { searchParams: Promise<Record<string, string | string[]>> }) {
|
||||
const { console: data } = await requireConsole("/console/audit");
|
||||
const params = await searchParams;
|
||||
const f: Filters = {};
|
||||
for (const k of filters) {
|
||||
const v = params[k];
|
||||
if (typeof v === "string" && v.trim()) f[k] = v.trim().slice(0, 100);
|
||||
}
|
||||
const before = typeof params.before === "string" && /^\d+$/.test(params.before) ? Number(params.before) : undefined;
|
||||
const { entries, before: next } = unwrap(await api.console.audit.get({ query: { ...f, ...(before ? { before } : {}), limit: 100 } }));
|
||||
const older = new URLSearchParams({ ...f, ...(next ? { before: String(next) } : {}) });
|
||||
const moderator = data.moderation !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Audit Log">
|
||||
{moderator ? "Every change on the hub." : "Changes to what you manage, and the ones you made."}
|
||||
</PageHeader>
|
||||
<ConsoleNav />
|
||||
<form action="/console/audit" className="mb-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Input name="q" defaultValue={f.q} placeholder="Search" aria-label="Search" />
|
||||
<Input name="action" defaultValue={f.action} placeholder="Action, e.g. catalog." aria-label="Action" />
|
||||
<Input name="actor" defaultValue={f.actor} placeholder="Done by (username)" aria-label="Done by" />
|
||||
{moderator && <Input name="person" defaultValue={f.person} placeholder="About or by a person" aria-label="Person" />}
|
||||
<Input name="catalog" defaultValue={f.catalog} placeholder="Catalog ID" aria-label="Catalog" />
|
||||
<Input name="product" defaultValue={f.product} placeholder="Product ID" aria-label="Product" />
|
||||
<Input type="date" name="from" defaultValue={f.from} aria-label="From" />
|
||||
<Input type="date" name="to" defaultValue={f.to} aria-label="To" />
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit">Filter</Button>
|
||||
{Object.keys(f).length > 0 && (
|
||||
<Link href="/console/audit" className={buttonVariants({ variant: "outline" })}>
|
||||
Clear
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<AuditTable entries={entries} empty={Object.keys(f).length ? "Nothing matches." : "Nothing yet."} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{next && entries.length === 100 && (
|
||||
<div className="mt-4">
|
||||
<Link href={`/console/audit?${older}`} className={buttonVariants({ variant: "outline" })}>
|
||||
Older
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CatalogStatus } from "@/components/catalogs";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { formatSize, RatingTag } from "@/components/products";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, unwrap, type CatalogMember, type CatalogPage } from "@/lib/api";
|
||||
import { AuditTable } from "../../audit-table";
|
||||
import { ConsoleNav, requireConsole } from "../../console-nav";
|
||||
import {
|
||||
addOperator,
|
||||
createInvite,
|
||||
decideMember,
|
||||
delist,
|
||||
removeFromTeam,
|
||||
revokeInvite,
|
||||
rotateKey,
|
||||
saveCatalog,
|
||||
setSuspended,
|
||||
} from "../actions";
|
||||
|
||||
export const metadata: Metadata = { title: "Catalog" };
|
||||
|
||||
type Perm = CatalogPage["permissions"][number];
|
||||
|
||||
export default async function ManageCatalog({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const { me } = await requireConsole(`/console/catalogs/${id}`);
|
||||
const answer = await api.catalogs({ id }).get();
|
||||
if (answer.error) notFound();
|
||||
const data = unwrap(answer);
|
||||
const has = (p: Perm) => data.permissions.includes(p);
|
||||
if (!has("audit:read")) notFound();
|
||||
const c = data.catalog;
|
||||
|
||||
const [team, members, invites, hosted, audit] = await Promise.all([
|
||||
api.catalogs({ id }).team.get().then(unwrap),
|
||||
has("member:approve") ? api.catalogs({ id }).members.get().then(unwrap) : undefined,
|
||||
has("member:invite") ? api.catalogs({ id }).invites.get().then(unwrap) : undefined,
|
||||
api.catalogs({ id }).products.get().then(unwrap),
|
||||
api.console.audit.get({ query: { catalog: id, limit: 20 } }).then(unwrap),
|
||||
]);
|
||||
const byStatus = (s: CatalogMember["status"]) => members?.members.filter((m) => m.status === s) ?? [];
|
||||
const onTeam = team.roles.some((r) => r.personId === me.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={c.name}>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<CatalogStatus status={c.status} />
|
||||
<span className="font-mono text-xs">{c.url}</span>
|
||||
{c.status === "approved" && (
|
||||
<Link href={`/catalogs/${id}`} className="text-sm underline-offset-4 hover:underline">
|
||||
Public page
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
</PageHeader>
|
||||
<ConsoleNav />
|
||||
<div className="space-y-6">
|
||||
{(c.status === "suspended" || c.status === "rejected") && (
|
||||
<Alert>
|
||||
<AlertTitle>{c.status === "suspended" ? "Suspended by moderation" : "Rejected"}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{c.statusReason || "No reason given."}{" "}
|
||||
{c.status === "suspended" && "Its products aren't available from it, and it can't take uploads."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat label="Products" value={String(data.hosted.products)} note={`${data.hosted.revisions} revisions`} />
|
||||
<Stat label="Storage" value={formatSize(data.hosted.bytes)} note="in the revisions it hosts" />
|
||||
{data.members && (
|
||||
<>
|
||||
<Stat label="Members" value={String(data.members.members)} note={`${data.members.banned} banned`} />
|
||||
<Stat label="Requests" value={String(data.members.requests)} note="waiting" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
{!has("catalog:edit") && <CardDescription>Only the owner and operators change these.</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={saveCatalog} fields={{ id }}>
|
||||
<fieldset disabled={!has("catalog:edit")} className="grid gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="name">Name</FieldLabel>
|
||||
<Input id="name" name="name" defaultValue={c.name} required maxLength={64} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="url">Address</FieldLabel>
|
||||
<Input id="url" name="url" type="url" defaultValue={c.url} required maxLength={300} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="contact">Takedown contact</FieldLabel>
|
||||
<Input id="contact" name="contact" defaultValue={c.contact} required maxLength={200} />
|
||||
</Field>
|
||||
<Choice
|
||||
name="visibility"
|
||||
label="Visibility"
|
||||
value={c.visibility}
|
||||
options={[
|
||||
["public", "Public (listed)"],
|
||||
["private", "Private (by link)"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="membership"
|
||||
label="Membership"
|
||||
value={c.membership}
|
||||
options={[
|
||||
["open", "Open"],
|
||||
["approval", "By approval"],
|
||||
["invite", "Invite only"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="maxRating"
|
||||
label="Highest rating"
|
||||
value={c.maxRating}
|
||||
options={[
|
||||
["general", "General only"],
|
||||
["adult", "Adult too"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="uploads"
|
||||
label="Who may upload"
|
||||
value={c.uploads}
|
||||
options={[
|
||||
["anyone", "Anyone"],
|
||||
["members", "Members"],
|
||||
["team", "Only the team"],
|
||||
]}
|
||||
/>
|
||||
<Choice
|
||||
name="mirrors"
|
||||
label="Mirroring others' products"
|
||||
value={c.mirrors}
|
||||
options={[
|
||||
["auto", "Accept"],
|
||||
["approve", "Team approves"],
|
||||
["off", "Off"],
|
||||
]}
|
||||
/>
|
||||
</fieldset>
|
||||
{has("catalog:edit") && <SubmitButton pendingLabel="Saving…">Save</SubmitButton>}
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{has("catalog:rotate-key") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Key</CardTitle>
|
||||
<CardDescription>
|
||||
The catalog signs in to the hub with its ID ({id}) and key. A new key stops the old one at once.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={rotateKey} fields={{ id }}>
|
||||
<SubmitButton variant="outline" pendingLabel="Making one…" confirm="The old key stops working at once. Go on?">
|
||||
New key
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Team</CardTitle>
|
||||
<CardDescription>The owner, and operators: they manage everything but the team itself.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ul className="divide-y text-sm">
|
||||
{team.owner && (
|
||||
<li className="flex items-center justify-between py-2">
|
||||
<span>{team.owner.username}</span>
|
||||
<Tag tone="strong">owner</Tag>
|
||||
</li>
|
||||
)}
|
||||
{team.roles.map((r) => (
|
||||
<li key={r.personId + r.role} className="flex items-center justify-between gap-2 py-2">
|
||||
<span>{r.username}</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag>operator</Tag>
|
||||
{(has("team:manage") || r.personId === me.id) && (
|
||||
<ActionForm action={removeFromTeam} fields={{ id, username: r.username }}>
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
pendingLabel="Removing…"
|
||||
confirm={r.personId === me.id ? "Leave this catalog's team?" : `Take ${r.username} off the team?`}
|
||||
>
|
||||
{r.personId === me.id ? "Leave" : "Remove"}
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{has("team:manage") && (
|
||||
<ActionForm action={addOperator} fields={{ id }}>
|
||||
<div className="flex max-w-md gap-2">
|
||||
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
|
||||
<SubmitButton pendingLabel="Adding…">Add operator</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
)}
|
||||
{!onTeam && team.owner?.personId !== me.id && (
|
||||
<p className="text-sm text-muted-foreground">You see this as a moderator.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{members && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Members</CardTitle>
|
||||
<CardDescription>
|
||||
{c.membership === "open"
|
||||
? "Open: anyone can get its products. Bans still apply."
|
||||
: c.membership === "approval"
|
||||
? "By approval: people ask, the team decides."
|
||||
: "Invite only: people join with an invitation."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<MemberList id={id} title="Requests" members={byStatus("requested")} actions={["approve", "decline"]} />
|
||||
<MemberList id={id} title="Members" members={byStatus("member")} actions={["ban", "remove"]} />
|
||||
<MemberList id={id} title="Banned" members={byStatus("banned")} actions={["unban"]} />
|
||||
<ActionForm action={decideMember} fields={{ id, decision: "add" }}>
|
||||
<div className="flex max-w-md gap-2">
|
||||
<Input name="username" placeholder="T5DE username" aria-label="Username" required maxLength={64} />
|
||||
<SubmitButton variant="outline" pendingLabel="Adding…">
|
||||
Add member
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{invites && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invitations</CardTitle>
|
||||
<CardDescription>Links that let people join (also an approval or invite-only catalog) at once.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{invites.invites.length > 0 && (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Link</TableHead>
|
||||
<TableHead>Used</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invites.invites.map((i) => (
|
||||
<TableRow key={i.code}>
|
||||
<TableCell className="font-mono text-xs break-all whitespace-normal">{i.url}</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{i.uses}
|
||||
{i.maxUses !== null && ` of ${i.maxUses}`}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{i.expiresAt ? <DateTime iso={i.expiresAt} dateOnly /> : "never"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ActionForm action={revokeInvite} fields={{ id, code: i.code }}>
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Revoking…">
|
||||
Revoke
|
||||
</SubmitButton>
|
||||
</ActionForm>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
<ActionForm action={createInvite} fields={{ id }}>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Field className="w-36">
|
||||
<FieldLabel htmlFor="maxUses">Uses</FieldLabel>
|
||||
<Input id="maxUses" name="maxUses" type="number" min={1} max={10000} placeholder="Any" />
|
||||
</Field>
|
||||
<Field className="w-36">
|
||||
<FieldLabel htmlFor="expiresInDays">Days</FieldLabel>
|
||||
<Input id="expiresInDays" name="expiresInDays" type="number" min={1} max={365} placeholder="Never" />
|
||||
</Field>
|
||||
<SubmitButton pendingLabel="Making it…">New invitation</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Products</CardTitle>
|
||||
<CardDescription>What it hosts. Delisting stops hosting a product here; other catalogs may still host it.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hosted.products.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing yet.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead>Creator</TableHead>
|
||||
<TableHead>Revisions</TableHead>
|
||||
<TableHead>Since</TableHead>
|
||||
{has("hosting:delist") && <TableHead />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hosted.products.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/products/${p.id}`} className="underline-offset-4 hover:underline">
|
||||
{p.name}
|
||||
</Link>{" "}
|
||||
<RatingTag rating={p.rating === "adult" ? "adult" : "general"} />{" "}
|
||||
{p.removed && <Tag tone="danger">removed</Tag>}
|
||||
{p.deleted && <Tag>deleted</Tag>}
|
||||
</TableCell>
|
||||
<TableCell>{p.creator}</TableCell>
|
||||
<TableCell className="tabular-nums">{p.revisions.join(", ")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={p.since} dateOnly />
|
||||
</TableCell>
|
||||
{has("hosting:delist") && (
|
||||
<TableCell className="text-right">
|
||||
<ActionForm action={delist} fields={{ id, product: p.id }}>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Input name="reason" placeholder="Reason" aria-label="Reason" required maxLength={500} className="h-8 w-40" />
|
||||
<SubmitButton size="sm" variant="outline" pendingLabel="Delisting…" confirm={`Stop hosting ${p.name}?`}>
|
||||
Delist
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</ActionForm>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{has("catalog:suspend") && (c.status === "approved" || c.status === "suspended") && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Moderation</CardTitle>
|
||||
<CardDescription>
|
||||
A suspended catalog's products aren't available from it, it can't take uploads, and its team sees why.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={setSuspended} fields={{ id }}>
|
||||
{c.status === "suspended" ? (
|
||||
<SubmitButton name="intent" value="reinstate" pendingLabel="Reinstating…">
|
||||
Reinstate
|
||||
</SubmitButton>
|
||||
) : (
|
||||
<div className="flex max-w-xl gap-2">
|
||||
<Input name="reason" placeholder="Reason (shown to its team)" aria-label="Reason" required maxLength={500} />
|
||||
<SubmitButton name="intent" value="suspend" variant="outline" pendingLabel="Suspending…" confirm={`Suspend ${c.name}?`}>
|
||||
Suspend
|
||||
</SubmitButton>
|
||||
</div>
|
||||
)}
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Changes</CardTitle>
|
||||
<CardDescription>
|
||||
<Link href={`/console/audit?catalog=${id}`} className="underline-offset-4 hover:underline">
|
||||
All changes to this catalog
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AuditTable entries={audit.entries} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value, note }: { label: string; value: string; note: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardDescription>{label}</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">{value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">{note}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Choice({ name, label, value, options }: { name: string; label: string; value: string; options: [string, string][] }) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={name}>{label}</FieldLabel>
|
||||
<NativeSelect id={name} name={name} defaultValue={value} className="w-full">
|
||||
{options.map(([v, l]) => (
|
||||
<NativeSelectOption key={v} value={v}>
|
||||
{l}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
const memberActions = {
|
||||
approve: { label: "Approve", variant: "default" as const },
|
||||
decline: { label: "Decline", variant: "outline" as const },
|
||||
ban: { label: "Ban", variant: "outline" as const },
|
||||
remove: { label: "Remove", variant: "outline" as const },
|
||||
unban: { label: "Unban", variant: "outline" as const },
|
||||
};
|
||||
|
||||
function MemberList({
|
||||
id,
|
||||
title,
|
||||
members,
|
||||
actions,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
members: CatalogMember[];
|
||||
actions: (keyof typeof memberActions)[];
|
||||
}) {
|
||||
if (members.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">
|
||||
{title} <span className="text-muted-foreground tabular-nums">{members.length}</span>
|
||||
</h3>
|
||||
<ul className="divide-y text-sm">
|
||||
{members.map((m) => (
|
||||
<li key={m.personId} className="flex items-center justify-between gap-2 py-2">
|
||||
<span>
|
||||
{m.username}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{m.invited ? "invited, " : ""}
|
||||
<DateTime iso={m.since} dateOnly />
|
||||
</span>
|
||||
</span>
|
||||
<ActionForm action={decideMember} fields={{ id, username: m.username }}>
|
||||
<div className="flex gap-2">
|
||||
{actions.map((a) => (
|
||||
<SubmitButton
|
||||
key={a}
|
||||
size="sm"
|
||||
variant={memberActions[a].variant}
|
||||
name="decision"
|
||||
value={a}
|
||||
confirm={a === "ban" ? `Ban ${m.username} from this catalog?` : undefined}
|
||||
>
|
||||
{memberActions[a].label}
|
||||
</SubmitButton>
|
||||
))}
|
||||
</div>
|
||||
</ActionForm>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use server";
|
||||
// Managing a catalog from the console: settings, key, team, members, invitations, what it hosts, suspension.
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { attempt, field, type ActionState } from "@/lib/actions";
|
||||
|
||||
/** Runs a change to a catalog, then shows its pages again. */
|
||||
async function onCatalog(form: FormData, change: (id: string) => Promise<string | void>): Promise<ActionState> {
|
||||
const id = field(form, "id");
|
||||
return attempt(async () => {
|
||||
const notice = await change(id);
|
||||
revalidatePath(`/console/catalogs/${id}`);
|
||||
revalidatePath(`/catalogs/${id}`);
|
||||
revalidatePath("/console/catalogs");
|
||||
revalidatePath("/catalogs");
|
||||
return notice;
|
||||
});
|
||||
}
|
||||
|
||||
const pick = <T extends string>(v: string, options: readonly T[]): T | undefined => options.find((o) => o === v);
|
||||
|
||||
export async function saveCatalog(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
unwrap(
|
||||
await api.catalogs({ id }).patch({
|
||||
name: field(form, "name"),
|
||||
url: field(form, "url"),
|
||||
contact: field(form, "contact"),
|
||||
visibility: pick(field(form, "visibility"), ["public", "private"] as const),
|
||||
membership: pick(field(form, "membership"), ["open", "approval", "invite"] as const),
|
||||
maxRating: pick(field(form, "maxRating"), ["general", "adult"] as const),
|
||||
uploads: pick(field(form, "uploads"), ["anyone", "members", "team"] as const),
|
||||
mirrors: pick(field(form, "mirrors"), ["auto", "approve", "off"] as const),
|
||||
}),
|
||||
);
|
||||
return "Saved.";
|
||||
});
|
||||
}
|
||||
|
||||
export async function rotateKey(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
const { key } = unwrap(await api.catalogs({ id }).key.post());
|
||||
return `New key (shown once; put it in the catalog's CATALOG_KEY and restart it): ${key}`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function setSuspended(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
if (field(form, "intent") === "reinstate") unwrap(await api.catalogs({ id }).reinstate.post());
|
||||
else unwrap(await api.catalogs({ id }).suspend.post({ reason: field(form, "reason") }));
|
||||
});
|
||||
}
|
||||
|
||||
export async function addOperator(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
const username = field(form, "username");
|
||||
unwrap(await api.catalogs({ id }).team({ username }).put({ role: "catalog-operator" }));
|
||||
return `${username} is an operator now.`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFromTeam(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
unwrap(await api.catalogs({ id }).team({ username: field(form, "username") }).delete({}, { query: { role: "catalog-operator" } }));
|
||||
});
|
||||
}
|
||||
|
||||
/** A member or request: approve, decline, add, ban, unban or remove. */
|
||||
export async function decideMember(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
const member = api.catalogs({ id }).members({ username: field(form, "username") });
|
||||
switch (field(form, "decision")) {
|
||||
case "approve":
|
||||
case "add":
|
||||
unwrap(await member.put({ status: "member" }));
|
||||
break;
|
||||
case "ban":
|
||||
unwrap(await member.put({ status: "banned" }));
|
||||
break;
|
||||
default: // decline, unban, remove
|
||||
unwrap(await member.delete());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function createInvite(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
const maxUses = Number(field(form, "maxUses")) || undefined;
|
||||
const expiresInDays = Number(field(form, "expiresInDays")) || undefined;
|
||||
const { invite } = unwrap(await api.catalogs({ id }).invites.post({ maxUses, expiresInDays }));
|
||||
return `Invitation link: ${invite.url}`;
|
||||
});
|
||||
}
|
||||
|
||||
export async function revokeInvite(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
unwrap(await api.catalogs({ id }).invites({ code: field(form, "code") }).delete());
|
||||
});
|
||||
}
|
||||
|
||||
export async function delist(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
const product = field(form, "product");
|
||||
unwrap(await api.catalogs({ id }).products({ product }).delete({}, { query: { reason: field(form, "reason") } }));
|
||||
revalidatePath(`/products/${product}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Someone joins a catalog (or asks to), with an invitation code if they have one; or leaves it. */
|
||||
export async function membership(_: ActionState, form: FormData): Promise<ActionState> {
|
||||
return onCatalog(form, async (id) => {
|
||||
if (field(form, "intent") === "leave") {
|
||||
unwrap(await api.catalogs({ id }).join.delete());
|
||||
revalidatePath("/library");
|
||||
return "You left.";
|
||||
}
|
||||
const invite = field(form, "invite");
|
||||
const { membership } = unwrap(await api.catalogs({ id }).join.post(invite ? { invite } : {}));
|
||||
revalidatePath("/library");
|
||||
return membership === "member" ? "You're a member." : "Asked. The catalog's team will decide.";
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { CatalogStatus, membership } from "@/components/catalogs";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { PageHeader, Tag } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { decideCatalog } from "../actions";
|
||||
import { ConsoleNav, requireModerator } from "../console-nav";
|
||||
import { ConsoleNav, requireConsole } from "../console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Catalogs" };
|
||||
|
||||
export default async function ConsoleCatalogs() {
|
||||
await requireModerator("/console/catalogs");
|
||||
const { catalogs } = unwrap(await api.console.catalogs.get());
|
||||
await requireConsole("/console/catalogs");
|
||||
const { catalogs, moderator } = unwrap(await api.console.catalogs.get());
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Catalogs" />
|
||||
<PageHeader title="Catalogs">{moderator ? "Every registered catalog." : "Catalogs you're on the team of."}</PageHeader>
|
||||
<ConsoleNav />
|
||||
<Card>
|
||||
<CardContent>
|
||||
@@ -29,19 +30,22 @@ export default async function ConsoleCatalogs() {
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Address</TableHead>
|
||||
<TableHead>Takedown contact</TableHead>
|
||||
<TableHead>Access</TableHead>
|
||||
<TableHead>Registered</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead />
|
||||
{moderator && <TableHead />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{catalogs.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/console/catalogs/${c.id}`} className="underline-offset-4 hover:underline">
|
||||
{c.name}
|
||||
</Link>{" "}
|
||||
{c.mine ? <Tag>owner</Tag> : c.team ? <Tag>team</Tag> : null}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{c.url}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{c.contact}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<span className="capitalize">{c.visibility}</span>, {membership[c.membership]?.toLowerCase() ?? c.membership}
|
||||
</TableCell>
|
||||
@@ -51,29 +55,31 @@ export default async function ConsoleCatalogs() {
|
||||
<TableCell>
|
||||
<CatalogStatus status={c.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
|
||||
<div className="flex justify-end gap-2">
|
||||
{c.status !== "approved" && (
|
||||
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
|
||||
Approve
|
||||
</SubmitButton>
|
||||
)}
|
||||
{c.status !== "rejected" && (
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
name="decision"
|
||||
value="reject"
|
||||
pendingLabel="Rejecting…"
|
||||
confirm={`Reject ${c.name}?`}
|
||||
>
|
||||
Reject
|
||||
</SubmitButton>
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
</TableCell>
|
||||
{moderator && (
|
||||
<TableCell className="text-right">
|
||||
{(c.status === "pending" || c.status === "rejected") && (
|
||||
<ActionForm action={decideCatalog} fields={{ id: c.id }} className="space-y-1">
|
||||
<div className="flex justify-end gap-2">
|
||||
<SubmitButton size="sm" name="decision" value="approve" pendingLabel="Approving…">
|
||||
Approve
|
||||
</SubmitButton>
|
||||
{c.status !== "rejected" && (
|
||||
<SubmitButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
name="decision"
|
||||
value="reject"
|
||||
pendingLabel="Rejecting…"
|
||||
confirm={`Reject ${c.name}?`}
|
||||
>
|
||||
Reject
|
||||
</SubmitButton>
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import { NavLink } from "@/components/header-client";
|
||||
import { getMe, isModerator, type Person } from "@/lib/api";
|
||||
import { api, getMe, isModerator, unwrap, type ConsoleData, type Person } from "@/lib/api";
|
||||
|
||||
/** The signed-in moderator, or off to sign in; others get not found (the console isn't for them). */
|
||||
/** The signed-in moderator, or off to sign in; others get not found (these pages aren't for them). */
|
||||
export async function requireModerator(path: string): Promise<Person> {
|
||||
const me = await getMe();
|
||||
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
|
||||
@@ -10,14 +11,47 @@ export async function requireModerator(path: string): Promise<Person> {
|
||||
return me;
|
||||
}
|
||||
|
||||
export function ConsoleNav() {
|
||||
/** The console as this person sees it (once per request); undefined if it isn't for them. */
|
||||
const loadConsole = cache(async (): Promise<ConsoleData | undefined> => {
|
||||
const answer = await api.console.get();
|
||||
if (answer.error) return undefined;
|
||||
return unwrap(answer);
|
||||
});
|
||||
|
||||
/**
|
||||
* The signed-in person and their console, or off to sign in. People who manage nothing get not found: the console
|
||||
* is for those controlling a product, catalog or room.
|
||||
*/
|
||||
export async function requireConsole(path: string): Promise<{ me: Person; console: ConsoleData }> {
|
||||
const me = await getMe();
|
||||
if (!me) redirect(`/signin?next=${encodeURIComponent(path)}`);
|
||||
const data = await loadConsole();
|
||||
if (!data) notFound();
|
||||
return { me, console: data };
|
||||
}
|
||||
|
||||
type Section = ConsoleData["sections"][number];
|
||||
|
||||
// Products are managed on their own pages for now; settings are on the overview.
|
||||
const tabs: { section: Section; href: string; label: string }[] = [
|
||||
{ section: "overview", href: "/console", label: "Overview" },
|
||||
{ section: "catalogs", href: "/console/catalogs", label: "Catalogs" },
|
||||
{ section: "people", href: "/console/people", label: "People" },
|
||||
{ section: "audit", href: "/console/audit", label: "Audit" },
|
||||
];
|
||||
|
||||
/** The console's sections this person may see. */
|
||||
export async function ConsoleNav() {
|
||||
const sections = new Set<Section>((await loadConsole())?.sections ?? ["overview"]);
|
||||
return (
|
||||
<nav className="mb-8 flex gap-1 border-b pb-3 text-sm">
|
||||
<NavLink href="/console" exact>
|
||||
Overview
|
||||
</NavLink>
|
||||
<NavLink href="/console/people">People</NavLink>
|
||||
<NavLink href="/console/catalogs">Catalogs</NavLink>
|
||||
{tabs
|
||||
.filter((t) => sections.has(t.section))
|
||||
.map((t) => (
|
||||
<NavLink key={t.section} href={t.href} exact={t.section === "overview"}>
|
||||
{t.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -3,61 +3,77 @@ import Link from "next/link";
|
||||
import { ActionForm, SubmitButton } from "@/components/forms";
|
||||
import { OnlineList } from "@/components/online-list";
|
||||
import { PageHeader } from "@/components/status";
|
||||
import { DateTime } from "@/components/time";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api, unwrap, type AuditEntry } from "@/lib/api";
|
||||
import { api, unwrap } from "@/lib/api";
|
||||
import { setRegistration } from "./actions";
|
||||
import { ConsoleNav, requireModerator } from "./console-nav";
|
||||
import { AuditTable } from "./audit-table";
|
||||
import { ConsoleNav, requireConsole } from "./console-nav";
|
||||
|
||||
export const metadata: Metadata = { title: "Console" };
|
||||
|
||||
type Stat = { label: string; value: number; note: string; href?: string };
|
||||
|
||||
export default async function Console() {
|
||||
const me = await requireModerator("/console");
|
||||
const [data, settings] = await Promise.all([api.console.get().then(unwrap), api.console.settings.get().then(unwrap)]);
|
||||
const stats = [
|
||||
{ label: "People", value: data.counts.people, note: "accounts" },
|
||||
{ label: "Waiting", value: data.counts.waiting, note: "for approval", href: "/console/people?waiting=true" },
|
||||
{ label: "IMVU accounts", value: data.counts.links, note: "linked" },
|
||||
{ label: "Computers", value: data.counts.computers, note: "signed in" },
|
||||
const { me, console: data } = await requireConsole("/console");
|
||||
const settings = me.role === "admin" ? await api.console.settings.get().then(unwrap) : undefined;
|
||||
|
||||
const todo: Stat[] = [
|
||||
{ label: "Member Requests", value: data.todo.memberRequests, note: "waiting in your catalogs", href: "/console/catalogs" },
|
||||
];
|
||||
if (data.moderation) {
|
||||
todo.push(
|
||||
{ label: "Catalogs", value: data.todo.pendingCatalogs, note: "waiting for approval", href: "/console/catalogs" },
|
||||
{ label: "Accounts", value: data.todo.waitingPeople, note: "waiting for approval", href: "/console/people?waiting=true" },
|
||||
);
|
||||
}
|
||||
const mine: Stat[] = [
|
||||
{ label: "Your Products", value: data.mine.products, note: "you created", href: `/products?creator=${encodeURIComponent(me.username)}` },
|
||||
{ label: "Your Catalogs", value: data.mine.catalogs, note: "you're on the team of", href: "/console/catalogs" },
|
||||
{ label: "Your Rooms", value: data.mine.rooms, note: "you host", href: "/rooms" },
|
||||
];
|
||||
const hub: Stat[] = data.moderation
|
||||
? [
|
||||
{ label: "People", value: data.moderation.counts.people, note: "accounts" },
|
||||
{ label: "IMVU Accounts", value: data.moderation.counts.links, note: "linked" },
|
||||
{ label: "Computers", value: data.moderation.counts.computers, note: "signed in" },
|
||||
]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Console">Moderation.</PageHeader>
|
||||
<PageHeader title="Console">What you manage on T5DE.</PageHeader>
|
||||
<ConsoleNav />
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardHeader>
|
||||
<CardDescription>{s.label}</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{s.href ? (
|
||||
<Link href={s.href} className="underline-offset-4 hover:underline">
|
||||
{s.note}
|
||||
</Link>
|
||||
) : (
|
||||
s.note
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<StatGrid stats={todo.filter((s) => s.value > 0 || data.moderation)} />
|
||||
<StatGrid stats={mine.filter((s) => s.value > 0)} />
|
||||
{hub.length > 0 && <StatGrid stats={hub} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Registration</CardTitle>
|
||||
<CardDescription>
|
||||
{settings.registration === "closed"
|
||||
? "Closed: new accounts can sign in, but wait for a moderator's approval."
|
||||
: "Open: new accounts can use the hub at once."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{me.role === "admin" && (
|
||||
{data.moderation && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Online Now</CardTitle>
|
||||
<CardDescription>
|
||||
{data.moderation.connections} {data.moderation.connections === 1 ? "connection" : "connections"} when
|
||||
the page loaded
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OnlineList initial={data.moderation.online} moderator />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{settings && (
|
||||
<Card id="settings">
|
||||
<CardHeader>
|
||||
<CardTitle>Registration</CardTitle>
|
||||
<CardDescription>
|
||||
{settings.registration === "closed"
|
||||
? "Closed: new accounts can sign in, but wait for a moderator's approval."
|
||||
: "Open: new accounts can use the hub at once."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActionForm action={setRegistration}>
|
||||
<div className="flex gap-2">
|
||||
@@ -69,28 +85,21 @@ export default async function Console() {
|
||||
</div>
|
||||
</ActionForm>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Online now</CardTitle>
|
||||
<CardTitle>Recent Changes</CardTitle>
|
||||
<CardDescription>
|
||||
{data.connections} {data.connections === 1 ? "connection" : "connections"} when the page loaded
|
||||
{data.moderation ? "Across the hub." : "To what you manage, and by you."}{" "}
|
||||
<Link href="/console/audit" className="underline-offset-4 hover:underline">
|
||||
Full audit log
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OnlineList initial={data.online} moderator />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Audit log</CardTitle>
|
||||
<CardDescription>The latest 50 changes.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AuditLog entries={data.audit} />
|
||||
<AuditTable entries={data.audit} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -98,36 +107,27 @@ export default async function Console() {
|
||||
);
|
||||
}
|
||||
|
||||
function AuditLog({ entries }: { entries: AuditEntry[] }) {
|
||||
if (entries.length === 0) return <p className="text-sm text-muted-foreground">Nothing yet.</p>;
|
||||
function StatGrid({ stats }: { stats: Stat[] }) {
|
||||
if (stats.length === 0) return null;
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>When</TableHead>
|
||||
<TableHead>Who</TableHead>
|
||||
<TableHead>What</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Detail</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((e, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-muted-foreground">
|
||||
<DateTime iso={e.at} />
|
||||
</TableCell>
|
||||
<TableCell>{e.actor || <span className="text-muted-foreground">hub</span>}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.action}</TableCell>
|
||||
<TableCell>{e.target}</TableCell>
|
||||
<TableCell className="text-xs whitespace-normal text-muted-foreground">
|
||||
{Object.entries((e.detail ?? {}) as Record<string, unknown>)
|
||||
.map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
||||
.join(", ")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<Card key={s.label}>
|
||||
<CardHeader>
|
||||
<CardDescription>{s.label}</CardDescription>
|
||||
<CardTitle className="text-3xl font-semibold tabular-nums">{s.value}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{s.href ? (
|
||||
<Link href={s.href} className="underline-offset-4 hover:underline">
|
||||
{s.note}
|
||||
</Link>
|
||||
) : (
|
||||
s.note
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
|
||||
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const [me, data] = await Promise.all([getMe(), loadProduct(id)]);
|
||||
const { product, revisions, inLibrary, stayOn, mine } = data;
|
||||
const { product, revisions, inLibrary, stayOn, mine, available, catalogs } = data;
|
||||
const using = inLibrary ? inUse(revisions, stayOn ?? product.latest) : undefined;
|
||||
|
||||
return (
|
||||
@@ -62,10 +62,24 @@ export default async function ProductPage({ params }: { params: Promise<{ id: st
|
||||
Remove
|
||||
</SubmitButton>
|
||||
</>
|
||||
) : (
|
||||
) : available ? (
|
||||
<SubmitButton name="intent" value="get" pendingLabel="Getting…">
|
||||
Get
|
||||
</SubmitButton>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Members only. Join{" "}
|
||||
{catalogs.map((c, i) => (
|
||||
<span key={c.id}>
|
||||
{i > 0 && (i === catalogs.length - 1 ? " or " : ", ")}
|
||||
<Link href={`/catalogs/${c.id}`} className="font-medium text-foreground underline-offset-4 hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
{c.joined === "requested" && " (asked)"}
|
||||
</span>
|
||||
))}{" "}
|
||||
to get it.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</ActionForm>
|
||||
|
||||
@@ -8,15 +8,24 @@ import { api, getMe, unwrap } from "@/lib/api";
|
||||
|
||||
export const metadata: Metadata = { title: "Products" };
|
||||
|
||||
export default async function Products({ searchParams }: { searchParams: Promise<{ q?: string | string[] }> }) {
|
||||
const raw = (await searchParams).q;
|
||||
const q = typeof raw === "string" ? raw.trim().slice(0, 100) : "";
|
||||
const [me, { products }] = await Promise.all([getMe(), api.products.get({ query: q ? { q } : {} }).then(unwrap)]);
|
||||
export default async function Products({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string | string[]; creator?: string | string[] }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const one = (v: string | string[] | undefined, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
|
||||
const q = one(params.q, 100);
|
||||
const creator = one(params.creator, 64);
|
||||
const [me, { products }] = await Promise.all([
|
||||
getMe(),
|
||||
api.products.get({ query: { ...(q ? { q } : {}), ...(creator ? { creator } : {}) } }).then(unwrap),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<PageHeader title="Products">Community products for T5DE.</PageHeader>
|
||||
<PageHeader title="Products">{creator ? `By ${creator}.` : "Community products for T5DE."}</PageHeader>
|
||||
{me && (
|
||||
<Link href="/products/new" className={buttonVariants({ className: "px-4" })}>
|
||||
New product
|
||||
@@ -25,6 +34,7 @@ export default async function Products({ searchParams }: { searchParams: Promise
|
||||
</div>
|
||||
<form action="/products" className="mb-6 flex w-full max-w-md gap-2">
|
||||
<Input type="search" name="q" defaultValue={q} placeholder="Search products" aria-label="Search" />
|
||||
{creator && <input type="hidden" name="creator" value={creator} />}
|
||||
<Button type="submit" variant="outline">
|
||||
Search
|
||||
</Button>
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Tag } from "@/components/status";
|
||||
|
||||
export const membership: Record<string, string> = { open: "Open", approval: "By approval", invite: "Invite only" };
|
||||
|
||||
/** A catalog's review status: approved, pending or rejected. */
|
||||
/** A catalog's status: approved, pending, rejected or suspended. */
|
||||
export function CatalogStatus({ status }: { status: string }) {
|
||||
if (status === "approved") return <Tag tone="strong">approved</Tag>;
|
||||
if (status === "rejected") return <Tag tone="danger">rejected</Tag>;
|
||||
if (status === "rejected" || status === "suspended") return <Tag tone="danger">{status}</Tag>;
|
||||
return <Tag>{status}</Tag>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getMe, isModerator } from "@/lib/api";
|
||||
import { getMe } from "@/lib/api";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { NavLink, ProfileMenu } from "./header-client";
|
||||
import logo from "./t5de.png";
|
||||
@@ -28,7 +28,7 @@ export async function Header() {
|
||||
Download
|
||||
</a>
|
||||
{me ? (
|
||||
<ProfileMenu username={me.username} picture={me.picture} console={isModerator(me)} />
|
||||
<ProfileMenu username={me.username} picture={me.picture} console={me.console} />
|
||||
) : (
|
||||
<>
|
||||
<Link href="/signin" className={buttonVariants({ variant: "ghost" })}>
|
||||
|
||||
@@ -41,6 +41,11 @@ export type AgeInfo = Data<V1["age"]["get"]>;
|
||||
export type OnlineEntry = Data<V1["online"]["get"]>["online"][number];
|
||||
export type ConsoleData = Data<V1["console"]["get"]>;
|
||||
export type AuditEntry = ConsoleData["audit"][number];
|
||||
export type CatalogPage = Data<ReturnType<V1["catalogs"]>["get"]>;
|
||||
export type CatalogMember = Data<ReturnType<V1["catalogs"]>["members"]["get"]>["members"][number];
|
||||
export type CatalogInvite = Data<ReturnType<V1["catalogs"]>["invites"]["get"]>["invites"][number];
|
||||
export type HostedProduct = Data<ReturnType<V1["catalogs"]>["products"]["get"]>["products"][number];
|
||||
export type CatalogTeam = Data<ReturnType<V1["catalogs"]>["team"]["get"]>;
|
||||
export type ProductSummary = Data<V1["products"]["get"]>["products"][number];
|
||||
export type ProductPage = Data<ReturnType<V1["products"]>["get"]>;
|
||||
export type Revision = ProductPage["revisions"][number];
|
||||
|
||||
+14
-3
@@ -20,7 +20,7 @@ is. Codes: `invalid`, `signed_out`, `forbidden`, `not_found`, `no_such_imvu_acco
|
||||
| `POST /api/auth/sign-up/email` | `{email, password, name, username}`. Usernames 3-32 of `A-Za-z0-9_`; passwords 10-256 characters. The first account is an admin. With registration closed, the others wait for approval (`approved: false`): every route but `/me` answers 403 `awaiting_approval`, and the rest of the hub treats them as signed out. |
|
||||
| `POST /api/auth/sign-in/username` | `{username, password}` |
|
||||
| `POST /api/auth/sign-out` | |
|
||||
| `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null` |
|
||||
| `GET /api/v1/me` | `{person}` (`approved` false while waiting), with `picture`: the avatar picture of their first linked IMVU account (from IMVU, kept an hour), or `null`, and `console`: whether the console is for them |
|
||||
| `GET /api/v1/hub` | (anyone) `{registration}`: `open` or `closed` |
|
||||
|
||||
## Proxies
|
||||
@@ -103,11 +103,22 @@ person-level providers (phone wallets, document checks) are started here.
|
||||
| `POST /api/v1/age/:provider/complete` | `{ref, response}`: the browser's answer, for wallet providers |
|
||||
| `POST /api/v1/age/webhook/:provider` | the provider's webhook; the provider checks its signature (401 if it isn't the provider's). A later answer replaces an earlier one (an adult answer taken back is `revoked`) |
|
||||
|
||||
## Console (moderators; roles: admins)
|
||||
## Console
|
||||
|
||||
Permissions, roles and scopes are in docs/console.md. A missing permission answers 403 `forbidden` with
|
||||
`missing: {permission, scope}`. Catalog management routes are in docs/products.md.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/console` | (anyone controlling a catalog, product or hosted room, and moderators; else 403) `{sections, mine, todo, moderation, audit}`: `moderation` (`{counts, connections, online}`) only for moderators, `audit` the latest 20 entries they may see |
|
||||
| `GET /api/v1/console/audit?catalog=&product=&room=&person=&actor=&action=&q=&from=&to=&before=&limit=` | `{entries, before}`: entries the person may see (docs/console.md), newest first; `action` matches a prefix, `from`/`to` are days, `before` an entry ID for the next page. Entries are `{id, at, actor, action, target, tags, tagNames, detail}` |
|
||||
| `GET /api/v1/console/permissions?catalog=&product=` | `{roles, permissions}` at that scope (the hub if neither) |
|
||||
| `GET /api/v1/console/catalogs` | `{catalogs, moderator}`: every catalog for moderators, else those on whose team the person is |
|
||||
|
||||
Moderators:
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| `GET /api/v1/console` | `{counts, connections, online, audit}` |
|
||||
| `GET /api/v1/console/people?q=&waiting=true` | `{people}` (`waiting`: only accounts waiting for approval) |
|
||||
| `POST /api/v1/console/people/:id/approve` | `{person}` |
|
||||
| `POST /api/v1/console/people/:id/decline` | an account waiting for approval: deletes it |
|
||||
|
||||
@@ -470,6 +470,8 @@ Phases for products, catalogs and imports (products.md, console.md, imports.md):
|
||||
|
||||
1. Permissions and console foundations: the permission check, roles, the console for everyone who controls
|
||||
something, a tagged audit log, membership enforced, catalog settings, team and key rotation, suspension.
|
||||
**Done** (2026-11-05), except catalog deletion requests and the hosted rooms' scope, which come with cases
|
||||
(phase 3). Built-in roles are code (`apps/api/src/permissions.ts`); the `role` table comes with custom roles.
|
||||
2. Product info: drafts, moderator edits with reasons and locks, info history, unlisted products, tags and category.
|
||||
3. Cases (reports, appeals, reviews), file blocks and the catalogs' changes feed; notifications; API tokens;
|
||||
two-factor sign-in for sensitive permissions.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
CREATE TABLE "catalog_change" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"catalog_id" text NOT NULL,
|
||||
"data" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "catalog_invite" (
|
||||
"code" text PRIMARY KEY NOT NULL,
|
||||
"catalog_id" text NOT NULL,
|
||||
"created_by" text,
|
||||
"max_uses" integer,
|
||||
"uses" integer DEFAULT 0 NOT NULL,
|
||||
"expires_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "catalog_member" (
|
||||
"catalog_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"status" text NOT NULL,
|
||||
"invite" text,
|
||||
"decided_by" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "catalog_member_catalog_id_user_id_pk" PRIMARY KEY("catalog_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "role_assignment" (
|
||||
"user_id" text NOT NULL,
|
||||
"role" text NOT NULL,
|
||||
"scope_type" text NOT NULL,
|
||||
"scope_id" text NOT NULL,
|
||||
"granted_by" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "role_assignment_user_id_role_scope_type_scope_id_pk" PRIMARY KEY("user_id","role","scope_type","scope_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "audit" ADD COLUMN "tags" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD COLUMN "status_reason" text DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD COLUMN "max_rating" text DEFAULT 'adult' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD COLUMN "uploads" text DEFAULT 'anyone' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD COLUMN "mirrors" text DEFAULT 'approve' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_change" ADD CONSTRAINT "catalog_change_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_invite" ADD CONSTRAINT "catalog_invite_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_invite" ADD CONSTRAINT "catalog_invite_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_catalog_id_catalog_id_fk" FOREIGN KEY ("catalog_id") REFERENCES "public"."catalog"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "catalog_member" ADD CONSTRAINT "catalog_member_decided_by_user_id_fk" FOREIGN KEY ("decided_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "role_assignment" ADD CONSTRAINT "role_assignment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "role_assignment" ADD CONSTRAINT "role_assignment_granted_by_user_id_fk" FOREIGN KEY ("granted_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "catalog_change_catalog" ON "catalog_change" USING btree ("catalog_id","id");--> statement-breakpoint
|
||||
CREATE INDEX "catalog_invite_catalog" ON "catalog_invite" USING btree ("catalog_id");--> statement-breakpoint
|
||||
CREATE INDEX "catalog_member_user" ON "catalog_member" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "role_assignment_scope" ON "role_assignment" USING btree ("scope_type","scope_id");--> statement-breakpoint
|
||||
CREATE INDEX "audit_tags" ON "audit" USING gin ("tags");--> statement-breakpoint
|
||||
-- Tags for entries written before tags existed (target was the catalog, product or room).
|
||||
UPDATE "audit" SET "tags" = jsonb_build_object('catalog', "target") WHERE "action" LIKE 'catalog.%' AND "target" <> '';--> statement-breakpoint
|
||||
UPDATE "audit" SET "tags" = jsonb_build_object('product', "target") WHERE "action" LIKE 'product.%' AND "target" <> '';--> statement-breakpoint
|
||||
UPDATE "audit" SET "tags" = jsonb_build_object('room', "target") WHERE "action" LIKE 'hosted_room.%' AND "target" <> '';
|
||||
File diff suppressed because it is too large.
Load diff
@@ -71,6 +71,13 @@
|
||||
"when": 1793707706030,
|
||||
"tag": "0009_registration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1793862017365,
|
||||
"tag": "0010_catalog_management",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import { fileURLToPath } from "node:url";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export { schema };
|
||||
export type { DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema";
|
||||
export type { AuditTags, DecorationItem, FileEntry, ParentInfo, RoomModerator, SceneItem } from "./schema";
|
||||
export * from "drizzle-orm";
|
||||
|
||||
const migrationsFolder = fileURLToPath(new URL("../drizzle", import.meta.url));
|
||||
|
||||
@@ -221,10 +221,15 @@ export const audit = pgTable(
|
||||
action: text("action").notNull(),
|
||||
target: text("target").notNull().default(""),
|
||||
detail: jsonb("detail").$type<Record<string, unknown>>().notNull().default({}),
|
||||
// The resources the change touches ({catalog, product, room, person}): who may see the entry (docs/console.md).
|
||||
tags: jsonb("tags").$type<AuditTags>().notNull().default({}),
|
||||
},
|
||||
(t) => [index("audit_at").on(t.at)],
|
||||
(t) => [index("audit_at").on(t.at), index("audit_tags").using("gin", t.tags)],
|
||||
);
|
||||
|
||||
/** The resources an audit entry touches. */
|
||||
export type AuditTags = { catalog?: string; product?: string; room?: string; person?: string };
|
||||
|
||||
// ---- Products (docs/products.md) ----
|
||||
|
||||
export type FileEntry = { name: string; sha256: string; size: number };
|
||||
@@ -259,13 +264,89 @@ export const catalog = pgTable(
|
||||
contact: text("contact").notNull(), // takedown and legal contact
|
||||
visibility: text("visibility").notNull().default("public"), // "public", "private"
|
||||
membership: text("membership").notNull().default("open"), // "open", "approval", "invite"
|
||||
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected"
|
||||
status: text("status").notNull().default("pending"), // "pending", "approved", "rejected", "suspended"
|
||||
statusReason: text("status_reason").notNull().default(""), // why it was rejected or suspended
|
||||
maxRating: text("max_rating").notNull().default("adult"), // the highest rating it hosts: "general", "adult"
|
||||
uploads: text("uploads").notNull().default("anyone"), // who may upload: "anyone", "members", "team"
|
||||
mirrors: text("mirrors").notNull().default("approve"), // mirroring others' revisions: "auto", "approve", "off"
|
||||
keyHash: text("key_hash").notNull(), // sha256 of its key, hex
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex("catalog_url").on(t.url)],
|
||||
);
|
||||
|
||||
// A role given to a person at a scope (docs/console.md). Hub-wide roles are user.role; owners are derived (a
|
||||
// catalog's owner_id, a product's creator_id), so they're never here.
|
||||
export const roleAssignment = pgTable(
|
||||
"role_assignment",
|
||||
{
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
role: text("role").notNull(), // e.g. "catalog-operator"
|
||||
scopeType: text("scope_type").notNull(), // "catalog"
|
||||
scopeId: text("scope_id").notNull(),
|
||||
grantedBy: text("granted_by").references(() => user.id, { onDelete: "set null" }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.userId, t.role, t.scopeType, t.scopeId] }),
|
||||
index("role_assignment_scope").on(t.scopeType, t.scopeId),
|
||||
],
|
||||
);
|
||||
|
||||
// A person's membership of a catalog: asked to join, a member, or banned from it.
|
||||
export const catalogMember = pgTable(
|
||||
"catalog_member",
|
||||
{
|
||||
catalogId: text("catalog_id")
|
||||
.notNull()
|
||||
.references(() => catalog.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
status: text("status").notNull(), // "requested", "member", "banned"
|
||||
invite: text("invite"), // the invite code they joined with
|
||||
decidedBy: text("decided_by").references(() => user.id, { onDelete: "set null" }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.catalogId, t.userId] }), index("catalog_member_user").on(t.userId)],
|
||||
);
|
||||
|
||||
// An invitation to join a catalog: a code, used up to maxUses times (null: no limit) until it expires.
|
||||
export const catalogInvite = pgTable(
|
||||
"catalog_invite",
|
||||
{
|
||||
code: text("code").primaryKey(),
|
||||
catalogId: text("catalog_id")
|
||||
.notNull()
|
||||
.references(() => catalog.id, { onDelete: "cascade" }),
|
||||
createdBy: text("created_by").references(() => user.id, { onDelete: "set null" }),
|
||||
maxUses: integer("max_uses"),
|
||||
uses: integer("uses").notNull().default(0),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("catalog_invite_catalog").on(t.catalogId)],
|
||||
);
|
||||
|
||||
// What a catalog must act on (GET /api/v1/catalog/changes): files to delete, revisions to mirror, its status.
|
||||
export const catalogChange = pgTable(
|
||||
"catalog_change",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
catalogId: text("catalog_id")
|
||||
.notNull()
|
||||
.references(() => catalog.id, { onDelete: "cascade" }),
|
||||
data: jsonb("data").$type<Record<string, unknown>>().notNull(), // {type, ...}
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("catalog_change_catalog").on(t.catalogId, t.id)],
|
||||
);
|
||||
|
||||
// A product: its info, owned by its creator. Its files are in revisions.
|
||||
export const product = pgTable(
|
||||
"product",
|
||||
|
||||
Reference in new issue
Block a user