Files
home/server.js
dsyoon c21a7b3739 DB 기반 관리자 권한으로 전환
- ncue_user에 is_admin 추가 및 can_manage 호환 유지
- /api/auth/sync 및 관리 API를 DB is_admin 기반으로 변경
- index.html 폴백에서 관리자 이메일 하드코딩 제거
- .env로 관리자 부트스트랩(ADMIN_EMAILS) 지원

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 21:09:02 +09:00

323 lines
12 KiB
JavaScript

import "dotenv/config";
import express from "express";
import helmet from "helmet";
import path from "node:path";
import { fileURLToPath } from "node:url";
import pg from "pg";
import { createRemoteJWKSet, jwtVerify } from "jose";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
function env(name, fallback = "") {
return (process.env[name] ?? fallback).toString();
}
function must(name) {
const v = env(name).trim();
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
function safeIdent(s) {
const v = String(s || "").trim();
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(v)) throw new Error("Invalid TABLE identifier");
return v;
}
function parseCsv(s) {
return String(s || "")
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}
function parseEmailCsv(s) {
return parseCsv(s).map((x) => x.toLowerCase());
}
const PORT = Number(env("PORT", "8000")) || 8000;
const DB_HOST = must("DB_HOST");
const DB_PORT = Number(env("DB_PORT", "5432")) || 5432;
const DB_NAME = must("DB_NAME");
const DB_USER = must("DB_USER");
const DB_PASSWORD = must("DB_PASSWORD");
const TABLE = safeIdent(env("TABLE", "ncue_user") || "ncue_user");
const CONFIG_TABLE = "ncue_app_config";
const CONFIG_TOKEN = env("CONFIG_TOKEN", "").trim();
const ADMIN_EMAILS = new Set(parseEmailCsv(env("ADMIN_EMAILS", "")));
// Auth0 config via .env (preferred)
const AUTH0_DOMAIN = env("AUTH0_DOMAIN", "").trim();
const AUTH0_CLIENT_ID = env("AUTH0_CLIENT_ID", "").trim();
const AUTH0_GOOGLE_CONNECTION = env("AUTH0_GOOGLE_CONNECTION", "").trim();
const pool = new pg.Pool({
host: DB_HOST,
port: DB_PORT,
database: DB_NAME,
user: DB_USER,
password: DB_PASSWORD,
ssl: false,
max: 10,
});
const app = express();
app.use(
helmet({
contentSecurityPolicy: false, // keep simple for static + Auth0
})
);
app.use(express.json({ limit: "256kb" }));
app.get("/healthz", async (_req, res) => {
try {
await pool.query("select 1 as ok");
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false });
}
});
function getBearer(req) {
const h = req.headers.authorization || "";
const m = /^Bearer\s+(.+)$/i.exec(h);
return m ? m[1].trim() : "";
}
async function verifyIdToken(idToken, { issuer, audience }) {
const jwks = createRemoteJWKSet(new URL(`${issuer}.well-known/jwks.json`));
const { payload } = await jwtVerify(idToken, jwks, {
issuer,
audience,
});
return payload;
}
async function ensureUserTable() {
// Create table if missing + add columns for upgrades
await pool.query(`
create table if not exists public.${TABLE} (
sub text primary key,
email text,
name text,
picture text,
provider text,
first_login_at timestamptz,
last_login_at timestamptz,
last_logout_at timestamptz,
is_admin boolean not null default false,
can_manage boolean not null default false,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
)
`);
await pool.query(`create index if not exists idx_${TABLE}_email on public.${TABLE} (email)`);
await pool.query(`alter table public.${TABLE} add column if not exists first_login_at timestamptz`);
await pool.query(`alter table public.${TABLE} add column if not exists last_logout_at timestamptz`);
await pool.query(`alter table public.${TABLE} add column if not exists is_admin boolean not null default false`);
// Backward-compat: previously can_manage was used as admin flag. Preserve existing admins.
await pool.query(`update public.${TABLE} set is_admin = true where can_manage = true and is_admin = false`);
// Keep can_manage consistent with is_admin going forward.
await pool.query(`update public.${TABLE} set can_manage = is_admin where can_manage is distinct from is_admin`);
}
async function ensureConfigTable() {
await pool.query(`
create table if not exists public.${CONFIG_TABLE} (
key text primary key,
value jsonb not null,
updated_at timestamptz not null default now()
)
`);
}
app.post("/api/auth/sync", async (req, res) => {
try {
await ensureUserTable();
const idToken = getBearer(req);
if (!idToken) return res.status(401).json({ ok: false, error: "missing_token" });
const issuer = String(req.headers["x-auth0-issuer"] || "").trim();
const audience = String(req.headers["x-auth0-clientid"] || "").trim();
if (!issuer || !audience) return res.status(400).json({ ok: false, error: "missing_auth0_headers" });
const payload = await verifyIdToken(idToken, { issuer, audience });
const sub = String(payload.sub || "").trim();
const email = payload.email ? String(payload.email).trim().toLowerCase() : null;
const name = payload.name ? String(payload.name).trim() : null;
const picture = payload.picture ? String(payload.picture).trim() : null;
const provider = sub.includes("|") ? sub.split("|", 1)[0] : null;
if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" });
const bootstrapAdmin = Boolean(email && ADMIN_EMAILS.has(email));
const q = `
insert into public.${TABLE}
(sub, email, name, picture, provider, first_login_at, last_login_at, is_admin, can_manage, updated_at)
values
($1, $2, $3, $4, $5, now(), now(), $6, $6, now())
on conflict (sub) do update set
email = excluded.email,
name = excluded.name,
picture = excluded.picture,
provider = excluded.provider,
first_login_at = coalesce(public.${TABLE}.first_login_at, excluded.first_login_at),
last_login_at = now(),
is_admin = (public.${TABLE}.is_admin or public.${TABLE}.can_manage or $6),
can_manage = (public.${TABLE}.is_admin or public.${TABLE}.can_manage or $6),
updated_at = now()
returning is_admin, can_manage, first_login_at, last_login_at, last_logout_at
`;
const r = await pool.query(q, [sub, email, name, picture, provider, bootstrapAdmin]);
const isAdmin = Boolean(r.rows?.[0]?.is_admin);
res.json({ ok: true, canManage: isAdmin, user: r.rows?.[0] || null });
} catch (e) {
res.status(401).json({ ok: false, error: "verify_failed" });
}
});
app.post("/api/auth/logout", async (req, res) => {
try {
await ensureUserTable();
const idToken = getBearer(req);
if (!idToken) return res.status(401).json({ ok: false, error: "missing_token" });
const issuer = String(req.headers["x-auth0-issuer"] || "").trim();
const audience = String(req.headers["x-auth0-clientid"] || "").trim();
if (!issuer || !audience) return res.status(400).json({ ok: false, error: "missing_auth0_headers" });
const payload = await verifyIdToken(idToken, { issuer, audience });
const sub = String(payload.sub || "").trim();
if (!sub) return res.status(400).json({ ok: false, error: "missing_sub" });
const q = `
update public.${TABLE}
set last_logout_at = now(),
updated_at = now()
where sub = $1
returning last_logout_at
`;
const r = await pool.query(q, [sub]);
res.json({ ok: true, last_logout_at: r.rows?.[0]?.last_logout_at || null });
} catch (e) {
res.status(401).json({ ok: false, error: "verify_failed" });
}
});
// Shared auth config for all browsers (read-only public)
app.get("/api/config/auth", async (_req, res) => {
try {
// Prefer .env config (no UI needed)
if (AUTH0_DOMAIN && AUTH0_CLIENT_ID && AUTH0_GOOGLE_CONNECTION) {
return res.json({
ok: true,
value: {
auth0: { domain: AUTH0_DOMAIN, clientId: AUTH0_CLIENT_ID },
connections: { google: AUTH0_GOOGLE_CONNECTION },
// Deprecated: admin is stored per-user in DB (ncue_user.is_admin)
adminEmails: [],
},
updated_at: null,
source: "env",
});
}
await ensureConfigTable();
const r = await pool.query(`select value, updated_at from public.${CONFIG_TABLE} where key = $1`, ["auth"]);
if (!r.rows?.length) return res.status(404).json({ ok: false, error: "not_set" });
const v = r.rows[0].value || {};
// legacy: allowedEmails -> adminEmails
if (v && typeof v === "object" && !v.adminEmails && Array.isArray(v.allowedEmails)) {
v.adminEmails = v.allowedEmails;
}
res.json({ ok: true, value: v, updated_at: r.rows[0].updated_at, source: "db" });
} catch (e) {
res.status(500).json({ ok: false, error: "server_error" });
}
});
// Write auth config (protected by CONFIG_TOKEN)
app.post("/api/config/auth", async (req, res) => {
try {
await ensureConfigTable();
if (!CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "config_token_not_set" });
const token = String(req.headers["x-config-token"] || "").trim();
if (token !== CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "forbidden" });
const body = req.body && typeof req.body === "object" ? req.body : {};
const auth0 = body.auth0 && typeof body.auth0 === "object" ? body.auth0 : {};
const connections = body.connections && typeof body.connections === "object" ? body.connections : {};
// legacy: allowedEmails -> adminEmails
const adminEmails = Array.isArray(body.adminEmails)
? body.adminEmails
: Array.isArray(body.allowedEmails)
? body.allowedEmails
: [];
const domain = String(auth0.domain || "").trim();
const clientId = String(auth0.clientId || "").trim();
const googleConn = String(connections.google || "").trim();
const emails = adminEmails.map((x) => String(x).trim().toLowerCase()).filter(Boolean);
if (!domain || !clientId || !googleConn) {
return res.status(400).json({ ok: false, error: "missing_fields" });
}
const value = {
auth0: { domain, clientId },
connections: { google: googleConn },
adminEmails: emails,
};
await pool.query(
`insert into public.${CONFIG_TABLE} (key, value, updated_at)
values ($1, $2::jsonb, now())
on conflict (key) do update set value = excluded.value, updated_at = now()`,
["auth", JSON.stringify(value)]
);
res.json({ ok: true });
} catch (e) {
res.status(500).json({ ok: false, error: "server_error" });
}
});
// CONFIG_TOKEN-protected: set per-user admin flag in DB
app.post("/api/admin/users/set_admin", async (req, res) => {
try {
await ensureUserTable();
if (!CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "config_token_not_set" });
const token = String(req.headers["x-config-token"] || "").trim();
if (token !== CONFIG_TOKEN) return res.status(403).json({ ok: false, error: "forbidden" });
const body = req.body && typeof req.body === "object" ? req.body : {};
const sub = String(body.sub || "").trim();
const email = String(body.email || "").trim().toLowerCase();
const isAdmin = typeof body.isAdmin === "boolean" ? body.isAdmin : typeof body.admin === "boolean" ? body.admin : null;
if (typeof isAdmin !== "boolean") return res.status(400).json({ ok: false, error: "missing_isAdmin" });
if (!sub && !email) return res.status(400).json({ ok: false, error: "missing_identifier" });
const q = sub
? `update public.${TABLE} set is_admin = $1, can_manage = $1, updated_at = now() where sub = $2 returning sub, email, is_admin`
: `update public.${TABLE} set is_admin = $1, can_manage = $1, updated_at = now() where email = $2 returning sub, email, is_admin`;
const r = await pool.query(q, [isAdmin, sub ? sub : email]);
if (!r.rows?.length) return res.status(404).json({ ok: false, error: "not_found" });
res.json({ ok: true, user: r.rows[0] });
} catch {
res.status(500).json({ ok: false, error: "server_error" });
}
});
// Serve static site
app.use(express.static(__dirname, { extensions: ["html"] }));
app.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`listening on http://localhost:${PORT}`);
});