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:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
.project-env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.project-env.example
|
||||||
|
npm-debug.log*
|
||||||
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
HANDOFF.md
|
||||||
18
.project-env.example
Normal file
18
.project-env.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# AI DEV 포털 설정 예시. 실제 값은 .project-env (git 제외) 또는 Kubero Env 에.
|
||||||
|
|
||||||
|
# --- 포털 ---
|
||||||
|
PORTAL_BRAND=AI DEV
|
||||||
|
PORT=3000
|
||||||
|
# 로컬: http://localhost:3000 / 배포: https://portal.bokdev.in
|
||||||
|
BASE_URL=http://localhost:3000
|
||||||
|
# 세션 서명 키 (배포 시 임의의 긴 문자열로 교체)
|
||||||
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# --- DB (OpenEverest appdb, portal schema 전용 role) ---
|
||||||
|
# host 는 클러스터 내부 OpenEverest pgbouncer (워크스페이스/Kubero 양쪽 도달).
|
||||||
|
DATABASE_URL=postgresql://portal_app:7e2950ce6ed0c5a1457ea5b5@postgresql-6ox-pgbouncer.everest.svc.cluster.local:5432/appdb?options=-c%20search_path%3Dportal
|
||||||
|
|
||||||
|
# --- Keycloak OIDC (realm bokdev, client portal) ---
|
||||||
|
OIDC_ISSUER=https://keycloak.bokdev.in/realms/bokdev
|
||||||
|
OIDC_CLIENT_ID=portal
|
||||||
|
OIDC_CLIENT_SECRET=REPLACE_WITH_PORTAL_CLIENT_SECRET
|
||||||
35
README.md
Normal file
35
README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# AI DEV — 사내 개발 포털
|
||||||
|
|
||||||
|
직원이 한 곳에서 **사내 개발 사이트로 이동**하고, **만든 프로젝트를 공유·코멘트**하는 포털.
|
||||||
|
GitHub 감성 UI. Keycloak SSO(사번 로그인). Node(Express+EJS) + Postgres.
|
||||||
|
|
||||||
|
> 브랜드명 `AI DEV` 는 `PORTAL_BRAND` 환경변수로 바꿀 수 있습니다.
|
||||||
|
|
||||||
|
## 기능
|
||||||
|
- **Keycloak OIDC 로그인** (realm `bokdev`, client `portal`, 사번 = preferred_username)
|
||||||
|
- **개발 사이트 모음** — 개발 핵심(Coder/Gitea/Kubero/Harbor) + AI·데이터(LiteLLM/Chat/OpenEverest/MinIO). 목록은 `src/sites.js`.
|
||||||
|
- **프로젝트 공유** — repo/앱 URL·설명·태그
|
||||||
|
- **코멘트 / 스타**
|
||||||
|
|
||||||
|
## 로컬 실행
|
||||||
|
```bash
|
||||||
|
cp .project-env.example .project-env # 값 채우기 (OIDC_CLIENT_SECRET 등)
|
||||||
|
set -a; . ./.project-env; set +a
|
||||||
|
npm install
|
||||||
|
npm run db:init # schema.sql 적용 (최초 1회)
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
- 로컬에서 OIDC 콜백을 받으려면 Keycloak `portal` client redirect 에
|
||||||
|
`http://localhost:3000/auth/callback` 이 등록돼 있어야 합니다(이미 등록됨).
|
||||||
|
|
||||||
|
## 배포 (Kubero)
|
||||||
|
- NodeJS buildpack (`npm install` → `node index.js`). Dockerfile 불필요.
|
||||||
|
- Kubero Env: `BASE_URL=https://portal.bokdev.in`, `DATABASE_URL`, `OIDC_*`, `SESSION_SECRET`, `PORTAL_BRAND`.
|
||||||
|
- 도메인: `portal.bokdev.in` (게이트웨이/라우트 별도 구성 필요) 또는 `<app>.apps.bokdev.in`.
|
||||||
|
- 배포 도메인이 정해지면 Keycloak `portal` client redirect 에 `https://<도메인>/auth/callback` 추가.
|
||||||
|
|
||||||
|
## 연결 정보
|
||||||
|
- **DB**: OpenEverest `postgresql-6ox` 의 `appdb`, schema `portal`, role `portal_app`.
|
||||||
|
- **Issuer**: `https://keycloak.bokdev.in/realms/bokdev`
|
||||||
|
|
||||||
|
자세한 작업 규약은 `CLAUDE.md`.
|
||||||
2
index.js
Normal file
2
index.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Kubero NodeJS buildpack 진입점 (run = node index.js). 서버 구현은 src/server.js.
|
||||||
|
import "./src/server.js";
|
||||||
1193
package-lock.json
generated
Normal file
1193
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "ai-dev-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "AI DEV 사내 개발 포털 — Keycloak SSO + 사이트 모음 + 프로젝트 공유/코멘트",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --watch src/server.js",
|
||||||
|
"start": "node index.js",
|
||||||
|
"db:init": "node scripts/init-db.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"openid-client": "^5.7.0",
|
||||||
|
"pg": "^8.13.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
135
public/style.css
Normal file
135
public/style.css
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
/* GitHub 감성 (Primer 팔레트 근사). 라이트 테마. */
|
||||||
|
:root {
|
||||||
|
--canvas: #ffffff;
|
||||||
|
--canvas-subtle: #f6f8fa;
|
||||||
|
--border: #d0d7de;
|
||||||
|
--border-muted: #d8dee4;
|
||||||
|
--fg: #1f2328;
|
||||||
|
--fg-muted: #656d76;
|
||||||
|
--accent: #0969da;
|
||||||
|
--accent-emphasis: #0969da;
|
||||||
|
--success: #1a7f37;
|
||||||
|
--btn-primary: #1f883d;
|
||||||
|
--btn-primary-hover: #1a7f37;
|
||||||
|
--header-bg: #24292f;
|
||||||
|
--star: #9a6700;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", Helvetica, Arial, sans-serif;
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--canvas-subtle);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* 헤더 */
|
||||||
|
.header {
|
||||||
|
background: var(--header-bg);
|
||||||
|
color: #fff;
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.header .brand { font-weight: 600; font-size: 16px; color: #fff; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.header .brand:hover { text-decoration: none; }
|
||||||
|
.header .spacer { flex: 1; }
|
||||||
|
.header .who { color: #d1d9e0; font-size: 13px; }
|
||||||
|
.header a.navlink { color: #fff; font-size: 14px; }
|
||||||
|
|
||||||
|
/* 컨테이너 */
|
||||||
|
.container { max-width: 1012px; margin: 24px auto; padding: 0 16px; }
|
||||||
|
.layout { display: grid; grid-template-columns: 1fr 320px; gap: 24px; }
|
||||||
|
@media (max-width: 880px) { .layout { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* 카드 */
|
||||||
|
.box {
|
||||||
|
background: var(--canvas);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.box-header {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--canvas-subtle);
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
.box-body { padding: 16px; }
|
||||||
|
.box-row {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.box-row:last-child { border-bottom: 0; }
|
||||||
|
|
||||||
|
/* 사이트 그리드 */
|
||||||
|
.site-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
|
||||||
|
@media (max-width: 540px) { .site-grid { grid-template-columns: 1fr; } }
|
||||||
|
.site-card {
|
||||||
|
display: flex; gap: 12px; align-items: flex-start;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px;
|
||||||
|
padding: 12px; background: var(--canvas);
|
||||||
|
}
|
||||||
|
.site-card:hover { border-color: var(--accent); text-decoration: none; }
|
||||||
|
.site-card .ico { font-size: 22px; line-height: 1; }
|
||||||
|
.site-card .name { font-weight: 600; color: var(--fg); }
|
||||||
|
.site-card .desc { color: var(--fg-muted); font-size: 12px; }
|
||||||
|
.group-title { font-size: 13px; font-weight: 600; color: var(--fg-muted); margin: 20px 0 8px; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
|
||||||
|
/* 버튼 */
|
||||||
|
.btn {
|
||||||
|
display: inline-block; padding: 5px 16px; font-size: 14px; font-weight: 500;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; background: var(--canvas-subtle);
|
||||||
|
color: var(--fg); cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #f3f4f6; text-decoration: none; }
|
||||||
|
.btn-primary { background: var(--btn-primary); border-color: rgba(31,35,40,.15); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--btn-primary-hover); color: #fff; }
|
||||||
|
.btn-sm { padding: 3px 12px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* 폼 */
|
||||||
|
input[type=text], textarea, input[type=url] {
|
||||||
|
width: 100%; padding: 6px 12px; border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; font-size: 14px; font-family: inherit;
|
||||||
|
}
|
||||||
|
textarea { min-height: 80px; resize: vertical; }
|
||||||
|
label { display: block; font-weight: 600; margin: 10px 0 4px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* 프로젝트 리스트 */
|
||||||
|
.proj-item { padding: 16px; border-bottom: 1px solid var(--border-muted); }
|
||||||
|
.proj-item:last-child { border-bottom: 0; }
|
||||||
|
.proj-title { font-size: 16px; font-weight: 600; }
|
||||||
|
.proj-meta { color: var(--fg-muted); font-size: 12px; margin-top: 4px; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block; background: #ddf4ff; color: var(--accent);
|
||||||
|
border-radius: 2em; padding: 0 8px; font-size: 12px; margin-right: 4px;
|
||||||
|
}
|
||||||
|
.counter { color: var(--fg-muted); font-size: 12px; display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
|
||||||
|
/* 아바타 */
|
||||||
|
.avatar {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
|
background: var(--accent); color: #fff; display: inline-flex;
|
||||||
|
align-items: center; justify-content: center; font-size: 12px; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 코멘트 */
|
||||||
|
.comment { border: 1px solid var(--border); border-radius: 6px; margin-bottom: 12px; }
|
||||||
|
.comment-head { background: var(--canvas-subtle); padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||||
|
.comment-body { padding: 12px; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* 로그인 */
|
||||||
|
.login-wrap { max-width: 340px; margin: 80px auto; text-align: center; }
|
||||||
|
.login-card { padding: 32px 24px; }
|
||||||
|
.login-logo { font-size: 40px; }
|
||||||
|
.login-title { font-size: 24px; font-weight: 300; margin: 12px 0 24px; }
|
||||||
|
.alert { background: #ffebe9; border: 1px solid #ff818266; color: #cf222e; padding: 8px 12px; border-radius: 6px; margin-bottom: 16px; font-size: 13px; }
|
||||||
|
.muted { color: var(--fg-muted); font-size: 12px; }
|
||||||
43
schema.sql
Normal file
43
schema.sql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
-- AI DEV portal 스키마 (search_path=portal). 멱등.
|
||||||
|
-- 직원 식별자는 사번(Keycloak preferred_username). 표시 이름은 name.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
sabun text PRIMARY KEY, -- 사번 (Keycloak preferred_username)
|
||||||
|
name text NOT NULL,
|
||||||
|
email text,
|
||||||
|
avatar_seed text, -- 아바타 색/이니셜용
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
last_login timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 공유 프로젝트 (직원이 만든 앱/repo 를 포털에 공유)
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
owner_sabun text NOT NULL REFERENCES users(sabun),
|
||||||
|
title text NOT NULL,
|
||||||
|
description text DEFAULT '',
|
||||||
|
repo_url text DEFAULT '', -- Gitea repo
|
||||||
|
app_url text DEFAULT '', -- 배포된 앱 (*.apps.bokdev.in)
|
||||||
|
tags text DEFAULT '', -- 콤마 구분
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_projects_created ON projects(created_at DESC);
|
||||||
|
|
||||||
|
-- 코멘트
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
author_sabun text NOT NULL REFERENCES users(sabun),
|
||||||
|
body text NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_comments_project ON comments(project_id, created_at);
|
||||||
|
|
||||||
|
-- 스타(좋아요)
|
||||||
|
CREATE TABLE IF NOT EXISTS stars (
|
||||||
|
project_id bigint NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
sabun text NOT NULL REFERENCES users(sabun),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (project_id, sabun)
|
||||||
|
);
|
||||||
18
scripts/init-db.js
Normal file
18
scripts/init-db.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// schema.sql 을 DATABASE_URL 대상에 적용 (멱등). 사용: npm run db:init
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import pg from "pg";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sql = fs.readFileSync(path.join(__dirname, "..", "schema.sql"), "utf8");
|
||||||
|
const url = process.env.DATABASE_URL;
|
||||||
|
if (!url) {
|
||||||
|
console.error("DATABASE_URL 미설정 — .project-env 를 로드했는지 확인하세요");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const client = new pg.Client({ connectionString: url });
|
||||||
|
await client.connect();
|
||||||
|
await client.query(sql);
|
||||||
|
await client.end();
|
||||||
|
console.log("schema.sql 적용 완료");
|
||||||
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: "오브젝트 스토리지 콘솔" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
17
views/_header.ejs
Normal file
17
views/_header.ejs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title><%= brand %></title>
|
||||||
|
<link rel="stylesheet" href="/public/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<a class="brand" href="/"><span>🧠</span><%= brand %></a>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<% if (user) { %>
|
||||||
|
<span class="who"><%= user.name %> (<%= user.sabun %>)</span>
|
||||||
|
<a class="navlink" href="/logout">로그아웃</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
79
views/dashboard.ejs
Normal file
79
views/dashboard.ejs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<%- include("_header") %>
|
||||||
|
<div class="container">
|
||||||
|
<div class="layout">
|
||||||
|
<!-- 메인: 사이트 모음 + 공유 프로젝트 -->
|
||||||
|
<div>
|
||||||
|
<div class="box">
|
||||||
|
<div class="box-header">🔗 개발 사이트 바로가기</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<% siteGroups.forEach(function(g){ %>
|
||||||
|
<div class="group-title"><%= g.group %></div>
|
||||||
|
<div class="site-grid">
|
||||||
|
<% g.sites.forEach(function(s){ %>
|
||||||
|
<a class="site-card" href="<%= s.url %>" target="_blank" rel="noopener">
|
||||||
|
<span class="ico"><%= s.icon %></span>
|
||||||
|
<span>
|
||||||
|
<span class="name"><%= s.name %></span><br/>
|
||||||
|
<span class="desc"><%= s.desc %></span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box" style="margin-top:24px">
|
||||||
|
<div class="box-header">📂 공유 프로젝트</div>
|
||||||
|
<% if (projects.length === 0) { %>
|
||||||
|
<div class="box-body muted">아직 공유된 프로젝트가 없습니다. 오른쪽에서 추가해 보세요.</div>
|
||||||
|
<% } else { %>
|
||||||
|
<% projects.forEach(function(p){ %>
|
||||||
|
<div class="proj-item">
|
||||||
|
<a class="proj-title" href="/projects/<%= p.id %>"><%= p.title %></a>
|
||||||
|
<div class="proj-meta">
|
||||||
|
<%= p.owner_name %> · <%= new Date(p.updated_at).toLocaleDateString("ko-KR") %>
|
||||||
|
<span class="counter">⭐ <%= p.star_count %></span>
|
||||||
|
<span class="counter">💬 <%= p.comment_count %></span>
|
||||||
|
</div>
|
||||||
|
<% if (p.description) { %><div class="muted" style="margin-top:6px"><%= p.description %></div><% } %>
|
||||||
|
<% if (p.tags) { %>
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<% p.tags.split(",").map(t=>t.trim()).filter(Boolean).forEach(function(t){ %>
|
||||||
|
<span class="tag"><%= t %></span>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 사이드바: 프로젝트 공유 폼 -->
|
||||||
|
<div>
|
||||||
|
<div class="box">
|
||||||
|
<div class="box-header">➕ 프로젝트 공유</div>
|
||||||
|
<div class="box-body">
|
||||||
|
<form method="post" action="/projects">
|
||||||
|
<label>제목 *</label>
|
||||||
|
<input type="text" name="title" required placeholder="내 앱 이름" />
|
||||||
|
<label>설명</label>
|
||||||
|
<textarea name="description" placeholder="무엇을 만들었나요?"></textarea>
|
||||||
|
<label>Repo URL</label>
|
||||||
|
<input type="url" name="repo_url" placeholder="https://gitea.bokdev.in/..." />
|
||||||
|
<label>앱 URL</label>
|
||||||
|
<input type="url" name="app_url" placeholder="https://....apps.bokdev.in" />
|
||||||
|
<label>태그 (콤마 구분)</label>
|
||||||
|
<input type="text" name="tags" placeholder="node, ai" />
|
||||||
|
<div style="margin-top:16px">
|
||||||
|
<button class="btn btn-primary" style="width:100%" type="submit">공유하기</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
views/login.ejs
Normal file
14
views/login.ejs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<%- include("_header") %>
|
||||||
|
<div class="login-wrap">
|
||||||
|
<div class="box login-card">
|
||||||
|
<div class="login-logo">🧠</div>
|
||||||
|
<div class="login-title"><%= brand %></div>
|
||||||
|
<% if (typeof error !== "undefined" && error) { %>
|
||||||
|
<div class="alert"><%= error %></div>
|
||||||
|
<% } %>
|
||||||
|
<a class="btn btn-primary" style="width:100%" href="/auth/login">Keycloak 으로 로그인</a>
|
||||||
|
<p class="muted" style="margin-top:16px">사번으로 로그인합니다. (사내 SSO)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
views/project.ejs
Normal file
46
views/project.ejs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<%- include("_header") %>
|
||||||
|
<div class="container" style="max-width:760px">
|
||||||
|
<a href="/" class="muted">← 대시보드</a>
|
||||||
|
<div class="box" style="margin-top:12px">
|
||||||
|
<div class="box-body">
|
||||||
|
<h2 style="margin:0 0 4px"><%= project.title %></h2>
|
||||||
|
<div class="proj-meta"><%= project.owner_name %> 님이 공유 · <%= new Date(project.created_at).toLocaleDateString("ko-KR") %></div>
|
||||||
|
<% if (project.description) { %><p style="margin-top:12px; white-space:pre-wrap"><%= project.description %></p><% } %>
|
||||||
|
<div style="margin-top:12px; display:flex; gap:8px; align-items:center; flex-wrap:wrap">
|
||||||
|
<% if (project.repo_url) { %><a class="btn btn-sm" href="<%= project.repo_url %>" target="_blank">🍵 Repo</a><% } %>
|
||||||
|
<% if (project.app_url) { %><a class="btn btn-sm" href="<%= project.app_url %>" target="_blank">🚀 앱 열기</a><% } %>
|
||||||
|
<form method="post" action="/projects/<%= project.id %>/star" style="display:inline">
|
||||||
|
<button class="btn btn-sm" type="submit"><%= starred ? "★ 스타 취소" : "☆ 스타" %> (<%= project.star_count %>)</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% if (project.tags) { %>
|
||||||
|
<div style="margin-top:12px">
|
||||||
|
<% project.tags.split(",").map(t=>t.trim()).filter(Boolean).forEach(function(t){ %>
|
||||||
|
<span class="tag"><%= t %></span>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 style="margin:24px 0 12px">💬 코멘트 (<%= comments.length %>)</h3>
|
||||||
|
<% comments.forEach(function(c){ %>
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-head"><strong><%= c.author_name %></strong> · <%= new Date(c.created_at).toLocaleString("ko-KR") %></div>
|
||||||
|
<div class="comment-body"><%= c.body %></div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
|
||||||
|
<div class="box" style="margin-top:16px">
|
||||||
|
<div class="box-body">
|
||||||
|
<form method="post" action="/projects/<%= project.id %>/comments">
|
||||||
|
<textarea name="body" placeholder="코멘트를 남겨보세요" required></textarea>
|
||||||
|
<div style="margin-top:8px; text-align:right">
|
||||||
|
<button class="btn btn-primary" type="submit">코멘트 등록</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user