Skip to content

Privatize images bucket and migrate URLs#1526

Merged
riderx merged 5 commits into
mainfrom
riderx/exif-risk-email
Jan 30, 2026
Merged

Privatize images bucket and migrate URLs#1526
riderx merged 5 commits into
mainfrom
riderx/exif-risk-email

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Jan 29, 2026

Summary (AI generated)

  • Make images bucket private, migrate stored URLs to paths, and tighten storage RLS.
  • Serve signed image URLs for app/org/user API responses while storing paths in DB.
  • Add signed URL caching with expiry on the frontend.

Test plan (AI generated)

  • Apply migration and confirm images bucket is private.
  • Upload app icon, org logo, and user avatar; confirm they render.
  • Call app/org API endpoints and verify returned icon_url, logo, and image_url are signed URLs.

Screenshots (AI generated)

  • Not applicable.

Checklist (AI generated)

  • My code follows the code style of this project and passes
    bun run lint:backend && bun run lint.
  • My change requires a change to the documentation.
  • I have updated the documentation
    accordingly.
  • My change has adequate E2E test coverage.
  • I have tested my code manually, and I have provided steps how to reproduce
    my tests

Summary by CodeRabbit

  • Security Improvements
    • Image storage is now private with temporary signed access instead of public URLs.
    • Enhanced access controls for user avatars, app icons, and organization logos.
    • Existing image references automatically migrated to secure storage paths.

✏️ Tip: You can customize this high-level summary in your review settings.

Copilot AI review requested due to automatic review settings January 29, 2026 19:56
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 29, 2026

📝 Walkthrough

Walkthrough

This PR transitions image storage from public to private accessibility by implementing signed URL generation for app icons, user avatars, and organization logos with 7-day expiration. The images bucket visibility is changed from public to private, with new fine-grained RLS policies managing access. URL normalization ensures backward compatibility.

Changes

Cohort / File(s) Summary
Signed URL Infrastructure
src/services/storage.ts, supabase/functions/_backend/utils/storage.ts
New utility functions for generating and caching signed image URLs. Frontend version includes in-memory LRU cache (max 500 entries); backend version calls Supabase storage API with 7-day TTL. Both normalize paths by stripping leading slashes and "images/" prefix.
Frontend App/Icon Handling
src/components/dashboard/AppSetting.vue, src/pages/app/index.vue
Mount operations and data fetching now sign app icon URLs using createSignedImageUrl. EditPhoto flow stores storage path in database while assigning signed URL to local state.
Frontend User/Avatar Handling
src/modules/auth.ts, src/services/photos.ts
User updates transform image_url to signed URL. Photo upload callback signature changed from (success, url) to (success, storagePath, signedUrl); stores storagePath in DB, applies signedUrl to local state.
Frontend Organization/Logo Handling
src/services/photos.ts, src/stores/organization.ts
Organization photo uploads follow same pattern as user uploads. fetchOrganizations and getMembers now resolve logos and image_urls to signed URLs using Promise.all.
Backend App Endpoints
supabase/functions/_backend/public/app/get.ts, supabase/functions/_backend/public/app/post.ts, supabase/functions/_backend/public/app/put.ts
GET operations sign icon_urls before returning. POST/PUT operations normalize incoming icon paths and sign resulting URLs in response data.
Backend Organization Endpoints
supabase/functions/_backend/public/organization/get.ts, supabase/functions/_backend/public/organization/put.ts, supabase/functions/_backend/public/organization/members/get.ts
GET operations sign logos and member image_urls. PUT operation normalizes logo input and signs output URL. All use Promise.all for concurrent signing.
Storage Security & Migration
supabase/migrations/20260129121000_private_images_bucket.sql, supabase/seed.sql
Images bucket changed from public to private. Migration introduces 4 new RLS policies (read/insert/update/delete) with org and user-based access control logic. URL normalization via regex cleanup handles backward compatibility for existing references.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

enhancement

Poem

🐰 Signed URLs now protect our precious hops,
No more public paths—the privacy blooms!
Seven-day secrets in each cached zoom,
RLS policies guard the image crops. 🌿📸

