Harden public attack surface
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-05-23 11:36:02 +08:00
parent 2070cae477
commit c1b03278ca
43 changed files with 83 additions and 16098 deletions

1
.gitignore vendored
View File

@ -63,6 +63,7 @@ migration-*.txt
# 本地抓包/竞品参考产物,不能提交
artifacts/liblib-*/
analysis/qiaodou-extract/
# 模型文件
*.model

View File

@ -1,181 +0,0 @@
# 桥豆麻衣酱 App 近似源码提取说明
## 结论
已从安装包 `/Applications/桥豆麻衣酱.app` 的可执行文件中成功提取出前端静态资源。
这不是开发者原始仓库源码,而是:
- Tauri 桌面壳里的前端打包产物
- 可直接检索的 JS/CSS/worker/wasm 资源
- 足够用于做接口、权限、业务逻辑、模型支持范围的审计
当前共提取出 `29` 个资源,清单见:
- `manifest.json`
## 目录
- `app.arm64`
- 从通用 Mach-O 中切出来的 arm64 可执行文件
- `extract_tauri_assets.js`
- 本次用于提取内嵌资源的脚本
- `manifest.json`
- 每个资源的偏移、压缩大小、解压大小、输出路径
- `extracted/assets/`
- 解压出的前端资源
## 最重要的文件
- `extracted/assets/index-m-NWkaV-.js`
- 主前端 bundle,约 `4.0 MB`
- React + Vite 风格打包产物
- 绝大多数业务逻辑都在这里
- `extracted/assets/index-CgHmA0qR.css`
- 主样式文件,约 `182 KB`
- `extracted/assets/thumbnailWorker-Iu1AjlwR.js`
- 缩略图 worker,负责图片缩放和 JPEG 缩略图生成
- `extracted/assets/ort.bundle.min-OfoG_cy9.mjs`
- ONNX Runtime Web bundle
- `extracted/assets/ort.webgpu.bundle.min-CEayb2S6.mjs`
- ONNX Runtime WebGPU bundle
- `extracted/assets/ort-wasm-simd-threaded.jsep-D5Jk56-t.wasm`
- ONNX Runtime wasm 二进制,约 `23.9 MB`
## 从 bundle 中确认到的内容
### 1. 这确实是 Tauri 桌面应用
在主 bundle 中能直接看到 Tauri 调用痕迹和桌面特有命令,比如:
- `download_file_bypass_cors`
- `proxy_fetch`
- `start_local_server`
可在主文件里直接搜这些关键字。
### 2. 它不是纯聊天壳,而是带图像/视频工作流能力
主 bundle 中能搜到很多工作流节点和媒体能力相关关键词,例如:
- `gen-image`
- `gen-video`
- `storyboard-node`
- `inpaint-crop`
- `motion-control`
- `custom-agent`
- `local-save`
这说明它有比较完整的图像、视频、分镜、局部重绘、本地保存等功能。
### 3. 默认端点和多供应商能力都在前端 bundle 里
主 bundle 中可见默认端点配置:
- `https://ai.comfly.chat`
- `https://ai.t8star.cn`
- `https://api.midjourney.com`
还可以看到对多个供应商和模型系的支持痕迹,例如:
- `openai`
- `gemini`
- `deepseek`
- `doubao`
- `jimeng`
- `kling`
- `vidu`
- `zhipu`
- `moonshot`
- `qwen`
- `minimax`
- `midjourney`
- `suno`
### 4. 图片审核地址的默认值写在前端状态初始化里
主 bundle 内能直接看到默认审核地址:
- `http://118.196.112.236:3428/api/moderation/image`
同时它还读取这几个本地存储键:
- `qiaodoumayi_seedance_moderation_url`
- `qiaodoumayi_seedance_moderation_user_id`
- `qiaodoumayi_seedance_moderation_key_hex`
这说明图片审核地址并不是仅服务端下发,也可能由本地配置覆盖。
### 5. 本地保存能力通过本地服务和文件落地配合实现
主 bundle 中可见:
- `start_local_server`
- `qiaodoumayi_download_directory`
这说明它支持启动本地接收器/本地服务,再结合用户设置的下载目录做文件保存。
## 建议优先看的位置
### 主 bundle
文件:
- `extracted/assets/index-m-NWkaV-.js`
建议优先搜索这些词:
- `qiaodoumayi_`
- `seedance`
- `moderation`
- `download_file_bypass_cors`
- `proxy_fetch`
- `start_local_server`
- `runninghub`
- `api.openai.com`
- `generativelanguage.googleapis.com`
- `dashscope.aliyuncs.com`
### 缩略图 worker
文件:
- `extracted/assets/thumbnailWorker-Iu1AjlwR.js`
这个文件很短,适合先看,能快速确认:
- 它会 `fetch` 图片
- 用 `createImageBitmap` 和 `OffscreenCanvas` 做缩放
- 最终转成 JPEG data URL 返回给主线程
## 已确认的几个关键定位点
以下行号来自提取后的 bundle,可作为检索起点:
- `index-m-NWkaV-.js:924`
- `qiaodoumayi_seedance_moderation_url`
- `index-m-NWkaV-.js:1673`
- `download_file_bypass_cors`
- `index-m-NWkaV-.js:2068`
- `start_local_server`
注意:
- 这是打包文件,很多行非常长
- 行号只能作为“进入附近区域”的参考,不等于原始源码位置
## 局限
- 没找到 sourcemap
- 没恢复出原始 React/TypeScript 目录结构
- 变量名、模块边界、注释都已经过打包压缩处理
- 目前恢复的是“近似源码”,不是开发者仓库
## 怎么继续深挖
如果要继续做更细的审计,下一步建议按下面顺序:
1. 以 `index-m-NWkaV-.js` 为主,按 `qiaodoumayi_`、`seedance`、`proxy`、`download` 搜配置和命令入口
2. 把出现的外连域名、接口路径、Tauri 命令整理成一张表
3. 继续从本地存储和日志交叉验证哪些配置在这台机器上真正被启用
4. 对最敏感的几条链路单独追踪:图片审核、本地代理、本地保存、文件下载绕过 CORS

