diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index 2fc22dca5..14255ea9d 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -225,6 +225,7 @@ export class TripsMcp { is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'), cover_image: z .string() + .max(2048) .nullable() .optional() .describe('Cover image path, e.g. /uploads/covers/abc.jpg; null removes the cover'), diff --git a/shared/src/storage/storage.schema.spec.ts b/shared/src/storage/storage.schema.spec.ts index 3bc8cbf64..fd927af0c 100644 --- a/shared/src/storage/storage.schema.spec.ts +++ b/shared/src/storage/storage.schema.spec.ts @@ -224,7 +224,7 @@ describe('the reclaim contract', () => { expect(storageReclaimReportSchema.safeParse(old).success).toBe(false); }); - it('names every skip reason a pass reports, the brake that used to skip a category gone', () => { + it('names every skip reason a pass reports, a running backup included, the old brake gone', () => { expect([...STORAGE_RECLAIM_SKIP_REASONS]).toEqual([ 'not_reclaimed', 'sources_missing', @@ -234,6 +234,7 @@ describe('the reclaim contract', () => { 'list_failed', 'source_failed', 'delete_failed', + 'backup_running', 'interrupted', ]); for (const skipped of STORAGE_RECLAIM_SKIP_REASONS) { @@ -264,10 +265,20 @@ describe('the reclaim contract', () => { it('the requests are closed objects, and a started check answers started: true', () => { expect(storageReclaimSettingsRequestSchema.parse({ deleteEnabled: true })).toEqual({ deleteEnabled: true }); expect(storageReclaimSettingsRequestSchema.safeParse({ deleteEnabled: true, extra: 1 }).success).toBe(false); - expect(storageReclaimConfirmRequestSchema.parse({ category: 'covers' })).toEqual({ category: 'covers' }); - expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'nowhere' }).success).toBe(false); + expect(storageReclaimConfirmRequestSchema.parse({ category: 'covers', unreferenced: 300 })).toEqual({ + category: 'covers', + unreferenced: 300, + }); + expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'nowhere', unreferenced: 3 }).success).toBe(false); expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers', count: 3 }).success).toBe(false); expect(storageReclaimRunStartedSchema.parse({ started: true })).toEqual({ started: true }); expect(storageReclaimRunStartedSchema.safeParse({ started: false }).success).toBe(false); }); + it('a confirmation names the count the admin was shown, a whole number of objects', () => { + expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers' }).success).toBe(false); + expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers', unreferenced: -1 }).success).toBe(false); + expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers', unreferenced: 1.5 }).success).toBe(false); + expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers', unreferenced: '3' }).success).toBe(false); + expect(storageReclaimConfirmRequestSchema.safeParse({ category: 'covers', unreferenced: 0 }).success).toBe(true); + }); }); diff --git a/shared/src/storage/storage.schema.ts b/shared/src/storage/storage.schema.ts index d23c99c1d..02a83df3e 100644 --- a/shared/src/storage/storage.schema.ts +++ b/shared/src/storage/storage.schema.ts @@ -291,6 +291,8 @@ export const storageReclaimCategorySchema = z.enum(STORAGE_RECLAIM_CATEGORIES); * - `owner_unknown`: this installation's owner marker could not be written there, so a shared place cannot be ruled out; * - `list_failed` / `source_failed`: the listing or a reference lookup failed, so the pass stopped there; * - `delete_failed`: deleting failed on the backend, so the pass stopped deleting in this category; + * - `backup_running`: a backup was reading the uploads when this category's objects were due, so the pass + * deleted nothing there (from then on, for the rest of the pass); the next pass tries again; * - `interrupted`: the pass stopped (`stopped` says why) before it finished this category; its figures are partial. * A skip keeps the marks it found: only a pass that judges the category moves them. */ @@ -303,6 +305,7 @@ export const STORAGE_RECLAIM_SKIP_REASONS = [ 'list_failed', 'source_failed', 'delete_failed', + 'backup_running', 'interrupted', ] as const; @@ -317,8 +320,9 @@ export const storageReclaimCategoryReportSchema = z.object({ eligible: storageObjectCountSchema, deleted: storageObjectCountSchema, /** - * More than a fifth of the category is unreferenced, and more than an admin - * last confirmed: deletion waits for a confirmation (POST .../reclaim/confirm). + * More than a fifth of the category is unreferenced and no admin confirmation + * covers that count: deletion waits for one (POST .../reclaim/confirm). The + * admin state reads false once a confirmation covers it, before the next pass. */ confirmationRequired: z.boolean(), skipped: z.enum(STORAGE_RECLAIM_SKIP_REASONS).nullable(), @@ -359,9 +363,16 @@ export const storageReclaimRunStartedSchema = z.object({ started: z.literal(true /** * Lets the pass delete in a category whose unreferenced share is over a - * fifth, up to the count the last report showed for it. + * fifth. `unreferenced` is the count the admin was shown; the server refuses + * (409 `reclaim_confirm_stale`) when its last report's count for the category + * differs from it by more than a tenth or by more than 50 objects. The + * confirmation then covers up to one and a half times that count (at least 100 + * more) for 14 days, and is used up once the share falls under a fifth again. */ -export const storageReclaimConfirmRequestSchema = z.strictObject({ category: storageReclaimCategorySchema }); +export const storageReclaimConfirmRequestSchema = z.strictObject({ + category: storageReclaimCategorySchema, + unreferenced: z.number().int().nonnegative(), +}); export type StorageReclaimConfirmRequest = z.infer; export const storageAdminStateSchema = z.object({ diff --git a/shared/src/trip/trip.schema.spec.ts b/shared/src/trip/trip.schema.spec.ts index ba84982d3..e95c9187e 100644 --- a/shared/src/trip/trip.schema.spec.ts +++ b/shared/src/trip/trip.schema.spec.ts @@ -48,6 +48,13 @@ describe('tripUpdateRequestSchema', () => { expect(tripUpdateRequestSchema.safeParse({}).success).toBe(true); expect(tripUpdateRequestSchema.safeParse({ is_archived: 1, cover_image: null }).success).toBe(true); }); + + it('bounds cover_image like a place image url: a replaced cover is queued for release', () => { + expect(tripUpdateRequestSchema.safeParse({ cover_image: `/uploads/covers/${'a'.repeat(2000)}` }).success).toBe( + true, + ); + expect(tripUpdateRequestSchema.safeParse({ cover_image: 'x'.repeat(2049) }).success).toBe(false); + }); }); describe('tripAddMemberRequestSchema', () => { diff --git a/shared/src/trip/trip.schema.ts b/shared/src/trip/trip.schema.ts index 0e9b3c6c9..bf5bc5f22 100644 --- a/shared/src/trip/trip.schema.ts +++ b/shared/src/trip/trip.schema.ts @@ -118,7 +118,9 @@ export const tripUpdateRequestSchema = z.object({ reminder_days: z.number().optional(), day_count: z.number().optional(), is_archived: z.union([z.boolean(), z.number()]).optional(), - cover_image: z.string().nullable().optional(), + // Bounded like a place's image url: a replaced cover is queued for release, + // and every upload path the old value spells out is one queued object. + cover_image: z.string().max(2048).nullable().optional(), }); export type TripUpdateRequest = z.infer;