🚥 Pre-merge checks | ✅ 2 | ❌ 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 (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Privatize images bucket and migrate URLs' directly and clearly summarizes the main change: making the images bucket private and migrating URL references.
Description check ✅ Passed The description includes all required sections (Summary, Test plan, Screenshots, Checklist) with substantive content provided. While checklist items are unchecked and test plan involves AI generation, the core required information is present and addresses the PR objectives.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch riderx/exif-risk-email

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 SQLFluff (4.0.0)
supabase/migrations/20260129121000_private_images_bucket.sql

User Error: No dialect was specified. You must configure a dialect or specify one on the command line using --dialect after the command. Available dialects:
ansi, athena, bigquery, clickhouse, databricks, db2, doris, duckdb, exasol, flink, greenplum, hive, impala, mariadb, materialize, mysql, oracle, postgres, redshift, snowflake, soql, sparksql, sqlite, starrocks, teradata, trino, tsql, vertica


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.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8480c08bd9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +17 to +19
UPDATE public.apps
SET icon_url = regexp_replace(split_part(icon_url, '?', 1), '^.*/storage/v1/object/(public/|sign/)?images/', '')
WHERE icon_url IS NOT NULL
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve API-friendly icon_url after path normalization

This migration rewrites apps.icon_url to a storage path. The public app endpoints (e.g., supabase/functions/_backend/public/app/get.ts and app/post.ts) return icon_url verbatim, so after this runs API/CLI consumers will receive a relative path in a now-private bucket rather than a usable URL. The frontend now signs paths client-side, but external clients won’t, which breaks backward compatibility for any integration that expects an image URL. Consider returning signed URLs (or keeping URL-form values for API responses) to avoid breaking existing consumers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/dashboard/AppSetting.vue (1)

724-729: Fix bucket selection for the icon upload.

Line 725 uses .from(\images/org/...`), which incorrectly passes a path as the bucket name. Supabase Storage expects the bucket name only; the path belongs in the .upload()call. Use theimagesbucket and passiconPathto.upload()` instead.

🛠️ Proposed fix
-          const { error } = await supabase.storage
-            .from(`images/org/${appRef.value?.owner_org.id}/${props.appId}`)
-            .upload('icon', blob, {
+          const { error } = await supabase.storage
+            .from('images')
+            .upload(iconPath, blob, {
               contentType: mimeType,
             })
🤖 Fix all issues with AI agents
In `@src/services/storage.ts`:
- Around line 17-31: The cache currently stores raw signed URLs (signedUrlCache)
which expire after SIGNED_URL_TTL_SECONDS; modify the caching to store an object
containing { url, expiresAt } (expiresAt = Date.now() +
SIGNED_URL_TTL_SECONDS*1000) keyed by cacheKey, and in the retrieval path (where
signedUrlCache.get(cacheKey) is used) check expiresAt and treat expired entries
as a cache miss that triggers a refresh via the same
useSupabase().storage.from('images').createSignedUrl(normalized,
SIGNED_URL_TTL_SECONDS) flow; update signedUrlCache.set to store the object and
always return data.signedUrl when fresh, returning '' only on real fetch errors.

In `@supabase/migrations/20260129120000_private_images_bucket.sql`:
- Around line 31-45: The SQL policy uses 0-based indices on
storage.foldername(name) which in Postgres is 1-based; update every occurrence
of (storage.foldername(name))[0] → (storage.foldername(name))[1],
(storage.foldername(name))[1] → (storage.foldername(name))[2], and
(storage.foldername(name))[2] → (storage.foldername(name))[3] across all
policies (SELECT/USING checks, INSERT, both UPDATE policies, and DELETE) so path
segment checks (e.g. org/{org_id}/{app_id}/... and user/{user_id}/avatar/...)
work correctly; ensure you change these in expressions that call
public.check_min_rights and public.get_identity_org_appid as well as any direct
comparisons of storage.foldername(name).
🧹 Nitpick comments (4)
src/services/storage.ts (2)

1-1: Use the ~/ alias for the Supabase import.

Line 1 uses a relative import even though this file lives under src/.
As per coding guidelines: Use ~/ alias for imports from src/ directory in frontend TypeScript and Vue components.

♻️ Proposed change
-import { useSupabase } from './supabase'
+import { useSupabase } from '~/services/supabase'

10-11: Avoid dropping absolute URLs.

If legacy data already stores a public/signed URL, returning '' removes the image entirely. Consider returning the URL as‑is when it’s already absolute.

💡 Suggested tweak
-  if (path.includes('://'))
-    return ''
+  if (path.includes('://'))
+    return path
src/services/photos.ts (2)

9-9: Use ~/ alias for the new src import.
Switch to ~/services/storage for frontend import consistency.

♻️ Proposed change
-import { createSignedImageUrl } from './storage'
+import { createSignedImageUrl } from '~/services/storage'

As per coding guidelines: src/**/*.{ts,tsx,vue,js}: Use ~/ alias for imports from src/ directory in frontend TypeScript and Vue components.


16-37: Avoid orphaned uploads when signed URL creation fails.
If upload succeeds but createSignedImageUrl returns empty, we return failure but leave the object behind. Consider deleting the object or retrying to avoid storage bloat.

🧹 Example cleanup on signed URL failure
   const signedUrl = error ? '' : await createSignedImageUrl(storagePath)

   isLoading.value = false

-  if (error || !signedUrl)
-    await callback(false, '', '')
-  else
-    await callback(true, storagePath, signedUrl)
+  if (error || !signedUrl) {
+    if (!error)
+      await supabase.storage.from('images').remove([storagePath])
+    await callback(false, '', '')
+    return
+  }
+  await callback(true, storagePath, signedUrl)

Comment thread src/services/storage.ts
Comment thread supabase/migrations/20260129120000_private_images_bucket.sql Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Makes the Supabase images bucket private and shifts the app to store image paths in DB while resolving them to signed URLs at runtime.

Changes:

  • Set the images storage bucket to private (seed + migration) and migrate existing stored public URLs to bucket-relative paths.
  • Replace storage RLS on storage.objects for the images bucket to support private access (org/app icons + org/user images).
  • Add a frontend helper to create signed image URLs and update multiple frontend call sites to use it.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
supabase/seed.sql Seeds images bucket as private.
supabase/migrations/20260129120000_private_images_bucket.sql Makes bucket private, migrates URL→path in DB, and replaces storage RLS policies for images.
src/stores/organization.ts Resolves org logos and member avatars via signed URLs when loading org data.
src/services/storage.ts Adds createSignedImageUrl helper (and caching) for private bucket access.
src/services/photos.ts Uploads images to images and persists storage paths; resolves signed URLs for immediate UI use.
src/pages/app/index.vue Resolves apps.icon_url via signed URLs when listing apps.
src/modules/auth.ts Resolves the logged-in user avatar via signed URLs.
src/components/dashboard/AppSetting.vue Stores icon paths in DB and resolves signed URLs for display after upload.

Comment thread src/services/storage.ts Outdated
Comment on lines +10 to +11
if (path.includes('://'))
return ''
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

createSignedImageUrl returns an empty string whenever the input contains ://. That will blank out valid non-Supabase URLs (e.g., externally hosted icons) and also makes the UI brittle if any legacy full storage URLs slip through. Consider returning the original URL unchanged for absolute URLs, and (optionally) only normalizing/signing when the value refers to the images bucket.

Suggested change
if (path.includes('://'))
return ''
// If this is an absolute URL (e.g. https://...), return it as-is.
if (path.includes('://'))
return path

Copilot uses AI. Check for mistakes.
Comment thread src/services/storage.ts
Comment on lines +3 to +5
const SIGNED_URL_TTL_SECONDS = 60 * 60 * 24 * 7
const signedUrlCache = new Map<string, string>()

Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

signedUrlCache stores signed URLs indefinitely, but createSignedUrl responses expire after SIGNED_URL_TTL_SECONDS. This can lead to stale/broken images after expiry and unbounded memory growth over long sessions. Consider caching with an expiry timestamp (and refreshing when expired) and/or adding a max size / LRU eviction.

Copilot uses AI. Check for mistakes.
Comment thread src/components/dashboard/AppSetting.vue Outdated
Comment on lines 726 to 727
.from(`images/org/${appRef.value?.owner_org.id}/${props.appId}`)
.upload('icon', blob, {
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

The Storage API .from() expects a bucket name, but this code passes a bucket-like path (images/org/...). That will fail unless a bucket literally named images/org/... exists. Use .from('images') and upload using the full object path (e.g., the iconPath you computed) so the upload path and the value stored in apps.icon_url stay consistent.

Suggested change
.from(`images/org/${appRef.value?.owner_org.id}/${props.appId}`)
.upload('icon', blob, {
.from('images')
.upload(iconPath, blob, {

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +88
OR EXISTS (
SELECT 1
FROM public.orgs o
JOIN public.org_users ou_self ON ou_self.org_id = o.id
WHERE ou_self.user_id = auth_user.uid
AND regexp_replace(o.logo, '^.*/storage/v1/object/(public/|sign/)?[^/]+/', '') = name
)
)
)
)
OR EXISTS (
-- Org logo access for API keys (logo stored as path or public URL)
SELECT 1
FROM public.orgs o
WHERE regexp_replace(o.logo, '^.*/storage/v1/object/(public/|sign/)?[^/]+/', '') = name
AND public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], o.id),
o.id,
NULL::character varying,
NULL::bigint
)
)
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

The SELECT policy for storage.objects grants access when regexp_replace(o.logo, '^.*/storage/v1/object/(public/|sign/)?[^/]+/', '') = name, effectively trusting the public.orgs.logo column to decide which objects in the images bucket a caller may read. Because logo is updatable by org admins (public.orgs UPDATE is gated only by check_min_rights('admin', get_identity_org_allowed(...))), an org admin can point their logo at an arbitrary path (e.g., another user’s avatar or a different org’s asset) and then gain read access to that object via this policy, breaking tenant isolation. This policy should instead derive allowed object paths from stable ownership data (e.g., org IDs / user IDs and fixed folder prefixes) rather than from an arbitrary, user-controlled text field like o.logo.

Suggested change
OR EXISTS (
SELECT 1
FROM public.orgs o
JOIN public.org_users ou_self ON ou_self.org_id = o.id
WHERE ou_self.user_id = auth_user.uid
AND regexp_replace(o.logo, '^.*/storage/v1/object/(public/|sign/)?[^/]+/', '') = name
)
)
)
)
OR EXISTS (
-- Org logo access for API keys (logo stored as path or public URL)
SELECT 1
FROM public.orgs o
WHERE regexp_replace(o.logo, '^.*/storage/v1/object/(public/|sign/)?[^/]+/', '') = name
AND public.check_min_rights(
'read'::public.user_min_right,
public.get_identity_org_allowed('{read,upload,write,all}'::public.key_mode[], o.id),
o.id,
NULL::character varying,
NULL::bigint
)
)
)
)
)

