Persist event sign designs and harden invitation permissions
This commit is contained in:
@@ -8,6 +8,7 @@ import { managerRouter } from "./routers/manager";
|
||||
import { photosRouter } from "./routers/photos";
|
||||
import { platformRouter } from "./routers/platform";
|
||||
import { viewerRouter } from "./routers/viewer";
|
||||
import { signsRouter } from "./routers/signs";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
health: publicProcedure.query(() => ({
|
||||
@@ -15,6 +16,7 @@ export const appRouter = createTRPCRouter({
|
||||
service: "album-trpc",
|
||||
})),
|
||||
viewer: viewerRouter,
|
||||
signs: signsRouter,
|
||||
event: eventRouter,
|
||||
banners: bannersRouter,
|
||||
guest: guestRouter,
|
||||
|
||||
@@ -18,11 +18,13 @@ import { z } from "zod";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
loadGroupAccess,
|
||||
loadEventAccess,
|
||||
requireEventPermission,
|
||||
protectedProcedure,
|
||||
requireGroupPermission,
|
||||
} from "../trpc";
|
||||
import { GROUP_PERMISSIONS } from "@/server/permissions";
|
||||
import { getPlatformRole } from "@/server/roles";
|
||||
import { EVENT_PERMISSIONS, GROUP_PERMISSIONS, PLATFORM_PERMISSIONS } from "@/server/permissions";
|
||||
import { getPlatformRole, hasPlatformPermission } from "@/server/roles";
|
||||
import { resolveGroupQuota } from "@/server/entitlements";
|
||||
import { countGroupOwners } from "@/server/membership";
|
||||
import { writeAudit } from "@/server/audit";
|
||||
@@ -33,6 +35,25 @@ import { publicAppOrigin } from "@/server/public-app-url";
|
||||
import { GROUP_COOKIE, serializeCookie } from "@/server/cookies";
|
||||
import { invites } from "@album/database";
|
||||
|
||||
async function validateInviteGrants(userId: string, input: {
|
||||
groupId?: string; eventId?: string; eventRole?: string;
|
||||
grantUnlimitedEvents?: boolean; grantEventLimit?: number | null; grantComplimentary?: boolean;
|
||||
}) {
|
||||
const platformRole = await getPlatformRole(userId);
|
||||
if ((input.grantUnlimitedEvents || input.grantEventLimit != null || input.grantComplimentary) &&
|
||||
!hasPlatformPermission(platformRole, PLATFORM_PERMISSIONS.ENTITLEMENTS_MANAGE)) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Only platform administrators can grant event entitlements." });
|
||||
}
|
||||
if (input.eventId) {
|
||||
const { event, access } = await loadEventAccess(userId, input.eventId, platformRole);
|
||||
if (event.groupId !== input.groupId) throw new TRPCError({ code: "BAD_REQUEST", message: "Event must belong to this workspace." });
|
||||
requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_MANAGE);
|
||||
if (input.eventRole === "owner") requireEventPermission(access.permissions, EVENT_PERMISSIONS.PEOPLE_GRANT_OWNER);
|
||||
} else if (input.eventRole) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "An event is required for an event role." });
|
||||
}
|
||||
}
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
list: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getDb()
|
||||
@@ -222,6 +243,7 @@ export const groupRouter = createTRPCRouter({
|
||||
);
|
||||
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
||||
const token = newToken();
|
||||
await validateInviteGrants(ctx.session.user.id, input);
|
||||
await getDb().insert(invites).values({
|
||||
kind: "email",
|
||||
email: input.email,
|
||||
@@ -266,6 +288,7 @@ export const groupRouter = createTRPCRouter({
|
||||
platformRole,
|
||||
);
|
||||
requireGroupPermission(access.permissions, GROUP_PERMISSIONS.PEOPLE_MANAGE);
|
||||
await validateInviteGrants(ctx.session.user.id, input);
|
||||
const code = await createInviteCode({
|
||||
reusable: input.reusable ?? false,
|
||||
maxUses: input.maxUses ?? 1,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { events, eventSigns, getDb } from "@album/database";
|
||||
import { savedSignSchema } from "@album/contracts";
|
||||
import { createPresignedGetUrl, createPresignedPutUrl, getObjectBuffer, headObject, putObject, deleteObject } from "@album/storage";
|
||||
import { createTRPCRouter, protectedProcedure, loadEventAccess, requireEventPermission, EVENT_PERMISSIONS } from "../trpc";
|
||||
import { getPlatformRole } from "@/server/roles";
|
||||
import { signPaper, type SignDesign } from "@/lib/guest-sign";
|
||||
import { writeAudit } from "@/server/audit";
|
||||
|
||||
const eventInput = z.object({ eventId: z.string().uuid() });
|
||||
const pendingKey = (eventId: string, userId: string, uploadId: string) => `events/${eventId}/signs/pending/${userId}/${uploadId}.json`;
|
||||
async function access(userId: string, eventId: string, write: boolean) {
|
||||
const result = await loadEventAccess(userId, eventId, await getPlatformRole(userId));
|
||||
requireEventPermission(result.access.permissions, write ? EVENT_PERMISSIONS.SETTINGS_MANAGE : EVENT_PERMISSIONS.OVERVIEW_READ);
|
||||
return result;
|
||||
}
|
||||
|
||||
export const signsRouter = createTRPCRouter({
|
||||
get: protectedProcedure.input(eventInput).query(async ({ ctx, input }) => {
|
||||
await access(ctx.session.user.id, input.eventId, false);
|
||||
const [saved] = await getDb().select().from(eventSigns).where(eq(eventSigns.eventId, input.eventId));
|
||||
return saved ? { revision: saved.revision, url: await createPresignedGetUrl(saved.objectKey), updatedAt: saved.updatedAt } : null;
|
||||
}),
|
||||
prepare: protectedProcedure.input(eventInput).mutation(async ({ ctx, input }) => {
|
||||
await access(ctx.session.user.id, input.eventId, true);
|
||||
const uploadId = crypto.randomUUID();
|
||||
return { uploadId, url: await createPresignedPutUrl({ key: pendingKey(input.eventId, ctx.session.user.id, uploadId), contentType: "application/json" }) };
|
||||
}),
|
||||
save: protectedProcedure.input(eventInput.extend({ uploadId: z.string().uuid(), revision: z.string().uuid().nullable() })).mutation(async ({ ctx, input }) => {
|
||||
const { event } = await access(ctx.session.user.id, input.eventId, true);
|
||||
const key = pendingKey(input.eventId, ctx.session.user.id, input.uploadId);
|
||||
const metadata = await headObject(key);
|
||||
if (!metadata?.ContentLength || metadata.ContentLength > 20 * 1024 * 1024) throw new TRPCError({ code: "BAD_REQUEST", message: "Design must be under 20 MB. Try a smaller photo." });
|
||||
const bytes = await getObjectBuffer(key);
|
||||
if (bytes.length > 20 * 1024 * 1024) throw new TRPCError({ code: "BAD_REQUEST", message: "Design is too large." });
|
||||
let design;
|
||||
try {
|
||||
design = savedSignSchema.parse(JSON.parse(bytes.toString("utf8")));
|
||||
signPaper(design as SignDesign);
|
||||
} catch { throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid design. Check the wording, images, and paper size." }); }
|
||||
const revision = crypto.randomUUID();
|
||||
const objectKey = `events/${input.eventId}/signs/saved/${revision}.json`;
|
||||
// Publish a validated immutable copy, never the still-writable upload URL.
|
||||
await putObject({ key: objectKey, body: Buffer.from(JSON.stringify(design)), contentType: "application/json" });
|
||||
try {
|
||||
await getDb().transaction(async tx => {
|
||||
await tx.select({ id: events.id }).from(events).where(eq(events.id, input.eventId)).for("update");
|
||||
const [current] = await tx.select().from(eventSigns).where(eq(eventSigns.eventId, input.eventId));
|
||||
if ((current?.revision ?? null) !== input.revision) throw new TRPCError({ code: "CONFLICT", message: "Someone saved a newer design. Reload it before saving your changes." });
|
||||
await tx.insert(eventSigns).values({ eventId: input.eventId, revision, objectKey }).onConflictDoUpdate({ target: eventSigns.eventId, set: { revision, objectKey, updatedAt: new Date() } });
|
||||
await writeAudit({ eventId: input.eventId, groupId: event.groupId, actorUserId: ctx.session.user.id, action: "sign.save", subjectType: "event", subjectId: input.eventId }, tx as unknown as ReturnType<typeof getDb>);
|
||||
});
|
||||
} catch (error) { await deleteObject(objectKey).catch(() => {}); throw error; }
|
||||
await deleteObject(key).catch(() => {});
|
||||
return { revision };
|
||||
}),
|
||||
});
|
||||
@@ -58,7 +58,7 @@ export function assertInviteRedeemable(invite: typeof invites.$inferSelect) {
|
||||
if (invite.status === "expired") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
|
||||
}
|
||||
if (invite.expiresAt && invite.expiresAt < new Date()) {
|
||||
if (invite.expiresAt && invite.expiresAt <= new Date()) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invite expired" });
|
||||
}
|
||||
if (!invite.reusable && invite.status === "accepted") {
|
||||
@@ -79,7 +79,13 @@ export async function redeemInviteForUser(
|
||||
input: { token: string; userId: string },
|
||||
db: Database = getDb(),
|
||||
) {
|
||||
const invite = await findInviteByToken(input.token, db);
|
||||
return db.transaction(tx => redeemLockedInvite(input, tx as unknown as Database));
|
||||
}
|
||||
|
||||
async function redeemLockedInvite(input: { token: string; userId: string }, db: Database) {
|
||||
// Serialize redemptions and keep membership grants and usage accounting atomic.
|
||||
const [invite] = await db.select().from(invites)
|
||||
.where(eq(invites.tokenHash, hashToken(input.token))).limit(1).for("update");
|
||||
if (!invite) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invite not found" });
|
||||
}
|
||||
@@ -93,6 +99,9 @@ export async function redeemInviteForUser(
|
||||
if (!actor) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
if (invite.kind === "email" && (!invite.email || invite.email.trim().toLowerCase() !== actor.email.trim().toLowerCase())) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Sign in with the email address this invitation was sent to." });
|
||||
}
|
||||
|
||||
let groupId = invite.groupId;
|
||||
if (
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import sharp from "sharp";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { getDb, user, groups, events, eventMemberships, groupMemberships, invites, eventSigns, auditEvents } from "@album/database";
|
||||
import { deletePrefix } from "@album/storage";
|
||||
import { hashToken } from "./tokens";
|
||||
import { redeemInviteForUser } from "./invites";
|
||||
import { signsRouter } from "./api/routers/signs";
|
||||
import { groupRouter } from "./api/routers/group";
|
||||
import type { TrpcContext } from "./api/trpc";
|
||||
|
||||
test.skipIf(process.env.POLISH_INTEGRATION !== "1")("invitation isolation, atomic redemption, and saved sign roundtrip/conflicts", async () => {
|
||||
for (const key of ["DATABASE_URL", "S3_ENDPOINT"]) if (!["localhost", "127.0.0.1"].includes(new URL(process.env[key]!).hostname)) throw new Error("Local services required");
|
||||
const db = getDb();
|
||||
const id = crypto.randomUUID();
|
||||
const people = await db.insert(user).values([0, 1].map(n => ({ id: `${id}-${n}`, name: "Polish test", email: `${id}-${n}@manyangles.test`, emailVerified: true }))).returning();
|
||||
const [group] = await db.insert(groups).values({ name: "Polish test", slug: id, createdByUserId: people[0]!.id }).returning();
|
||||
const [event] = await db.insert(events).values({ groupId: group!.id, title: "Polish test", slug: id }).returning();
|
||||
const ctx = (n: number): TrpcContext => ({ session: { user: people[n]!, session: {} } as TrpcContext["session"], cookies: new Map(), activeGroupId: null, requestOrigin: "http://localhost:3000", clientIdentifier: id, guestTokenForEvent: () => null, setCookies: [], appendSetCookie: () => {} });
|
||||
try {
|
||||
await db.insert(groupMemberships).values({ groupId: group!.id, userId: people[0]!.id, role: "owner" });
|
||||
await db.insert(eventMemberships).values({ eventId: event!.id, userId: people[0]!.id, role: "owner" });
|
||||
const token = crypto.randomUUID();
|
||||
await db.insert(invites).values({ kind: "email", email: people[0]!.email.toUpperCase(), tokenHash: hashToken(token), groupId: group!.id, maxUses: 1 });
|
||||
await expect(redeemInviteForUser({ token, userId: people[1]!.id })).rejects.toThrow("email address");
|
||||
const attempts = await Promise.allSettled([redeemInviteForUser({ token, userId: people[0]!.id }), redeemInviteForUser({ token, userId: people[0]!.id })]);
|
||||
expect(attempts.filter(r => r.status === "fulfilled")).toHaveLength(1);
|
||||
const [used] = await db.select().from(invites).where(eq(invites.tokenHash, hashToken(token)));
|
||||
expect(used!.usedCount).toBe(1);
|
||||
const workspace = groupRouter.createCaller(ctx(0));
|
||||
await expect(workspace.createCode({ groupId: group!.id, grantUnlimitedEvents: true })).rejects.toThrow("platform administrators");
|
||||
const signs = signsRouter.createCaller(ctx(0));
|
||||
await expect(signsRouter.createCaller(ctx(1)).prepare({ eventId: event!.id })).rejects.toThrow();
|
||||
expect(await signs.get({ eventId: event!.id })).toBeNull();
|
||||
const image = `data:image/png;base64,${(await sharp({ create: { width: 8, height: 8, channels: 3, background: "#4055b5" } }).png().toBuffer()).toString("base64")}`;
|
||||
const design = { title: "Saved test", headline: "Share your moments", message: "Photos welcome", paper: "card6x4", ink: "indigo", layout: "compact", background: "#f1eadf", accent: "#473a30", backgroundImage: image, logoImage: image };
|
||||
const upload = async () => {
|
||||
const prepared = await signs.prepare({ eventId: event!.id });
|
||||
const response = await fetch(prepared.url, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(design) });
|
||||
expect(response.ok).toBe(true);
|
||||
return prepared.uploadId;
|
||||
};
|
||||
const first = await signs.save({ eventId: event!.id, uploadId: await upload(), revision: null });
|
||||
const saved = await signs.get({ eventId: event!.id });
|
||||
expect(saved!.revision).toBe(first.revision);
|
||||
expect(await (await fetch(saved!.url)).json()).toEqual(design);
|
||||
await expect(signs.save({ eventId: event!.id, uploadId: await upload(), revision: null })).rejects.toThrow("newer design");
|
||||
expect((await signs.get({ eventId: event!.id }))!.revision).toBe(first.revision);
|
||||
const second = await signs.save({ eventId: event!.id, uploadId: await upload(), revision: first.revision });
|
||||
expect(second.revision).not.toBe(first.revision);
|
||||
await db.update(eventMemberships).set({ role: "viewer" }).where(and(eq(eventMemberships.eventId, event!.id), eq(eventMemberships.userId, people[0]!.id)));
|
||||
await db.delete(groupMemberships).where(eq(groupMemberships.groupId, group!.id));
|
||||
await expect(signs.prepare({ eventId: event!.id })).rejects.toThrow();
|
||||
} finally {
|
||||
await deletePrefix(`events/${event!.id}/signs/`);
|
||||
await db.delete(eventSigns).where(eq(eventSigns.eventId, event!.id));
|
||||
await db.delete(invites).where(eq(invites.groupId, group!.id));
|
||||
await db.delete(auditEvents).where(eq(auditEvents.groupId, group!.id));
|
||||
await db.delete(events).where(eq(events.id, event!.id));
|
||||
await db.delete(groups).where(eq(groups.id, group!.id));
|
||||
await db.delete(user).where(inArray(user.id, people.map(p => p.id)));
|
||||
}
|
||||
}, 30000);
|
||||
Reference in New Issue
Block a user