Prepare production and Coolify deployment with original exports and datetime pickers
This commit is contained in:
@@ -16,11 +16,13 @@ import {
|
||||
groupMemberships,
|
||||
guests,
|
||||
photos,
|
||||
photoExports,
|
||||
submissions,
|
||||
user,
|
||||
} from "@album/database";
|
||||
import {
|
||||
createEventInputSchema,
|
||||
exportPhotosInputSchema,
|
||||
checkEventSlugInputSchema,
|
||||
generateEventSlugInputSchema,
|
||||
locationSearchInputSchema,
|
||||
@@ -79,6 +81,30 @@ async function signedPhotoUrls(photo: {
|
||||
}
|
||||
|
||||
export const managerRouter = createTRPCRouter({
|
||||
requestExport: protectedProcedure.input(exportPhotosInputSchema).mutation(async ({ctx,input}) => {
|
||||
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
||||
const [job] = await getDb().insert(photoExports).values({eventId:input.eventId,requestedBy:ctx.session.user.id,filter:input.filter,expiresAt:new Date(Date.now()+86400000)}).onConflictDoNothing().returning({id:photoExports.id});
|
||||
if (!job) throw new TRPCError({code:"CONFLICT",message:"An export is already queued or processing."});
|
||||
await writeAudit({eventId:input.eventId,actorUserId:ctx.session.user.id,action:"photo.export.requested",subjectType:"export",subjectId:job.id,metadata:{filter:input.filter}});
|
||||
return job;
|
||||
}),
|
||||
exports: protectedProcedure.input(z.object({eventId:z.string().uuid()})).query(async ({ctx,input}) => {
|
||||
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
||||
return getDb().select({id:photoExports.id,status:photoExports.status,filter:photoExports.filter,total:photoExports.total,processed:photoExports.processed,expiresAt:photoExports.expiresAt}).from(photoExports)
|
||||
.where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.requestedBy,ctx.session.user.id))).orderBy(desc(photoExports.createdAt)).limit(10);
|
||||
}),
|
||||
downloadExport: protectedProcedure.input(z.object({eventId:z.string().uuid(),exportId:z.string().uuid()})).mutation(async ({ctx,input}) => {
|
||||
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
|
||||
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
|
||||
const [job] = await getDb().select().from(photoExports).where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.id,input.exportId),eq(photoExports.requestedBy,ctx.session.user.id)));
|
||||
if (!job || job.status !== "ready" || job.expiresAt <= new Date()) throw new TRPCError({code:"NOT_FOUND",message:"Export is unavailable or expired."});
|
||||
return {url:await createPresignedGetUrl(`exports/${input.eventId}/${job.id}.zip`,Math.max(1,Math.min(300,Math.floor((job.expiresAt.getTime()-Date.now())/1000))))};
|
||||
}),
|
||||
searchLocations: protectedProcedure.input(locationSearchInputSchema).query(async ({ ctx, input }) => {
|
||||
const platformRole = await getPlatformRole(ctx.session.user.id);
|
||||
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, platformRole);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {test,expect} from "bun:test";
|
||||
import {and,eq,ne} from "drizzle-orm";
|
||||
import {events,eventMemberships,getDb,groups,photoExports,user} from "@album/database";
|
||||
import {managerRouter} from "./api/routers/manager";
|
||||
import type {TrpcContext} from "./api/trpc";
|
||||
|
||||
test.skipIf(process.env.EXPORT_PERMISSIONS_INTEGRATION!=="1")("export authorization, requester scoping, revoked access and expiry",async()=>{
|
||||
if (!["localhost","127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
|
||||
const db=getDb();
|
||||
const id=crypto.randomUUID();
|
||||
const [person]=await db.insert(user).values({id,name:"Export test",email:`${id}@manyangles.test`,emailVerified:true}).returning();
|
||||
const [group]=await db.select({id:groups.id}).from(groups).limit(1);
|
||||
const [event]=await db.insert(events).values({groupId:group!.id,title:"Permission test",slug:`export-access-${id}`}).returning();
|
||||
const ctx:TrpcContext={session:{user:person!,session:{}} as TrpcContext["session"],cookies:new Map(),activeGroupId:null,requestOrigin:"http://localhost:3000",clientIdentifier:id,guestTokenForEvent:()=>null,setCookies:[],appendSetCookie:()=>{}};
|
||||
const caller=managerRouter.createCaller(ctx);
|
||||
try {
|
||||
await expect(caller.requestExport({eventId:event!.id,filter:"all"})).rejects.toThrow();
|
||||
await db.insert(eventMemberships).values({eventId:event!.id,userId:id,role:"viewer"});
|
||||
await expect(caller.requestExport({eventId:event!.id,filter:"all"})).rejects.toThrow();
|
||||
await db.update(eventMemberships).set({role:"owner"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
|
||||
const job=await caller.requestExport({eventId:event!.id,filter:"all"});
|
||||
const scope=and(eq(photoExports.eventId,event!.id),eq(photoExports.id,job.id));
|
||||
await db.update(photoExports).set({status:"ready"}).where(scope);
|
||||
expect((await caller.downloadExport({eventId:event!.id,exportId:job.id})).url).toContain("X-Amz-Expires=300");
|
||||
const [other]=await db.select({id:user.id}).from(user).where(ne(user.id,id)).limit(1);
|
||||
await db.update(photoExports).set({requestedBy:other!.id}).where(scope);
|
||||
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow();
|
||||
await db.update(photoExports).set({requestedBy:id}).where(scope);
|
||||
await expect(caller.downloadExport({eventId:crypto.randomUUID(),exportId:job.id})).rejects.toThrow();
|
||||
await db.update(eventMemberships).set({role:"viewer"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
|
||||
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow();
|
||||
await db.update(eventMemberships).set({role:"owner"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
|
||||
await db.update(photoExports).set({expiresAt:new Date(0)}).where(scope);
|
||||
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow("expired");
|
||||
} finally {
|
||||
await db.delete(events).where(eq(events.id,event!.id));
|
||||
await db.delete(user).where(eq(user.id,id));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user