feat: Openinary medya galerisi — görsel yükleme/seçme (mucomutfak klasörü)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AyrisAI
2026-07-21 10:06:59 +03:00
co-authored by Claude Fable 5
parent 8974cb3e1e
commit b3a3a4b450
7 changed files with 414 additions and 2 deletions
+37
View File
@@ -14,6 +14,8 @@ const { pool, ensureSchema, seedFromFiles, exportToFiles, isValidSlug } =
const { startPublish, getPublishState, DIST_DIR } = await import(
"./publish.mjs"
);
const { listMedia, proxyUpload, deleteMedia, mediaEnabled, MEDIA_ROOT } =
await import("./media.mjs");
const PORT = Number(process.env.ADMIN_PORT || 3001);
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
@@ -133,6 +135,41 @@ app.post("/api/admin/import", requireAuth, async (_req, res) => {
res.json(result);
});
// ---- Medya (Openinary proxy, mucomutfak klasörü) ----
app.get("/api/admin/media", requireAuth, async (req, res) => {
if (!mediaEnabled())
return res.status(503).json({ error: "Medya servisi yapılandırılmamış" });
try {
res.json(await listMedia(String(req.query.path || MEDIA_ROOT)));
} catch (e) {
res.status(e.status || 500).json({ error: e.message });
}
});
// Multipart body Openinary'ye stream edilir; express.json bu content-type'a dokunmaz
app.post("/api/admin/media/upload", requireAuth, async (req, res) => {
if (!mediaEnabled())
return res.status(503).json({ error: "Medya servisi yapılandırılmamış" });
try {
const { status, body } = await proxyUpload(req);
res.status(status).json(body);
} catch (e) {
res.status(502).json({ error: e.message });
}
});
app.delete("/api/admin/media", requireAuth, async (req, res) => {
if (!mediaEnabled())
return res.status(503).json({ error: "Medya servisi yapılandırılmamış" });
try {
await deleteMedia(String(req.query.path || ""));
res.json({ ok: true });
} catch (e) {
res.status(e.status || 500).json({ error: e.message });
}
});
// Publish: DB -> JSON -> vite-ssg build -> dist swap (runs in background)
app.post("/api/admin/publish", requireAuth, (_req, res) => {
const started = startPublish();
+105
View File
@@ -0,0 +1,105 @@
// server/media.mjs — Openinary (media.ayris.tech) proxy, "mucomutfak" klasörüne kilitli
const BASE = () => process.env.OPENINARY_API_URL?.replace(/\/$/, "");
const KEY = () => process.env.OPENINARY_API_KEY;
export const MEDIA_ROOT = "mucomutfak";
export function mediaEnabled() {
return Boolean(BASE() && KEY());
}
function assertInsideRoot(p) {
const clean = String(p || "").replace(/^\/+|\/+$/g, "");
if (
clean !== MEDIA_ROOT &&
!clean.startsWith(`${MEDIA_ROOT}/`)
) {
throw Object.assign(new Error(`Yol '${MEDIA_ROOT}' klasörü içinde olmalı`), {
status: 400,
});
}
if (clean.includes("..")) {
throw Object.assign(new Error("Geçersiz yol"), { status: 400 });
}
return clean;
}
function deliveryUrl(path, transform = "") {
const t = transform ? `${transform}/` : "";
return `${BASE()}/t/${t}${path}`;
}
// Klasör içeriğini listele
export async function listMedia(path = MEDIA_ROOT) {
const clean = assertInsideRoot(path);
const res = await fetch(
`${BASE()}/api/storage?path=${encodeURIComponent(clean)}`,
{ headers: { Authorization: `Bearer ${KEY()}` } },
);
if (!res.ok) {
throw Object.assign(new Error(`Openinary listeleme hatası (${res.status})`), {
status: 502,
});
}
const data = await res.json();
const IMG_RE = /\.(webp|jpe?g|png|gif|svg|avif|heic|heif)$/i;
return {
path: clean,
folders: (data.folders || []).map((f) => ({ name: f.name, path: f.path })),
files: (data.files || [])
.filter((f) => IMG_RE.test(f.name))
.map((f) => ({
name: f.name,
path: f.path,
size: f.size,
mtime: f.mtime,
url: deliveryUrl(f.path),
thumb: deliveryUrl(f.path, "w_240,h_240,c_fill"),
})),
};
}
// Multipart isteği olduğu gibi Openinary'ye aktar (folder alanı client FormData'sında)
export async function proxyUpload(req) {
const res = await fetch(`${BASE()}/api/upload`, {
method: "POST",
headers: {
Authorization: `Bearer ${KEY()}`,
"content-type": req.headers["content-type"],
...(req.headers["content-length"]
? { "content-length": req.headers["content-length"] }
: {}),
},
body: req,
duplex: "half",
});
const body = await res.json().catch(() => ({}));
if (body?.files) {
for (const f of body.files) {
if (f.path) {
f.url = deliveryUrl(f.path);
f.thumb = deliveryUrl(f.path, "w_240,h_240,c_fill");
}
}
}
return { status: res.status, body };
}
export async function deleteMedia(path) {
const clean = assertInsideRoot(path);
if (clean === MEDIA_ROOT) {
throw Object.assign(new Error("Kök klasör silinemez"), { status: 400 });
}
const res = await fetch(
`${BASE()}/api/storage/${clean
.split("/")
.map(encodeURIComponent)
.join("/")}`,
{ method: "DELETE", headers: { Authorization: `Bearer ${KEY()}` } },
);
if (!res.ok) {
throw Object.assign(new Error(`Silme hatası (${res.status})`), {
status: 502,
});
}
return true;
}