Skip to content

Commit d2c38f7

Browse files
committed
Updated storage URL env variables.
1 parent d8aa30a commit d2c38f7

File tree

14 files changed

+376
-60
lines changed

14 files changed

+376
-60
lines changed

.github/workflows/production-jobs.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,6 @@ jobs:
4040
INTERNAL_API_KEY: ${{ secrets.INTERNAL_API_KEY }}
4141
DATABASE_URL: ${{ secrets.DATABASE_URL }}
4242
NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
43+
SUPABASE_STORAGE_URL: ${{ secrets.SUPABASE_STORAGE_URL }}
4344
SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }}
4445
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}

apps/admin/complete.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ NEXT_PUBLIC_API_URL=http://localhost:4000
44
# Supabase
55
NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321
66
NEXT_PUBLIC_SUPABASE_ANON_KEY=
7+
NEXT_PUBLIC_STORAGE_URL=
78

89
# Google OAuth (for Google sign-in button)
910
NEXT_PUBLIC_GOOGLE_CLIENT_ID=

apps/admin/next.config.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
/**
2+
* Next.js configuration for the admin frontend.
3+
*/
14
import "./src/env.mjs";
25

36
/** @type {import('next').NextConfig} */
47
const supabaseUrl = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL);
8+
const storageUrl = new URL(
9+
process.env.NEXT_PUBLIC_STORAGE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL,
10+
);
511

612
const isLocal =
7-
supabaseUrl.hostname === "127.0.0.1" || supabaseUrl.hostname === "localhost";
13+
storageUrl.hostname === "127.0.0.1" || storageUrl.hostname === "localhost";
814

