Skip to content

feat: implement photo rotation functionality for existing attachments#1508

Open
bfritscher wants to merge 1 commit into
sysadminsmedia:mainfrom
bfritscher:feat/rotate-existing-photo
Open

feat: implement photo rotation functionality for existing attachments#1508
bfritscher wants to merge 1 commit into
sysadminsmedia:mainfrom
bfritscher:feat/rotate-existing-photo

Conversation

@bfritscher

Copy link
Copy Markdown

What type of PR is this?

feat: implement photo rotation functionality for existing attachments in ImageDialog by refactoring code from CreateModal

What this PR does / why we need it:

Adds the ability to rotate photos that have already been uploaded, following up on the earlier create-flow rotation work from #621 / #666.

The backend attachment update API only updates attachment metadata and does not replace the stored file binary, so this change keeps the backend untouched and handles rotation in the frontend by uploading a rotated replacement image and deleting the original attachment.

Changes made:

  • frontend/components/Item/ImageDialog.vue
    • added a rotate action for existing uploaded photos
    • rotates the current image client-side
    • persists the rotated result by uploading a replacement photo and deleting the original attachment
    • saves the rotated image when the dialog is closed, including outside-click close
    • refreshes the replacement attachment before returning so the caller gets the new attachment id and thumbnail data
  • frontend/composables/utils.ts
    • extracted shared browser-side image helpers for:
      • blob -> data URL
      • data URL -> File
      • rotate image data URL 90 degrees clockwise
  • frontend/components/Item/CreateModal.vue
    • refactored the existing create-item photo rotation flow to use the shared helpers instead of local duplicate logic
  • frontend/components/ui/dialog-provider/utils.ts
    • extended the item image dialog close result to support a replace action in addition to delete
    • kept the existing dialog params unchanged to minimize surface area
  • frontend/pages/item/[id]/index.vue
    • handles photo replacement from the image dialog
    • updates the whole item object so the details page reacts correctly to the new attachment id / thumbnail
    • uses the attachment id as the photo key so Vue does not reuse stale image nodes
  • frontend/pages/location/[id]/index/index.vue
    • mirrors the same replacement handling for location photos
  • frontend/pages/item/[id]/index/edit.vue
    • handles the image dialog replace result for item edit attachments
  • frontend/pages/location/[id]/index/edit.vue
    • handles the image dialog replace result for location edit attachments

Which issue(s) this PR fixes:

Fixes #855

Special notes for your reviewer:

First time contribution, iterated with GPT 5.4 High, and reviewed all the diff.

This intentionally avoids backend changes.

The important implementation detail is that rotating an already-uploaded photo is done as:

  1. load current image in the frontend
  2. rotate it in the browser
  3. upload the rotated image as a new attachment
  4. delete the original attachment

Please focus review on:

  • ImageDialog.vue replacement flow
  • the replace dialog result handling in the item/location pages
  • whether the current UX for saving on dialog close feels appropriate

Testing

Manual testing:

  • uploaded photos during item creation and confirmed rotation still works there
  • opened an existing item photo, rotated it, and closed via the X button
  • opened an existing item photo, rotated it, and closed via outside click
  • confirmed the item details page updates to the new attachment id after replacement
  • confirmed the photo thumbnail continues to render after replacement
  • repeated the same replacement flow from edit screens

… in ImageDialog by refactoring code from CreateModal
@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Summary by CodeRabbit

  • New Features
    • Image rotation: Users can now rotate photos 90 degrees in image dialogs before confirming changes.
    • Image replacement: Added the ability to replace existing images in dialogs, providing more workflow flexibility than deletion alone with automatic synchronization.

Walkthrough

This PR refactors image rotation across the application by extracting utilities, implementing a full rotate-and-persist workflow in ImageDialog, extending the dialog result contract, and updating consumer handlers to support immutable attachment state updates.

Changes

Image rotation with client-side persist workflow