View File

@ -1,181 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const INPUT = "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/index-m-NWkaV-.js";
const OUTPUT_JSON = "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/bundle-intel.json";
const source = fs.readFileSync(INPUT, "utf8");
function unique(values) {
return Array.from(new Set(values));
}
function safeEvalObjectLiteral(literal) {
return vm.runInNewContext(`(${literal})`, Object.create(null), {
timeout: 1000,
});
}
function extractBetween(startMarker, endMarker) {
const start = source.indexOf(startMarker);
if (start < 0) return null;
const from = start + startMarker.length;
const end = source.indexOf(endMarker, from);
if (end < 0) return null;
return source.slice(from, end);
}
function normalizeUrl(url) {
return String(url || "").replace(/[.,;]+$/, "").trim();
}
function isLikelyAppRelevantUrl(url) {
if (!url || url === "http://" || url === "https://") return false;
return !(
url.includes("w3.org/") ||
url.includes("apache.org/") ||
url.includes("reactjs.org/") ||
url.includes("github.com/") ||
url.includes("raw.github.com/") ||
url.includes("developer.mozilla.org/") ||
url.includes("feross.org") ||
url.includes("stuk.github.io/") ||
url.includes("stuartk.com/jszip")
);
}
const storageKeys = unique(
Array.from(source.matchAll(/qiaodoumayi_[A-Za-z0-9_]+/g)).map((match) => match[0])
).sort();
const tauriCommands = unique(
Array.from(source.matchAll(/yn\("([a-zA-Z0-9_:-]+)"/g)).map((match) => match[1])
).sort();
const requireHints = unique(
Array.from(source.matchAll(/\.\.\/[A-Za-z0-9_./-]+/g)).map((match) => match[0])
).sort();
const allUrls = unique(
Array.from(source.matchAll(/https?:\/\/[^\s"'`\\)<>,]+/g)).map((match) => normalizeUrl(match[0]))
).sort();
const appRelevantUrls = allUrls.filter(isLikelyAppRelevantUrl);
const domains = unique(
appRelevantUrls
.map((url) => {
try {
return new URL(url).host;
} catch {
return null;
}
})
.filter(Boolean)
).sort();
let quickConnectConfig = {};
const quickConnectLiteral = extractBetween("DV=", ";function npe");
if (quickConnectLiteral) {
quickConnectConfig = safeEvalObjectLiteral(quickConnectLiteral);
}
let nodeLabelMap = {};
const nodeLabelMatch = source.match(/function Rm\(e\)\{return(\{[\s\S]*?\})\[e\]\|\|"节点"\}/);
if (nodeLabelMatch) {
nodeLabelMap = safeEvalObjectLiteral(nodeLabelMatch[1]);
}
const nodeSizeMap = {};
const nodeSizeMatch = source.match(/function jO\(e\)\{switch\(e\)\{([\s\S]*?)default:return\{w:(\d+),h:(\d+)\}\}\}/);
if (nodeSizeMatch) {
const body = nodeSizeMatch[1];
const defaultSize = { w: Number(nodeSizeMatch[2]), h: Number(nodeSizeMatch[3]) };
nodeSizeMap.default = defaultSize;
const groupedCases = body.matchAll(/((?:case"[^"]+":)+)return\{w:(\d+),h:(\d+)\}/g);
for (const groupedCase of groupedCases) {
const labels = Array.from(groupedCase[1].matchAll(/case"([^"]+)":/g)).map((match) => match[1]);
const size = { w: Number(groupedCase[2]), h: Number(groupedCase[3]) };
for (const label of labels) {
nodeSizeMap[label] = size;
}
}
}
const quickConnectTypes = Object.keys(quickConnectConfig);
const nodeTypes = unique([
...Object.keys(nodeLabelMap),
...Object.keys(nodeSizeMap).filter((key) => key !== "default"),
...quickConnectTypes,
...quickConnectTypes.flatMap((type) =>
(quickConnectConfig[type]?.output || []).map((item) => item.type)
),
]).sort();
const nodeCatalog = nodeTypes.map((type) => ({
type,
label: nodeLabelMap[type] || null,
size: nodeSizeMap[type] || nodeSizeMap.default || null,
quickConnectOutputs: (quickConnectConfig[type]?.output || []).map((item) => ({
type: item.type,
label: item.label,
})),
}));
const indexedDb = {
databaseName: source.includes('"qiaodoumayi_projects"') ? "qiaodoumayi_projects" : null,
version: source.includes('qiaodoumayi_projects",Ioe=2') ? 2 : null,
stores: unique(
Array.from(source.matchAll(/"(project_meta|project_payload|project_assets)"/g)).map(
(match) => match[1]
)
),
};
const snippets = {};
for (const term of [
"qiaodoumayi_seedance_moderation_url",
"download_file_bypass_cors",
"start_local_server",
"qiaodoumayi_projects",
"qiaodoumayi_script_projects",
"qiaodoumayi_saved_skills",
"qiaodoumayi_characters",
"https://ai.comfly.chat/sora/v1/characters",
"https://www.runninghub.cn",
]) {
const index = source.indexOf(term);
snippets[term] =
index >= 0
? {
index,
preview: source.slice(Math.max(0, index - 180), Math.min(source.length, index + 260)),
}
: null;
}
const result = {
input: INPUT,
stats: {
bytes: Buffer.byteLength(source, "utf8"),
lines: source.split("\n").length,
hasSourceMap: /sourceMappingURL/.test(source),
},
storageKeys,
tauriCommands,
requireHints,
urls: {
appRelevant: appRelevantUrls,
all: allUrls,
domains,
},
indexedDb,
quickConnectConfig,
nodeLabelMap,
nodeSizeMap,
nodeCatalog,
snippets,
};
fs.writeFileSync(OUTPUT_JSON, `${JSON.stringify(result, null, 2)}\n`);
console.log(`Wrote ${OUTPUT_JSON}`);

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -1,116 +0,0 @@
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
const INPUT =
"/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/app.arm64";
const OUTPUT_ROOT =
"/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted";
const MANIFEST =
"/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/manifest.json";
const ASSET_REGEX =
/\/assets\/[A-Za-z0-9 _.@/-]+\.(?:mjs|wasm|webp|png|svg|css|js|ico)(?=[^A-Za-z0-9_.@/-]|$)/g;
const INDEX_HTML_REGEX = /\/index\.html/g;
function collectOffsets(text, regex) {
const hits = [];
let match;
while ((match = regex.exec(text)) !== null) {
hits.push({
value: match[0],
offset: match.index,
});
}
return hits;
}
function uniqueByOffset(items) {
const seen = new Set();
return items.filter((item) => {
if (seen.has(item.offset)) {
return false;
}
seen.add(item.offset);
return true;
});
}
function ensureDir(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function main() {
const buffer = fs.readFileSync(INPUT);
const text = buffer.toString("latin1");
const assets = uniqueByOffset(collectOffsets(text, ASSET_REGEX)).sort(
(a, b) => a.offset - b.offset,
);
const indexHtmlHits = uniqueByOffset(collectOffsets(text, INDEX_HTML_REGEX)).sort(
(a, b) => a.offset - b.offset,
);
if (!assets.length) {
throw new Error("No embedded /assets/* entries were found.");
}
const nextBoundaries = [...assets.map((a) => a.offset), ...indexHtmlHits.map((a) => a.offset)]
.sort((a, b) => a - b);
const manifest = [];
for (const asset of assets) {
const contentStart = asset.offset + Buffer.byteLength(asset.value);
const nextOffset = nextBoundaries.find((offset) => offset > asset.offset);
if (!nextOffset) {
manifest.push({
asset: asset.value,
offset: asset.offset,
status: "skipped_no_boundary",
});
continue;
}
const compressed = buffer.subarray(contentStart, nextOffset);
try {
const decompressed = zlib.brotliDecompressSync(compressed);
const outputPath = path.join(OUTPUT_ROOT, asset.value.replace(/^\//, ""));
ensureDir(outputPath);
fs.writeFileSync(outputPath, decompressed);
manifest.push({
asset: asset.value,
offset: asset.offset,
nextOffset,
compressedBytes: compressed.length,
decompressedBytes: decompressed.length,
outputPath,
status: "ok",
});
} catch (error) {
manifest.push({
asset: asset.value,
offset: asset.offset,
nextOffset,
compressedBytes: compressed.length,
status: "decompress_failed",
error: error instanceof Error ? error.message : String(error),
});
}
}
fs.mkdirSync(OUTPUT_ROOT, { recursive: true });
fs.writeFileSync(MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`);
const ok = manifest.filter((item) => item.status === "ok").length;
const failed = manifest.filter((item) => item.status !== "ok").length;
console.log(`Extracted: ${ok}`);
console.log(`Failed: ${failed}`);
console.log(`Manifest: ${MANIFEST}`);
}
main();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@ -1 +0,0 @@
(function(){"use strict";async function h(l,e,n){if(typeof createImageBitmap>"u"||typeof OffscreenCanvas>"u")throw new Error("createImageBitmap or OffscreenCanvas not supported in this worker");const s=await fetch(l);if(!s.ok)throw new Error("Failed to fetch image data");const d=await s.blob(),t=await createImageBitmap(d);try{let a=t.width,r=t.height;if(a>e||r>e){const i=Math.min(e/a,e/r);a=Math.round(a*i),r=Math.round(r*i)}const f=new OffscreenCanvas(a,r),o=f.getContext("2d");if(!o)throw new Error("Failed to get OffscreenCanvas context");o.imageSmoothingEnabled=!0,o.imageSmoothingQuality="high",o.drawImage(t,0,0,a,r);const g=await f.convertToBlob({type:"image/jpeg",quality:n});return await new Promise((i,u)=>{const c=new FileReader;c.onloadend=()=>i(c.result),c.onerror=u,c.readAsDataURL(g)})}finally{t.close()}}self.onmessage=async l=>{const e=l.data;try{if(e.type==="generate"){const n=await h(e.imageData,e.maxSize,e.quality),s={type:"result",id:e.id,thumbnailData:n};self.postMessage(s)}else if(e.type==="batch-generate"&&e.batch){const s=(await Promise.allSettled(e.batch.map(t=>h(t.imageData,t.maxSize,t.quality).then(a=>({id:t.id,thumbnailData:a})).catch(a=>({id:t.id,error:a.message}))))).map((t,a)=>{var r;return t.status==="fulfilled"?t.value:{id:e.batch[a].id,error:((r=t.reason)==null?void 0:r.message)||"Unknown error"}}),d={type:"batch-result",id:e.id,batch:s};self.postMessage(d)}}catch(n){const s={type:e.type==="generate"?"result":"batch-result",id:e.id,thumbnailData:"",error:n instanceof Error?n.message:String(n)};self.postMessage(s)}}})();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@ -1,263 +0,0 @@
[
{
"asset": "/assets/index-m-NWkaV-.js",
"offset": 4359604,
"nextOffset": 5205083,
"compressedBytes": 845454,
"decompressedBytes": 4047131,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/index-m-NWkaV-.js",
"status": "ok"
},
{
"asset": "/assets/suno baise--dMPbHiP.webp",
"offset": 5205083,
"nextOffset": 5535535,
"compressedBytes": 330420,
"decompressedBytes": 5138,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/suno baise--dMPbHiP.webp",
"status": "ok"
},
{
"asset": "/assets/thumbnailWorker-Iu1AjlwR.js",
"offset": 5535535,
"nextOffset": 5536567,
"compressedBytes": 997,
"decompressedBytes": 1572,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/thumbnailWorker-Iu1AjlwR.js",
"status": "ok"
},
{
"asset": "/assets/gemini-color-BRjDr-0b.webp",
"offset": 5536567,
"nextOffset": 5844908,
"compressedBytes": 308307,
"decompressedBytes": 15722,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/gemini-color-BRjDr-0b.webp",
"status": "ok"
},
{
"asset": "/assets/openai-DM5htlBi.webp",
"offset": 5844908,
"nextOffset": 6097573,
"compressedBytes": 252637,
"decompressedBytes": 15628,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/openai-DM5htlBi.webp",
"status": "ok"
},
{
"asset": "/assets/ort-wasm-simd-threaded.jsep-D5Jk56-t.wasm",
"offset": 6097573,
"nextOffset": 10035082,
"compressedBytes": 3937460,
"decompressedBytes": 23914392,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/ort-wasm-simd-threaded.jsep-D5Jk56-t.wasm",
"status": "ok"
},
{
"asset": "/assets/openai bai-C9wdsUvK.webp",
"offset": 10035082,
"nextOffset": 10050199,
"compressedBytes": 15085,
"decompressedBytes": 15756,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/openai bai-C9wdsUvK.webp",
"status": "ok"
},
{
"asset": "/assets/ort.bundle.min-OfoG_cy9.mjs",
"offset": 10050199,
"nextOffset": 10148887,
"compressedBytes": 98653,
"decompressedBytes": 399902,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/ort.bundle.min-OfoG_cy9.mjs",
"status": "ok"
},
{
"asset": "/assets/midjourney bai-4Fw3hs7F.webp",
"offset": 10148887,
"nextOffset": 10213919,
"compressedBytes": 64996,
"decompressedBytes": 15956,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/midjourney bai-4Fw3hs7F.webp",
"status": "ok"
},
{
"asset": "/assets/midjourney-BOG0L-7m.webp",
"offset": 10213919,
"nextOffset": 10388773,
"compressedBytes": 174822,
"decompressedBytes": 15882,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/midjourney-BOG0L-7m.webp",
"status": "ok"
},
{
"asset": "/assets/doubao-color-BfZ9o9VO.webp",
"offset": 10388773,
"nextOffset": 10592561,
"compressedBytes": 203754,
"decompressedBytes": 19890,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/doubao-color-BfZ9o9VO.webp",
"status": "ok"
},
{
"asset": "/assets/qwen-color-ig8k3JUL.webp",
"offset": 10592561,
"nextOffset": 11246775,
"compressedBytes": 654182,
"decompressedBytes": 19066,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/qwen-color-ig8k3JUL.webp",
"status": "ok"
},
{
"asset": "/assets/kling-color-O6Fl0YZh.webp",
"offset": 11246775,
"nextOffset": 11275612,
"compressedBytes": 28804,
"decompressedBytes": 28800,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/kling-color-O6Fl0YZh.webp",
"status": "ok"
},
{
"asset": "/assets/minimax-color-Dx2mFylD.webp",
"offset": 11275612,
"nextOffset": 11486472,
"compressedBytes": 210825,
"decompressedBytes": 13020,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/minimax-color-Dx2mFylD.webp",
"status": "ok"
},
{
"asset": "/assets/index-CgHmA0qR.css",
"offset": 11486472,
"nextOffset": 11511029,
"compressedBytes": 24531,
"decompressedBytes": 182008,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/index-CgHmA0qR.css",
"status": "ok"
},
{
"asset": "/assets/ort.webgpu.bundle.min-CEayb2S6.mjs",
"offset": 11511029,
"nextOffset": 11609722,
"compressedBytes": 98651,
"decompressedBytes": 399923,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/ort.webgpu.bundle.min-CEayb2S6.mjs",
"status": "ok"
},
{
"asset": "/assets/deepseek-color-XniBTJPh.webp",
"offset": 11609722,
"nextOffset": 11871348,
"compressedBytes": 261590,
"decompressedBytes": 20426,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/deepseek-color-XniBTJPh.webp",
"status": "ok"
},
{
"asset": "/assets/suno-Xv1k_XXC.webp",
"offset": 11871348,
"nextOffset": 12859194,
"compressedBytes": 987820,
"decompressedBytes": 4954,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/suno-Xv1k_XXC.webp",
"status": "ok"
},
{
"asset": "/assets/moonshot-Cy56VaSZ.webp",
"offset": 12859194,
"nextOffset": 12869861,
"compressedBytes": 10637,
"decompressedBytes": 11248,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/moonshot-Cy56VaSZ.webp",
"status": "ok"
},
{
"asset": "/assets/grok bai-CiCMAJ0q.webp",
"offset": 12869861,
"nextOffset": 13298951,
"compressedBytes": 429060,
"decompressedBytes": 10538,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/grok bai-CiCMAJ0q.webp",
"status": "ok"
},
{
"asset": "/assets/vidu-color-DfcYl6hz.webp",
"offset": 13298951,
"nextOffset": 14410560,
"compressedBytes": 1111577,
"decompressedBytes": 26920,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/vidu-color-DfcYl6hz.webp",
"status": "ok"
},
{
"asset": "/assets/zhipu-color-PY4a2Lc6.webp",
"offset": 14410560,
"nextOffset": 14563629,
"compressedBytes": 153036,
"decompressedBytes": 22086,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/zhipu-color-PY4a2Lc6.webp",
"status": "ok"
},
{
"asset": "/assets/flux bai-CYYe7fkT.webp",
"offset": 14563629,
"nextOffset": 14571059,
"compressedBytes": 7400,
"decompressedBytes": 8146,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/flux bai-CYYe7fkT.webp",
"status": "ok"
},
{
"asset": "/assets/nanobanana-color-Re8s94pO.webp",
"offset": 14571059,
"nextOffset": 14584320,
"compressedBytes": 13223,
"decompressedBytes": 13366,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/nanobanana-color-Re8s94pO.webp",
"status": "ok"
},
{
"asset": "/assets/jimeng-color-CtBciSlW.webp",
"offset": 14584320,
"nextOffset": 14604596,
"compressedBytes": 20242,
"decompressedBytes": 20274,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/jimeng-color-CtBciSlW.webp",
"status": "ok"
},
{
"asset": "/assets/bytedance-color-CPzTHojG.webp",
"offset": 14604596,
"nextOffset": 14611653,
"compressedBytes": 7020,
"decompressedBytes": 7016,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/bytedance-color-CPzTHojG.webp",
"status": "ok"
},
{
"asset": "/assets/grok-DEnnqgaa.webp",
"offset": 14611653,
"nextOffset": 15025424,
"compressedBytes": 413745,
"decompressedBytes": 10334,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/grok-DEnnqgaa.webp",
"status": "ok"
},
{
"asset": "/assets/flux-CzJA5PDw.webp",
"offset": 15025424,
"nextOffset": 15104855,
"compressedBytes": 79405,
"decompressedBytes": 8072,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/flux-CzJA5PDw.webp",
"status": "ok"
},
{
"asset": "/assets/moonshot baise-CDuFHouK.webp",
"offset": 15104855,
"nextOffset": 15397413,
"compressedBytes": 292522,
"decompressedBytes": 11172,
"outputPath": "/Users/lanmu/Desktop/ai-chatbot-main/analysis/qiaodou-extract/extracted/assets/moonshot baise-CDuFHouK.webp",
"status": "ok"
}
]

View File

@ -1,598 +0,0 @@
# 桥豆麻衣酱 App 复刻导向功能结构报告
## 目标
这份报告不是单纯做逆向记录,而是回答一个更实际的问题:
- 这个 App 到底由哪些核心子系统组成
- 哪些能力最值得优先复刻到我们项目的无限画布
- 我们当前项目已经有什么
- 还缺什么
配套机器可读情报见:
- `bundle-intel.json`
- `README.md`
- `extracted/assets/index-m-NWkaV-.js`
## 一句话结论
“桥豆麻衣酱”不是一个简单的聊天壳,它更像一个:
- `Tauri 桌面壳`
- `无限画布 / 工作流节点编辑器`
- `多供应商图像/视频/角色/分镜生产平台`
- `带本地缓存、本地导出、本地保存、本地代理能力的创作工作台`
从提取到的前端 bundle 看,它的产品结构已经明显超过“聊天 + 生图”阶段,核心是“节点化创作系统”。
## 1. 结构总览
### A. 桌面壳与原生能力
前端 bundle 中直接调用的 Tauri 命令包括:
- `download_file_bypass_cors`
- `proxy_fetch`
- `proxy_fetch_raw`
- `proxy_dashscope_request`
- `start_local_server`
- `open_folder_in_explorer`
- `export_project`
- `import_project`
- `export_categorized_project`
- `import_categorized_project`
- `read_file_as_base64`
- `write_base64_to_file`
- `write_base64_to_files_batch`
- `write_file_chunked`
- `process_large_data_rust`
- `rust_cache_get`
- `rust_cache_set`
- `rust_cache_remove`
这说明它不是“前端页面 + 远程 API”这么简单,而是有明确的桌面工作站属性:
- 本地文件读写
- 本地代理请求
- 本地导入导出
- 本地缓存
- 大数据处理交给 Rust
### B. 项目与持久化层
前端 bundle 里能明确看到一个 IndexedDB 工程库:
- 数据库名:`qiaodoumayi_projects`
- 版本:`2`
- Store:
- `project_meta`
- `project_payload`
- `project_assets`
这点非常关键,它说明这个 App 的工程化不是只靠 localStorage,而是分成了:
- 项目元信息
- 项目完整 payload
- 项目资源资产
这对无限画布非常重要,因为一旦你要支持:
- 多节点
- 分镜
- 图像/视频结果
- 历史恢复
- 复杂项目切换
单纯靠后端对话消息或一个大 JSON 是不够的。
### C. 配置与本地状态层
bundle 中存在大量 `qiaodoumayi_*` 配置键,能看出它的本地状态体系非常丰富。按功能大致分组如下。
#### 基础平台配置
- `qiaodoumayi_api_configs`
- `qiaodoumayi_global_key`
- `qiaodoumayi_global_base_url`
- `qiaodoumayi_active_endpoint`
- `qiaodoumayi_last_used_model`
- `qiaodoumayi_model_settings_memory`
#### 画布与 UI 偏好
- `qiaodoumayi_show_canvas_grid`
- `qiaodoumayi_show_canvas_minimap`
- `qiaodoumayi_show_canvas_snap`
- `qiaodoumayi_canvas_bg_color_light`
- `qiaodoumayi_canvas_bg_color_dark`
- `qiaodoumayi_left_toolbar_collapsed`
- `qiaodoumayi_right_toolbar_collapsed`
- `qiaodoumayi_viewport_size`
- `qiaodoumayi_theme`
- `qiaodoumayi_chat_width`
- `qiaodoumayi_chat_font_size`
#### 项目 / 内容 / 历史
- `qiaodoumayi_projects`
- `qiaodoumayi_project_name`
- `qiaodoumayi_history`
- `qiaodoumayi_content_store`
- `qiaodoumayi_thumbnail_store`
- `qiaodoumayi_chat_sessions`
#### 资源库 / 提示词库 / 技能库
- `qiaodoumayi_prompt_library`
- `qiaodoumayi_agent_prompt_library`
- `qiaodoumayi_saved_skills`
- `qiaodoumayi_rh_app_library`
- `qiaodoumayi_pose_library_panel`
#### 角色 / 剧本 / 视频素材相关
- `qiaodoumayi_characters`
- `qiaodoumayi_seedance_roles`
- `qiaodoumayi_kling_subjects`
- `qiaodoumayi_script_projects`
- `qiaodoumayi_script_current_id`
#### 本地保存与缓存
- `qiaodoumayi_download_directory`
- `qiaodoumayi_saved_folder_history`
- `qiaodoumayi_cache_dir`
- `qiaodoumayi_cache_directory_config`
- `qiaodoumayi_autosave_enabled`
- `qiaodoumayi_autosave_interval_minutes`
#### 审核与特殊服务
- `qiaodoumayi_seedance_moderation_url`
- `qiaodoumayi_seedance_moderation_user_id`
- `qiaodoumayi_seedance_moderation_key_hex`
- `qiaodoumayi_jimeng_super_resolution_url`
- `qiaodoumayi_jimeng_super_resolution_api_key`
结论是:这个 App 有比较完整的“本地工作台态”,不是一个一次性生成器。
## 2. 工作流画布才是核心
### 节点系统规模
从 bundle 中恢复出的节点目录至少有 `34` 个节点类型。它不是简单的“图片节点 + 视频节点”,而是工作流节点系统。
比较关键的一批节点如下:
- `input-image`
- `video-input`
- `text-node`
- `novel-input`
- `video-analyze`
- `storyboard-node`
- `storyboard-chart-node`
- `table-editor-node`
- `gen-image`
- `gen-video`
- `gen-music`
- `motion-control`
- `camera-movement`
- `professional-camera`
- `inpaint-crop`
- `inpaint-stitch`
- `local-save`
- `preview`
- `custom-agent`
- `rh-app`
- `rh-comfy`
- `jimeng-super-resolution`
- `create-character`
- `create-scene`
- `generate-character-image`
- `generate-character-video`
- `generate-scene-image`
- `generate-scene-video`
- `extract-characters-scenes`
- `character-description`
- `scene-description`
- `doodle-canvas`
- `canvas-node`
- `image-compare`
### 节点中文名与默认尺寸
bundle 中还能直接恢复出节点显示名和默认尺寸,说明画布布局系统已经很成熟。
一些典型例子:
- `storyboard-node`:`分镜节点`,默认 `600x500`
- `custom-agent`:`自定义代理`,默认 `500x800`
- `video-analyze`:`视频分析`,默认 `400x500`
- `gen-image`:`生成图片`,默认 `360x340`
- `gen-video`:`生成视频`,默认 `360x340`
- `local-save`:`本地保存`,默认 `320x380`
- `jimeng-super-resolution`:`智能超清`,默认 `320x280`
- `inpaint-crop`:`裁剪局部重绘`,默认 `340x380`
- `doodle-canvas`:`涂鸦画板`,默认 `500x300`
这说明它的节点系统并不是运行时随便拼出来的,而是有预定义的产品级节点目录。
### 快速连接关系
bundle 中还恢复出了一个 `QUICK_CONNECT_CONFIG`,也就是“节点之间下一步建议怎么连”。
几条非常有价值的链路如下:
- `text-node -> gen-image / gen-video / custom-agent`
- `input-image -> gen-image / gen-video / storyboard-node / inpaint-crop`
- `video-input -> preview / rh-app`
- `video-analyze -> storyboard-node / gen-image`
- `table-editor-node -> storyboard-node`
- `storyboard-node -> gen-image / gen-video`
- `storyboard-chart-node -> preview`
- `inpaint-stitch -> preview / local-save`
- `novel-input -> custom-agent / text-node / rh-app`
这基本已经把它的产品思路暴露出来了:
- 文本和图像不是终点,而是工作流入口
- 视频分析可以进入分镜
- 分镜又可以再生图、再生视频
- 最后再进入预览和保存
这正是一个“无限画布 + 创作编排器”的产品结构。
## 3. 它的内容生产链路
### A. 图像/视频生成
从 bundle 中可见大量供应商和模型痕迹:
- `OpenAI`
- `Gemini`
- `DeepSeek`
- `Doubao`
- `Jimeng`
- `Kling`
- `Vidu`
- `Zhipu`
- `Moonshot`
- `Qwen`
- `MiniMax`
- `Midjourney`
- `Suno`
默认端点与内置端点包括:
- `https://ai.comfly.chat`
- `https://ai.t8star.cn`
- `https://api.midjourney.com`
- `https://api.openai.com`
- `https://generativelanguage.googleapis.com`
- `https://dashscope.aliyuncs.com`
这说明它本质上是一个“多后端编排前端”,不是单模型专用 UI。
### B. 角色库与分镜库
bundle 中能明确看到:
- 角色库缓存:`qiaodoumayi_characters`
- 远程角色入口:`https://ai.comfly.chat/sora/v1/characters`
- 剧本工程:`qiaodoumayi_script_projects`
- 剧本当前项目:`qiaodoumayi_script_current_id`
- `storyboard-node`
- `storyboard-chart-node`
- `extract-characters-scenes`
这说明它很重视:
- 角色一致性
- 从视频或文本抽结构
- 从剧本进入画布
- 从画布进入分镜
### C. 视频理解与视频工作流
bundle 中可见 `video-analyze` 节点,以及连接视频输入、关键帧、分镜节点的逻辑痕迹。还能看到:
- `motion-control`
- `camera-movement`
- `professional-camera`
- `generate-character-video`
- `generate-scene-video`
这意味着视频在它这里不是“最终结果播放器”,而是完整工作流的一部分。
### D. 技能 / Agent / RH 应用
bundle 中可见:
- `custom-agent`
- `qiaodoumayi_saved_skills`
- `qiaodoumayi_agent_prompt_library`
- `qiaodoumayi_rh_app_library`
- `runninghub`
- `rh-app`
- `rh-comfy`
而且还能看到对 Skill 市场的介绍性内容,这说明:
- 它不是只有模型参数页
- 它还有“技能 / 应用 / Agent 编排”的产品层
## 4. 它的本地桌面工作站特征
### 本地保存链路
bundle 中能直接看到:
- `start_local_server`
- `qiaodoumayi_download_directory`
- `open_folder_in_explorer`
这说明它把“生成完成后的文件落地”当成产品一等公民,而不是交给浏览器下载框完事。
### 本地代理链路
bundle 中能直接看到:
- `proxy_fetch`
- `proxy_fetch_raw`
- `proxy_dashscope_request`
- `http://127.0.0.1:41600/proxy`
- `http://127.0.0.1:9527`
- `http://127.0.0.1:9528`
- `http://127.0.0.1:9529`
- `http://127.0.0.1:9530`
这说明它桌面端很可能在做:
- 统一代理请求
- 规避前端直连限制
- 衔接本地保存
- 为多供应商请求做本地协调
### 文件导入导出
bundle 中明确有:
- `export_project`
- `import_project`
- `export_categorized_project`
- `import_categorized_project`
这意味着项目不是“只存在于当前设备当前会话”,而是支持工程级交换。
## 5. 审核与风控链路
bundle 与本机配置都表明有一条图片审核配置:
- `qiaodoumayi_seedance_moderation_url`
- 默认值:`http://118.196.112.236:3428/api/moderation/image`
更重要的不是这个地址本身,而是它体现出:
- 它对图像生成/图像上传流程做了专门审核位
- 审核是可配置项,不一定完全写死在服务端
如果后续你要复刻到自己的项目里,这条链路可以先抽象成:
- `preflight moderation hook`
而不是一开始就复刻它的具体实现。
## 6. 和我们当前项目的差距
### 我们已经有的基础
从当前项目代码看,我们已经有一个“可编辑无限画布”的基础框架:
- 画布节点类型定义在 `types/canvas/index.ts`
- 画布状态与节点/边管理在 `store/canvas/index.ts`
- 画布渲染在 `components/canvas/CanvasWorkspace.tsx`
- 画布服务层在 `services/canvas/index.ts`
当前项目已有的节点类型是:
- `message`
- `image`
- `video`
- `text`
- `scene-fusion`
- `model3d`
- `world3d`
并且已经具备一批图片侧能力:
- 上传
- 生图
- 抠图
- 扩图
- 框选抠图
- 文本编辑
- 图层拆分
- 场景融合
- 3D 结果展示
这意味着我们不是从零开始。
### 当前最明显的缺口
和“桥豆麻衣酱”相比,我们现在缺的不是单个 API,而是“工作流层”。
最明显的差距有:
#### 1. 节点目录不够完整
我们现在的 NodeType 还偏“媒体结果节点”,而桥豆那边已经是“生产流程节点”。
缺失最明显的一批:
- `input-image`
- `video-input`
- `video-analyze`
- `storyboard-node`
- `storyboard-chart-node`
- `table-editor-node`
- `custom-agent`
- `local-save`
- `preview`
- `motion-control`
- `camera-movement`
- `professional-camera`
- `create-character`
- `create-scene`
- `generate-character-image`
- `generate-character-video`
- `generate-scene-image`
- `generate-scene-video`
#### 2. 缺少工作流连接语义
我们当前画布有边,但边的语义还很轻,更多像自由连线。
桥豆那边已经存在:
- 节点输入输出能力
- 快速连接建议
- 按节点类型组织的下一步操作
也就是“有图结构”,但我们还没有“有工作流协议”。
#### 3. 缺少工程级项目资产层
我们当前项目侧重后端接口和画布状态保存;桥豆那边明显还有:
- 项目 meta
- 项目 payload
- 项目 assets
- 本地缓存
- 本地缩略图
- 导入导出
这对大项目体验差异会非常明显。
#### 4. 缺少角色/剧本/分镜三套资源库
桥豆明显把这三类做成一级能力:
- 角色库
- 剧本项目
- 分镜节点
而我们现在更像“图像生成画布”,还不是“叙事创作画布”。
## 7. 最值得优先复刻的部分
如果目标是尽快把桥豆的核心能力迁移到我们的无限画布,我建议按下面顺序做。
### 第一阶段:先补工作流骨架
先不要一口气复刻所有节点,先补最核心的工作流层:
- 节点注册表
- 节点默认尺寸
- 节点输入输出 schema
- 快速连接配置
- 节点执行状态机
- 结果节点与中间节点区分
优先节点建议:
- `text-node`
- `input-image`
- `gen-image`
- `gen-video`
- `preview`
- `local-save`
### 第二阶段:补分镜与视频链路
这是桥豆和普通画布产品差异最大的地方:
- `video-input`
- `video-analyze`
- `storyboard-node`
- `storyboard-chart-node`
- `table-editor-node`
有了这批节点,画布就从“结果展示面板”升级成“创作流程面板”。
### 第三阶段:补角色与剧本资产层
这部分能把产品从“工具”拉到“系统”:
- 角色库
- 剧本项目
- 角色一致性生图/生视频
- 场景节点
### 第四阶段:补桌面工作站能力
如果以后你们也走桌面端,这一层再跟进:
- 本地保存目录
- 本地导入导出
- 本地代理
- 本地缓存
Web 版可以先抽象接口,不需要第一天就复刻 Tauri。
## 8. 对我们项目最直接的落地建议
### 建议 1
把当前无限画布从“自由画板节点”升级为“节点注册表驱动”。
也就是新增一个统一的节点目录:
- `type`
- `label`
- `defaultSize`
- `inputPorts`
- `outputPorts`
- `capabilities`
- `quickActions`
### 建议 2
把现有 `image` / `video` 节点保留,但视为“结果节点”,再新增“流程节点”。
这会比强行把所有功能都塞进 `image` 节点更可持续。
### 建议 3
优先把桥豆的 `QUICK_CONNECT_CONFIG` 翻译成我们自己的配置文件。
因为这一步几乎不依赖后端,却能立刻让无限画布的操作逻辑接近桥豆。
### 建议 4
先抽象“审核钩子”“本地保存钩子”“代理请求钩子”,不要一开始绑定具体厂商。
这样后面无论是 Web、桌面版还是服务端代理版,都能接进去。
## 9. 现在已经产出的资产
你现在可以直接继续用下面这些文件:
- `analysis/qiaodou-extract/README.md`
- 提取说明与入口文件
- `analysis/qiaodou-extract/bundle-intel.json`
- 机器可读情报,适合继续做脚本分析
- `analysis/qiaodou-extract/analyze_bundle.js`
- 可重复运行的 bundle 分析脚本
- `analysis/qiaodou-extract/extracted/assets/index-m-NWkaV-.js`
- 主 bundle,适合继续按关键词深挖
## 最终判断
如果你的目标是“把桥豆麻衣酱的核心功能复刻到我们项目的无限画布”,那接下来最该复刻的不是某个单独接口,而是它这三层结构:
- `工作流节点体系`
- `项目/资产持久化体系`
- `角色/分镜/视频分析资源体系`
一旦这三层搭起来,具体接 OpenAI、Gemini、Kling、Jimeng、Vidu 这些后端,反而是后面的工作。

View File

@ -1,10 +1,23 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
export const runtime = "nodejs";
export const maxDuration = 10;
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json(
{
success: false,
data: null,
error: "未授权,请先登录",
},
{ status: 401 }
);
}
const body = await request.json();
// 转发到 Florence-2 后端(本地服务)
@ -31,4 +44,3 @@ export async function POST(request: Request) {
);
}
}

View File

@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { auth } from "@/app/(auth)/auth";
import { uploadFile } from "@/lib/storage/service";
import { callQwen3Vl } from "@/lib/ai/qwen3-vl";
import sharp from "sharp";
@ -33,6 +34,40 @@ interface PickObjectResult {
const MAX_VISION_SIDE = 500;
const PICK_OBJECT_FETCH_TIMEOUT_MS = 15000;
const MAX_PICK_OBJECT_IMAGE_BYTES = 10 * 1024 * 1024;
function isBlockedImageHost(hostname: string) {
const host = hostname.toLowerCase();
if (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host.endsWith(".local") ||
host.endsWith(".internal")
) {
return true;
}
const parts = host.split(".").map((part) => Number(part));
if (parts.length === 4 && parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
const [a, b] = parts;
return (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
);
}
return host.startsWith("fc") || host.startsWith("fd") || host.startsWith("fe80");
}
function ensureImageSizeAllowed(byteLength: number) {
if (byteLength > MAX_PICK_OBJECT_IMAGE_BYTES) {
throw new Error("图片过大,请上传 10MB 以内的图片");
}
}
async function downscaleImageForVision(buffer: Buffer) {
const source = sharp(buffer, { failOn: "none" });
@ -85,6 +120,11 @@ async function normalizeImageInput(imageData: string): Promise<string> {
if (!raw) throw new Error("缺少图片数据");
if (/^https?:\/\//i.test(raw)) {
const url = new URL(raw);
if (!["http:", "https:"].includes(url.protocol) || isBlockedImageHost(url.hostname)) {
throw new Error("图片地址不允许访问");
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), PICK_OBJECT_FETCH_TIMEOUT_MS);
try {
@ -95,7 +135,12 @@ async function normalizeImageInput(imageData: string): Promise<string> {
if (!response.ok) {
throw new Error(`拉取图片失败: HTTP ${response.status}`);
}
const contentLength = Number(response.headers.get("content-length") || "0");
if (contentLength) {
ensureImageSizeAllowed(contentLength);
}
const arrayBuffer = await response.arrayBuffer();
ensureImageSizeAllowed(arrayBuffer.byteLength);
return uploadVisionAnalysisImage(Buffer.from(arrayBuffer), "pick-object-url");
} finally {
clearTimeout(timeout);
@ -108,12 +153,22 @@ async function normalizeImageInput(imageData: string): Promise<string> {
}
const base64Body = base64Match[2];
ensureImageSizeAllowed(Math.ceil((base64Body.length * 3) / 4));
const buffer = Buffer.from(base64Body, "base64");
ensureImageSizeAllowed(buffer.byteLength);
return uploadVisionAnalysisImage(buffer, "pick-object-base64");
}
export async function POST(request: Request) {
try {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({
success: false,
error: "未授权,请先登录",
}, { status: 401 });
}
const body: PickObjectRequest = await request.json();
const { imageData, clickX, clickY } = body;
@ -133,6 +188,13 @@ export async function POST(request: Request) {
error: '缺少点击坐标',
}, { status: 400 });
}
if (clickX < 0 || clickX > 1 || clickY < 0 || clickY > 1) {
return NextResponse.json({
success: false,
error: '点击坐标超出范围',
}, { status: 400 });
}
const normalizedImage = await normalizeImageInput(imageData);

View File

@ -45,7 +45,6 @@ import {
} from "react";
import { toast } from "sonner";
import useSWR from "swr";
import capturedCanvasModules from "@/artifacts/liblib-frontend-capture-ed9a2c12570a49b9a1186017b2c38b52/canvas-modules.json";
import {
getGenerationCreditCostFromImageGenConfig,
getImageCreditCostFromConfig,
@ -505,9 +504,9 @@ type VideoAnalysisState = {
error?: string;
};
const capturedTools = ((capturedCanvasModules as any).tools || []) as CapturedTool[];
const capturedCounts = (capturedCanvasModules as any).counts || {};
const capturedWorkflows = ((capturedCanvasModules as any).workflows || []) as WorkflowPreset[];
const capturedTools: CapturedTool[] = [];
const capturedCounts: { byType?: Record<string, number> } = {};
const capturedWorkflows: WorkflowPreset[] = [];
const TOOL_TYPE_META: Record<
string,

View File

@ -15,6 +15,8 @@ const nextConfig: NextConfig = {
proxyClientMaxBodySize: "500mb", // 代理/中间件请求体限制
},
images: {
dangerouslyAllowLocalIP: false,
contentDispositionType: "attachment",
remotePatterns: [
{
hostname: "avatar.vercel.sh",
@ -61,6 +63,7 @@ const nextConfig: NextConfig = {
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains; preload" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{ key: "Content-Security-Policy", value: csp },

View File

@ -7,6 +7,7 @@
"postinstall": "node scripts/security/auto-enable-perf-guard.mjs",
"prebuild": "node scripts/security/auto-enable-perf-guard.mjs",
"build": "tsx lib/db/migrate && next build",
"postbuild": "find .next/static -type f -name '*.map' -delete 2>/dev/null || true",
"build:skip-prerender": "tsx lib/db/migrate && next build --no-lint || (echo 'Build with errors, creating fallback...' && mkdir -p .next && echo '{}' > .next/prerender-manifest.json && echo 'Build fallback created')",
"start": "next start",
"lint": "npx ultracite@latest check",