feat: add aliyun oss storage config

This commit is contained in:
kris
2026-03-29 16:05:25 +08:00
parent de23a6e921
commit 3307f79162
9 changed files with 1717 additions and 28 deletions

1194
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,13 +19,16 @@
"@capacitor/cli": "^8.2.0",
"@capacitor/core": "^8.2.0",
"@capacitor/preferences": "^8.0.1",
"ali-oss": "^6.23.0",
"clsx": "^2.1.1",
"next": "16.2.1",
"proxy-agent": "^5.0.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/ali-oss": "^6.23.3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View 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");

View 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 },
);
}
}

View 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 },
);
}
}

View 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,
};
}

View 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,
};
}

View 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");
}

View File

@@ -1,4 +1,5 @@
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";
export interface StoreAttachmentParams {
@@ -20,6 +21,23 @@ export interface AttachmentStorageProvider {
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 = {
backend: "server_file",
async storeAttachment(params) {
@@ -28,11 +46,63 @@ const serverFileProvider: AttachmentStorageProvider = {
};
export function getAttachmentStorageProvider(
config: Pick<UserAttachmentStorageConfig, "mode" | "ossProvider">,
config: UserAttachmentStorageConfig,
) {
if (config.mode === "server_file") {
return serverFileProvider;
}
if (config.mode === "oss" && config.ossProvider === "aliyun_oss" && config.aliyunOss) {
return createAliyunOssStorageProvider(config.aliyunOss);
}
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);
}