Layer / File(s) Summary
Shared image conversion and rotation utilities
frontend/composables/utils.ts
New blobToDataUrl, dataUrlToFile, and rotateImageDataUrl90Deg functions convert blobs to base64 URLs, parse data URLs back to Files with mime type validation, and perform 90° canvas-based rotation.
Dialog result type contract for attachment replace action
frontend/components/ui/dialog-provider/utils.ts
DialogID.ItemImage result type extends to support both delete and replace actions with rich payloads including attachment objects and matched oldId references.
ImageDialog rotate and persist implementation
frontend/components/Item/ImageDialog.vue
Dialog state tracks busy/dirty status and working image copies. New rotateAttachment applies canvas rotation; persistRotatedAttachmentAndClose uploads rotated image, deletes original, and closes with replace action. Delete closes with delete action. Template uses computed display source/type and pointer-down-outside handler.
CreateModal photo rotation refactor
frontend/components/Item/CreateModal.vue
Photo rotation is extracted into rotatePhoto(index) function using the shared rotateImageDataUrl90Deg and dataUrlToFile utilities. Product image prefill switches to camelCase dataUrlToFile.
Consumer page handlers for immutable attachment updates
frontend/pages/item/[id]/index.vue, frontend/pages/item/[id]/index/edit.vue, frontend/pages/location/[id]/index/edit.vue, frontend/pages/location/[id]/index/index.vue
Item and location pages handle the new replace dialog action and apply immutable attachment updates by reassigning the parent object with updated arrays. Guards prevent updates when item/location values are missing.

Sequence Diagram

sequenceDiagram
  participant User
  participant ImageDialog
  participant Canvas
  participant API
  User->>ImageDialog: Click rotate button
  ImageDialog->>Canvas: Load image, rotate 90°
  Canvas->>ImageDialog: Return rotated data URL
  ImageDialog->>ImageDialog: Mark dirty
  User->>ImageDialog: Click close/confirm
  ImageDialog->>API: Upload rotated image
  API->>ImageDialog: Return new attachment
  ImageDialog->>API: Delete original attachment
  API->>ImageDialog: Confirm delete
  ImageDialog->>User: Close with replace action
Loading

Security Considerations

