Restore HDR editing on existing posts with private staging and atomic saves #1

Open
opened 2026-10-24 22:57:22 +00:00 by Michael Westergaard · 0 comments
Owner

HDR controls on existing posts currently reject add/replace/remove to avoid non-atomic binary changes. Restore this later through private staging and atomic Save, without weakening privacy, history or the single-copy recovery work.

Implement backend endpoints/capability/validation, additive staging state and cleanup, atomic reference/history/replay integration and controlled-storage regressions described below. Update both shared contract copies when implementation begins. No frontend controls should be enabled before this capability is real.

Frontend companion: klafbang/VernissageWeb#1

Current code: Controllers/AttachmentsController.swift rejects attached HDR mutations; PostAccess/PostAttachmentEdits.swift contains metadata-only full snapshots. The separate attachmentHdrEdits field intentionally preserves those snapshot semantics.

Shared contract: atomic HDR edits v1 (proposed, deferred)

This is the shared implementation specification for both forks on michael. It extends the atomic attachment contract; it is not implemented by filing this issue. Implement later, after the current single-copy storage work. No rollout blocker or priority escalation is intended.

Capability and compatibility

Add atomicHdrEdits: true to GET /api/v1/post-access/capabilities only when all guarantees below are implemented. Frontend requires supported/enabled contract 1.x, atomicAttachmentEdits === true, and atomicHdrEdits === true. Missing/false keeps attached HDR controls disabled. Existing unattached HDR endpoints continue unchanged; direct attached HDR mutations still return 422 attachedMediaRequiresStatusSave. Do not add binary fields to the existing complete metadata snapshots.

Private upload staging

POST /api/v1/post-access/statuses/{statusId}/attachments/{attachmentId}/hdr-stages

