feat: support lan/tailnet endpoint selection for presigned URLs
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
52
packages/minio/src/endpointSelector.test.ts
Normal file
52
packages/minio/src/endpointSelector.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
22
packages/minio/src/endpointSelector.ts
Normal file
22
packages/minio/src/endpointSelector.ts
Normal 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
31
packages/minio/src/env.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user