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 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, {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
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 { 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user