feat: support lan/tailnet endpoint selection for presigned URLs

This commit is contained in:
William Valentin
2026-02-05 10:10:53 -08:00
parent d93caedb31
commit 35e3cbf52f
6 changed files with 161 additions and 35 deletions

View File

@@ -39,10 +39,12 @@ export async function GET(
const kindParam = url.searchParams.get("kind"); const kindParam = url.searchParams.get("kind");
const sizeParam = url.searchParams.get("size"); const sizeParam = url.searchParams.get("size");
const legacyVariantParam = url.searchParams.get("variant"); const legacyVariantParam = url.searchParams.get("variant");
const endpointParam = url.searchParams.get("endpoint");
let requestedKind: z.infer<typeof kindSchema> = "original"; let requestedKind: z.infer<typeof kindSchema> = "original";
let requestedSize: number | null = null; let requestedSize: number | null = null;
let legacyVariant: z.infer<typeof legacyVariantSchema> | null = null; let legacyVariant: z.infer<typeof legacyVariantSchema> | null = null;
let endpointOverride: "lan" | "tailnet" | undefined;
if (kindParam) { if (kindParam) {
const kindParsed = kindSchema.safeParse(kindParam); const kindParsed = kindSchema.safeParse(kindParam);
@@ -81,6 +83,25 @@ export async function GET(
requestedSize = "size" in mapped ? mapped.size : null; 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 db = getDb();
const rows = await db< const rows = await db<
{ {
@@ -171,6 +192,7 @@ export async function GET(
key, key,
responseContentType, responseContentType,
responseContentDisposition, responseContentDisposition,
endpoint: endpointOverride,
}); });
return Response.json(signed, { return Response.json(signed, {

View File

@@ -6,6 +6,8 @@ const envSchema = z.object({
APP_NAME: z.string().min(1).default("porthole"), APP_NAME: z.string().min(1).default("porthole"),
NEXT_PUBLIC_APP_NAME: z.string().min(1).optional(), NEXT_PUBLIC_APP_NAME: z.string().min(1).optional(),
ADMIN_TOKEN: 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<typeof envSchema> | undefined; let cachedEnv: z.infer<typeof envSchema> | undefined;
@@ -31,3 +33,13 @@ export function getAdminToken() {
const env = getEnv(); const env = getEnv();
return env.ADMIN_TOKEN; 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;
}

View File

@@ -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",
);
});

View File

@@ -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;
}

31
packages/minio/src/env.ts Normal file
View File

@@ -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<typeof envSchema>;
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;
}

View File

@@ -2,33 +2,16 @@ import "server-only";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; 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<typeof envSchema>;
let cachedEnv: MinioEnv | undefined;
let cachedInternal: S3Client | undefined; let cachedInternal: S3Client | undefined;
let cachedPublic: S3Client | undefined; let cachedPublic: S3Client | undefined;
export function getMinioEnv(): MinioEnv { export type { MinioEnv, PresignEndpointOverride };
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 function getMinioBucket() { export function getMinioBucket() {
return getMinioEnv().MINIO_BUCKET; return getMinioEnv().MINIO_BUCKET;
@@ -54,24 +37,27 @@ export function getMinioInternalClient(): S3Client {
return cachedInternal; return cachedInternal;
} }
export function getMinioPublicSigningClient(): S3Client { export function getMinioPublicSigningClient(
if (cachedPublic) return cachedPublic; override?: PresignEndpointOverride,
): S3Client {
if (!override && cachedPublic) return cachedPublic;
const env = getMinioEnv(); const env = getMinioEnv();
if (!env.MINIO_PUBLIC_ENDPOINT_TS) { const endpoint = resolvePresignEndpoint(env, override);
throw new Error("MINIO_PUBLIC_ENDPOINT_TS is required for presigned URL generation"); const client = new S3Client({
}
cachedPublic = new S3Client({
region: env.MINIO_REGION, region: env.MINIO_REGION,
endpoint: env.MINIO_PUBLIC_ENDPOINT_TS, endpoint,
forcePathStyle: true, forcePathStyle: true,
credentials: { credentials: {
accessKeyId: env.MINIO_ACCESS_KEY_ID, 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: { export async function presignGetObjectUrl(input: {
@@ -80,9 +66,10 @@ export async function presignGetObjectUrl(input: {
expiresSeconds?: number; expiresSeconds?: number;
responseContentType?: string; responseContentType?: string;
responseContentDisposition?: string; responseContentDisposition?: string;
endpoint?: PresignEndpointOverride;
}) { }) {
const env = getMinioEnv(); const env = getMinioEnv();
const s3 = getMinioPublicSigningClient(); const s3 = getMinioPublicSigningClient(input.endpoint);
const command = new GetObjectCommand({ const command = new GetObjectCommand({
Bucket: input.bucket, Bucket: input.bucket,