mirror of
https://github.com/rustfs/console.git
synced 2026-08-29 03:52:28 +08:00
251 lines
7.9 KiB
TypeScript
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 }
|
|
}
|