mirror of
https://github.com/rustfs/console.git
synced 2026-09-01 15:17:45 +08:00
feat: add NEXT_PUBLIC_API_PREFIX for reverse-proxy support (#109)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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, "/")
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user