Authenticated account plus existing XSRF protection and UUID-v4 Idempotency-Key are required. Multipart body contains exactly one file; accept validated AVIF bytes, 1 through 4,194,303 bytes (matching the existing backend's strict less-than-4-MiB limit). Allow at most 5 MiB for the complete multipart body; the file itself remains subject to the stricter limit. Validate actual AVIF content and record decoded dimensions; do not trust extension or caller-supplied storage metadata. Do not transcode the uploaded original. Require current ownership of the post and attachment and membership of that attachment in that post. Recheck eligibility before making the stage ready if upload I/O suspended.

Successful response: 201, Cache-Control: private, no-store:

{ "id": "opaque-stage-id", "statusId": "post-id", "attachmentId": "attachment-id", "expiresAt": "RFC3339 UTC", "sizeBytes": 12345, "mimeType": "image/avif", "width": 2000, "height": 1500 }

Stage IDs are opaque strings; all post/attachment IDs remain strings. Stages are owner/post/attachment-bound, private, and valid for 24 hours from becoming ready. Uploading does not alter the current attachment, history, post revision, delivery or visibility. Stages have no public URL and cannot be read by guest/follower/recipient/status-media routes. The editor previews the local selected file through a releasable blob URL; no new stage-read endpoint is needed.

Scope upload replay by actor/method/path/key, matching the file bytes (digest) and validated media type. While the stage remains ready, an identical retry returns the same stage and original expiry, without extending it or creating another object; different content returns 409 idempotencyConflict. Serialize/claim concurrent same-key uploads without holding database locks during upload/storage I/O. If the ready stage has expired, return 410 hdrStageExpired, requiring a fresh upload key. Consumed stages return 409 hdrStageAlreadyCommitted; cancelled stages return 404 hdrStageNotFound. Neither recreates an upload. Incomplete uploads must remain recoverable or safely cleanable, including failures between object creation and final database recording. Retain sufficient replay/tombstone information to prevent a late retry recreating a cancelled or consumed stage as a fresh upload.

Cancellation: DELETE /api/v1/post-access/statuses/{statusId}/attachments/{attachmentId}/hdr-stages/{stageId}, account/XSRF protected. Returns 204 for a matching unconsumed stage or its repeated cancellation. Atomically makes it unusable, then schedules safe cleanup. A stage already committed into a post returns 409 hdrStageAlreadyCommitted and must never delete its live/history object. Cancellation racing Save has one database winner; it cannot delete a successfully committed HDR. Unknown/not-owned/wrong-target stage IDs return 404 hdrStageNotFound without disclosing another owner's records. A target post that was deleted/changed is handled consistently with ordinary ownership/existence checks; expiry handles abandoned stages.

Atomic post-save addition

Extend only PUT /api/v1/statuses/{statusId} with:

attachmentHdrEdits?: Array<{ id: string; stageId: string | null }>;
  • Omitted/empty array: preserve HDR on every attachment.
  • Missing attachment entry: preserve that attachment's HDR.
  • { id, stageId: "..." }: add/replace HDR using the ready stage.
  • { id, stageId: null }: remove HDR; already absent is a no-op.
  • Null array, missing stageId, duplicate IDs, unknown fields, wrong value types, empty stage IDs, edits to an unselected attachment, or non-owned/wrong-target stages are invalid. This field is not a metadata replacement snapshot. Newly uploaded unattached photos retain their existing HDR preparation path; this addition targets photos already attached to this post. Reject this new field on POST creates rather than silently ignoring it.

Normal account/XSRF, If-Match and Idempotency-Key requirements apply. Atomically validate and consume stages, snapshot the old HDR file references into history, swap/remove current references, apply all other post changes, update revision, and record durable location/delivery/replay work. Stage records distinguish ready/consumed/cancelled/expired states. Consumed bytes become the referenced media object: no permanent extra staging copy. Validation/conflict failures leave the post/history/revision unchanged and stages available until their original expiry. Only one successful operation may consume a stage; new-key reuse returns 409 hdrStageAlreadyCommitted.

Use the existing StatusDto and quoted ETag for 200 success; originalHdrFile reflects the committed result. Full-body idempotency matching includes attachmentHdrEdits. A successful unchanged retry replays BEFORE stage expiry/consumption or stale-revision validation, with current owner/existence checks, and creates no extra history, consumption, location moves or delivery. Original key/body/If-Match must be retained for lost-response and committed-storage-error retries. Existing 503 mediaNotReady and 503 mediaRevocationPending meanings and priority remain unchanged.

Storage, history and cleanup

Integrate with the single-copy location/recovery model. Promote the stage's existing private object through durable state, then move it as current audience policy requires. Replaced/removed HDR stays referenced by history and becomes protected when history-only. Current/snapshot authorization must locate it correctly. Old public origin bytes must be removed/moved safely; never serve a protected history image through its old public origin. Existing Followers/external-Mentioned public-media exceptions and CDN cache-expiry exception remain unchanged.

Expiry/cancellation/orphan cleanup must atomically establish that no committed current/history reference, consumption, active upload or unfinished move needs the object. A referenced object in the wrong location is reconciled, not deleted. Recheck authoritative bucket/key and generation; stale stage cleanup must not remove promoted media. Account/post/attachment deletion, crashes, partial moves and concurrent cleanup must converge safely. Keep one retained object per variant in steady state; temporary recovery copies are permitted. Perform external I/O outside shared database locks.

Errors

Use existing post-access JSON envelope, no-store responses and failures paths. Keep 401 authenticationRequired, ordinary post/ownership errors, 412 revisionConflict, 428 revisionRequired/idempotencyKeyRequired, and 409 idempotencyConflict. Add 422 invalidHdrStage for invalid upload, 422 invalidAttachmentHdrEdits for malformed/invalid edit structure or attachment selection, 404 hdrStageNotFound for unknown/not-owned/misbound stages, 410 hdrStageExpired for expired unconsumed stages, and 409 hdrStageAlreadyCommitted for consumed-stage reuse/cancellation. Field errors identify file or attachmentHdrEdits[i].stageId/.id. Replays after an already successful Save must not fail because the referenced stage is consumed/expired.

Shared acceptance

Verify HDR add/replace/remove, omitted-preserve and explicit-null removal; combined metadata/visibility/order edits; invalid media and size boundaries; cross-owner/target attempts; concurrent stage use/cancel/expiry/cleanup; stale revision with untouched draft/stage; lost upload/save responses; original-request replay after consumption/expiry; old HDR accessible only under history authorization; public-to-private origin cleanup and failure recovery; single-copy steady state; crashes and stale orphan/deletion work. Run browser integration with service workers enabled against the real API and disposable storage, including upgrade/refresh, restricted previews, post-save HDR/fallback display and cleanup/revocation warnings. Record actual frontend/backend versions and limitations.

HDR controls on existing posts currently reject add/replace/remove to avoid non-atomic binary changes. Restore this later through private staging and atomic Save, without weakening privacy, history or the single-copy recovery work. Implement backend endpoints/capability/validation, additive staging state and cleanup, atomic reference/history/replay integration and controlled-storage regressions described below. Update both shared contract copies when implementation begins. No frontend controls should be enabled before this capability is real. Frontend companion: https://git.westergaard.cloud/klafbang/VernissageWeb/issues/1 Current code: `Controllers/AttachmentsController.swift` rejects attached HDR mutations; `PostAccess/PostAttachmentEdits.swift` contains metadata-only full snapshots. The separate `attachmentHdrEdits` field intentionally preserves those snapshot semantics. ## Shared contract: atomic HDR edits v1 (proposed, deferred) This is the shared implementation specification for both forks on `michael`. It extends the atomic attachment contract; it is not implemented by filing this issue. Implement later, after the current single-copy storage work. No rollout blocker or priority escalation is intended. ### Capability and compatibility Add `atomicHdrEdits: true` to `GET /api/v1/post-access/capabilities` only when all guarantees below are implemented. Frontend requires supported/enabled contract 1.x, `atomicAttachmentEdits === true`, and `atomicHdrEdits === true`. Missing/false keeps attached HDR controls disabled. Existing unattached HDR endpoints continue unchanged; direct attached HDR mutations still return `422 attachedMediaRequiresStatusSave`. Do not add binary fields to the existing complete metadata snapshots. ### Private upload staging `POST /api/v1/post-access/statuses/{statusId}/attachments/{attachmentId}/hdr-stages` Authenticated account plus existing XSRF protection and UUID-v4 `Idempotency-Key` are required. Multipart body contains exactly one `file`; accept validated AVIF bytes, 1 through 4,194,303 bytes (matching the existing backend's strict less-than-4-MiB limit). Allow at most 5 MiB for the complete multipart body; the file itself remains subject to the stricter limit. Validate actual AVIF content and record decoded dimensions; do not trust extension or caller-supplied storage metadata. Do not transcode the uploaded original. Require current ownership of the post and attachment and membership of that attachment in that post. Recheck eligibility before making the stage ready if upload I/O suspended. Successful response: `201`, `Cache-Control: private, no-store`: ```json { "id": "opaque-stage-id", "statusId": "post-id", "attachmentId": "attachment-id", "expiresAt": "RFC3339 UTC", "sizeBytes": 12345, "mimeType": "image/avif", "width": 2000, "height": 1500 } ``` Stage IDs are opaque strings; all post/attachment IDs remain strings. Stages are owner/post/attachment-bound, private, and valid for 24 hours from becoming ready. Uploading does not alter the current attachment, history, post revision, delivery or visibility. Stages have no public URL and cannot be read by guest/follower/recipient/status-media routes. The editor previews the local selected file through a releasable blob URL; no new stage-read endpoint is needed. Scope upload replay by actor/method/path/key, matching the file bytes (digest) and validated media type. While the stage remains ready, an identical retry returns the same stage and original expiry, without extending it or creating another object; different content returns `409 idempotencyConflict`. Serialize/claim concurrent same-key uploads without holding database locks during upload/storage I/O. If the ready stage has expired, return `410 hdrStageExpired`, requiring a fresh upload key. Consumed stages return `409 hdrStageAlreadyCommitted`; cancelled stages return `404 hdrStageNotFound`. Neither recreates an upload. Incomplete uploads must remain recoverable or safely cleanable, including failures between object creation and final database recording. Retain sufficient replay/tombstone information to prevent a late retry recreating a cancelled or consumed stage as a fresh upload. Cancellation: `DELETE /api/v1/post-access/statuses/{statusId}/attachments/{attachmentId}/hdr-stages/{stageId}`, account/XSRF protected. Returns `204` for a matching unconsumed stage or its repeated cancellation. Atomically makes it unusable, then schedules safe cleanup. A stage already committed into a post returns `409 hdrStageAlreadyCommitted` and must never delete its live/history object. Cancellation racing Save has one database winner; it cannot delete a successfully committed HDR. Unknown/not-owned/wrong-target stage IDs return `404 hdrStageNotFound` without disclosing another owner's records. A target post that was deleted/changed is handled consistently with ordinary ownership/existence checks; expiry handles abandoned stages. ### Atomic post-save addition Extend only `PUT /api/v1/statuses/{statusId}` with: ```typescript attachmentHdrEdits?: Array<{ id: string; stageId: string | null }>; ``` - Omitted/empty array: preserve HDR on every attachment. - Missing attachment entry: preserve that attachment's HDR. - `{ id, stageId: "..." }`: add/replace HDR using the ready stage. - `{ id, stageId: null }`: remove HDR; already absent is a no-op. - Null array, missing stageId, duplicate IDs, unknown fields, wrong value types, empty stage IDs, edits to an unselected attachment, or non-owned/wrong-target stages are invalid. This field is not a metadata replacement snapshot. Newly uploaded unattached photos retain their existing HDR preparation path; this addition targets photos already attached to this post. Reject this new field on POST creates rather than silently ignoring it. Normal account/XSRF, `If-Match` and `Idempotency-Key` requirements apply. Atomically validate and consume stages, snapshot the old HDR file references into history, swap/remove current references, apply all other post changes, update revision, and record durable location/delivery/replay work. Stage records distinguish ready/consumed/cancelled/expired states. Consumed bytes become the referenced media object: no permanent extra staging copy. Validation/conflict failures leave the post/history/revision unchanged and stages available until their original expiry. Only one successful operation may consume a stage; new-key reuse returns `409 hdrStageAlreadyCommitted`. Use the existing `StatusDto` and quoted ETag for `200` success; `originalHdrFile` reflects the committed result. Full-body idempotency matching includes `attachmentHdrEdits`. A successful unchanged retry replays BEFORE stage expiry/consumption or stale-revision validation, with current owner/existence checks, and creates no extra history, consumption, location moves or delivery. Original key/body/If-Match must be retained for lost-response and committed-storage-error retries. Existing `503 mediaNotReady` and `503 mediaRevocationPending` meanings and priority remain unchanged. ### Storage, history and cleanup Integrate with the single-copy location/recovery model. Promote the stage's existing private object through durable state, then move it as current audience policy requires. Replaced/removed HDR stays referenced by history and becomes protected when history-only. Current/snapshot authorization must locate it correctly. Old public origin bytes must be removed/moved safely; never serve a protected history image through its old public origin. Existing Followers/external-Mentioned public-media exceptions and CDN cache-expiry exception remain unchanged. Expiry/cancellation/orphan cleanup must atomically establish that no committed current/history reference, consumption, active upload or unfinished move needs the object. A referenced object in the wrong location is reconciled, not deleted. Recheck authoritative bucket/key and generation; stale stage cleanup must not remove promoted media. Account/post/attachment deletion, crashes, partial moves and concurrent cleanup must converge safely. Keep one retained object per variant in steady state; temporary recovery copies are permitted. Perform external I/O outside shared database locks. ### Errors Use existing post-access JSON envelope, no-store responses and failures paths. Keep `401 authenticationRequired`, ordinary post/ownership errors, `412 revisionConflict`, `428 revisionRequired`/`idempotencyKeyRequired`, and `409 idempotencyConflict`. Add `422 invalidHdrStage` for invalid upload, `422 invalidAttachmentHdrEdits` for malformed/invalid edit structure or attachment selection, `404 hdrStageNotFound` for unknown/not-owned/misbound stages, `410 hdrStageExpired` for expired unconsumed stages, and `409 hdrStageAlreadyCommitted` for consumed-stage reuse/cancellation. Field errors identify `file` or `attachmentHdrEdits[i].stageId`/`.id`. Replays after an already successful Save must not fail because the referenced stage is consumed/expired. ### Shared acceptance Verify HDR add/replace/remove, omitted-preserve and explicit-null removal; combined metadata/visibility/order edits; invalid media and size boundaries; cross-owner/target attempts; concurrent stage use/cancel/expiry/cleanup; stale revision with untouched draft/stage; lost upload/save responses; original-request replay after consumption/expiry; old HDR accessible only under history authorization; public-to-private origin cleanup and failure recovery; single-copy steady state; crashes and stale orphan/deletion work. Run browser integration with service workers enabled against the real API and disposable storage, including upgrade/refresh, restricted previews, post-save HDR/fallback display and cleanup/revocation warnings. Record actual frontend/backend versions and limitations.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
klafbang/VernissageServer#1
No description provided.