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=) 로 돌아가 모달을 다시 연다. 카드에서는 "/". 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();