diff --git a/components.d.ts b/components.d.ts index 0b98d93..37e677a 100644 --- a/components.d.ts +++ b/components.d.ts @@ -12,6 +12,7 @@ export {} declare module 'vue' { export interface GlobalComponents { AdminField: typeof import('./src/components/Admin/Field.vue')['default'] + AdminMediaPicker: typeof import('./src/components/Admin/MediaPicker.vue')['default'] Button: typeof import('./src/components/Button.vue')['default'] Footer: typeof import('./src/components/Footer.vue')['default'] Header: typeof import('./src/components/Header.vue')['default'] diff --git a/server/index.mjs b/server/index.mjs index 2a2970d..e3c3db2 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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(); diff --git a/server/media.mjs b/server/media.mjs new file mode 100644 index 0000000..62c3bf2 --- /dev/null +++ b/server/media.mjs @@ -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; +} diff --git a/src/components/Admin/Field.vue b/src/components/Admin/Field.vue index fb8c106..45b5b44 100644 --- a/src/components/Admin/Field.vue +++ b/src/components/Admin/Field.vue @@ -153,9 +153,12 @@ diff --git a/src/composables/useMediaPicker.ts b/src/composables/useMediaPicker.ts new file mode 100644 index 0000000..fd0ee10 --- /dev/null +++ b/src/composables/useMediaPicker.ts @@ -0,0 +1,29 @@ +import { reactive } from "vue"; + +type SelectCallback = (url: string) => void; + +// Admin medya galerisi için modül-seviyesi paylaşılan durum: +// Field.vue picker'ı açar, MediaPicker.vue seçimi geri bildirir. +const state = reactive({ + visible: false, + callback: null as SelectCallback | null, +}); + +export function useMediaPicker() { + return { + state, + open(cb: SelectCallback) { + state.callback = cb; + state.visible = true; + }, + select(url: string) { + state.callback?.(url); + state.callback = null; + state.visible = false; + }, + close() { + state.callback = null; + state.visible = false; + }, + }; +} diff --git a/src/pages/admin/index.vue b/src/pages/admin/index.vue index 6aa5455..e57e11c 100644 --- a/src/pages/admin/index.vue +++ b/src/pages/admin/index.vue @@ -32,7 +32,8 @@ -