From 210e44db5cb59497a78eb7666f6257426e879da7 Mon Sep 17 00:00:00 2001 From: Henry <30578846+hjball@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:16:42 +0000 Subject: [PATCH] feat: add env var to use virtual hosted urls (#338) --- .env.example | 1 + README.md | 1 + apps/web/src/env.ts | 9 ++++++++ apps/web/src/utils/helpers.test.ts | 34 +++++++++++++++++++++++++++++- apps/web/src/utils/helpers.ts | 3 ++- cloud/docker-compose.yml | 1 + docker-compose.yml | 1 + turbo.json | 1 + 8 files changed, 49 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index da315b3b..8ce7cfc6 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,7 @@ NEXT_PUBLIC_STORAGE_URL= NEXT_PUBLIC_AVATAR_BUCKET_NAME= NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME= NEXT_PUBLIC_STORAGE_DOMAIN= +NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS= # Auth config (optional) NEXT_PUBLIC_ALLOW_CREDENTIALS= diff --git a/README.md b/README.md index ba935725..fd7b4c8f 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ pnpm dev | `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` | | `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` | | `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` | +| `NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS` | Use virtual-hosted style URLs (bucket.domain.com) | For file uploads (optional) | `true` | | `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` | | `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` | | `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` | diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index d5465f52..25282070 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -96,6 +96,13 @@ export const env = createEnv({ NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(), NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(), NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(), + NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: z + .string() + .transform((s) => (s === "" ? undefined : s)) + .refine( + (s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false", + ) + .optional(), NEXT_PUBLIC_APP_VERSION: z.string().optional(), NEXT_PUBLIC_ALLOW_CREDENTIALS: z .string() @@ -134,6 +141,8 @@ export const env = createEnv({ NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME, NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN, + NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: + process.env.NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS, NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION, NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS, NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP, diff --git a/apps/web/src/utils/helpers.test.ts b/apps/web/src/utils/helpers.test.ts index 3223ea03..434e625e 100644 --- a/apps/web/src/utils/helpers.test.ts +++ b/apps/web/src/utils/helpers.test.ts @@ -51,9 +51,10 @@ describe("getAvatarUrl", () => { }); describe("virtual-hosted URLs (Tigris/AWS S3)", () => { - it("constructs virtual-hosted URL when STORAGE_DOMAIN is set", () => { + it("constructs virtual-hosted URL when USE_VIRTUAL_HOSTED_URLS is true and STORAGE_DOMAIN is set", () => { mockEnv.mockImplementation((key: string) => { const vars: Record = { + NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "true", NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev", NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars", NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev", @@ -65,5 +66,36 @@ describe("getAvatarUrl", () => { "https://kan-avatars.fly.storage.tigris.dev/user123/avatar.jpg", ); }); + + it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is false even if STORAGE_DOMAIN is set", () => { + mockEnv.mockImplementation((key: string) => { + const vars: Record = { + NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS: "false", + NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev", + NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars", + NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev", + }; + return vars[key]; + }); + + expect(getAvatarUrl("user123/avatar.jpg")).toBe( + "https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg", + ); + }); + + it("uses path-style URL when USE_VIRTUAL_HOSTED_URLS is not set even if STORAGE_DOMAIN is set", () => { + mockEnv.mockImplementation((key: string) => { + const vars: Record = { + NEXT_PUBLIC_STORAGE_DOMAIN: "fly.storage.tigris.dev", + NEXT_PUBLIC_AVATAR_BUCKET_NAME: "kan-avatars", + NEXT_PUBLIC_STORAGE_URL: "https://fly.storage.tigris.dev", + }; + return vars[key]; + }); + + expect(getAvatarUrl("user123/avatar.jpg")).toBe( + "https://fly.storage.tigris.dev/kan-avatars/user123/avatar.jpg", + ); + }); }); }); diff --git a/apps/web/src/utils/helpers.ts b/apps/web/src/utils/helpers.ts index a05eb780..25defca5 100644 --- a/apps/web/src/utils/helpers.ts +++ b/apps/web/src/utils/helpers.ts @@ -53,9 +53,10 @@ export const getAvatarUrl = (imageOrKey: string | null) => { } const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME"); + const useVirtualHostedUrls = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS"); const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN"); - if (storageDomain) { + if (useVirtualHostedUrls === "true" && storageDomain) { return `https://${bucket}.${storageDomain}/${imageOrKey}`; } diff --git a/cloud/docker-compose.yml b/cloud/docker-compose.yml index e0133d4b..852a1815 100644 --- a/cloud/docker-compose.yml +++ b/cloud/docker-compose.yml @@ -56,6 +56,7 @@ services: - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME} - NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME} - NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN} + - NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=${NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS} # Auth config (optional) - NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS} diff --git a/docker-compose.yml b/docker-compose.yml index 1edc5e8c..556d5a9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,7 @@ services: - NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME} - NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME} - NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN} + - NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS=${NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS} # White label - NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY=${NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY} diff --git a/turbo.json b/turbo.json index 009a9ed2..0377ec16 100644 --- a/turbo.json +++ b/turbo.json @@ -111,6 +111,7 @@ "STRIPE_TEAM_PLAN_YEARLY_PRICE_ID", "NEXT_PUBLIC_STORAGE_DOMAIN", "NEXT_PUBLIC_STORAGE_URL", + "NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS", "NEXT_PUBLIC_AVATAR_BUCKET_NAME", "NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME", "NEXT_PUBLIC_ALLOW_CREDENTIALS",