DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We sign the content type and the byte length into the upload URL, then re-read the file anyway

We shipped a form this week for the one support ticket we most want people to open: "the practice did not match my real test". It is also how a buyer claims our refund guarantee, so the fields are structured rather than free text, and it accepts up to three screenshots.

Three design decisions in it are worth writing down.

No schema change, because the fields live in a message

A mismatch report is an ordinary support ticket. Type bug, category practice-mismatch, and the structured fields ride in the meta column of the opening message:

const meta: MismatchReportMeta = {
  kind: MISMATCH_CATEGORY,
  provider,
  gameId: gameName ? gameId : null,
  employer,
  satOn,
  providerUnlocked: dbUser?.unlocked_providers?.includes(provider) ?? false,
  screenshots: stored,
};
Enter fullscreen mode Exit fullscreen mode

The whole feature added zero migrations. Every existing thing a ticket can do (threading, email replies, the admin queue, cascade on account deletion) works on day one because it is a ticket.

The field I would point at is providerUnlocked. It is a copy, at report time, of whether that account had bought that provider. The live answer is one query away, so copying it is redundant by every normal rule. But the report may become a refund claim, and the thing that matters then is whether they had bought it when they reported the mismatch, not whether they still have it when somebody reads the ticket three days later. A denormalised boolean is the right call when the question is about a moment.

The upload binds type and length, and the server does not trust that

Screenshots go straight from the browser to private object storage through a presigned PUT, so a 5 MB file never passes through a serverless function. The signature covers more than the key:

return getSignedUrl(getClient(), command, {
  expiresIn: UPLOAD_URL_TTL_SECONDS,
  signableHeaders: new Set(['content-type', 'content-length']),
});
Enter fullscreen mode Exit fullscreen mode

Content type and exact byte length are both signed, so the upload has to be the file the server already approved. That is the part most tutorials stop at, and it is not enough, because it only holds if the storage provider enforces the signed headers and if the client is the one we wrote. So when the report itself arrives, the route reads the object back:

// Re-read what is actually in storage. The upload URL bound type and size,
// but this is the check that does not depend on the client or on R2
// enforcing the signed headers.
let inspections;
try {
  inspections = await Promise.all(screenshots.map((key) => inspectScreenshot(key)));
} catch (error) { ... }
Enter fullscreen mode Exit fullscreen mode

inspectScreenshot is a HEAD for the size plus a 12-byte ranged GET for the magic bytes, which is about as cheap as a read gets. Then the sniffed type has to equal the type that key was issued for:

if (!found || !found.sniffedType || found.sniffedType !== expected ||
    found.size > MISMATCH_LIMITS.screenshotMaxBytes) {
  invalidKeys.push(key);
}
Enter fullscreen mode Exit fullscreen mode

Anything that fails is deleted from storage before the 400 goes back, and the response names the failing keys so the dialog can mark those three thumbnails rather than making the person start again.

The sniffer itself is 38 lines of byte comparisons for the three types we accept, with the only comment that matters sitting on top of it:

 * Identifies a PNG, JPEG or WebP from its first bytes, or null for anything
 * else. The declared content type is only a claim; this is what the file is.
Enter fullscreen mode Exit fullscreen mode

It lives in a module with no server-only imports, which means the browser dialog runs the same checkScreenshot the route runs. One definition of "acceptable image", two call sites, no drift.

Two smaller choices in the same area. The admin thumbnail is a presigned GET with a ten minute life that forces ResponseContentType to the type we verified and ResponseContentDisposition: 'inline', so whatever the stored object claims about itself is irrelevant to the browser rendering it. And if one screenshot cannot be signed, it keeps a null URL and the admin view says so, rather than a storage hiccup failing the whole ticket.

A cascade cannot reach object storage

This is the bug we nearly shipped. Ticket rows cascade when an account is deleted. The files those rows point at do not.

/**
 * Deletes every screenshot a user has uploaded. For account deletion: the
 * ticket rows cascade with the account, but the objects they point at do not.
 * Returns how many objects were removed.
 */
export async function deleteUserScreenshots(userId: string): Promise<number> {
Enter fullscreen mode Exit fullscreen mode

It pages ListObjectsV2 by the user's own prefix and batch-deletes, and it is wired into both the self-serve delete and the admin delete, as a best-effort step before the database work:

// Support screenshots live in object storage, which the cascade cannot
// reach. Same best-effort rule as the calendar revoke above.
await deleteUserScreenshots(user!.id).catch((error) =>
  logError('Failed to delete support screenshots before account deletion', error)
);
Enter fullscreen mode Exit fullscreen mode

Every object lives under support/mismatch/<userId>/, which is what makes a prefix delete correct and also how the route proves a key in a submitted report belongs to the person submitting it. The feature's flat layout is doing two jobs.

That commit also did the part that is not code: a new paragraph in the privacy policy naming the new category of data, the storage provider entry extended, the retention line amended, and the policy version moved from 1.9.0 to 1.10.0. If you add a place where personal data lands and you do not move that number, the policy is now wrong and nothing will tell you.

Two details only a phone exposes

The file input's accept attribute lists exactly the three types we take:

/** The `accept` attribute for the file input. Listing only these makes iOS hand over HEIC photos as JPEG. */
export const SCREENSHOT_ACCEPT = SCREENSHOT_TYPES.join(',');
Enter fullscreen mode Exit fullscreen mode

An iPhone screenshot of an assessment is a HEIC file. Name the types you want and iOS converts on the way out; name image/* and you get HEIC, which fails the sniffer, which is a dead end for the person reporting a real problem.

And the date field validates one day into the future:

/**
 * Whether a YYYY-MM-DD string is a real calendar date no later than tomorrow
 * (UTC). One day of slack covers anyone whose local date is ahead of UTC.
 */
Enter fullscreen mode Exit fullscreen mode

Someone in Auckland reporting a test they sat today should not be told they are reporting a test from the future.

See it

Open our privacy policy and search for "screenshots". Four hits: the new paragraph describing what a mismatch report stores, the storage provider entry, the line that says screenshots are deleted with your account, and the retention row. The header reads "Last updated: 8 November 2026 ยท Version: 1.10.0". The refund guarantee the reports feed is clause 5.3 of the terms, at version 1.13.0.

The form itself sits behind an account on the help and support pages, and there is a link to it on the screen at the end of a practice run. Everything it reports about is on the open side of the site: the test library and the guides.

The honest summary: the presigned upload took an afternoon, the second check took an hour, and the twenty lines that delete the objects when an account goes are the only part of it I would have been embarrassed to leave out.

Top comments (0)