// server/index.mjs — Admin API for src/data content stored in Postgres import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const envPath = path.resolve(__dirname, "../.env"); if (fs.existsSync(envPath)) process.loadEnvFile(envPath); const { default: express } = await import("express"); const { pool, ensureSchema, seedFromFiles, exportToFiles, isValidSlug } = await import("./db.mjs"); 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; if (!process.env.DATABASE_URL) { console.error("[admin] DATABASE_URL missing in .env"); process.exit(1); } if (!ADMIN_PASSWORD) { console.error("[admin] ADMIN_PASSWORD missing in .env"); process.exit(1); } // In-memory session tokens (dev-grade auth; fine for local admin use) const sessions = new Set(); const app = express(); app.use(express.json({ limit: "5mb" })); function requireAuth(req, res, next) { const token = (req.headers.authorization || "").replace(/^Bearer\s+/i, ""); if (!token || !sessions.has(token)) { return res.status(401).json({ error: "Unauthorized" }); } next(); } app.post("/api/admin/login", (req, res) => { const password = String(req.body?.password ?? ""); const ok = password.length === ADMIN_PASSWORD.length && crypto.timingSafeEqual(Buffer.from(password), Buffer.from(ADMIN_PASSWORD)); if (!ok) return res.status(401).json({ error: "Invalid password" }); const token = crypto.randomBytes(32).toString("hex"); sessions.add(token); res.json({ token }); }); app.post("/api/admin/logout", requireAuth, (req, res) => { const token = (req.headers.authorization || "").replace(/^Bearer\s+/i, ""); sessions.delete(token); res.json({ ok: true }); }); // List datasets app.get("/api/admin/datasets", requireAuth, async (_req, res) => { const { rows } = await pool.query( `SELECT page, locale, updated_at, pg_column_size(content) AS bytes, jsonb_typeof(content) AS type FROM page_data ORDER BY page, locale`, ); res.json(rows); }); // Get one dataset app.get("/api/admin/datasets/:page/:locale", requireAuth, async (req, res) => { const { page, locale } = req.params; if (!isValidSlug(page) || !isValidSlug(locale)) { return res.status(400).json({ error: "Invalid page/locale" }); } const { rows } = await pool.query( "SELECT page, locale, content, updated_at FROM page_data WHERE page=$1 AND locale=$2", [page, locale], ); if (!rows.length) return res.status(404).json({ error: "Not found" }); res.json(rows[0]); }); // Create/update a dataset app.put("/api/admin/datasets/:page/:locale", requireAuth, async (req, res) => { const { page, locale } = req.params; if (!isValidSlug(page) || !isValidSlug(locale)) { return res.status(400).json({ error: "Invalid page/locale" }); } const content = req.body?.content; if (content === undefined) { return res.status(400).json({ error: "Missing content" }); } const { rows } = await pool.query( `INSERT INTO page_data (page, locale, content, updated_at) VALUES ($1, $2, $3, now()) ON CONFLICT (page, locale) DO UPDATE SET content = EXCLUDED.content, updated_at = now() RETURNING page, locale, updated_at`, [page, locale, JSON.stringify(content)], ); res.json(rows[0]); }); // Delete a dataset app.delete( "/api/admin/datasets/:page/:locale", requireAuth, async (req, res) => { const { page, locale } = req.params; if (!isValidSlug(page) || !isValidSlug(locale)) { return res.status(400).json({ error: "Invalid page/locale" }); } const r = await pool.query( "DELETE FROM page_data WHERE page=$1 AND locale=$2", [page, locale], ); res.json({ deleted: r.rowCount }); }, ); // DB -> src/data JSON files (so vite dev / SSG build picks up changes) app.post("/api/admin/export", requireAuth, async (_req, res) => { const written = await exportToFiles(); res.json({ written }); }); // src/data JSON files -> DB (overwrites DB rows) app.post("/api/admin/import", requireAuth, async (_req, res) => { const result = await seedFromFiles({ overwrite: true }); 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(); if (!started) return res.status(409).json({ error: "Publish already running" }); res.json({ ok: true }); }); app.get("/api/admin/publish/status", requireAuth, (_req, res) => { res.json(getPublishState()); }); // Serve the built static site (production). API routes above take precedence. app.use( express.static(DIST_DIR, { setHeaders(res, filePath) { // Hashed assets can be cached hard; HTML must always revalidate if (/\.(js|css|woff2?|ttf|webp|jpg|jpeg|png|svg|gif|ico)$/.test(filePath)) { res.setHeader("Cache-Control", "public, max-age=31536000, immutable"); } else { res.setHeader("Cache-Control", "no-cache"); } }, }), ); // SPA fallback for client-only routes (/admin) and unknown paths app.use((req, res, next) => { if (req.method !== "GET" || req.path.startsWith("/api")) return next(); const index = path.join(DIST_DIR, "index.html"); if (!fs.existsSync(index)) return next(); res.setHeader("Cache-Control", "no-cache"); res.sendFile(index); }); app.use((err, _req, res, _next) => { console.error("[admin]", err); res.status(500).json({ error: err.message || "Server error" }); }); await ensureSchema(); const seeded = await seedFromFiles({ overwrite: false }); console.log( `[admin] schema ready, seed: ${seeded.written}/${seeded.total} new rows`, ); app.listen(PORT, () => { console.log(`[admin] API listening on http://localhost:${PORT}`); // In production the image ships without content-fresh dist; rebuild from DB on boot if (process.env.PUBLISH_ON_BOOT === "true") { console.log("[admin] PUBLISH_ON_BOOT=true — publishing site from DB..."); startPublish(); } });