Copilot uses AI. Check for mistakes.
@riderx riderx force-pushed the riderx/exif-risk-email branch from 407d02e to f63c87a Compare January 29, 2026 21:20
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🤖 Fix all issues with AI agents
In `@src/services/photos.ts`:
- Around line 9-10: The imports at the top of src/services/photos.ts use
relative paths; update them to use the project alias by replacing the two
imports (createSignedImageUrl and useSupabase) so they import from '~/storage'
and '~/supabase' respectively (look for the import statements referencing
createSignedImageUrl and useSupabase and swap the path to the '~/...' alias to
satisfy frontend linting and guidelines).
- Around line 16-24: Modify uploadPhotoShared to accept an optional prefix
(e.g., prefix?: string) so org-scoped uploads use an org-based path; inside
uploadPhotoShared build storagePath as `${prefix ??
`${main.user?.id}`}/${fileName}` instead of always using the user id, and update
uploadPhotoOrg (the caller) to pass the org-scoped prefix (e.g.,
`org/${orgId}/${appId}` or `org/${orgId}`) when invoking uploadPhotoShared so
org logos are stored under the org/{org_id}/... path that matches the RLS
policy.
- Around line 30-37: The code currently treats a missing signedUrl as a failure
but leaves the uploaded file in storage; modify the post-upload branch so that
when there is no upload error but createSignedImageUrl(storagePath) returns
falsy (signedUrl is falsy) you call a cleanup routine to remove the uploaded
object (e.g., deleteFile(storagePath) or storage.delete(storagePath)), log any
cleanup error, then invoke callback(false, '', ''); update the block around
signedUrl, createSignedImageUrl, storagePath and callback to perform this
cleanup path and ensure isLoading.value is set false before calling the
callback.

