From 35e3cbf52fdff09e73e98b9c54d21387589f5737 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 5 Feb 2026 10:10:53 -0800 Subject: [PATCH] feat: support lan/tailnet endpoint selection for presigned URLs --- apps/web/app/api/assets/[id]/url/route.ts | 22 ++++++++ packages/config/src/index.ts | 12 +++++ packages/minio/src/endpointSelector.test.ts | 52 +++++++++++++++++++ packages/minio/src/endpointSelector.ts | 22 ++++++++ packages/minio/src/env.ts | 31 +++++++++++ packages/minio/src/index.ts | 57 ++++++++------------- 6 files changed, 161 insertions(+), 35 deletions(-) create mode 100644 packages/minio/src/endpointSelector.test.ts create mode 100644 packages/minio/src/endpointSelector.ts create mode 100644 packages/minio/src/env.ts diff --git a/apps/web/app/api/assets/[id]/url/route.ts b/apps/web/app/api/assets/[id]/url/route.ts index bb401b5..d0d4d0f 100644 --- a/apps/web/app/api/assets/[id]/url/route.ts +++ b/apps/web/app/api/assets/[id]/url/route.ts @@ -39,10 +39,12 @@ export async function GET( const kindParam = url.searchParams.get("kind"); const sizeParam = url.searchParams.get("size"); const legacyVariantParam = url.searchParams.get("variant"); + const endpointParam = url.searchParams.get("endpoint"); let requestedKind: z.infer = "original"; let requestedSize: number | null = null; let legacyVariant: z.infer | null = null; + let endpointOverride: "lan" | "tailnet" | undefined; if (kindParam) { const kindParsed = kindSchema.safeParse(kindParam); @@ -81,6 +83,25 @@ export async function GET( requestedSize = "size" in mapped ? mapped.size : null; } + if (endpointParam) { + if (endpointParam !== "lan" && endpointParam !== "tailnet") { + return Response.json( + { + error: "invalid_query", + issues: [ + { + code: "custom", + message: "endpoint must be lan or tailnet", + path: ["endpoint"], + }, + ], + }, + { status: 400 }, + ); + } + endpointOverride = endpointParam; + } + const db = getDb(); const rows = await db< { @@ -171,6 +192,7 @@ export async function GET( key, responseContentType, responseContentDisposition, + endpoint: endpointOverride, }); return Response.json(signed, { diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index c416317..1904f33 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -6,6 +6,8 @@ const envSchema = z.object({ APP_NAME: z.string().min(1).default("porthole"), NEXT_PUBLIC_APP_NAME: z.string().min(1).optional(), ADMIN_TOKEN: z.string().min(1).optional(), + MINIO_PUBLIC_ENDPOINT_LAN: z.string().url().optional(), + MINIO_ENDPOINT_MODE: z.enum(["tailnet", "lan", "auto"]).default("auto"), }); let cachedEnv: z.infer | undefined; @@ -31,3 +33,13 @@ export function getAdminToken() { const env = getEnv(); return env.ADMIN_TOKEN; } + +export function getMinioEndpointMode() { + const env = getEnv(); + return env.MINIO_ENDPOINT_MODE; +} + +export function getMinioPublicEndpointLan() { + const env = getEnv(); + return env.MINIO_PUBLIC_ENDPOINT_LAN; +} diff --git a/packages/minio/src/endpointSelector.test.ts b/packages/minio/src/endpointSelector.test.ts new file mode 100644 index 0000000..183d379 --- /dev/null +++ b/packages/minio/src/endpointSelector.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "bun:test"; + +import { resolvePresignEndpoint } from "./endpointSelector"; +import type { MinioEnv } from "./env"; + +const baseEnv: MinioEnv = { + MINIO_INTERNAL_ENDPOINT: "http://minio:9000", + MINIO_PUBLIC_ENDPOINT_TS: "https://ts.example.com", + MINIO_PUBLIC_ENDPOINT_LAN: "https://lan.example.com", + MINIO_ACCESS_KEY_ID: "key", + MINIO_SECRET_ACCESS_KEY: "secret", + MINIO_REGION: "us-east-1", + MINIO_BUCKET: "media", + MINIO_PRESIGN_EXPIRES_SECONDS: 900, + MINIO_ENDPOINT_MODE: "auto", +}; + +test("auto endpoint mode defaults to tailnet", () => { + expect(resolvePresignEndpoint(baseEnv, undefined)).toBe( + "https://ts.example.com", + ); +}); + +test("endpoint=lan forces LAN endpoint", () => { + expect(resolvePresignEndpoint(baseEnv, "lan")).toBe( + "https://lan.example.com", + ); +}); + +test("endpoint=tailnet forces tailnet endpoint", () => { + expect(resolvePresignEndpoint(baseEnv, "tailnet")).toBe( + "https://ts.example.com", + ); +}); + +test("lan mode selects LAN endpoint", () => { + const env = { ...baseEnv, MINIO_ENDPOINT_MODE: "lan" as const }; + expect(resolvePresignEndpoint(env, undefined)).toBe( + "https://lan.example.com", + ); +}); + +test("lan mode without LAN endpoint throws", () => { + const env = { + ...baseEnv, + MINIO_ENDPOINT_MODE: "lan" as const, + MINIO_PUBLIC_ENDPOINT_LAN: undefined, + }; + expect(() => resolvePresignEndpoint(env, undefined)).toThrow( + "MINIO_PUBLIC_ENDPOINT_LAN is required", + ); +}); diff --git a/packages/minio/src/endpointSelector.ts b/packages/minio/src/endpointSelector.ts new file mode 100644 index 0000000..25fe692 --- /dev/null +++ b/packages/minio/src/endpointSelector.ts @@ -0,0 +1,22 @@ +import type { MinioEnv } from "./env"; + +export type PresignEndpointOverride = "lan" | "tailnet"; + +export function resolvePresignEndpoint( + env: MinioEnv, + override?: PresignEndpointOverride, +) { + const mode = override ?? env.MINIO_ENDPOINT_MODE; + if (mode === "lan") { + if (!env.MINIO_PUBLIC_ENDPOINT_LAN) { + throw new Error("MINIO_PUBLIC_ENDPOINT_LAN is required for lan endpoint mode"); + } + return env.MINIO_PUBLIC_ENDPOINT_LAN; + } + if (!env.MINIO_PUBLIC_ENDPOINT_TS) { + throw new Error( + "MINIO_PUBLIC_ENDPOINT_TS is required for presigned URL generation", + ); + } + return env.MINIO_PUBLIC_ENDPOINT_TS; +} diff --git a/packages/minio/src/env.ts b/packages/minio/src/env.ts new file mode 100644 index 0000000..16d1673 --- /dev/null +++ b/packages/minio/src/env.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const envSchema = z.object({ + MINIO_INTERNAL_ENDPOINT: z.string().url().optional(), + MINIO_PUBLIC_ENDPOINT_TS: z.string().url().optional(), + MINIO_PUBLIC_ENDPOINT_LAN: z.string().url().optional(), + MINIO_ACCESS_KEY_ID: z.string().min(1), + MINIO_SECRET_ACCESS_KEY: z.string().min(1), + MINIO_REGION: z.string().min(1).default("us-east-1"), + MINIO_BUCKET: z.string().min(1).default("media"), + MINIO_PRESIGN_EXPIRES_SECONDS: z.coerce + .number() + .int() + .positive() + .default(900), + MINIO_ENDPOINT_MODE: z.enum(["tailnet", "lan", "auto"]).default("auto"), +}); + +export type MinioEnv = z.infer; + +let cachedEnv: MinioEnv | undefined; + +export function getMinioEnv(): MinioEnv { + if (cachedEnv) return cachedEnv; + const parsed = envSchema.safeParse(process.env); + if (!parsed.success) { + throw new Error(`Invalid MinIO env: ${parsed.error.message}`); + } + cachedEnv = parsed.data; + return cachedEnv; +} diff --git a/packages/minio/src/index.ts b/packages/minio/src/index.ts index a85331b..639faa5 100644 --- a/packages/minio/src/index.ts +++ b/packages/minio/src/index.ts @@ -2,33 +2,16 @@ import "server-only"; import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; -import { z } from "zod"; +import { getMinioEnv, type MinioEnv } from "./env"; +import { + resolvePresignEndpoint, + type PresignEndpointOverride, +} from "./endpointSelector"; -const envSchema = z.object({ - MINIO_INTERNAL_ENDPOINT: z.string().url().optional(), - MINIO_PUBLIC_ENDPOINT_TS: z.string().url().optional(), - MINIO_ACCESS_KEY_ID: z.string().min(1), - MINIO_SECRET_ACCESS_KEY: z.string().min(1), - MINIO_REGION: z.string().min(1).default("us-east-1"), - MINIO_BUCKET: z.string().min(1).default("media"), - MINIO_PRESIGN_EXPIRES_SECONDS: z.coerce.number().int().positive().default(900) -}); - -type MinioEnv = z.infer; - -let cachedEnv: MinioEnv | undefined; let cachedInternal: S3Client | undefined; let cachedPublic: S3Client | undefined; -export function getMinioEnv(): MinioEnv { - if (cachedEnv) return cachedEnv; - const parsed = envSchema.safeParse(process.env); - if (!parsed.success) { - throw new Error(`Invalid MinIO env: ${parsed.error.message}`); - } - cachedEnv = parsed.data; - return cachedEnv; -} +export type { MinioEnv, PresignEndpointOverride }; export function getMinioBucket() { return getMinioEnv().MINIO_BUCKET; @@ -54,24 +37,27 @@ export function getMinioInternalClient(): S3Client { return cachedInternal; } -export function getMinioPublicSigningClient(): S3Client { - if (cachedPublic) return cachedPublic; +export function getMinioPublicSigningClient( + override?: PresignEndpointOverride, +): S3Client { + if (!override && cachedPublic) return cachedPublic; const env = getMinioEnv(); - if (!env.MINIO_PUBLIC_ENDPOINT_TS) { - throw new Error("MINIO_PUBLIC_ENDPOINT_TS is required for presigned URL generation"); - } - - cachedPublic = new S3Client({ + const endpoint = resolvePresignEndpoint(env, override); + const client = new S3Client({ region: env.MINIO_REGION, - endpoint: env.MINIO_PUBLIC_ENDPOINT_TS, + endpoint, forcePathStyle: true, credentials: { accessKeyId: env.MINIO_ACCESS_KEY_ID, - secretAccessKey: env.MINIO_SECRET_ACCESS_KEY - } + secretAccessKey: env.MINIO_SECRET_ACCESS_KEY, + }, }); - return cachedPublic; + if (!override) { + cachedPublic = client; + } + + return client; } export async function presignGetObjectUrl(input: { @@ -80,9 +66,10 @@ export async function presignGetObjectUrl(input: { expiresSeconds?: number; responseContentType?: string; responseContentDisposition?: string; + endpoint?: PresignEndpointOverride; }) { const env = getMinioEnv(); - const s3 = getMinioPublicSigningClient(); + const s3 = getMinioPublicSigningClient(input.endpoint); const command = new GetObjectCommand({ Bucket: input.bucket,