Files
rustfs-console/contexts/s3-context.tsx
T

251 lines
7.9 KiB
TypeScript

"use client"
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 { scheduleMicrotask } from "@/lib/schedule-microtask"
import type { SiteConfig } from "@/types/config"
interface S3Response {
response?: { body?: string }
[key: string]: unknown
}
type StreamCollector = (streamBody: unknown) => Promise<Uint8Array>
const readResponseBodyText = async (
body: unknown,
streamCollector: StreamCollector | undefined,
): Promise<string | null> => {
if (typeof body === "string") {
return body
}
if (body instanceof Uint8Array) {
return new TextDecoder("utf-8").decode(body)
}
if (!body || !streamCollector) {
return null
}
const bytes = await streamCollector(body)
return new TextDecoder("utf-8").decode(bytes)
}
const createS3ServiceError = (message: string, statusCode: number) => {
const error = new Error(message) as Error & {
Code?: string
$metadata?: { httpStatusCode?: number }
}
error.name = message
error.Code = message
error.$metadata = { httpStatusCode: statusCode }
return error
}
function patchReplicationBody(
body: string | undefined,
config: { Rules?: Array<{ DeleteReplication?: { Status?: string } }> } | undefined,
): string | undefined {
if (typeof body !== "string" || !config?.Rules?.length) return body
let i = 0
return body.replace(/<\/Rule>/g, () => {
const rule = config.Rules?.[i++]
const dr = rule?.DeleteReplication
const tag = dr ? `<DeleteReplication><Status>${dr.Status ?? "Disabled"}</Status></DeleteReplication>` : ""
return tag + "</Rule>"
})
}
interface S3ContextValue {
client: S3Client | null
isReady: boolean
}
const S3Context = createContext<S3ContextValue>({
client: null,
isReady: false,
})
export function S3Provider({ children }: { children: React.ReactNode }) {
const { credentials, isAuthenticated, logout } = useAuth()
const [s3Client, setS3Client] = useState<S3Client | null>(null)
const [isReady, setIsReady] = useState(false)
const router = useRouter()
useEffect(() => {
if (!isAuthenticated || !credentials?.AccessKeyId) {
scheduleMicrotask(() => {
setS3Client(null)
setIsReady(true)
})
return
}
scheduleMicrotask(() => setIsReady(false))
let cancelled = false
configManager.loadConfig().then((siteConfig: SiteConfig) => {
if (cancelled) return
const client = new S3Client({
endpoint: siteConfig.s3.endpoint,
region: siteConfig.s3.region || "us-east-1",
forcePathStyle: true,
// https://github.com/aws/aws-sdk-js-v3/issues/6834#issuecomment-2611346849
requestChecksumCalculation: "WHEN_REQUIRED",
credentials: {
accessKeyId: credentials?.AccessKeyId || "",
secretAccessKey: credentials?.SecretAccessKey || "",
sessionToken: credentials?.SessionToken || "",
},
})
addApiPrefixMiddleware(client)
/* eslint-disable @typescript-eslint/no-explicit-any -- AWS SDK middleware types are complex */
client.middlewareStack.add(
((next: any) => async (args: any) => {
const input = args?.input
if (
input &&
"ReplicationConfiguration" in input &&
"Bucket" in input &&
input.ReplicationConfiguration?.Rules?.length
) {
const raw =
typeof args.request?.body === "string"
? args.request.body
: args.request?.body instanceof Uint8Array
? new TextDecoder().decode(args.request.body)
: null
if (raw) {
const patched = patchReplicationBody(raw, input.ReplicationConfiguration)
if (patched != null) args.request.body = patched
}
}
return next(args)
}) as any,
{ step: "serialize", name: "injectDeleteReplication", priority: "low" },
)
client.middlewareStack.addRelativeTo(
((next: any) => async (args: any) => {
const result = await next(args)
const response = result?.response
const statusCode = response?.statusCode
if (typeof statusCode === "number" && statusCode >= 300) {
const streamCollector = client.config.streamCollector as StreamCollector | undefined
const bodyText = await readResponseBodyText(response.body, streamCollector)
const errorMessage = bodyText ? (getXmlErrorMessage(bodyText) ?? bodyText.trim()) : null
if (errorMessage) {
throw createS3ServiceError(errorMessage, statusCode)
}
}
return result
}) as any,
{
name: "normalizeXmlErrorResponse",
relation: "after",
toMiddleware: "deserializerMiddleware",
override: true,
},
)
client.middlewareStack.add(
((next: any) => async (args: any) => {
try {
const response = (await next(args)) as S3Response
if (response.response?.body && typeof response.response.body === "string") {
const body = response.response.body.trim()
if (body.match(/^<\?xml[^>]*\?><[^>]*><\/[^>]*>$/)) {
const tagName = body.match(/<([^>]*)><\/\1>/)?.[1]
if (tagName) {
const propertyName = tagName.replace(/(?:^|_)([a-z])/g, (_, letter: string) => letter.toUpperCase())
return {
response: response.response,
[propertyName]: null,
}
}
}
}
return response
} catch (error: unknown) {
const err = error as {
$metadata?: { httpStatusCode?: number }
Code?: string
name?: string
message?: string
}
if (err?.$metadata?.httpStatusCode === 401) {
logout()
router.replace("/auth/login/")
return { response: { statusCode: 401, headers: {} } }
}
if (err?.$metadata?.httpStatusCode === 403) {
const codeText = (err?.Code || err?.name || "").toLowerCase()
const isUnauthorizedAccess = codeText === "unauthorizedaccess"
const isInvalidAccessKey = codeText === "invalidaccesskeyid"
if (isUnauthorizedAccess || isInvalidAccessKey) {
logout()
router.replace("/auth/login/")
return { response: { statusCode: 401, headers: {} } }
}
}
const serviceErrorMessage = getServiceErrorMessage(error)
if (serviceErrorMessage) {
throw new Error(serviceErrorMessage)
}
throw error
}
}) as any,
{ step: "deserialize", name: "handleXmlResponse" },
)
/* eslint-enable @typescript-eslint/no-explicit-any */
setS3Client(client)
setIsReady(true)
})
return () => {
cancelled = true
}
}, [
isAuthenticated,
credentials?.AccessKeyId,
credentials?.SecretAccessKey,
credentials?.SessionToken,
logout,
router,
])
return <S3Context.Provider value={{ client: s3Client, isReady }}>{children}</S3Context.Provider>
}
export function useS3() {
const { client } = useContext(S3Context)
if (!client) {
throw new Error("useS3 must be used within S3Provider")
}
return client
}
export function useS3Optional() {
const { client } = useContext(S3Context)
return client
}
export function useS3Ready() {
const { client, isReady } = useContext(S3Context)
return { client, isReady }
}