AI DEV portal 초기 커밋
Express+EJS+Postgres 사내 개발 포털. - Keycloak OIDC(사번 로그인), GitHub 감성 UI - 사이트 모음 + 프로젝트 공유/코멘트/스타 - DB: OpenEverest appdb portal schema (portal_app role) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
53
src/auth.js
Normal file
53
src/auth.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Keycloak OIDC (Authorization Code). openid-client v5.
|
||||
import { Issuer, generators } from "openid-client";
|
||||
import { config } from "./config.js";
|
||||
|
||||
let client;
|
||||
|
||||
export async function initOidc() {
|
||||
const issuer = await Issuer.discover(config.oidc.issuer);
|
||||
client = new issuer.Client({
|
||||
client_id: config.oidc.clientId,
|
||||
client_secret: config.oidc.clientSecret,
|
||||
redirect_uris: [`${config.baseUrl}/auth/callback`],
|
||||
response_types: ["code"],
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
// 로그인 시작 — state/nonce 를 세션에 저장하고 Keycloak 으로 리다이렉트
|
||||
export function loginRedirect(req, res) {
|
||||
const state = generators.state();
|
||||
const nonce = generators.nonce();
|
||||
req.session.oidc = { state, nonce };
|
||||
const url = client.authorizationUrl({
|
||||
scope: "openid profile email",
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
res.redirect(url);
|
||||
}
|
||||
|
||||
// 콜백 처리 — 토큰 교환 후 사용자 클레임 반환
|
||||
export async function handleCallback(req) {
|
||||
const params = client.callbackParams(req);
|
||||
const { state, nonce } = req.session.oidc || {};
|
||||
const tokenSet = await client.callback(`${config.baseUrl}/auth/callback`, params, {
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
const claims = tokenSet.claims();
|
||||
return {
|
||||
sabun: claims.preferred_username, // 사번
|
||||
name: claims.name || claims.given_name || claims.preferred_username,
|
||||
email: claims.email,
|
||||
};
|
||||
}
|
||||
|
||||
// 로그인 필수 가드
|
||||
export function requireAuth(req, res, next) {
|
||||
if (req.session.user) return next();
|
||||
// API 요청은 401, 페이지 요청은 로그인으로
|
||||
if (req.path.startsWith("/api/")) return res.status(401).json({ error: "unauthorized" });
|
||||
return res.redirect("/login");
|
||||
}
|
||||
18
src/config.js
Normal file
18
src/config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// 설정 — 비밀값은 환경변수에서만. 로컬은 .project-env, 배포는 Kubero Env.
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || "3000", 10),
|
||||
baseUrl: process.env.BASE_URL || "http://localhost:3000",
|
||||
sessionSecret: process.env.SESSION_SECRET || "dev-only-insecure-secret",
|
||||
|
||||
databaseUrl: process.env.DATABASE_URL || "",
|
||||
|
||||
oidc: {
|
||||
issuer: process.env.OIDC_ISSUER || "https://keycloak.bokdev.in/realms/bokdev",
|
||||
clientId: process.env.OIDC_CLIENT_ID || "portal",
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET || "",
|
||||
// redirect 는 baseUrl + /auth/callback 로 구성
|
||||
},
|
||||
|
||||
// 포털 표시 이름 (변경 가능)
|
||||
brand: process.env.PORTAL_BRAND || "AI DEV",
|
||||
};
|
||||
78
src/db.js
Normal file
78
src/db.js
Normal file
@@ -0,0 +1,78 @@
|
||||
// Postgres 풀 (OpenEverest appdb, search_path=portal). DATABASE_URL 로 연결.
|
||||
import pg from "pg";
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const pool = new pg.Pool({ connectionString: config.databaseUrl });
|
||||
|
||||
export const q = (text, params) => pool.query(text, params);
|
||||
|
||||
// 로그인 시 사용자 upsert
|
||||
export async function upsertUser({ sabun, name, email }) {
|
||||
await q(
|
||||
`INSERT INTO users (sabun, name, email, avatar_seed, last_login)
|
||||
VALUES ($1,$2,$3,$1, now())
|
||||
ON CONFLICT (sabun) DO UPDATE
|
||||
SET name=EXCLUDED.name, email=EXCLUDED.email, last_login=now()`,
|
||||
[sabun, name, email || null]
|
||||
);
|
||||
}
|
||||
|
||||
// 프로젝트 목록 (스타 수 + 코멘트 수 + 소유자 이름 포함)
|
||||
export async function listProjects() {
|
||||
const { rows } = await q(
|
||||
`SELECT p.*, u.name AS owner_name,
|
||||
(SELECT count(*) FROM stars s WHERE s.project_id=p.id) AS star_count,
|
||||
(SELECT count(*) FROM comments c WHERE c.project_id=p.id) AS comment_count
|
||||
FROM projects p JOIN users u ON u.sabun=p.owner_sabun
|
||||
ORDER BY p.created_at DESC`
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getProject(id) {
|
||||
const { rows } = await q(
|
||||
`SELECT p.*, u.name AS owner_name,
|
||||
(SELECT count(*) FROM stars s WHERE s.project_id=p.id) AS star_count
|
||||
FROM projects p JOIN users u ON u.sabun=p.owner_sabun WHERE p.id=$1`,
|
||||
[id]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function listComments(projectId) {
|
||||
const { rows } = await q(
|
||||
`SELECT c.*, u.name AS author_name
|
||||
FROM comments c JOIN users u ON u.sabun=c.author_sabun
|
||||
WHERE c.project_id=$1 ORDER BY c.created_at ASC`,
|
||||
[projectId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function addProject({ owner, title, description, repoUrl, appUrl, tags }) {
|
||||
const { rows } = await q(
|
||||
`INSERT INTO projects (owner_sabun, title, description, repo_url, app_url, tags)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
[owner, title, description || "", repoUrl || "", appUrl || "", tags || ""]
|
||||
);
|
||||
return rows[0].id;
|
||||
}
|
||||
|
||||
export async function addComment({ projectId, author, body }) {
|
||||
await q(`INSERT INTO comments (project_id, author_sabun, body) VALUES ($1,$2,$3)`,
|
||||
[projectId, author, body]);
|
||||
}
|
||||
|
||||
export async function toggleStar({ projectId, sabun }) {
|
||||
const { rowCount } = await q(`DELETE FROM stars WHERE project_id=$1 AND sabun=$2`, [projectId, sabun]);
|
||||
if (rowCount === 0) {
|
||||
await q(`INSERT INTO stars (project_id, sabun) VALUES ($1,$2)`, [projectId, sabun]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function isStarred({ projectId, sabun }) {
|
||||
const { rowCount } = await q(`SELECT 1 FROM stars WHERE project_id=$1 AND sabun=$2`, [projectId, sabun]);
|
||||
return rowCount > 0;
|
||||
}
|
||||
116
src/server.js
Normal file
116
src/server.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import express from "express";
|
||||
import session from "express-session";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
import { config } from "./config.js";
|
||||
import { siteGroups } from "./sites.js";
|
||||
import { initOidc, loginRedirect, handleCallback, requireAuth } from "./auth.js";
|
||||
import * as db from "./db.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
|
||||
app.set("view engine", "ejs");
|
||||
app.set("views", path.join(__dirname, "..", "views"));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
app.use("/public", express.static(path.join(__dirname, "..", "public")));
|
||||
app.use(
|
||||
session({
|
||||
secret: config.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: { httpOnly: true, sameSite: "lax", maxAge: 8 * 3600 * 1000 },
|
||||
})
|
||||
);
|
||||
|
||||
// 모든 뷰에 공통 노출
|
||||
app.use((req, res, next) => {
|
||||
res.locals.brand = config.brand;
|
||||
res.locals.user = req.session.user || null;
|
||||
next();
|
||||
});
|
||||
|
||||
app.get("/healthz", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// --- 인증 ---
|
||||
app.get("/login", (req, res) => {
|
||||
if (req.session.user) return res.redirect("/");
|
||||
res.render("login");
|
||||
});
|
||||
app.get("/auth/login", (req, res) => loginRedirect(req, res));
|
||||
app.get("/auth/callback", async (req, res) => {
|
||||
try {
|
||||
const u = await handleCallback(req);
|
||||
await db.upsertUser(u);
|
||||
req.session.user = u;
|
||||
res.redirect("/");
|
||||
} catch (e) {
|
||||
console.error("oidc callback error", e);
|
||||
res.status(500).render("login", { error: "로그인 처리 중 오류가 발생했습니다." });
|
||||
}
|
||||
});
|
||||
app.get("/logout", (req, res) => {
|
||||
req.session.destroy(() => res.redirect("/login"));
|
||||
});
|
||||
|
||||
// --- 대시보드 (로그인 필요) ---
|
||||
app.get("/", requireAuth, async (req, res) => {
|
||||
const projects = await db.listProjects();
|
||||
res.render("dashboard", { siteGroups, projects });
|
||||
});
|
||||
|
||||
// --- 프로젝트 ---
|
||||
app.post("/projects", requireAuth, async (req, res) => {
|
||||
const { title, description, repo_url, app_url, tags } = req.body;
|
||||
if (!title || !title.trim()) return res.redirect("/");
|
||||
await db.addProject({
|
||||
owner: req.session.user.sabun,
|
||||
title: title.trim(),
|
||||
description,
|
||||
repoUrl: repo_url,
|
||||
appUrl: app_url,
|
||||
tags,
|
||||
});
|
||||
res.redirect("/");
|
||||
});
|
||||
|
||||
app.get("/projects/:id", requireAuth, async (req, res) => {
|
||||
const project = await db.getProject(req.params.id);
|
||||
if (!project) return res.status(404).send("not found");
|
||||
const comments = await db.listComments(project.id);
|
||||
const starred = await db.isStarred({ projectId: project.id, sabun: req.session.user.sabun });
|
||||
res.render("project", { project, comments, starred });
|
||||
});
|
||||
|
||||
app.post("/projects/:id/comments", requireAuth, async (req, res) => {
|
||||
if (req.body.body && req.body.body.trim()) {
|
||||
await db.addComment({
|
||||
projectId: req.params.id,
|
||||
author: req.session.user.sabun,
|
||||
body: req.body.body.trim(),
|
||||
});
|
||||
}
|
||||
res.redirect(`/projects/${req.params.id}`);
|
||||
});
|
||||
|
||||
app.post("/projects/:id/star", requireAuth, async (req, res) => {
|
||||
await db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun });
|
||||
res.redirect(`/projects/${req.params.id}`);
|
||||
});
|
||||
|
||||
// --- 부팅 ---
|
||||
const start = async () => {
|
||||
try {
|
||||
await initOidc();
|
||||
console.log("OIDC discovery 완료:", config.oidc.issuer);
|
||||
} catch (e) {
|
||||
console.error("OIDC 초기화 실패(로그인 비활성):", e.message);
|
||||
}
|
||||
app.listen(config.port, "0.0.0.0", () =>
|
||||
console.log(`${config.brand} portal listening on :${config.port}`)
|
||||
);
|
||||
};
|
||||
start();
|
||||
22
src/sites.js
Normal file
22
src/sites.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// 포털에 모아둘 사이트 카탈로그. 카테고리: 개발 핵심 / AI·데이터.
|
||||
// icon 은 이모지(추가 의존성 없이 GitHub 감성 카드에 표시).
|
||||
export const siteGroups = [
|
||||
{
|
||||
group: "개발 핵심",
|
||||
sites: [
|
||||
{ name: "Coder", url: "https://coder.bokdev.in", icon: "💻", desc: "웹 개발 워크스페이스 (VS Code + AI CLI)" },
|
||||
{ name: "Gitea", url: "https://gitea.bokdev.in", icon: "🍵", desc: "사내 Git 저장소" },
|
||||
{ name: "Kubero", url: "https://kubero.bokdev.in", icon: "🚀", desc: "앱 배포 PaaS (*.apps.bokdev.in)" },
|
||||
{ name: "Harbor", url: "https://harbor.bokdev.in", icon: "📦", desc: "컨테이너 이미지 레지스트리" },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "AI · 데이터",
|
||||
sites: [
|
||||
{ name: "LiteLLM", url: "https://litellm.bokdev.in", icon: "🤖", desc: "AI 게이트웨이 (모델 키)" },
|
||||
{ name: "Chat", url: "https://chat.bokdev.in", icon: "💬", desc: "사내 챗 (Ollama)" },
|
||||
{ name: "OpenEverest", url: "https://openeverest.bokdev.in",icon: "🗄️", desc: "DBaaS (DB 프로비저닝)" },
|
||||
{ name: "MinIO", url: "https://minioc.bokdev.in", icon: "🪣", desc: "오브젝트 스토리지 콘솔" },
|
||||
],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user