mirror of
https://github.com/Anil-matcha/Open-Generative-AI.git
synced 2026-05-07 01:17:18 +00:00
Until the team has MuAPI credits we cannot upload trainer/studio
images to MuAPI's CDN, which previously caused POST /api/trainers
to fail outright with a 502. Now the route always saves a local
copy first and only attempts MuAPI as an enhancement.
Behaviour change
----------------
- Trainer/studio rows are created even without a MuAPI key. The
imageUrl points at a new /api/uploads/<kind>/<filename> route
that streams the file out of /data/uploads/.
- If MuAPI is reachable (key present + upload succeeds) we still
prefer its CDN URL, identical to before for the happy-path team
who already has credits.
- The slice-4 worker will need to detect local-only imageUrls and
re-upload to MuAPI at job-submission time. Tracked, not in this PR.
Files
-----
- lib/localUploadStore.js: add readLocal() and publicUrlFor() so the
serving route and the upload route share the same naming scheme.
Path-traversal guard on the read side (no '/', '..', '\' in name).
- app/api/uploads/[kind]/[name]/route.js: GET serves the bytes with
the right Content-Type and a 1h private cache header. Whitelists
kind to {trainers, studios}.
- app/api/trainers/route.js, app/api/studios/route.js: rewritten so
the create flow is "DB row -> local backup -> optional MuAPI". On
total failure (both MuAPI and local) we delete the row to avoid
orphans. The response now also surfaces a `muapiNote` string so
the UI can hint at "no credits yet".
59 lines
1.9 KiB
JavaScript
59 lines
1.9 KiB
JavaScript
import { mkdir, readFile, writeFile, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
|
|
export const ROOT = process.env.UPLOAD_DIR || '/data/uploads';
|
|
|
|
export async function saveLocalBackup(kind, id, file) {
|
|
try {
|
|
const dir = path.join(ROOT, kind);
|
|
await mkdir(dir, { recursive: true });
|
|
const ext = inferExtension(file);
|
|
const fileName = `${id}${ext}`;
|
|
const target = path.join(dir, fileName);
|
|
const buf = Buffer.from(await file.arrayBuffer());
|
|
await writeFile(target, buf);
|
|
return { localPath: target, fileName };
|
|
} catch (err) {
|
|
console.warn(`[localUploadStore] backup write skipped (${kind}/${id}):`, err.message);
|
|
return { localPath: null, fileName: null };
|
|
}
|
|
}
|
|
|
|
export async function readLocal(kind, fileName) {
|
|
// Reject path traversal — only accept simple names.
|
|
if (!fileName || fileName.includes('/') || fileName.includes('..') || fileName.includes('\\')) {
|
|
return null;
|
|
}
|
|
const target = path.join(ROOT, kind, fileName);
|
|
try {
|
|
await stat(target);
|
|
const buf = await readFile(target);
|
|
return { buf, contentType: contentTypeFor(fileName) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function publicUrlFor(kind, fileName) {
|
|
return `/api/uploads/${kind}/${encodeURIComponent(fileName)}`;
|
|
}
|
|
|
|
function inferExtension(file) {
|
|
const name = file?.name || '';
|
|
const dot = name.lastIndexOf('.');
|
|
if (dot >= 0) return name.slice(dot).toLowerCase();
|
|
const type = file?.type || '';
|
|
if (type.includes('png')) return '.png';
|
|
if (type.includes('webp')) return '.webp';
|
|
if (type.includes('jpeg') || type.includes('jpg')) return '.jpg';
|
|
return '.bin';
|
|
}
|
|
|
|
function contentTypeFor(fileName) {
|
|
const lower = fileName.toLowerCase();
|
|
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
|
|
if (lower.endsWith('.png')) return 'image/png';
|
|
if (lower.endsWith('.webp')) return 'image/webp';
|
|
if (lower.endsWith('.gif')) return 'image/gif';
|
|
return 'application/octet-stream';
|
|
}
|