feat: add aliyun oss storage config
This commit is contained in:
1194
package-lock.json
generated
1194
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,13 +19,16 @@
|
|||||||
"@capacitor/cli": "^8.2.0",
|
"@capacitor/cli": "^8.2.0",
|
||||||
"@capacitor/core": "^8.2.0",
|
"@capacitor/core": "^8.2.0",
|
||||||
"@capacitor/preferences": "^8.0.1",
|
"@capacitor/preferences": "^8.0.1",
|
||||||
|
"ali-oss": "^6.23.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
|
"proxy-agent": "^5.0.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/ali-oss": "^6.23.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
92
scripts/verify-storage-config.mjs
Normal file
92
scripts/verify-storage-config.mjs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const baseUrl = process.env.BOSS_TEST_BASE_URL || "http://127.0.0.1:3000";
|
||||||
|
|
||||||
|
const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
throw new Error(`LOGIN_FAILED:${loginResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = (loginResponse.headers.get("set-cookie") || "").split(";")[0];
|
||||||
|
if (!cookie) {
|
||||||
|
throw new Error("COOKIE_MISSING");
|
||||||
|
}
|
||||||
|
|
||||||
|
const getResponse = await fetch(`${baseUrl}/api/v1/storage/config`, {
|
||||||
|
headers: {
|
||||||
|
cookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!getResponse.ok) {
|
||||||
|
throw new Error(`GET_CONFIG_FAILED:${getResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getJson = await getResponse.json();
|
||||||
|
if (!getJson.ok || getJson.config?.mode !== "server_file") {
|
||||||
|
throw new Error("DEFAULT_STORAGE_MODE_INVALID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchPayload = {
|
||||||
|
mode: "server_file",
|
||||||
|
ossProvider: "aliyun_oss",
|
||||||
|
aliyunOss: {
|
||||||
|
enabled: false,
|
||||||
|
accessKeyId: "ak-test",
|
||||||
|
accessKeySecret: "oss-secret-test",
|
||||||
|
bucket: "boss-private-bucket",
|
||||||
|
endpoint: "oss-cn-shanghai.aliyuncs.com",
|
||||||
|
region: "oss-cn-shanghai",
|
||||||
|
prefix: "boss/custom/",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchResponse = await fetch(`${baseUrl}/api/v1/storage/config`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
cookie,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(patchPayload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!patchResponse.ok) {
|
||||||
|
throw new Error(`PATCH_CONFIG_FAILED:${patchResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchJson = await patchResponse.json();
|
||||||
|
if (!patchJson.ok) {
|
||||||
|
throw new Error("PATCH_CONFIG_NOT_OK");
|
||||||
|
}
|
||||||
|
if (patchJson.config?.aliyunOss?.accessKeySecretConfigured !== true) {
|
||||||
|
throw new Error("SECRET_SANITIZE_FLAG_MISSING");
|
||||||
|
}
|
||||||
|
if ("accessKeySecretEncrypted" in (patchJson.config?.aliyunOss ?? {})) {
|
||||||
|
throw new Error("ENCRYPTED_SECRET_LEAKED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rereadResponse = await fetch(`${baseUrl}/api/v1/storage/config`, {
|
||||||
|
headers: {
|
||||||
|
cookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rereadResponse.ok) {
|
||||||
|
throw new Error(`GET_CONFIG_REREAD_FAILED:${rereadResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rereadJson = await rereadResponse.json();
|
||||||
|
if (rereadJson.config?.aliyunOss?.accessKeyId !== "ak-test") {
|
||||||
|
throw new Error("PATCHED_CONFIG_NOT_PERSISTED");
|
||||||
|
}
|
||||||
|
if (rereadJson.config?.aliyunOss?.accessKeySecretConfigured !== true) {
|
||||||
|
throw new Error("SECRET_FLAG_NOT_PERSISTED");
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("OK");
|
||||||
53
src/app/api/v1/storage/config/route.ts
Normal file
53
src/app/api/v1/storage/config/route.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireRequestSession } from "@/lib/boss-auth";
|
||||||
|
import {
|
||||||
|
getAttachmentStorageConfig,
|
||||||
|
upsertAttachmentStorageConfig,
|
||||||
|
} from "@/lib/boss-data";
|
||||||
|
import {
|
||||||
|
type AttachmentStorageConfigPatch,
|
||||||
|
applyAttachmentStorageConfigPatch,
|
||||||
|
} from "@/lib/boss-storage-config";
|
||||||
|
import {
|
||||||
|
normalizeStorageError,
|
||||||
|
sanitizeAttachmentStorageConfig,
|
||||||
|
} from "@/lib/boss-storage";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await requireRequestSession(request);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getAttachmentStorageConfig(session.account);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
config: sanitizeAttachmentStorageConfig(config),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
const session = await requireRequestSession(request);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as AttachmentStorageConfigPatch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await getAttachmentStorageConfig(session.account);
|
||||||
|
const nextConfig = await applyAttachmentStorageConfigPatch(existing, body);
|
||||||
|
const saved = await upsertAttachmentStorageConfig(nextConfig);
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
config: sanitizeAttachmentStorageConfig(saved),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, message: normalizeStorageError(error) },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/api/v1/storage/config/validate/route.ts
Normal file
49
src/app/api/v1/storage/config/validate/route.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { requireRequestSession } from "@/lib/boss-auth";
|
||||||
|
import {
|
||||||
|
getAttachmentStorageConfig,
|
||||||
|
upsertAttachmentStorageConfig,
|
||||||
|
} from "@/lib/boss-data";
|
||||||
|
import {
|
||||||
|
type AttachmentStorageConfigPatch,
|
||||||
|
applyAttachmentStorageConfigPatch,
|
||||||
|
} from "@/lib/boss-storage-config";
|
||||||
|
import {
|
||||||
|
normalizeStorageError,
|
||||||
|
sanitizeAttachmentStorageConfig,
|
||||||
|
validateAttachmentStorageConfig,
|
||||||
|
} from "@/lib/boss-storage";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await requireRequestSession(request);
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => ({}))) as AttachmentStorageConfigPatch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await getAttachmentStorageConfig(session.account);
|
||||||
|
const draft = await applyAttachmentStorageConfigPatch(existing, body);
|
||||||
|
const result = await validateAttachmentStorageConfig(draft);
|
||||||
|
const saved = await upsertAttachmentStorageConfig({
|
||||||
|
...draft,
|
||||||
|
validatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
provider: result.provider,
|
||||||
|
bucket: result.bucket,
|
||||||
|
endpoint: result.endpoint,
|
||||||
|
region: result.region,
|
||||||
|
config: sanitizeAttachmentStorageConfig(saved),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, message: normalizeStorageError(error) },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/lib/boss-storage-aliyun-oss.ts
Normal file
86
src/lib/boss-storage-aliyun-oss.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import path from "node:path";
|
||||||
|
import OSS from "ali-oss";
|
||||||
|
import type { UserAttachmentStorageConfig } from "@/lib/boss-data";
|
||||||
|
import type { AttachmentStorageProvider, StoreAttachmentParams, StoredAttachmentRecord } from "@/lib/boss-storage";
|
||||||
|
import { sanitizeFileName } from "@/lib/boss-attachments";
|
||||||
|
import { decryptStorageSecret } from "@/lib/boss-storage-secrets";
|
||||||
|
|
||||||
|
type AliyunOssConfig = NonNullable<UserAttachmentStorageConfig["aliyunOss"]>;
|
||||||
|
|
||||||
|
function accountStorageSegment(account: string) {
|
||||||
|
const normalized = account.normalize("NFKC").trim();
|
||||||
|
const digest = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
||||||
|
return `acct-${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePrefix(prefix?: string) {
|
||||||
|
const trimmed = (prefix ?? "boss/").trim();
|
||||||
|
const normalized = trimmed.replace(/^\/+|\/+$/g, "");
|
||||||
|
return normalized || "boss";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildObjectKey(params: StoreAttachmentParams, prefix?: string) {
|
||||||
|
const now = new Date();
|
||||||
|
return path.posix.join(
|
||||||
|
normalizePrefix(prefix),
|
||||||
|
accountStorageSegment(params.account),
|
||||||
|
String(now.getUTCFullYear()),
|
||||||
|
String(now.getUTCMonth() + 1).padStart(2, "0"),
|
||||||
|
`${params.messageId}-${sanitizeFileName(params.fileName)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAliyunOssClient(config: AliyunOssConfig) {
|
||||||
|
if (!config.enabled) {
|
||||||
|
throw new Error("ALIYUN_OSS_NOT_ENABLED");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!config.accessKeyId.trim() ||
|
||||||
|
!config.accessKeySecretEncrypted.trim() ||
|
||||||
|
!config.bucket.trim() ||
|
||||||
|
!config.endpoint.trim() ||
|
||||||
|
!config.region.trim()
|
||||||
|
) {
|
||||||
|
throw new Error("ALIYUN_OSS_CONFIG_INCOMPLETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessKeySecret = await decryptStorageSecret(config.accessKeySecretEncrypted);
|
||||||
|
return new OSS({
|
||||||
|
accessKeyId: config.accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
bucket: config.bucket,
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
region: config.region,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAliyunOssStorageProvider(config: AliyunOssConfig): AttachmentStorageProvider {
|
||||||
|
return {
|
||||||
|
backend: "aliyun_oss",
|
||||||
|
async storeAttachment(params: StoreAttachmentParams): Promise<StoredAttachmentRecord> {
|
||||||
|
const client = await createAliyunOssClient(config);
|
||||||
|
const objectKey = buildObjectKey(params, config.prefix);
|
||||||
|
await client.put(objectKey, params.buffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": params.mimeType || "application/octet-stream",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
storageBackend: "aliyun_oss",
|
||||||
|
storagePath: objectKey,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateAliyunOssConfig(config: AliyunOssConfig) {
|
||||||
|
const client = await createAliyunOssClient(config);
|
||||||
|
await client.getBucketInfo(config.bucket);
|
||||||
|
return {
|
||||||
|
provider: "aliyun_oss" as const,
|
||||||
|
bucket: config.bucket,
|
||||||
|
endpoint: config.endpoint,
|
||||||
|
region: config.region,
|
||||||
|
};
|
||||||
|
}
|
||||||
100
src/lib/boss-storage-config.ts
Normal file
100
src/lib/boss-storage-config.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import type { UserAttachmentStorageConfig } from "@/lib/boss-data";
|
||||||
|
import { encryptStorageSecret } from "@/lib/boss-storage-secrets";
|
||||||
|
|
||||||
|
export interface AttachmentStorageConfigPatch {
|
||||||
|
mode?: UserAttachmentStorageConfig["mode"];
|
||||||
|
ossProvider?: UserAttachmentStorageConfig["ossProvider"];
|
||||||
|
aliyunOss?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
accessKeyId?: string;
|
||||||
|
accessKeySecret?: string;
|
||||||
|
bucket?: string;
|
||||||
|
endpoint?: string;
|
||||||
|
region?: string;
|
||||||
|
prefix?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimText(value: string | undefined) {
|
||||||
|
return value?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePrefix(prefix: string | undefined, fallback?: string) {
|
||||||
|
const candidate = prefix?.trim() || fallback?.trim() || "boss/";
|
||||||
|
return candidate || "boss/";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAliyunOssBaseConfig(existing?: UserAttachmentStorageConfig["aliyunOss"]) {
|
||||||
|
return {
|
||||||
|
enabled: existing?.enabled ?? false,
|
||||||
|
accessKeyId: existing?.accessKeyId ?? "",
|
||||||
|
accessKeySecretEncrypted: existing?.accessKeySecretEncrypted ?? "",
|
||||||
|
bucket: existing?.bucket ?? "",
|
||||||
|
endpoint: existing?.endpoint ?? "",
|
||||||
|
region: existing?.region ?? "",
|
||||||
|
prefix: existing?.prefix ?? "boss/",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAttachmentStorageConfigPatch(
|
||||||
|
existing: UserAttachmentStorageConfig,
|
||||||
|
patch: AttachmentStorageConfigPatch,
|
||||||
|
): Promise<UserAttachmentStorageConfig> {
|
||||||
|
const mode = patch.mode ?? existing.mode;
|
||||||
|
const effectiveOssProvider =
|
||||||
|
mode === "oss" ? patch.ossProvider ?? existing.ossProvider ?? "aliyun_oss" : existing.ossProvider;
|
||||||
|
|
||||||
|
const nextAliyun = buildAliyunOssBaseConfig(existing.aliyunOss);
|
||||||
|
if (patch.aliyunOss) {
|
||||||
|
if (patch.aliyunOss.enabled !== undefined) {
|
||||||
|
nextAliyun.enabled = patch.aliyunOss.enabled;
|
||||||
|
}
|
||||||
|
if (patch.aliyunOss.accessKeyId !== undefined) {
|
||||||
|
nextAliyun.accessKeyId = trimText(patch.aliyunOss.accessKeyId);
|
||||||
|
}
|
||||||
|
if (patch.aliyunOss.bucket !== undefined) {
|
||||||
|
nextAliyun.bucket = trimText(patch.aliyunOss.bucket);
|
||||||
|
}
|
||||||
|
if (patch.aliyunOss.endpoint !== undefined) {
|
||||||
|
nextAliyun.endpoint = trimText(patch.aliyunOss.endpoint);
|
||||||
|
}
|
||||||
|
if (patch.aliyunOss.region !== undefined) {
|
||||||
|
nextAliyun.region = trimText(patch.aliyunOss.region);
|
||||||
|
}
|
||||||
|
if (patch.aliyunOss.prefix !== undefined) {
|
||||||
|
nextAliyun.prefix = normalizePrefix(patch.aliyunOss.prefix, nextAliyun.prefix);
|
||||||
|
}
|
||||||
|
if (patch.aliyunOss.accessKeySecret !== undefined) {
|
||||||
|
nextAliyun.accessKeySecretEncrypted = await encryptStorageSecret(
|
||||||
|
patch.aliyunOss.accessKeySecret,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "oss") {
|
||||||
|
if (effectiveOssProvider !== "aliyun_oss") {
|
||||||
|
throw new Error("ALIYUN_OSS_PROVIDER_REQUIRED");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!nextAliyun.accessKeyId ||
|
||||||
|
!nextAliyun.accessKeySecretEncrypted ||
|
||||||
|
!nextAliyun.bucket ||
|
||||||
|
!nextAliyun.endpoint ||
|
||||||
|
!nextAliyun.region
|
||||||
|
) {
|
||||||
|
throw new Error("ALIYUN_OSS_CONFIG_INCOMPLETE");
|
||||||
|
}
|
||||||
|
nextAliyun.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account: existing.account,
|
||||||
|
mode,
|
||||||
|
ossProvider: effectiveOssProvider,
|
||||||
|
aliyunOss: nextAliyun,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
validatedAt: patch.mode !== undefined || patch.ossProvider !== undefined || patch.aliyunOss
|
||||||
|
? undefined
|
||||||
|
: existing.validatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
96
src/lib/boss-storage-secrets.ts
Normal file
96
src/lib/boss-storage-secrets.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const STORAGE_SECRET_KEY_FILE = "storage-secret.key";
|
||||||
|
const STORAGE_SECRET_VERSION = "v1";
|
||||||
|
const STORAGE_SECRET_ALGORITHM = "aes-256-gcm";
|
||||||
|
|
||||||
|
function detectRuntimeRoot(startDir: string) {
|
||||||
|
let current = startDir;
|
||||||
|
while (true) {
|
||||||
|
if (
|
||||||
|
path.basename(current) === "boss" &&
|
||||||
|
path.basename(path.dirname(current)) === "code"
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const parent = path.dirname(current);
|
||||||
|
if (parent === current) {
|
||||||
|
return startDir;
|
||||||
|
}
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRuntimeRoot() {
|
||||||
|
if (process.env.BOSS_RUNTIME_ROOT?.trim()) {
|
||||||
|
return path.resolve(process.env.BOSS_RUNTIME_ROOT);
|
||||||
|
}
|
||||||
|
if (process.env.BOSS_STATE_FILE?.trim()) {
|
||||||
|
return path.dirname(path.dirname(path.resolve(process.env.BOSS_STATE_FILE)));
|
||||||
|
}
|
||||||
|
return detectRuntimeRoot(process.cwd());
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStorageSecretKeyPath() {
|
||||||
|
return path.join(resolveRuntimeRoot(), "data", STORAGE_SECRET_KEY_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveKeyFromSource(source: string) {
|
||||||
|
return createHash("sha256").update(source.normalize("NFKC")).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStorageSecretKey() {
|
||||||
|
if (process.env.BOSS_STORAGE_SECRET_KEY?.trim()) {
|
||||||
|
return deriveKeyFromSource(process.env.BOSS_STORAGE_SECRET_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyPath = resolveStorageSecretKeyPath();
|
||||||
|
try {
|
||||||
|
const existing = await readFile(keyPath, "utf8");
|
||||||
|
return deriveKeyFromSource(existing.trim());
|
||||||
|
} catch {
|
||||||
|
const generated = randomBytes(32).toString("hex");
|
||||||
|
await mkdir(path.dirname(keyPath), { recursive: true });
|
||||||
|
await writeFile(keyPath, `${generated}\n`, { encoding: "utf8", mode: 0o600 });
|
||||||
|
return deriveKeyFromSource(generated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptStorageSecret(plainText: string) {
|
||||||
|
const normalized = plainText.trim();
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error("ALIYUN_OSS_SECRET_REQUIRED");
|
||||||
|
}
|
||||||
|
const key = await getStorageSecretKey();
|
||||||
|
const iv = randomBytes(12);
|
||||||
|
const cipher = createCipheriv(STORAGE_SECRET_ALGORITHM, key, iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(normalized, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return [
|
||||||
|
STORAGE_SECRET_VERSION,
|
||||||
|
iv.toString("hex"),
|
||||||
|
tag.toString("hex"),
|
||||||
|
encrypted.toString("hex"),
|
||||||
|
].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptStorageSecret(cipherText: string) {
|
||||||
|
const [version, ivHex, tagHex, payloadHex] = cipherText.split(":");
|
||||||
|
if (version !== STORAGE_SECRET_VERSION || !ivHex || !tagHex || !payloadHex) {
|
||||||
|
throw new Error("INVALID_STORAGE_SECRET_FORMAT");
|
||||||
|
}
|
||||||
|
const key = await getStorageSecretKey();
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
STORAGE_SECRET_ALGORITHM,
|
||||||
|
key,
|
||||||
|
Buffer.from(ivHex, "hex"),
|
||||||
|
);
|
||||||
|
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(Buffer.from(payloadHex, "hex")),
|
||||||
|
decipher.final(),
|
||||||
|
]);
|
||||||
|
return decrypted.toString("utf8");
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AttachmentStorageBackend, UserAttachmentStorageConfig } from "@/lib/boss-data";
|
import type { AttachmentStorageBackend, UserAttachmentStorageConfig } from "@/lib/boss-data";
|
||||||
|
import { createAliyunOssStorageProvider, validateAliyunOssConfig } from "@/lib/boss-storage-aliyun-oss";
|
||||||
import { storeServerFileAttachment } from "@/lib/boss-storage-server-file";
|
import { storeServerFileAttachment } from "@/lib/boss-storage-server-file";
|
||||||
|
|
||||||
export interface StoreAttachmentParams {
|
export interface StoreAttachmentParams {
|
||||||
@@ -20,6 +21,23 @@ export interface AttachmentStorageProvider {
|
|||||||
storeAttachment(params: StoreAttachmentParams): Promise<StoredAttachmentRecord>;
|
storeAttachment(params: StoreAttachmentParams): Promise<StoredAttachmentRecord>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SanitizedUserAttachmentStorageConfig {
|
||||||
|
account: string;
|
||||||
|
mode: UserAttachmentStorageConfig["mode"];
|
||||||
|
ossProvider?: UserAttachmentStorageConfig["ossProvider"];
|
||||||
|
aliyunOss?: {
|
||||||
|
enabled: boolean;
|
||||||
|
accessKeyId: string;
|
||||||
|
accessKeySecretConfigured: boolean;
|
||||||
|
bucket: string;
|
||||||
|
endpoint: string;
|
||||||
|
region: string;
|
||||||
|
prefix?: string;
|
||||||
|
};
|
||||||
|
updatedAt: string;
|
||||||
|
validatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const serverFileProvider: AttachmentStorageProvider = {
|
const serverFileProvider: AttachmentStorageProvider = {
|
||||||
backend: "server_file",
|
backend: "server_file",
|
||||||
async storeAttachment(params) {
|
async storeAttachment(params) {
|
||||||
@@ -28,11 +46,63 @@ const serverFileProvider: AttachmentStorageProvider = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function getAttachmentStorageProvider(
|
export function getAttachmentStorageProvider(
|
||||||
config: Pick<UserAttachmentStorageConfig, "mode" | "ossProvider">,
|
config: UserAttachmentStorageConfig,
|
||||||
) {
|
) {
|
||||||
if (config.mode === "server_file") {
|
if (config.mode === "server_file") {
|
||||||
return serverFileProvider;
|
return serverFileProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config.mode === "oss" && config.ossProvider === "aliyun_oss" && config.aliyunOss) {
|
||||||
|
return createAliyunOssStorageProvider(config.aliyunOss);
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error("ATTACHMENT_STORAGE_MODE_NOT_SUPPORTED");
|
throw new Error("ATTACHMENT_STORAGE_MODE_NOT_SUPPORTED");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sanitizeAttachmentStorageConfig(
|
||||||
|
config: UserAttachmentStorageConfig,
|
||||||
|
): SanitizedUserAttachmentStorageConfig {
|
||||||
|
return {
|
||||||
|
account: config.account,
|
||||||
|
mode: config.mode,
|
||||||
|
ossProvider: config.ossProvider,
|
||||||
|
aliyunOss: config.aliyunOss
|
||||||
|
? {
|
||||||
|
enabled: config.aliyunOss.enabled,
|
||||||
|
accessKeyId: config.aliyunOss.accessKeyId,
|
||||||
|
accessKeySecretConfigured: Boolean(config.aliyunOss.accessKeySecretEncrypted),
|
||||||
|
bucket: config.aliyunOss.bucket,
|
||||||
|
endpoint: config.aliyunOss.endpoint,
|
||||||
|
region: config.aliyunOss.region,
|
||||||
|
prefix: config.aliyunOss.prefix,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
updatedAt: config.updatedAt,
|
||||||
|
validatedAt: config.validatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStorageError(error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : "UNKNOWN_STORAGE_ERROR";
|
||||||
|
switch (message) {
|
||||||
|
case "ALIYUN_OSS_NOT_ENABLED":
|
||||||
|
return "阿里 OSS 尚未启用。";
|
||||||
|
case "ALIYUN_OSS_CONFIG_INCOMPLETE":
|
||||||
|
return "阿里 OSS 配置不完整,请补齐 AccessKey、Bucket、Endpoint 和 Region。";
|
||||||
|
case "ALIYUN_OSS_SECRET_REQUIRED":
|
||||||
|
return "请填写 AccessKey Secret。";
|
||||||
|
case "ALIYUN_OSS_PROVIDER_REQUIRED":
|
||||||
|
return "当前只支持阿里 OSS,请先选择阿里 OSS。";
|
||||||
|
case "INVALID_STORAGE_SECRET_FORMAT":
|
||||||
|
return "当前 OSS 密钥格式无效,请重新填写 AccessKey Secret。";
|
||||||
|
default:
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateAttachmentStorageConfig(config: UserAttachmentStorageConfig) {
|
||||||
|
if (config.mode !== "oss" || config.ossProvider !== "aliyun_oss" || !config.aliyunOss) {
|
||||||
|
throw new Error("ALIYUN_OSS_NOT_ENABLED");
|
||||||
|
}
|
||||||
|
return validateAliyunOssConfig(config.aliyunOss);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user