In `@supabase/functions/_backend/public/app/get.ts`:
- Line 7: Reorder the imports in get.ts to match the linter's import grouping:
put external/third-party imports first, then internal/absolute imports, and
place the relative import for createSignedImageUrl (import {
createSignedImageUrl } from '../../utils/storage.ts') in the correct group and
position among the other imports in this file so the linter no longer flags
import order.

In `@supabase/functions/_backend/public/app/post.ts`:
- Line 7: The import order is lint-failing; reorder the imports so they follow
the project's lint rules (external packages first, then internal aliases, then
relative paths). Move the import of createSignedImageUrl and normalizeImagePath
from '../../utils/storage.ts' to the correct position among the other imports so
relative utility imports appear after external/alias imports and before any
local module imports; adjust only the import ordering, not the imported symbols
or file names.

In `@supabase/functions/_backend/public/app/put.ts`:
- Line 7: The import for createSignedImageUrl and normalizeImagePath from
'../../utils/storage.ts' is out of order; move this relative import so it
appears with the other local/relative imports (after external/third-party
imports and before any type-only imports) and order it alphabetically within
that group to satisfy the linter—look for the import line containing
createSignedImageUrl, normalizeImagePath and place it with the other local
imports in the correct group and order.

In `@supabase/functions/_backend/public/organization/get.ts`:
- Around line 110-116: Indentation is off near the end of the handler: align the
closing brace and the return statement so that the return c.json(org) is at the
same indentation level as the if (org.logo) block and the surrounding function
body; adjust whitespace around the lines using createSignedImageUrl, org.logo
assignment, and the final return to match the project's linting style (ensure
the closing `}` for the function and `return c.json(org)` are correctly
indented).
- Line 8: The import order in get.ts is out of lint-compliant sequence; move the
import of createSignedImageUrl from '../../utils/storage.ts' so that it follows
the project's import grouping and alphabetical/structured ordering rules (e.g.,
built-ins, external packages, internal aliases, then relative imports),
adjusting surrounding imports in get.ts to match lint rules while keeping the
createSignedImageUrl symbol and path unchanged.

In `@supabase/functions/_backend/public/organization/members/get.ts`:
- Around line 73-74: The cloudlog call is currently logging member PII via the
signedMembers object; change the logging to avoid emails/image URLs by logging
only non‑PII info (e.g., signedMembers.length or mapping to non‑PII identifiers
like member.id and member.role) and keep returning the full signedMembers in
c.json; update the call to cloudlog to pass the sanitized summary (count or
mapped safe fields) instead of the full signedMembers object.
- Around line 8-9: Reorder the relative imports to satisfy the linter: group
sibling/relative imports together and alphabetize them so the import for
createSignedImageUrl from '../../../utils/storage.ts' appears before the import
for supabaseApikey from '../../../utils/supabase.ts'; update the import
statements in get.ts accordingly so the linter's import order rule passes.

In `@supabase/functions/_backend/public/organization/put.ts`:
- Line 8: The import of createSignedImageUrl and normalizeImagePath should be
reordered to satisfy the project's linting rules; move the "import {
createSignedImageUrl, normalizeImagePath } from '../../utils/storage.ts'" so
that it falls with other internal/project imports (after external/third-party
imports) and order names consistently (alphabetical) within the import
statement; update the single import line in put.ts to follow the project's
import grouping and ordering conventions.

In `@supabase/functions/_backend/utils/storage.ts`:
- Around line 29-48: The createSignedImageUrl function uses supabaseAdmin(c) and
a permissive '://'-based URL passthrough; change it to use the
user-authenticated client supabaseWithAuth(c, c.get('auth')) when calling
.storage.from('images').createSignedUrl to enforce RLS (replace any
supabaseAdmin(c) usage in createSignedImageUrl), and tighten the early-return
check so only URLs that are already signed (e.g., contain
'/storage/v1/object/sign/') are returned unchanged instead of any '://'; keep
normalizeImagePath usage and null guards as-is.
🧹 Nitpick comments (1)
supabase/functions/_backend/utils/storage.ts (1)

