feat: add NEXT_PUBLIC_API_PREFIX for reverse-proxy support (#109)

This commit is contained in:
Oliver
2026-05-08 03:56:18 +02:00
committed by GitHub
parent 22a38e0c14
commit 5b0d8ccb7c
10 changed files with 320 additions and 5 deletions
+7
View File
@@ -8,6 +8,13 @@ NEXT_PUBLIC_SERVER_HOST=
# API base URL (optional, defaults to ${NEXT_PUBLIC_SERVER_HOST}/rustfs/admin/v3)
# NEXT_PUBLIC_API_BASE_URL=
# Reverse-proxy support: when set, all S3 / admin / STS requests are sent
# with this prefix on the wire, while SigV4 signs the un-prefixed path.
# A reverse proxy in front of rustfs must strip this prefix before
# forwarding (e.g. nginx `rewrite ^/rustfs/api/(.*) /$1 break;`).
# Leave empty (default) for direct deployments.
# NEXT_PUBLIC_API_PREFIX=/rustfs/api
# S3 configuration
NEXT_PUBLIC_S3_ENDPOINT=
NEXT_PUBLIC_S3_REGION=us-east-1
+3
View File
@@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { S3Client } from "@aws-sdk/client-s3"
import { useAuth } from "@/contexts/auth-context"
import { addApiPrefixMiddleware } from "@/lib/api-prefix-middleware"
import { configManager } from "@/lib/config"
import { getServiceErrorMessage, getXmlErrorMessage } from "@/lib/error-handler"
import type { SiteConfig } from "@/types/config"
@@ -104,6 +105,8 @@ export function S3Provider({ children }: { children: React.ReactNode }) {
},
})
addApiPrefixMiddleware(client)
/* eslint-disable @typescript-eslint/no-explicit-any -- AWS SDK middleware types are complex */
client.middlewareStack.add(
((next: any) => async (args: any) => {
+47
View File
@@ -0,0 +1,47 @@
// Adds NEXT_PUBLIC_API_PREFIX to the request path AFTER SigV4 signing for
// AWS SDK v3 clients (@aws-sdk/client-s3, @aws-sdk/client-sts). The signed
// canonical-request URI stays un-prefixed; the wire request carries the
// prefix so a reverse proxy can route it to the rustfs origin while
// rustfs's signature verification (which sees the un-prefixed path after
// the proxy strips the prefix) still matches.
//
// Used because AWS SDK v3's SigV4 signer has no strip-on-sign hook. For
// our custom AwsClient (lib/aws4fetch.ts) we instead patch the signer
// directly to strip the prefix before computing the canonical string.
const getApiPrefix = (): string => (process.env.NEXT_PUBLIC_API_PREFIX || "").replace(/\/$/, "")
// AWS SDK middleware types are generic over per-client Input/Output unions, so
// a structurally-typed wrapper that's compatible with both S3Client and STSClient
// requires `any` here. Narrowing inside the middleware body keeps it safe.
interface MiddlewareClient {
middlewareStack: {
addRelativeTo: (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mw: any,
opts: { name: string; relation: "before" | "after"; toMiddleware: string; override?: boolean },
) => void
}
}
type FinalizeArgs = { request?: { path?: string } & Record<string, unknown> }
export function addApiPrefixMiddleware(client: MiddlewareClient): void {
const apiPrefix = getApiPrefix()
if (!apiPrefix) return
client.middlewareStack.addRelativeTo(
(next: (args: FinalizeArgs) => Promise<unknown>) => async (args: FinalizeArgs) => {
const request = args?.request
if (request && typeof request.path === "string" && !request.path.startsWith(apiPrefix)) {
request.path = apiPrefix + (request.path === "/" ? "/" : request.path)
}
return next(args)
},
{
name: "addRustfsApiPrefix",
relation: "after",
toMiddleware: "awsAuthMiddleware",
},
)
}
+9 -3
View File
@@ -300,14 +300,20 @@ export class AwsV4Signer {
params.set("X-Amz-SignedHeaders", this.signedHeaders)
}
const apiPrefix = (process.env.NEXT_PUBLIC_API_PREFIX || "").replace(/\/$/, "")
let signingPathname = this.url.pathname
if (apiPrefix && signingPathname.startsWith(apiPrefix)) {
signingPathname = signingPathname.slice(apiPrefix.length) || "/"
}
if (this.service === "s3") {
try {
this.encodedPath = decodeURIComponent(this.url.pathname.replace(/\+/g, " "))
this.encodedPath = decodeURIComponent(signingPathname.replace(/\+/g, " "))
} catch {
this.encodedPath = this.url.pathname
this.encodedPath = signingPathname
}
} else {
this.encodedPath = this.url.pathname.replace(/\/+/g, "/")
this.encodedPath = signingPathname.replace(/\/+/g, "/")
}
if (!singleEncode) {
this.encodedPath = encodeURIComponent(this.encodedPath).replace(/%2F/g, "/")
+4 -1
View File
@@ -39,6 +39,8 @@ const REQUEST_TIMEOUT = 5000
const HEALTH_REQUEST_TIMEOUT = 5000
const HEALTH_PATHS = ["/rustfs/console/health", "/health"] as const
const getApiPrefix = (): string => (process.env.NEXT_PUBLIC_API_PREFIX || "").replace(/\/$/, "")
const isBrowser = (): boolean => typeof window !== "undefined"
const getCurrentHostInfo = (): HostInfo | null => {
@@ -52,10 +54,11 @@ const getCurrentHostInfo = (): HostInfo | null => {
}
export const createDefaultConfig = (serverHost: string): SiteConfig => {
const apiPrefix = getApiPrefix()
return {
serverHost,
api: {
baseURL: `${serverHost}${API_PATH}`,
baseURL: `${serverHost}${apiPrefix}${API_PATH}`,
},
s3: {
endpoint: serverHost,
+5 -1
View File
@@ -13,17 +13,21 @@ let configCacheTime = 0
let configPromise: Promise<SiteConfig> | null = null
const CACHE_DURATION = 60000
const getApiPrefix = (): string => (process.env.NEXT_PUBLIC_API_PREFIX || "").replace(/\/$/, "")
function loadRuntimeConfig(): SiteConfig | null {
try {
const serverHost =
process.env.NEXT_PUBLIC_SERVER_HOST ||
(process.env.NEXT_PUBLIC_API_BASE_URL ?? "").replace(/\/rustfs\/admin\/v3$/, "")
const apiPrefix = getApiPrefix()
if (serverHost) {
return {
serverHost,
api: {
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || `${serverHost}/rustfs/admin/v3`,
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || `${serverHost}${apiPrefix}/rustfs/admin/v3`,
},
s3: {
endpoint: process.env.NEXT_PUBLIC_S3_ENDPOINT || serverHost,
+3
View File
@@ -1,5 +1,6 @@
import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"
import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from "@aws-sdk/types"
import { addApiPrefixMiddleware } from "@/lib/api-prefix-middleware"
import type { SiteConfig } from "@/types/config"
export async function getStsToken(
@@ -13,6 +14,8 @@ export async function getStsToken(
credentials: credentials,
})
addApiPrefixMiddleware(stsClient)
const command = new AssumeRoleCommand({
RoleArn: roleArn,
RoleSessionName: "console",
+93
View File
@@ -0,0 +1,93 @@
import test from "node:test"
import assert from "node:assert/strict"
import { addApiPrefixMiddleware } from "../../lib/api-prefix-middleware"
// Minimal mock of the AWS SDK middlewareStack contract: capture each
// addRelativeTo() call so the test can inspect the registered middleware
// and the relation/toMiddleware metadata.
type MwOpts = { name: string; relation: "before" | "after"; toMiddleware: string; override?: boolean }
function makeFakeClient() {
const calls: Array<{ mw: unknown; opts: MwOpts }> = []
const client = {
middlewareStack: {
addRelativeTo(mw: unknown, opts: MwOpts) {
calls.push({ mw, opts })
},
},
}
return { client, calls }
}
const PREFIX_ENV = "NEXT_PUBLIC_API_PREFIX"
function withEnv<T>(value: string | undefined, fn: () => T): T {
const prev = process.env[PREFIX_ENV]
if (value === undefined) delete process.env[PREFIX_ENV]
else process.env[PREFIX_ENV] = value
try {
return fn()
} finally {
if (prev === undefined) delete process.env[PREFIX_ENV]
else process.env[PREFIX_ENV] = prev
}
}
test("addApiPrefixMiddleware: no-op when NEXT_PUBLIC_API_PREFIX is empty", () => {
withEnv("", () => {
const { client, calls } = makeFakeClient()
addApiPrefixMiddleware(client)
assert.equal(calls.length, 0)
})
})
test("addApiPrefixMiddleware: registers a finalizeRequest-after-auth middleware when prefix is set", () => {
withEnv("/rustfs/api", () => {
const { client, calls } = makeFakeClient()
addApiPrefixMiddleware(client)
assert.equal(calls.length, 1)
assert.equal(calls[0].opts.name, "addRustfsApiPrefix")
assert.equal(calls[0].opts.relation, "after")
assert.equal(calls[0].opts.toMiddleware, "awsAuthMiddleware")
})
})
type RequestArgs = { request: { path: string } }
type Handler = (args: RequestArgs) => Promise<unknown>
type Middleware = (next: Handler) => Handler
test("addApiPrefixMiddleware: prepends the prefix to request.path", async () => {
await withEnv("/rustfs/api", async () => {
const { client, calls } = makeFakeClient()
addApiPrefixMiddleware(client)
const mw = calls[0].mw as Middleware
const handler = mw(async (args) => args)
const args = { request: { path: "/foo/bar" } }
await handler(args)
assert.equal(args.request.path, "/rustfs/api/foo/bar")
})
})
test("addApiPrefixMiddleware: normalizes a root path '/' to keep a single slash", async () => {
await withEnv("/rustfs/api", async () => {
const { client, calls } = makeFakeClient()
addApiPrefixMiddleware(client)
const mw = calls[0].mw as Middleware
const handler = mw(async (args) => args)
const args = { request: { path: "/" } }
await handler(args)
assert.equal(args.request.path, "/rustfs/api/")
})
})
test("addApiPrefixMiddleware: idempotent (does not double-apply the prefix)", async () => {
await withEnv("/rustfs/api", async () => {
const { client, calls } = makeFakeClient()
addApiPrefixMiddleware(client)
const mw = calls[0].mw as Middleware
const handler = mw(async (args) => args)
const args = { request: { path: "/rustfs/api/foo" } }
await handler(args)
assert.equal(args.request.path, "/rustfs/api/foo")
})
})
+76
View File
@@ -0,0 +1,76 @@
import test from "node:test"
import assert from "node:assert/strict"
import { AwsV4Signer } from "../../lib/aws4fetch"
const baseOpts = {
accessKeyId: "AKID",
secretAccessKey: "SECRET",
service: "s3",
region: "us-east-1",
}
const PREFIX_ENV = "NEXT_PUBLIC_API_PREFIX"
function withEnv<T>(value: string | undefined, fn: () => T): T {
const prev = process.env[PREFIX_ENV]
if (value === undefined) delete process.env[PREFIX_ENV]
else process.env[PREFIX_ENV] = value
try {
return fn()
} finally {
if (prev === undefined) delete process.env[PREFIX_ENV]
else process.env[PREFIX_ENV] = prev
}
}
test("AwsV4Signer: backward-compat — signs the full pathname when prefix is empty", () => {
withEnv("", () => {
const s = new AwsV4Signer({ ...baseOpts, url: "https://example.com/foo/bar" })
assert.equal(s.encodedPath, "/foo/bar")
})
})
test("AwsV4Signer: strips the prefix from the canonical path when set (root)", () => {
withEnv("/rustfs/api", () => {
const s = new AwsV4Signer({ ...baseOpts, url: "https://example.com/rustfs/api/" })
assert.equal(s.encodedPath, "/")
})
})
test("AwsV4Signer: strips the prefix from a path-style bucket+key URL", () => {
withEnv("/rustfs/api", () => {
const s = new AwsV4Signer({
...baseOpts,
url: "https://example.com/rustfs/api/mybucket/key.txt",
})
assert.equal(s.encodedPath, "/mybucket/key.txt")
})
})
test("AwsV4Signer: does NOT strip when the request path doesn't start with the prefix", () => {
withEnv("/rustfs/api", () => {
const s = new AwsV4Signer({ ...baseOpts, url: "https://example.com/other/path" })
assert.equal(s.encodedPath, "/other/path")
})
})
test("AwsV4Signer: preserves this.url (wire URL) untouched even when prefix is stripped from signing", () => {
withEnv("/rustfs/api", () => {
const s = new AwsV4Signer({
...baseOpts,
url: "https://example.com/rustfs/api/foo?Action=ListBuckets",
})
assert.equal(s.url.pathname, "/rustfs/api/foo")
assert.equal(s.url.search, "?Action=ListBuckets")
})
})
test("AwsV4Signer: normalizes a trailing slash on the prefix", () => {
withEnv("/rustfs/api/", () => {
const s = new AwsV4Signer({
...baseOpts,
url: "https://example.com/rustfs/api/mybucket",
})
assert.equal(s.encodedPath, "/mybucket")
})
})
+73
View File
@@ -0,0 +1,73 @@
import test from "node:test"
import assert from "node:assert/strict"
import { configManager } from "../../lib/config"
import { createDefaultConfig } from "../../lib/config-helpers"
const PREFIX_ENV = "NEXT_PUBLIC_API_PREFIX"
const HOST_ENV = "NEXT_PUBLIC_SERVER_HOST"
function withEnv<T>(vars: Record<string, string | undefined>, fn: () => T): T {
const prev: Record<string, string | undefined> = {}
for (const [k, v] of Object.entries(vars)) {
prev[k] = process.env[k]
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
try {
return fn()
} finally {
for (const [k, v] of Object.entries(prev)) {
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
}
}
test("config: backward-compat — empty prefix yields the original URLs", () => {
configManager.clearCache()
withEnv(
{ [PREFIX_ENV]: "", [HOST_ENV]: "https://app.example.com" },
() => {
const cfg = configManager.loadRuntimeConfig()
assert.ok(cfg)
assert.equal(cfg.s3.endpoint, "https://app.example.com")
assert.equal(cfg.api.baseURL, "https://app.example.com/rustfs/admin/v3")
},
)
})
test("config: loadRuntimeConfig adds the prefix to api.baseURL only (s3.endpoint stays clean)", () => {
configManager.clearCache()
withEnv(
{ [PREFIX_ENV]: "/rustfs/api", [HOST_ENV]: "https://app.example.com" },
() => {
const cfg = configManager.loadRuntimeConfig()
assert.ok(cfg)
assert.equal(cfg.api.baseURL, "https://app.example.com/rustfs/api/rustfs/admin/v3")
assert.equal(cfg.s3.endpoint, "https://app.example.com")
},
)
})
test("config: createDefaultConfig adds the prefix to api.baseURL only (browser-fallback path)", () => {
withEnv({ [PREFIX_ENV]: "/rustfs/api" }, () => {
const cfg = createDefaultConfig("https://app.example.com")
assert.equal(cfg.api.baseURL, "https://app.example.com/rustfs/api/rustfs/admin/v3")
assert.equal(cfg.s3.endpoint, "https://app.example.com")
})
})
test("config: createDefaultConfig with empty prefix yields the original URLs", () => {
withEnv({ [PREFIX_ENV]: "" }, () => {
const cfg = createDefaultConfig("https://app.example.com")
assert.equal(cfg.api.baseURL, "https://app.example.com/rustfs/admin/v3")
assert.equal(cfg.s3.endpoint, "https://app.example.com")
})
})
test("config: trailing slash on the prefix is normalized away", () => {
withEnv({ [PREFIX_ENV]: "/rustfs/api/" }, () => {
const cfg = createDefaultConfig("https://app.example.com")
assert.equal(cfg.api.baseURL, "https://app.example.com/rustfs/api/rustfs/admin/v3")
})
})