Files

226 lines
7.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}
});