15-20: Prefer returning null for non‑images bucket URLs in normalization.

When the URL parses but doesn’t match the images bucket pattern, the function returns the full URL, which undermines the “store paths” migration and won’t be signable once the bucket is private. Consider returning null so callers can explicitly reject/handle non‑images URLs.

♻️ Suggested tweak
     const match = url.pathname.match(STORAGE_URL_REGEX)
     if (match?.[2])
       return decodeURIComponent(match[2])
-    return trimmed
+    return null

Comment thread src/services/photos.ts
Comment on lines +9 to 10
import { createSignedImageUrl } from './storage'
import { useSupabase } from './supabase'
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

Use ~/ alias for src imports.

Frontend guidelines require ~/ alias for src/ imports; this new relative import will likely violate linting. Consider updating both imports in this block for consistency.

🔧 Suggested change
-import { createSignedImageUrl } from './storage'
-import { useSupabase } from './supabase'
+import { createSignedImageUrl } from '~/services/storage'
+import { useSupabase } from '~/services/supabase'
📝 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
import { createSignedImageUrl } from './storage'
import { useSupabase } from './supabase'
import { createSignedImageUrl } from '~/services/storage'
import { useSupabase } from '~/services/supabase'
🤖 Prompt for AI Agents
In `@src/services/photos.ts` around lines 9 - 10, The imports at the top of
src/services/photos.ts use relative paths; update them to use the project alias
by replacing the two imports (createSignedImageUrl and useSupabase) so they
import from '~/storage' and '~/supabase' respectively (look for the import
statements referencing createSignedImageUrl and useSupabase and swap the path to
the '~/...' alias to satisfy frontend linting and guidelines).

