226 lines
7.4 KiB
JavaScript
226 lines
7.4 KiB
JavaScript
// 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();
|
||
}
|
||
});
|