Files
ai-dev-portal/src/server.js
2620227 84ce8567e7 feat(profile): 프로필에 '한줄 소개' 추가 — 본인만 인라인 편집
- users.bio 컬럼(멱등) + db.setBio / getUserProfile 조회 포함
- POST /profile/bio (본인만, fetch JSON / 폼 POST 폴백)
- 프로필 히어로: 이름·사번 한 줄로 합치고 한줄 소개를 3줄째로 배치
- 본인은 연필 버튼으로 인라인 편집(저장/취소·Esc), 최대 80자
- word-break: keep-all 로 한글 단어 중간 잘림 방지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 13:43:49 +09:00

317 lines
14 KiB
JavaScript

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 { toolsForUser } from "./sites.js";
import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady, logoutUrl } from "./auth.js";
import { icon, identicon, identiconSvg, avatar, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js";
import { fetchMainLang, fetchReadme } from "./gitea.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.manualUrl = config.manualUrl;
res.locals.user = req.session.user || null;
res.locals.icon = icon;
res.locals.identicon = identicon;
res.locals.avatar = avatar;
res.locals.langColor = langColor;
res.locals.renderMarkdown = renderMarkdown;
res.locals.mdPlain = mdPlain;
next();
});
app.get("/healthz", (_req, res) => res.json({ ok: true }));
// 브라우저가 페이지마다 자동 요청하는 /favicon.ico → svg 파비콘으로 넘긴다(콘솔 404 제거).
app.get("/favicon.ico", (_req, res) => res.redirect(301, "/public/favicon.svg"));
// fetch(AJAX) 요청인지: app.js 가 X-Requested-With 를 붙인다.
const isAjax = (req) =>
req.get("X-Requested-With") === "fetch" || (req.get("Accept") || "").includes("application/json");
// 로컬 경로만 허용하는 안전한 리다이렉트 대상.
const safeBack = (v, fallback) => (typeof v === "string" && v.startsWith("/") ? v : fallback);
// --- 인증 ---
// SSO 세션이 없으면 중간 페이지 없이 바로 Keycloak 로그인으로. (실패 시 login.ejs 폴백)
app.get("/login", (req, res) => {
if (req.session.user) return res.redirect("/");
if (req.query.error) return res.render("login", { error: req.query.error });
if (oidcReady()) return loginRedirect(req, res);
return res.render("login", { error: "로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요." });
});
app.get("/auth/login", (req, res) => loginRedirect(req, res));
app.get("/auth/callback", async (req, res) => {
try {
const u = await handleCallback(req);
// id_token 은 SSO 로그아웃용으로만 세션에 따로 보관(뷰/DB 에 노출 안 함)
const { id_token, ...user } = u;
req.session.idToken = id_token;
await db.upsertUser(user);
req.session.user = user; // { sabun, name, email, groups, isAdmin }
res.redirect("/");
} catch (e) {
console.error("oidc callback error", e);
res.status(500).render("login", { error: "로그인 처리 중 오류가 발생했습니다." });
}
});
app.get("/logout", (req, res) => {
const idToken = req.session.idToken;
// 로컬 세션을 먼저 destroy 한 뒤 Keycloak end-session 으로 보내
// SSO 세션까지 종료한다. (로컬만 지우면 재접속 시 자동 재로그인됨)
req.session.destroy(() => {
if (oidcReady()) return res.redirect(logoutUrl(idToken));
return res.redirect("/login");
});
});
// --- 아바타 이미지 ---
// 업로드한 프로필 사진이 있으면 그 bytes 를, 없으면 사번 기반 identicon SVG 로 응답.
app.get("/avatar/:sabun", requireAuth, async (req, res) => {
const sabun = req.params.sabun;
try {
const row = await db.getAvatar(sabun);
if (row && row.avatar) {
res.set("Content-Type", row.avatar_mime || "image/webp");
res.set("Cache-Control", "private, max-age=300");
return res.send(row.avatar);
}
} catch (e) {
console.error("avatar load error", e);
}
res.set("Content-Type", "image/svg+xml; charset=utf-8");
res.set("Cache-Control", "private, max-age=300");
res.send(identiconSvg(sabun, 128));
});
// --- 프로필 사진 업로드(본인) ---
// 브라우저에서 1:1 크롭·축소한 작은 이미지를 raw body 로 받는다(멀터/이미지 라이브러리 불필요).
app.post(
"/profile/avatar",
requireAuth,
express.raw({ type: ["image/webp", "image/jpeg", "image/png"], limit: "700kb" }),
async (req, res) => {
const buf = req.body;
const mime = (req.get("Content-Type") || "").split(";")[0].trim();
if (!Buffer.isBuffer(buf) || buf.length === 0) return res.status(400).json({ ok: false, error: "empty" });
if (!["image/webp", "image/jpeg", "image/png"].includes(mime)) return res.status(415).json({ ok: false, error: "type" });
await db.setAvatar({ sabun: req.session.user.sabun, buf, mime });
res.json({ ok: true });
}
);
// --- 한줄 소개 저장(본인) ---
// JS 있으면 fetch(JSON), 없으면 폼 POST → 프로필로 리다이렉트(점진적 향상).
app.post("/profile/bio", requireAuth, async (req, res) => {
const sabun = req.session.user.sabun;
const bio = String(req.body.bio || "").trim().slice(0, 80);
await db.setBio({ sabun, bio });
if (isAjax(req)) return res.json({ ok: true, bio });
res.redirect("/u/" + encodeURIComponent(sabun));
});
// --- 프로필 페이지 (본인/타인) — 프로필 + 해당 사용자의 프로젝트 목록 ---
app.get("/u/:sabun", requireAuth, async (req, res) => {
const me = req.session.user.sabun;
const profile = await db.getUserProfile(req.params.sabun);
if (!profile) return res.status(404).send("사용자를 찾을 수 없습니다.");
const projects = await db.listProjectsByOwner(profile.sabun, me);
const searchPlaceholder = profile.sabun === me
? "내 프로젝트 검색…"
: `${profile.name} 님의 프로젝트 검색…`;
res.render("profile", { profile, projects, isSelf: profile.sabun === me, searchPlaceholder });
});
// --- 대시보드 ---
app.get("/", requireAuth, async (req, res) => {
const me = req.session.user.sabun;
const projects = await db.listProjects(me);
const myProjects = projects.filter((p) => p.owner_sabun === me);
const feedProjects = projects.filter((p) => p.is_public);
// 모달 저장소용: 열람 가능한 모든 프로젝트의 댓글을 한 번에 받아 프로젝트별로 묶는다.
const allComments = await db.listCommentsByProjects(projects.map((p) => p.id));
const commentsByProject = {};
for (const c of allComments) (commentsByProject[c.project_id] ||= []).push(c);
res.render("dashboard", {
tools: toolsForUser(req.session.user.isAdmin),
myProjects,
feedProjects,
allProjects: projects,
commentsByProject,
toast: typeof req.query.toast === "string" ? req.query.toast : "",
egg: typeof req.query.egg === "string" ? req.query.egg : "",
openModal: typeof req.query.open === "string" ? req.query.open : "",
});
});
// --- 프로젝트 생성 ---
app.post("/projects", requireAuth, async (req, res) => {
const { title, description, repo_url, app_url, tags } = req.body;
if (!title || !title.trim()) return res.redirect("/?toast=" + encodeURIComponent("제목을 입력해주세요"));
const tagArr = String(tags || "")
.split(",")
.map((t) => t.trim().replace(/^#/, ""))
.filter(Boolean)
.slice(0, 5);
const repoUrl = (repo_url || "").trim();
// 메인 언어: 사용자가 직접 고르면 그 값, '자동 감지'면 Gitea 언어 통계 → 태그 추론 순으로 폴백.
const lang = (req.body.lang || "").trim() || (await fetchMainLang(repoUrl)) || langFromTags(tagArr);
await db.addProject({
owner: req.session.user.sabun,
title: title.trim(),
description: (description || "").trim(),
repoUrl,
appUrl: (app_url || "").trim(),
tags: tagArr.join(", "),
isPublic: req.body.is_public !== "0",
lang,
});
const msg = req.body.is_public === "0" ? "비공개로 등록됐습니다" : "등록 완료 · 피드에 공유됐습니다";
res.redirect("/?toast=" + encodeURIComponent(msg));
});
// --- 프로젝트 수정 (본인) --- 등록과 동일한 필드 파싱. 소유자 조건은 db.updateProject 에서 강제.
app.post("/projects/:id/edit", requireAuth, async (req, res) => {
const { title, description, repo_url, app_url, tags } = req.body;
const back = safeBack(req.body.return_to, "/");
const sep = back.includes("?") ? "&" : "?";
if (!title || !title.trim()) {
return res.redirect(back + sep + "toast=" + encodeURIComponent("제목을 입력해주세요"));
}
const tagArr = String(tags || "")
.split(",")
.map((t) => t.trim().replace(/^#/, ""))
.filter(Boolean)
.slice(0, 5);
const repoUrl = (repo_url || "").trim();
const lang = (req.body.lang || "").trim() || (await fetchMainLang(repoUrl)) || langFromTags(tagArr);
const n = await db.updateProject({
id: req.params.id,
sabun: req.session.user.sabun,
title: title.trim(),
description: (description || "").trim(),
repoUrl,
appUrl: (app_url || "").trim(),
tags: tagArr.join(", "),
isPublic: req.body.is_public !== "0",
lang,
});
const msg = n ? "수정되었습니다" : "수정 권한이 없습니다";
res.redirect(back + sep + "toast=" + encodeURIComponent(msg));
});
// --- 단독 상세 페이지 (모달의 비-JS 폴백 / 공유 링크) ---
app.get("/projects/:id", requireAuth, async (req, res) => {
const me = req.session.user.sabun;
const project = await db.getProject(req.params.id, me);
if (!project) return res.status(404).send("프로젝트를 찾을 수 없습니다.");
// 비공개는 본인만 열람
if (!project.is_public && project.owner_sabun !== me) return res.status(403).send("비공개 프로젝트입니다.");
const comments = await db.listComments(project.id);
// 뒤로가기 대상: ?from= 으로 넘어온 내부 경로(프로필 등)가 있으면 그곳, 없으면 대시보드.
// (뎁스 순서: 프로필 → 프로젝트 상세 로 들어왔으면 프로필로 돌아간다)
const from = req.query.from;
const safeFrom = typeof from === "string" && /^\/[^/]/.test(from) ? from : null; // 내부 절대경로만 허용
const back = safeFrom && safeFrom.startsWith("/u/")
? { href: safeFrom, label: "프로필" }
: { href: "/", label: "대시보드" };
res.render("project", { project, comments, starred: project.starred, back });
});
// --- 반응: ❤️ 좋아요 / ⭐ 즐겨찾기 ---
async function reactionHandler(kind, req, res) {
const result = await (kind === "heart"
? db.toggleHeart({ projectId: req.params.id, sabun: req.session.user.sabun })
: db.toggleStar({ projectId: req.params.id, sabun: req.session.user.sabun }));
if (isAjax(req)) return res.json(result);
res.redirect(safeBack(req.get("Referer"), "/"));
}
app.post("/projects/:id/heart", requireAuth, (req, res) => reactionHandler("heart", req, res));
app.post("/projects/:id/star", requireAuth, (req, res) => reactionHandler("star", req, res));
// --- 공개/비공개 전환 (본인) ---
app.post("/projects/:id/visibility", requireAuth, async (req, res) => {
const isPublic = req.body.is_public === "1";
await db.setVisibility({ id: req.params.id, sabun: req.session.user.sabun, isPublic });
const msg = isPublic ? "공개로 전환됐습니다 · 피드에 표시됩니다" : "비공개로 전환됐습니다";
// 모달에서 전환하면 return_to(=/?open=<id>) 로 돌아가 모달을 다시 연다. 카드에서는 "/".
const back = safeBack(req.body.return_to, "/");
const sep = back.includes("?") ? "&" : "?";
res.redirect(back + sep + "toast=" + encodeURIComponent(msg));
});
// --- README 가져오기 (등록 모달) — repo_url 의 Gitea README 를 마크다운 원문으로 반환 ---
app.get("/api/readme", requireAuth, async (req, res) => {
const content = await fetchReadme((req.query.repo_url || "").toString().trim());
if (!content) return res.status(404).json({ ok: false });
res.json({ ok: true, content });
});
// --- 프로젝트 삭제 (본인) ---
app.post("/projects/:id/delete", requireAuth, async (req, res) => {
const n = await db.deleteProject({ id: req.params.id, sabun: req.session.user.sabun });
res.redirect("/?toast=" + encodeURIComponent(n ? "삭제됐습니다" : "삭제 권한이 없습니다"));
});
// --- 댓글/대댓글 작성 ---
app.post("/projects/:id/comments", requireAuth, async (req, res) => {
const back = safeBack(req.body.return_to, `/projects/${req.params.id}`);
const body = (req.body.body || "").trim();
if (!body) return res.redirect(back);
const parentId = req.body.parent_id ? Number(req.body.parent_id) : null;
await db.addComment({
projectId: req.params.id,
author: req.session.user.sabun,
body,
parentId: parentId || null,
});
// 이스터에그 신호
let egg = "";
if (/\blgtm\b/i.test(body)) egg = "lgtm";
else if (body.indexOf("🚀") >= 0 || body.toLowerCase().indexOf(":shipit:") >= 0) egg = "rocket";
const sep = back.includes("?") ? "&" : "?";
res.redirect(egg ? back + sep + "egg=" + egg : back);
});
// --- 댓글 삭제 (본인) ---
app.post("/comments/:id/delete", requireAuth, async (req, res) => {
await db.deleteComment({ id: req.params.id, sabun: req.session.user.sabun });
res.redirect(safeBack(req.body.return_to, req.get("Referer") || "/"));
});
// --- 부팅 ---
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();