Comment thread src/services/photos.ts
Comment on lines +16 to 24
async function uploadPhotoShared(
data: string,
fileName: string,
contentType: string,
isLoading: Ref<boolean>,
callback: (success: boolean, storagePath: string, signedUrl: string) => Promise<void>,
) {
const storagePath = `${main.user?.id}/${fileName}`
const { error } = await supabase.storage
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

🧩 Analysis chain

🏁 Script executed:

# Search for storage policies in SQL files
find . -name "*.sql" | head -20

Repository: Cap-go/capgo

Length of output: 1166


🏁 Script executed:

# Search for storage-related RLS policies and bucket configuration
rg -n "storage\.|create policy|images" --type=sql | head -40

Repository: Cap-go/capgo

Length of output: 4701


🏁 Script executed:

# Search for uploadPhotoShared usage and org-related storage
rg -n "uploadPhotoShared|storagePath|org.*upload" --type=ts --type=tsx -B2 -A2

Repository: Cap-go/capgo

Length of output: 83


🏁 Script executed:

# Read the full storage RLS policy migration
cat -n supabase/migrations/20260129120000_private_images_bucket.sql

Repository: Cap-go/capgo

Length of output: 7413


🏁 Script executed:

# Search for uploadPhotoShared usage
rg -n "uploadPhotoShared" --type=ts -B3 -A3

Repository: Cap-go/capgo

Length of output: 1212


🏁 Script executed:

# Read the complete uploadPhotoOrg and uploadPhotoUser functions
cat -n src/services/photos.ts | head -110

Repository: Cap-go/capgo

Length of output: 4029


🏁 Script executed:

# Check if there's any path-based logic that switches between user and org paths
rg -n "org|storagePath" src/services/photos.ts -B2 -A2

Repository: Cap-go/capgo

Length of output: 3009


Pass org-based path to uploadPhotoShared() for org asset uploads to match RLS policy design.

The new RLS policy distinguishes between org/{org_id}/{app_id}/... paths (org-scoped) and {user_id}/... paths (user-scoped with org-member access). While uploadPhotoOrg() currently works because the RLS policy permits org members to read assets under other org members' user IDs (lines 51-65 of the policy), org logos should use the org/{org_id}/... prefix to align with the intended org-scoped path structure. Modify uploadPhotoShared() to accept an optional prefix parameter, or pass the org ID to it so org uploads use the correct path pattern.

🤖 Prompt for AI Agents
In `@src/services/photos.ts` around lines 16 - 24, Modify uploadPhotoShared to
accept an optional prefix (e.g., prefix?: string) so org-scoped uploads use an
org-based path; inside uploadPhotoShared build storagePath as `${prefix ??
`${main.user?.id}`}/${fileName}` instead of always using the user id, and update
uploadPhotoOrg (the caller) to pass the org-scoped prefix (e.g.,
`org/${orgId}/${appId}` or `org/${orgId}`) when invoking uploadPhotoShared so
org logos are stored under the org/{org_id}/... path that matches the RLS
policy.

Comment thread src/services/photos.ts
Comment on lines +30 to +37
const signedUrl = error ? '' : await createSignedImageUrl(storagePath)

isLoading.value = false

if (error || !res.publicUrl)
await callback(false, '')
if (error || !signedUrl)
await callback(false, '', '')
else
await callback(true, res.publicUrl)
await callback(true, storagePath, signedUrl)
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

🧩 Analysis chain

🏁 Script executed:

cat -n src/services/photos.ts | head -60

Repository: Cap-go/capgo

Length of output: 2315


🏁 Script executed:

sed -n '40-90p' src/services/photos.ts

Repository: Cap-go/capgo

Length of output: 107


🏁 Script executed:

cat -n src/services/photos.ts | sed -n '40,90p'

Repository: Cap-go/capgo

Length of output: 1930


🏁 Script executed:

cat -n src/services/storage.ts | grep -A 20 "createSignedImageUrl"

Repository: Cap-go/capgo

Length of output: 734


🏁 Script executed:

cat -n src/services/storage.ts | sed -n '7,50p'

Repository: Cap-go/capgo

Length of output: 1268


🏁 Script executed:

rg "uploadPhotoShared|uploadPhoto" src/services/photos.ts

Repository: Cap-go/capgo

Length of output: 828


🏁 Script executed:

rg "storage.*remove|delete.*image" src/services/

Repository: Cap-go/capgo

Length of output: 38


🏁 Script executed:

rg "\.remove\(" src/services/

Repository: Cap-go/capgo

Length of output: 38


🏁 Script executed:

rg "supabase.storage" src/services/photos.ts

Repository: Cap-go/capgo

Length of output: 98


🏁 Script executed:

rg "storage.*\(" src/ -A 2 -B 1 | grep -E "(remove|delete)" | head -20

Repository: Cap-go/capgo

Length of output: 38


🏁 Script executed:

rg "from\('images'\)" src/ -A 5 | head -40

Repository: Cap-go/capgo

Length of output: 1012


🏁 Script executed:

rg "image_url" src/ -B 2 -A 2 | head -50

Repository: Cap-go/capgo

Length of output: 2636


🏁 Script executed:

cat -n src/services/photos.ts | sed -n '55,60p'

Repository: Cap-go/capgo

Length of output: 273


🏁 Script executed:

rg "storage\." src/ | grep -E "(remove|delete|clean)"

Repository: Cap-go/capgo

Length of output: 38


🏁 Script executed:

rg "orphan|cleanup|janitor|cron" src/ -i | head -20

Repository: Cap-go/capgo

Length of output: 1787


🏁 Script executed:

rg "bucket" src/ | grep -i "rls\|policy\|permission" | head -20

Repository: Cap-go/capgo

Length of output: 38


Handle signed-URL failures after successful uploads to avoid orphaned storage files.

If the upload succeeds but createSignedImageUrl fails, the file is written to storage but the callback reports failure, preventing the storage path from being saved to the database. This creates unreferenced (orphaned) files that consume storage and are inaccessible. Consider cleaning up the uploaded file if signing fails.

🧹 Proposed fix (cleanup on signing failure)
-  const signedUrl = error ? '' : await createSignedImageUrl(storagePath)
-
-  isLoading.value = false
-
-  if (error || !signedUrl)
-    await callback(false, '', '')
-  else
-    await callback(true, storagePath, signedUrl)
+  if (error) {
+    isLoading.value = false
+    await callback(false, '', '')
+    return
+  }
+
+  const signedUrl = await createSignedImageUrl(storagePath)
+  if (!signedUrl) {
+    await supabase.storage.from('images').remove([storagePath])
+    isLoading.value = false
+    await callback(false, '', '')
+    return
+  }
+
+  isLoading.value = false
+  await callback(true, storagePath, signedUrl)
📝 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
const signedUrl = error ? '' : await createSignedImageUrl(storagePath)
isLoading.value = false
if (error || !res.publicUrl)
await callback(false, '')
if (error || !signedUrl)
await callback(false, '', '')
else
await callback(true, res.publicUrl)
await callback(true, storagePath, signedUrl)
if (error) {
isLoading.value = false
await callback(false, '', '')
return
}
const signedUrl = await createSignedImageUrl(storagePath)
if (!signedUrl) {
await supabase.storage.from('images').remove([storagePath])
isLoading.value = false
await callback(false, '', '')
return
}
isLoading.value = false
await callback(true, storagePath, signedUrl)
🤖 Prompt for AI Agents
In `@src/services/photos.ts` around lines 30 - 37, The code currently treats a
missing signedUrl as a failure but leaves the uploaded file in storage; modify
the post-upload branch so that when there is no upload error but
createSignedImageUrl(storagePath) returns falsy (signedUrl is falsy) you call a
cleanup routine to remove the uploaded object (e.g., deleteFile(storagePath) or
storage.delete(storagePath)), log any cleanup error, then invoke callback(false,
'', ''); update the block around signedUrl, createSignedImageUrl, storagePath
and callback to perform this cleanup path and ensure isLoading.value is set
false before calling the callback.

Comment thread supabase/functions/_backend/public/app/get.ts
Comment thread supabase/functions/_backend/public/app/post.ts
Comment thread supabase/functions/_backend/public/organization/get.ts Outdated
Comment thread supabase/functions/_backend/public/organization/members/get.ts Outdated
Comment on lines +73 to +74
cloudlog({ requestId: c.get('requestId'), message: 'Members', data: signedMembers })
return c.json(signedMembers)
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

Avoid logging member PII.

This log includes emails and image URLs for every member, which is a compliance risk. Prefer logging only counts or non‑PII identifiers.

🧹 Safer logging
-  cloudlog({ requestId: c.get('requestId'), message: 'Members', data: signedMembers })
+  cloudlog({
+    requestId: c.get('requestId'),
+    message: 'Members fetched',
+    count: signedMembers.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
cloudlog({ requestId: c.get('requestId'), message: 'Members', data: signedMembers })
return c.json(signedMembers)
cloudlog({
requestId: c.get('requestId'),
message: 'Members fetched',
count: signedMembers.length,
})
return c.json(signedMembers)
🤖 Prompt for AI Agents
In `@supabase/functions/_backend/public/organization/members/get.ts` around lines
73 - 74, The cloudlog call is currently logging member PII via the signedMembers
object; change the logging to avoid emails/image URLs by logging only non‑PII
info (e.g., signedMembers.length or mapping to non‑PII identifiers like
member.id and member.role) and keep returning the full signedMembers in c.json;
update the call to cloudlog to pass the sanitized summary (count or mapped safe
fields) instead of the full signedMembers object.

Comment thread supabase/functions/_backend/public/organization/put.ts
Comment on lines +29 to +48
export async function createSignedImageUrl(c: Context, rawPath?: string | null) {
if (!rawPath)
return null

if (rawPath.includes('://'))
return rawPath

const normalized = normalizeImagePath(rawPath)
if (!normalized)
return null

const { data, error } = await supabaseAdmin(c)
.storage
.from('images')
.createSignedUrl(normalized, SIGNED_URL_TTL_SECONDS)

if (error || !data?.signedUrl)
return null

return data.signedUrl
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

🧩 Analysis chain

🏁 Script executed:

# First, let's find where createSignedImageUrl is called to understand if it's user-facing
rg "createSignedImageUrl" --type ts --type js -B 2 -A 2

Repository: Cap-go/capgo

Length of output: 11022


🏁 Script executed:

# Check the full context of the storage.ts file
wc -l supabase/functions/_backend/utils/storage.ts

Repository: Cap-go/capgo

Length of output: 103


🏁 Script executed:

# Read the entire storage.ts file to understand the function and its context
cat -n supabase/functions/_backend/utils/storage.ts

Repository: Cap-go/capgo

Length of output: 1510


🏁 Script executed:

# Search for where rawPath originates - look for callers and their inputs
rg "rawPath" supabase/functions/_backend/utils/storage.ts -B 3 -A 3

Repository: Cap-go/capgo

Length of output: 371


🏁 Script executed:

# Check if there are any RLS policies or authorization checks before calling createSignedImageUrl
rg "createSignedImageUrl" -B 10 -A 2 --type ts --type js

Repository: Cap-go/capgo

Length of output: 25201


🏁 Script executed:

# Look for context on how this function is used in API endpoints
find supabase/functions/_backend -name "*.ts" -type f | head -20 | xargs rg -l "createSignedImageUrl"

Repository: Cap-go/capgo

Length of output: 40


🏁 Script executed:

# Check if there's a user-authenticated Supabase client function
rg "supabaseApikey|supabaseAdmin" supabase/functions/_backend/utils/ -A 2 -B 2 | head -50

Repository: Cap-go/capgo

Length of output: 3316


🏁 Script executed:

# Look for other storage operations to see patterns
rg "\.storage\." supabase/functions/_backend/ --type ts -B 3 -A 3 | head -80

Repository: Cap-go/capgo

Length of output: 6679


🏁 Script executed:

# Look at frontend storage.ts to see if it uses a different pattern
cat -n src/services/storage.ts

Repository: Cap-go/capgo

Length of output: 1508


🏁 Script executed:

# Check supabase.ts to see available client functions
cat -n supabase/functions/_backend/utils/supabase.ts | head -100

Repository: Cap-go/capgo

Length of output: 4188


🏁 Script executed:

# Check how the endpoints use the auth context
rg "c.get\('auth'\)" supabase/functions/_backend/public/ --type ts -B 2 -A 2 | head -40

Repository: Cap-go/capgo

Length of output: 3133


🏁 Script executed:

# Check if there's any documentation or comments about why admin is needed for storage
rg "createSignedUrl" supabase/functions/_backend/ --type ts -B 5 -A 5

Repository: Cap-go/capgo

Length of output: 781


🏁 Script executed:

# Look at the organization/delete.ts to see if it's also using admin for storage
cat -n supabase/functions/_backend/public/organization/delete.ts | head -60

Repository: Cap-go/capgo

Length of output: 2172


Use user-authenticated client instead of admin SDK for signed URL generation on user-facing endpoints.

The function uses supabaseAdmin(c) which violates the coding guideline: "Never use the Supabase admin SDK (with service key) for user-facing APIs; always use the client SDK with user authentication to enforce RLS policies." This bypasses storage RLS entirely. The frontend version (src/services/storage.ts) correctly uses useSupabase() (user-authenticated client) for the same operation. Consider using supabaseWithAuth(c, c.get('auth')) instead, which is already available in the backend utilities.

Additionally, the :// check at line 33–34 returns any full URL unchanged, bypassing the signing logic. If storage buckets are private, these legacy URLs will break. Restrict this to already-signed URLs (e.g., paths containing /storage/v1/object/sign/) to be more explicit about intent.

🤖 Prompt for AI Agents
In `@supabase/functions/_backend/utils/storage.ts` around lines 29 - 48, The
createSignedImageUrl function uses supabaseAdmin(c) and a permissive '://'-based
URL passthrough; change it to use the user-authenticated client
supabaseWithAuth(c, c.get('auth')) when calling
.storage.from('images').createSignedUrl to enforce RLS (replace any
supabaseAdmin(c) usage in createSignedImageUrl), and tighten the early-return
check so only URLs that are already signed (e.g., contain
'/storage/v1/object/sign/') are returned unchanged instead of any '://'; keep
normalizeImagePath usage and null guards as-is.

@riderx riderx force-pushed the riderx/exif-risk-email branch from e797583 to 8c0a693 Compare January 29, 2026 23:20
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/services/storage.ts`:
- Line 1: Replace the relative import in storage.ts with the project alias;
change the import of useSupabase (currently "import { useSupabase } from
'./supabase'") to use the '~/` alias that points to src, e.g. import from
'~/services/supabase' so the file uses the mandated alias for frontend
TypeScript/Vue imports.

In `@supabase/functions/_backend/public/app/get.ts`:
- Around line 35-38: The get handler is calling createSignedImageUrl which uses
the service-role supabaseAdmin client and bypasses RLS; refactor so signing uses
a request-scoped Supabase client (apikey/jwt) instead: change
createSignedImageUrl to accept a Supabase client parameter (or add a new helper
createSignedImageUrlWithClient), and update the get handler to pass its
request-scoped client (the same client used in getAll or created in the request
context) instead of supabaseAdmin; ensure supabaseAdmin is not used in
user-facing endpoints and reuse the new helper from both get and getAll.

Comment thread src/services/storage.ts
@@ -0,0 +1,43 @@
import { useSupabase } from './supabase'
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

Switch to the ~/ alias for src imports.

The relative import can violate frontend lint rules and the alias requirement.

🔧 Suggested change
-import { useSupabase } from './supabase'
+import { useSupabase } from '~/services/supabase'

As per coding guidelines: Use ~/ alias for imports from src/ directory in frontend TypeScript and Vue components.

🤖 Prompt for AI Agents
In `@src/services/storage.ts` at line 1, Replace the relative import in storage.ts
with the project alias; change the import of useSupabase (currently "import {
useSupabase } from './supabase'") to use the '~/` alias that points to src, e.g.
import from '~/services/supabase' so the file uses the mandated alias for
frontend TypeScript/Vue imports.

Comment on lines +35 to +38
if (data.icon_url) {
const signedIcon = await createSignedImageUrl(c, data.icon_url)
data.icon_url = signedIcon ?? ''
}
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

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the createSignedImageUrl implementation
fd -e ts -e js | xargs grep -l "createSignedImageUrl" | head -5

Repository: Cap-go/capgo

Length of output: 195


🏁 Script executed:

# Look at the specific file mentioned in the review
cat -n supabase/functions/_backend/public/app/get.ts | head -50

Repository: Cap-go/capgo

Length of output: 2483


🏁 Script executed:

# Search for the createSignedImageUrl function definition
rg "export.*createSignedImageUrl|function createSignedImageUrl" -A 10

Repository: Cap-go/capgo

Length of output: 1313


🏁 Script executed:

cat -n supabase/functions/_backend/utils/storage.ts | grep -A 20 "export async function createSignedImageUrl"

Repository: Cap-go/capgo

Length of output: 669


🏁 Script executed:

# Also check if supabaseAdmin is used anywhere in storage.ts
rg "supabaseAdmin" supabase/functions/_backend/utils/storage.ts

Repository: Cap-go/capgo

Length of output: 150


Avoid the admin SDK when signing URLs in user-facing endpoints.

createSignedImageUrl uses the service-role client (supabaseAdmin), which bypasses RLS. For user-facing endpoints, sign with a request-scoped client (apikey/jwt) to preserve access controls. Consider refactoring the helper to accept a Supabase client and reuse it for both get and getAll.

🔧 Possible direction
-import { createSignedImageUrl } from '../../utils/storage.ts'
+import { createSignedImageUrlWithClient } from '../../utils/storage.ts'
@@
-    const signedIcon = await createSignedImageUrl(c, data.icon_url)
+    const signedIcon = await createSignedImageUrlWithClient(
+      supabaseApikey(c, apikey.key),
+      data.icon_url,
+    )
🤖 Prompt for AI Agents
In `@supabase/functions/_backend/public/app/get.ts` around lines 35 - 38, The get
handler is calling createSignedImageUrl which uses the service-role
supabaseAdmin client and bypasses RLS; refactor so signing uses a request-scoped
Supabase client (apikey/jwt) instead: change createSignedImageUrl to accept a
Supabase client parameter (or add a new helper createSignedImageUrlWithClient),
and update the get handler to pass its request-scoped client (the same client
used in getAll or created in the request context) instead of supabaseAdmin;
ensure supabaseAdmin is not used in user-facing endpoints and reuse the new
helper from both get and getAll.

@riderx riderx merged commit f6fb628 into main Jan 30, 2026
10 of 11 checks passed
@riderx riderx deleted the riderx/exif-risk-email branch January 30, 2026 02:25
@coderabbitai coderabbitai Bot mentioned this pull request Jan 30, 2026
5 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants