diff --git a/app/api/canvas/local-edit/route.ts b/app/api/canvas/local-edit/route.ts index 48ecd8b3c..e302b7387 100644 --- a/app/api/canvas/local-edit/route.ts +++ b/app/api/canvas/local-edit/route.ts @@ -976,7 +976,8 @@ export async function POST(request: NextRequest) { const annotatedFilename = `canvas-local-edit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`; const annotatedUpload = await uploadFile( annotatedFilename, - toArrayBuffer(annotatedBuffer) + toArrayBuffer(annotatedBuffer), + { preferQiniuForm: true } ); const prompt = buildLocalEditPrompt(regions); const manualRegionReferenceImageUrls = regions.flatMap((region) => diff --git a/app/api/watermark-remove/route.ts b/app/api/watermark-remove/route.ts index 601165d40..c6b0ffbe3 100644 --- a/app/api/watermark-remove/route.ts +++ b/app/api/watermark-remove/route.ts @@ -35,7 +35,9 @@ async function resolveStoredQiniuImageUrl(source: string): Promise + value.toString("base64").replace(/\+/g, "-").replace(/\//g, "_"); + const policy = encode(Buffer.from(JSON.stringify({ + scope: `${config.bucket}:${key}`, + deadline: Math.floor(Date.now() / 1000) + 120, + insertOnly: 1, + }))); + const signature = encode(createHmac("sha1", config.secretAccessKey).update(policy).digest()); + const form = new FormData(); + form.append("token", `${config.accessKeyId}:${signature}:${policy}`); + form.append("key", key); + form.append("file", new Blob([buffer], { type: contentType }), key.split("/").at(-1)); + + const startedAt = Date.now(); + const response = await fetch("https://upload.qiniup.com/", { + method: "POST", + body: form, + redirect: "error", + signal: AbortSignal.timeout(45_000), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`Qiniu form upload returned HTTP ${response.status}`); + } + const result = await response.json() as { key?: string }; + if (result.key !== key) throw new Error("Qiniu form upload returned an unexpected key"); + logger.info("qiniu_form_upload_completed", { bytes: buffer.byteLength, elapsedMs: Date.now() - startedAt }); +} + // S3/MinIO 上传函数(使用 AWS SDK) async function uploadToS3( filename: string, buffer: ArrayBuffer, config: S3UploadConfig, - s3Client = createS3Client(config) + s3Client = createS3Client(config), + preferQiniuForm = false ) { const { endpoint, @@ -260,6 +297,14 @@ async function uploadToS3( const contentType = getContentType(filename); try { + if (preferQiniuForm && config.provider === "qiniu-kodo" && config.region === "cn-east-1") { + try { + await uploadQiniuForm(config, key, buffer, contentType); + return { url: buildS3PublicUrl(config, key), pathname: key, contentType }; + } catch (error) { + logger.warn("[Upload] Qiniu form upload failed, retrying the same object through S3", error); + } + } logger.debug(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`); // 上传文件 @@ -551,7 +596,8 @@ async function uploadWithAnyEnabledStorageConfig( // 上传文件到存储服务 export async function uploadFile( filename: string, - buffer: ArrayBuffer + buffer: ArrayBuffer, + options?: { preferQiniuForm?: boolean } ): Promise { logger.debug(`[Upload] Starting upload for file: ${filename}`); @@ -572,7 +618,7 @@ export async function uploadFile( logger.debug(`[Upload] Using database S3 config: ${selectedStorageConfig?.name}`); logger.debug(`[Upload] Endpoint: ${dbS3Config.endpoint}`); logger.debug(`[Upload] Bucket: ${dbS3Config.bucket}`); - const uploaded = await uploadToS3(filename, buffer, dbS3Config); + const uploaded = await uploadToS3(filename, buffer, dbS3Config, undefined, options?.preferQiniuForm); lastKnownGoodDbS3Config = dbS3Config; await maybeEnqueueAliyunMigrationTask({ uploaded, diff --git a/tests/routes/image-delivery-qiniu.test.ts b/tests/routes/image-delivery-qiniu.test.ts index d1dad76ee..c57094d7d 100644 --- a/tests/routes/image-delivery-qiniu.test.ts +++ b/tests/routes/image-delivery-qiniu.test.ts @@ -7,15 +7,15 @@ import { serverLoader } from "../helpers/canvas-download-server-loader"; import { NextResponse } from "next/server"; test.describe("Qiniu image delivery", () => { - for (const source of [ - "https://cdn.example/media/uploads/source.png", - "https://cdn.example/media/uploads/source.png?token=temporary", - "https://cdn.example/media-other/uploads/source.png", - ]) { + for (const [source, reusesSource] of [ + ["https://cdn.example/media/uploads/source.png", true], + ["https://cdn.example/media/uploads/direct/1a868070-0d5b-4c99-937d-f10d6dc59612/source.png", true], + ["https://cdn.example/media/uploads/source.png?token=temporary", false], + ["https://cdn.example/media-other/uploads/source.png", false], + ] as const) { test(`watermark removal handles ${source} according to its storage origin and signature`, async () => { const storedResult = "https://cdn.example/media/uploads/result.png"; const uploadedInput = "https://cdn.example/media/uploads/staged.png"; - const reusesSource = source === "https://cdn.example/media/uploads/source.png"; const transferred: string[] = []; const uploaded: string[] = []; const saved: string[] = []; @@ -66,6 +66,47 @@ test.describe("Qiniu image delivery", () => { }); } + for (const formWorks of [true, false]) { + test(`local edit storage ${formWorks ? "uses Qiniu form upload" : "reuses the same key on S3 fallback"}`, async () => { + const bytes = new Uint8Array([1, 2, 3]).buffer; + const config = { + id: "fixture", type: "qiniu-kodo", isEnabled: true, region: "cn-east-1", + endpoint: "https://s3.example", publicUrl: "https://cdn.example", + bucket: "images", accessKeyId: "fixture-ak", secretAccessKey: "fixture-sk", + }; + let formKey = ""; + const s3Keys: string[] = []; + const storage = serverLoader({ + "@vercel/blob": {}, + "@aws-sdk/client-s3": { + PutObjectCommand: class { constructor(public input: { Key: string }) {} }, + S3Client: class { + async send(command: { input: { Key: string } }) { s3Keys.push(command.input.Key); } + }, + }, + "@/lib/db/queries": { + getDefaultStorageConfig: async () => config, + getSystemSetting: async () => ({ value: { enabled: false } }), + }, + "@/lib/storage/migration-db": {}, + "@/lib/observability/logger": { createLogger: () => ({ debug() {}, warn() {}, info() {}, error() {} }) }, + fetch: async (url: string, init: RequestInit) => { + expect(url).toBe("https://upload.qiniup.com/"); + const form = init.body as FormData; + formKey = String(form.get("key")); + const token = String(form.get("token")); + const policy = JSON.parse(Buffer.from(token.split(":")[2], "base64url").toString()); + expect(policy).toMatchObject({ scope: `images:${formKey}`, insertOnly: 1 }); + expect((form.get("file") as File).size).toBe(3); + return formWorks ? Response.json({ key: formKey }) : new Response(null, { status: 503 }); + }, + })("lib/storage/service.ts"); + const saved = await storage.uploadFile("local-edit.png", bytes, { preferQiniuForm: true }); + expect(saved).toMatchObject({ pathname: formKey, url: "https://cdn.example/" + formKey }); + expect(s3Keys).toEqual(formWorks ? [] : [formKey]); + }); + } + test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => { const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png"; const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example");