⚠️ Data URL and File Validation: The new dataUrlToFile function validates that the mime type is image/*, which is good. Ensure that:

  • Base64 decoding of untrusted data URLs is safe and doesn't overflow buffers
  • Canvas operations with user-provided images don't expose information leaks through pixel timing
  • The API items.attachments.add validates file size, type, and scan before persisting

⚠️ State Management: Dialog state tracks working images as data URLs in memory. Ensure:

  • Large rotated images don't cause memory exhaustion or heap overflow issues
  • Dirty state properly resets on component unmount to avoid leaking base64 data
  • Rollback on failed delete properly cleans up temporary attachments from the API

⚠️ Attachment ID Matching: Replace operations match oldId to find and swap attachments. Ensure:

  • IDs are sufficiently random/opaque to prevent ID enumeration
  • The attachment array order is not relied upon for security boundaries
  • Delete on failed upload properly cascades to prevent orphaned attachments

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • sysadminsmedia/homebox#951: ItemImage dialog result/typing extension that this PR builds upon with additional replace action support.
  • sysadminsmedia/homebox#528: Multi-photo upload infrastructure in CreateModal that this PR refactors to use indexed rotatePhoto(index) function.
  • sysadminsmedia/homebox#666: Original 90° rotation feature in CreateModal that this PR extracts into shared rotateImageDataUrl90Deg utility with broader canvas-based support.

Suggested labels

⬆️ enhancement

Suggested reviewers

  • tankerkiller125
  • katosdev
  • tonyaellie

Poem

📸 Images spin on canvas threads,
Old attachments lay to bed,
Rotated pixels persist with care,
State immutable, fresh and fair—
A dance of blob and file anew. 🎨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main feature added—photo rotation for existing attachments—and is concise and specific.
Description check ✅ Passed The description includes all required sections: PR type (feat), detailed explanation of changes with file-by-file breakdown, issue reference (#855), special notes for reviewers, and testing details.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot added the ⬆️ enhancement New feature or request label May 22, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
frontend/components/Item/ImageDialog.vue (1)

170-178: ⚡ Quick win

Add a MIME allowlist before uploading the rotated file.

At Line 172, dataUrlToFile output is uploaded directly. Add an explicit image/* allowlist check before api.items.attachments.add(...) to reduce accidental/hostile non-image uploads in this flow.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/Item/ImageDialog.vue` around lines 170 - 178, Before
calling api.items.attachments.add, validate the MIME type returned by
dataUrlToFile(image.workingDataUrl, fileName); ensure file.type exists and
startsWith('image/') (i.e., an image/* allowlist). If the check fails, abort the
upload path (return or throw) and surface an error message or log; update the
code around getCurrentAttachmentState, dataUrlToFile, and the
api.items.attachments.add call to perform this guard and handle the rejection
cleanly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/components/Item/CreateModal.vue`:
- Line 238: rotatePhoto can be invoked concurrently for the same index, losing
intermediate rotations; add a per-photo in-flight guard (e.g., a Set or map
keyed by index) in the component state used by rotatePhoto to return early if
that index is already processing, and also bind the rotate button's disabled
state (the Button with `@click.prevent`="rotatePhoto(index)") to that in-flight
flag so rapid clicks are ignored until the rotation promise completes; ensure
the guard is cleared when rotatePhoto's async work finishes or errors so future
rotations are allowed.

In `@frontend/components/Item/ImageDialog.vue`:
- Line 9: The code manually imports composables blobToDataUrl, dataUrlToFile,
and rotateImageDataUrl90Deg from ~/composables/utils which violates the rule
that composables are auto-imported; remove the import statement and rely on the
auto-imported composable functions (blobToDataUrl, dataUrlToFile,
rotateImageDataUrl90Deg) where they are used in ImageDialog.vue so no manual
import remains and any linter errors about undefined symbols are resolved by the
repo's auto-import setup.
- Around line 202-207: The refresh after deleting the old attachment can fail
and leave image.attachmentId pointing at a deleted record, causing subsequent
closes to error; update the post-delete logic in the handler that calls
api.items.get (the block using refreshedData/refreshedError and
refreshedAttachment) to handle refreshedError or missing refreshedAttachment by
clearing image.attachmentId (set to null/undefined) and ensuring the dialog is
closed (call the same close/emit path you use on success) and show the toast
error; this prevents the dialog from getting stuck while still surfacing the
refresh failure.

In `@frontend/composables/utils.ts`:
- Around line 57-59: The current check only validates
mimeType.startsWith("image/") and thus allows SVGs; change the validation in
frontend/composables/utils.ts to use a strict allowlist of raster MIME types
(e.g. image/jpeg, image/png, image/webp, image/gif, image/bmp) instead of any
image/*, perform a case-insensitive comparison against that set for the mimeType
variable, and throw an error with a clear message (e.g. "Invalid mime type,
expected raster image") when not in the allowlist; update the surrounding code
that relies on this check (same function/validation block referencing mimeType)
to use the new allowlist logic.

---

Nitpick comments:
In `@frontend/components/Item/ImageDialog.vue`:
- Around line 170-178: Before calling api.items.attachments.add, validate the
MIME type returned by dataUrlToFile(image.workingDataUrl, fileName); ensure
file.type exists and startsWith('image/') (i.e., an image/* allowlist). If the
check fails, abort the upload path (return or throw) and surface an error
message or log; update the code around getCurrentAttachmentState, dataUrlToFile,
and the api.items.attachments.add call to perform this guard and handle the
rejection cleanly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0b45999f-2d07-4169-935a-52d0bb7ac622

📥 Commits

Reviewing files that changed from the base of the PR and between e8af2e3 and ae7e648.

📒 Files selected for processing (8)
  • frontend/components/Item/CreateModal.vue
  • frontend/components/Item/ImageDialog.vue
  • frontend/components/ui/dialog-provider/utils.ts
  • frontend/composables/utils.ts
  • frontend/pages/item/[id]/index.vue
  • frontend/pages/item/[id]/index/edit.vue
  • frontend/pages/location/[id]/index/edit.vue
  • frontend/pages/location/[id]/index/index.vue

}
"
>
<Button size="icon" type="button" variant="default" @click.prevent="rotatePhoto(index)">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard rotate against concurrent clicks to avoid lost rotations.

rotatePhoto can run concurrently for the same index; rapid clicks may apply only one effective 90° turn instead of cumulative turns.

🛠️ Suggested fix (per-photo in-flight guard + button disable)
+  const rotatingPhotoIndexes = ref<Set<number>>(new Set());
+
   async function rotatePhoto(index: number) {
     const photo = form.photos[index];
-    if (!photo) {
+    if (!photo || rotatingPhotoIndexes.value.has(index)) {
       return;
     }

     try {
+      rotatingPhotoIndexes.value.add(index);
       photo.fileBase64 = await rotateImageDataUrl90Deg(photo.fileBase64);
       photo.file = dataUrlToFile(photo.fileBase64, photo.photoName);
     } catch (error) {
       toast.error(t("components.item.create_modal.toast.rotate_process_failed"));
       console.error(error);
+    } finally {
+      rotatingPhotoIndexes.value.delete(index);
     }
   }
-                  <Button size="icon" type="button" variant="default" `@click.prevent`="rotatePhoto(index)">
+                  <Button
+                    size="icon"
+                    type="button"
+                    variant="default"
+                    :disabled="rotatingPhotoIndexes.has(index)"
+                    `@click.prevent`="rotatePhoto(index)"
+                  >

Also applies to: 560-573

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/Item/CreateModal.vue` at line 238, rotatePhoto can be
invoked concurrently for the same index, losing intermediate rotations; add a
per-photo in-flight guard (e.g., a Set or map keyed by index) in the component
state used by rotatePhoto to return early if that index is already processing,
and also bind the rotate button's disabled state (the Button with
`@click.prevent`="rotatePhoto(index)") to that in-flight flag so rapid clicks are
ignored until the rotation promise completes; ensure the guard is cleared when
rotatePhoto's async work finishes or errors so future rotations are allowed.

import type { ItemAttachment } from "~~/lib/api/types/data-contracts";
import { AttachmentTypes } from "~~/lib/api/types/non-generated";
import { DialogID } from "~/components/ui/dialog-provider/utils";
import { blobToDataUrl, dataUrlToFile, rotateImageDataUrl90Deg } from "~/composables/utils";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use composable auto-import instead of manual import.

At Line 9, importing from ~/composables/utils violates the frontend composable usage rule for this repo.

As per coding guidelines, "Do not manually import composables from composables/ directory - they are auto-imported".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/Item/ImageDialog.vue` at line 9, The code manually
imports composables blobToDataUrl, dataUrlToFile, and rotateImageDataUrl90Deg
from ~/composables/utils which violates the rule that composables are
auto-imported; remove the import statement and rely on the auto-imported
composable functions (blobToDataUrl, dataUrlToFile, rotateImageDataUrl90Deg)
where they are used in ImageDialog.vue so no manual import remains and any
linter errors about undefined symbols are resolved by the repo's auto-import
setup.

Comment on lines +202 to +207
const { data: refreshedData, error: refreshedError } = await api.items.get(image.itemId);
const refreshedAttachment = refreshedData?.attachments.find(attachment => attachment.id === createdAttachment.id);
if (refreshedError || !refreshedAttachment) {
toast.error(t("items.toast.failed_update_attachment"));
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle post-delete refresh failure to avoid a stuck dialog state.

At Line 202, if old attachment deletion succeeds but the refresh fails, the function returns without closing, while image.attachmentId still points to a deleted record. Subsequent closes keep failing (Attachment not found), so users can get trapped in the dialog.

Suggested fix
-    const { data: refreshedData, error: refreshedError } = await api.items.get(image.itemId);
-    const refreshedAttachment = refreshedData?.attachments.find(attachment => attachment.id === createdAttachment.id);
-    if (refreshedError || !refreshedAttachment) {
-      toast.error(t("items.toast.failed_update_attachment"));
-      return;
-    }
+    const { data: refreshedData, error: refreshedError } = await api.items.get(image.itemId);
+    const refreshedAttachment = refreshedData?.attachments.find(attachment => attachment.id === createdAttachment.id);
+    if (refreshedError || !refreshedAttachment) {
+      // Fallback to the newly created attachment we already have,
+      // so the dialog can still close and caller can update state.
+      const oldId = image.attachmentId;
+      resetImageState();
+      closeDialog(DialogID.ItemImage, {
+        action: "replace",
+        oldId,
+        attachment: createdAttachment,
+      });
+      toast.success(t("items.toast.attachment_updated"));
+      return;
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/components/Item/ImageDialog.vue` around lines 202 - 207, The refresh
after deleting the old attachment can fail and leave image.attachmentId pointing
at a deleted record, causing subsequent closes to error; update the post-delete
logic in the handler that calls api.items.get (the block using
refreshedData/refreshedError and refreshedAttachment) to handle refreshedError
or missing refreshedAttachment by clearing image.attachmentId (set to
null/undefined) and ensuring the dialog is closed (call the same close/emit path
you use on success) and show the toast error; this prevents the dialog from
getting stuck while still surfacing the refresh failure.

Comment on lines +57 to +59
if (!mimeType.startsWith("image/")) {
throw new Error("Invalid mime type, expected image");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict image MIME types to a safe allowlist.

Line 57 accepts any image/*, which can unintentionally permit SVG payloads. For attachment photos, constrain to raster formats to reduce XSS/file-content risk.

🔐 Suggested hardening diff
 export function dataUrlToFile(dataUrl: string, fileName: string): File {
   const parts = dataUrl.split(",");
   const mimeMatch = parts[0]?.match(/:(.*?);/);
   if (!mimeMatch?.[1]) {
     throw new Error("Invalid data URL format");
   }

-  const mimeType = mimeMatch[1];
-  if (!mimeType.startsWith("image/")) {
-    throw new Error("Invalid mime type, expected image");
-  }
+  const mimeType = mimeMatch[1].toLowerCase();
+  const allowedMimeTypes = new Set([
+    "image/jpeg",
+    "image/png",
+    "image/gif",
+    "image/webp",
+    "image/avif",
+  ]);
+  if (!allowedMimeTypes.has(mimeType)) {
+    throw new Error("Unsupported image mime type");
+  }

   const bytes = atob(parts[parts.length - 1] ?? "");
   const buffer = new Uint8Array(bytes.length);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!mimeType.startsWith("image/")) {
throw new Error("Invalid mime type, expected image");
}
const mimeType = mimeMatch[1].toLowerCase();
const allowedMimeTypes = new Set([
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/avif",
]);
if (!allowedMimeTypes.has(mimeType)) {
throw new Error("Unsupported image mime type");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/composables/utils.ts` around lines 57 - 59, The current check only
validates mimeType.startsWith("image/") and thus allows SVGs; change the
validation in frontend/composables/utils.ts to use a strict allowlist of raster
MIME types (e.g. image/jpeg, image/png, image/webp, image/gif, image/bmp)
instead of any image/*, perform a case-insensitive comparison against that set
for the mimeType variable, and throw an error with a clear message (e.g.
"Invalid mime type, expected raster image") when not in the allowlist; update
the surrounding code that relies on this check (same function/validation block
referencing mimeType) to use the new allowlist logic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⬆️ enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant