修复局部编辑合成图保存并覆盖直传原图 #70
@ -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) =>
|
||||
|
||||
@ -35,7 +35,9 @@ async function resolveStoredQiniuImageUrl(source: string): Promise<string | null
|
||||
!url.pathname.startsWith(prefix)) return null;
|
||||
|
||||
const objectKey = url.pathname.slice(prefix.length);
|
||||
return /^uploads\/[\w.-]+$/.test(objectKey) ? url.toString() : null;
|
||||
return /^uploads\/(?:direct\/[0-9a-f-]{36}\/)?[\w.-]+$/i.test(objectKey)
|
||||
? url.toString()
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -3444,11 +3444,13 @@ async function applyLocalEditCompositeToImages(params: {
|
||||
const [originalUpload, thumbnailUpload] = await Promise.all([
|
||||
uploadFile(
|
||||
`canvas-local-edit-composited-${suffix}.png`,
|
||||
bufferToArrayBuffer(mergedBuffer)
|
||||
bufferToArrayBuffer(mergedBuffer),
|
||||
{ preferQiniuForm: true }
|
||||
),
|
||||
uploadFile(
|
||||
`canvas-local-edit-composited-thumb-${suffix}.png`,
|
||||
bufferToArrayBuffer(thumbnailBuffer)
|
||||
bufferToArrayBuffer(thumbnailBuffer),
|
||||
{ preferQiniuForm: true }
|
||||
),
|
||||
]);
|
||||
mergedImages.push({
|
||||
|
||||
@ -3,6 +3,7 @@ import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSA
|
||||
|
||||
import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { put } from "@vercel/blob";
|
||||
import { createHmac } from "node:crypto";
|
||||
import {
|
||||
getDefaultStorageConfig,
|
||||
getStorageConfigById,
|
||||
@ -239,12 +240,48 @@ export async function maybeEnqueueStorageMigrationTask(params: {
|
||||
await maybeEnqueueAliyunMigrationTask(params);
|
||||
}
|
||||
|
||||
async function uploadQiniuForm(
|
||||
config: S3UploadConfig,
|
||||
key: string,
|
||||
buffer: ArrayBuffer,
|
||||
contentType: string
|
||||
) {
|
||||
const encode = (value: Buffer) =>
|
||||
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<UploadedFileResult> {
|
||||
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,
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user