915
/** @type {import('next').NextConfig} */
1016
const nextConfig = {
@@ -13,6 +19,12 @@ const nextConfig = {
1319
images: {
1420
unoptimized: isLocal,
1521
remotePatterns: [
22+
{
23+
protocol: storageUrl.protocol.replace(":", ""),
24+
hostname: storageUrl.hostname,
25+
port: storageUrl.port,
26+
pathname: "/storage/**",
27+
},
1628
{
1729
protocol: supabaseUrl.protocol.replace(":", ""),
1830
hostname: supabaseUrl.hostname,

apps/admin/src/env.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/**
2+
* Environment schema and runtime bindings for the admin frontend.
3+
*/
14
import { createEnv } from "@t3-oss/env-nextjs";
25
import { z } from "zod";
36

@@ -16,13 +19,15 @@ export const env = createEnv({
1619
NEXT_PUBLIC_API_URL: z.string().min(1),
1720
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
1821
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
22+
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
1923
NEXT_PUBLIC_GOOGLE_CLIENT_ID: z.string().min(1),
2024
NEXT_PUBLIC_APP_URL: z.string().url(),
2125
},
2226
runtimeEnv: {
2327
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
2428
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
2529
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
30+
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
2631
NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
2732
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
2833
SUPABASE_SERVICE_KEY: process.env.SUPABASE_SERVICE_KEY,

apps/api/src/trpc/routers/dpp-public/index.ts

Lines changed: 76 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import { resolveThemeConfigImageUrls } from "../../../utils/theme-config-images.
2323
import { slugSchema } from "../../../schemas/_shared/primitives.js";
2424
import { createTRPCRouter, publicProcedure } from "../../init.js";
2525

26+
const PRODUCTS_BUCKET = "products";
27+
2628
/**
2729
* UPID schema: 16-character alphanumeric identifier
2830
*/
@@ -38,6 +40,70 @@ const getThemePreviewSchema = z.object({
3840
brandSlug: slugSchema,
3941
});
4042

43+
/**
44+
* Escape regex metacharacters for a dynamic path pattern.
45+
*/
46+
function escapeRegExp(value: string): string {
47+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48+
}
49+
50+
/**
51+
* Decode URI path segments safely.
52+
*/
53+
function decodeStoragePath(path: string): string {
54+
return path
55+
.split("/")
56+
.map((segment) => {
57+
try {
58+
return decodeURIComponent(segment);
59+
} catch {
60+
return segment;
61+
}
62+
})
63+
.join("/");
64+
}
65+
66+
/**
67+
* Extract a bucket object path from known Supabase storage URL shapes.
68+
*/
69+
function extractStorageObjectPath(
70+
value: string,
71+
bucket: string,
72+
): string | null {
73+
const escapedBucket = escapeRegExp(bucket);
74+
const pattern = new RegExp(
75+
`(?:https?:\\/\\/[^/]+)?\\/storage\\/v1\\/object\\/(?:public|sign)\\/${escapedBucket}\\/(.+?)(?:[?#].*)?$`,
76+
"i",
77+
);
78+
const match = value.match(pattern);
79+
if (!match?.[1]) return null;
80+
return decodeStoragePath(match[1]);
81+
}
82+
83+
/**
84+
* Resolve snapshot image values to a current public URL on the configured storage domain.
85+
*/
86+
function resolveSnapshotProductImageUrl(
87+
storageClient: Parameters<typeof getPublicUrl>[0],
88+
imageValue: string | null | undefined,
89+
): string | null {
90+
if (!imageValue) return null;
91+
92+
const normalizedImage =
93+
extractStorageObjectPath(imageValue, PRODUCTS_BUCKET) ?? imageValue;
94+
if (
95+
normalizedImage.startsWith("http://") ||
96+
normalizedImage.startsWith("https://")
97+
) {
98+
return normalizedImage;
99+
}
100+
101+
return (
102+
getPublicUrl(storageClient, PRODUCTS_BUCKET, normalizedImage) ??
103+
normalizedImage
104+
);
105+
}
106+
41107
export const dppPublicRouter = createTRPCRouter({
42108
/**
43109
* Fetch theme data for screenshot preview.
@@ -115,24 +181,11 @@ export const dppPublicRouter = createTRPCRouter({
115181
? getPublicUrl(ctx.supabase, "dpp-themes", result.theme.stylesheetPath)
116182
: null;
117183

118-
// Resolve product image in the snapshot to public URL
119-
let productImageUrl: string | null = null;
120-
const snapshotImage = result.snapshot.productAttributes?.image;
121-
if (snapshotImage && typeof snapshotImage === "string") {
122-
// Check if it's already a full URL or a storage path
123-
if (
124-
snapshotImage.startsWith("http://") ||
125-
snapshotImage.startsWith("https://")
126-
) {
127-
productImageUrl = snapshotImage;
128-
} else {
129-
productImageUrl = getPublicUrl(
130-
ctx.supabase,
131-
"products",
132-
snapshotImage,
133-
);
134-
}
135-
}
184+
// Resolve product image in the snapshot to a current public URL.
185+
const productImageUrl = resolveSnapshotProductImageUrl(
186+
ctx.supabase,
187+
result.snapshot.productAttributes?.image,
188+
);
136189

137190
// Resolve image paths in themeConfig to full URLs
138191
const resolvedThemeConfig = resolveThemeConfigImageUrls(
@@ -266,23 +319,11 @@ export const dppPublicRouter = createTRPCRouter({
266319
? getPublicUrl(ctx.supabase, "dpp-themes", result.theme.stylesheetPath)
267320
: null;
268321

269-
// Resolve product image in the snapshot to public URL
270-
let productImageUrl: string | null = null;
271-
const snapshotImage = result.snapshot.productAttributes?.image;
272-
if (snapshotImage && typeof snapshotImage === "string") {
273-
if (
274-
snapshotImage.startsWith("http://") ||
275-
snapshotImage.startsWith("https://")
276-
) {
277-
productImageUrl = snapshotImage;
278-
} else {
279-
productImageUrl = getPublicUrl(
280-
ctx.supabase,
281-
"products",
282-
snapshotImage,
283-
);
284-
}
285-
}
322+
// Resolve product image in the snapshot to a current public URL.
323+
const productImageUrl = resolveSnapshotProductImageUrl(
324+
ctx.supabase,
325+
result.snapshot.productAttributes?.image,
326+
);
286327

287328
// Resolve image paths in themeConfig to full URLs
288329
const resolvedThemeConfig = resolveThemeConfigImageUrls(

apps/app/next.config.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
/**
2+
* Next.js configuration for the app frontend.
3+
*/
14
import "./src/env.mjs";
25

36
/** @type {import('next').NextConfig} */
47
const supabaseUrl = new URL(process.env.NEXT_PUBLIC_SUPABASE_URL);
8+
const storageUrl = new URL(
9+
process.env.NEXT_PUBLIC_STORAGE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL,
10+
);
511

612
const isLocal =
7-
supabaseUrl.hostname === "127.0.0.1" || supabaseUrl.hostname === "localhost";
13+
storageUrl.hostname === "127.0.0.1" || storageUrl.hostname === "localhost";
814

915
/** @type {import('next').NextConfig} */
1016
const nextConfig = {
@@ -20,6 +26,12 @@ const nextConfig = {
2026
images: {
2127
unoptimized: isLocal,
2228
remotePatterns: [
29+
{
30+
protocol: storageUrl.protocol.replace(":", ""),
31+
hostname: storageUrl.hostname,
32+
port: storageUrl.port,
33+
pathname: "/storage/**", // allow both public and sign URLs
34+
},
2335
{
2436
protocol: supabaseUrl.protocol.replace(":", ""),
2537
hostname: supabaseUrl.hostname,

apps/app/src/env.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/**
2+
* Environment schema and runtime bindings for the app frontend.
3+
*/
14
import { createEnv } from "@t3-oss/env-nextjs";
25
import { z } from "zod";
36

@@ -21,12 +24,14 @@ const env = createEnv({
2124
NEXT_PUBLIC_API_URL: z.string(),
2225
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string(),
2326
NEXT_PUBLIC_SUPABASE_URL: z.string(),
27+
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
2428
},
2529
runtimeEnv: {
2630
NEXT_PUBLIC_OPENPANEL_CLIENT_ID:
2731
process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID,
2832
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
2933
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
34+
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
3035
OPENPANEL_SECRET_KEY: process.env.OPENPANEL_SECRET_KEY,
3136
PORT: process.env.PORT,
3237
RESEND_API_KEY: process.env.RESEND_API_KEY,

apps/app/src/utils/storage-urls.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ export function buildPublicUrl(
4747
): string | null {
4848
if (!path) return null;
4949

50-
const supabaseUrl = getSupabaseUrl();
50+
const storageBaseUrl = getSupabaseUrl();
5151
const encodedPath = encodePath(path);
5252

53-
if (supabaseUrl) {
54-
return `${supabaseUrl}/storage/v1/object/public/${bucket}/${encodedPath}`;
53+
if (storageBaseUrl) {
54+
return `${storageBaseUrl}/storage/v1/object/public/${bucket}/${encodedPath}`;
5555
}
5656

5757
// Fallback for SSR or missing env var
@@ -192,9 +192,13 @@ export function resolveThemeConfigImageUrls<T>(themeConfig: T): T {
192192
// ============================================================================
193193

194194
/**
195-
* Get the Supabase URL from environment.
196-
* NEXT_PUBLIC_ env vars are available on both client and server.
195+
* Get the public storage base URL from environment.
196+
* Falls back to the Supabase URL when a dedicated storage URL is not configured.
197197
*/
198198
export function getSupabaseUrl(): string | null {
199-
return process.env.NEXT_PUBLIC_SUPABASE_URL ?? null;
199+
return (
200+
process.env.NEXT_PUBLIC_STORAGE_URL ??
201+
process.env.NEXT_PUBLIC_SUPABASE_URL ??
202+
null
203+
);
200204
}

apps/storage/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
.vercel

packages/db/src/utils/storage-url.ts

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
* Bucket name for product images.
1414
*/
1515
export const PRODUCTS_BUCKET = "products";
16+
const STORAGE_PUBLIC_PREFIX = "/storage/v1/object/public/";
1617

1718
// =============================================================================
1819
// URL BUILDING
@@ -28,6 +29,46 @@ function encodePath(path: string): string {
2829
.join("/");
2930
}
3031

32+
/**
33+
* Escape regex metacharacters for safe dynamic patterns.
34+
*/
35+
function escapeRegExp(value: string): string {
36+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37+
}
38+
39+
/**
40+
* Decode URI path segments safely.
41+
*/
42+
function decodePath(path: string): string {
43+
return path
44+
.split("/")
45+
.map((segment) => {
46+
try {
47+
return decodeURIComponent(segment);
48+
} catch {
49+
return segment;
50+
}
51+
})
52+
.join("/");
53+
}
54+
55+
/**
56+
* Extract a storage object path from a full public storage URL.
57+
*/
58+
function extractStoragePathFromPublicUrl(
59+
value: string,
60+
bucket: string,
61+
): string | null {
62+
const escapedBucket = escapeRegExp(bucket);
63+
const pattern = new RegExp(
64+
`${STORAGE_PUBLIC_PREFIX}${escapedBucket}/(.+?)(?:[?#].*)?$`,
65+
"i",
66+
);
67+
const match = value.match(pattern);
68+
if (!match?.[1]) return null;
69+
return decodePath(match[1]);
70+
}
71+
3172
/**
3273
* Build a public URL for a product image.
3374
*
@@ -41,15 +82,27 @@ export function buildProductImageUrl(
4182
): string | null {
4283
if (!imagePath) return null;
4384

44-
// If already a full URL, return as-is
45-
if (isFullUrl(imagePath)) return imagePath;
85+
// Normalize legacy full public storage URLs back to a storage path first.
86+
const extractedStoragePath = extractStoragePathFromPublicUrl(
87+
imagePath,
88+
PRODUCTS_BUCKET,
89+
);
90+
const normalizedPath = extractedStoragePath ?? imagePath;
91+
92+
// Preserve external full URLs as-is.
93+
if (isFullUrl(normalizedPath)) return normalizedPath;
4694

4795
// If no storage base URL available, return the path as-is
4896
// (fallback for backward compatibility)
49-
if (!storageBaseUrl) return imagePath;
97+
if (!storageBaseUrl) {
98+
return extractedStoragePath ? imagePath : normalizedPath;
99+
}
50100

51-
const encodedPath = encodePath(imagePath);
52-
return `${storageBaseUrl}/storage/v1/object/public/${PRODUCTS_BUCKET}/${encodedPath}`;
101+
const normalizedStorageBaseUrl = storageBaseUrl.endsWith("/")
102+
? storageBaseUrl.slice(0, -1)
103+
: storageBaseUrl;
104+
const encodedPath = encodePath(normalizedPath);
105+
return `${normalizedStorageBaseUrl}/storage/v1/object/public/${PRODUCTS_BUCKET}/${encodedPath}`;
53106
}
54107

55108
/**
@@ -65,8 +118,12 @@ export function isFullUrl(value: string | null | undefined): boolean {
65118
* Works in both Node.js and edge contexts.
66119
*/
67120
export function getSupabaseUrlFromEnv(): string | null {
68-
// Try common environment variable names
121+
// Prefer dedicated storage URL overrides, then fall back to Supabase URL.
69122
return (
70-
process.env.SUPABASE_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL ?? null
123+
process.env.SUPABASE_STORAGE_URL ??
124+
process.env.NEXT_PUBLIC_STORAGE_URL ??
125+
process.env.SUPABASE_URL ??
126+
process.env.NEXT_PUBLIC_SUPABASE_URL ??
127+
null
71128
);
72129
}

0 commit comments

Comments
 (0)