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 sizeParam = url.searchParams.get("size");
const legacyVariantParam = url.searchParams.get("variant");
const endpointParam = url.searchParams.get("endpoint");
let requestedKind: z.infer<typeof kindSchema> = "original";
let requestedSize: number | null = null;
let legacyVariant: z.infer<typeof legacyVariantSchema> | 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, {

View File

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

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 { 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 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,