From ace46d2fb85ee8dc9853094842407e27d77d4481 Mon Sep 17 00:00:00 2001 From: Hyemin Lee Date: Mon, 29 Jun 2026 17:26:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(server):=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20?= =?UTF-8?q?=EC=A0=84=EB=A9=B4=20=EA=B0=9C=ED=8E=B8=20=E2=80=94=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=C2=B7=EA=B0=80=EC=8B=9C=EC=84=B1=C2=B7=EB=8C=93?= =?UTF-8?q?=EA=B8=80/=EB=8C=80=EB=8C=93=EA=B8=80=C2=B7=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET / : 내 프로젝트/피드 분리, role 도구 필터, 댓글 일괄 조회(commentsByProject) - 반응(heart/star) AJAX JSON, visibility/delete(본인), 댓글/대댓글(parent_id) - 댓글 LGTM/🚀 egg 신호 + return_to 복귀, isAjax/safeBack 헬퍼 - res.locals 로 icon/identicon/langColor 노출 - db: listCommentsByProjects 추가, _header: body 시작 신호(toast/egg/open) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/db.js | 12 +++++ src/server.js | 133 +++++++++++++++++++++++++++++++++++----------- views/_header.ejs | 2 +- 3 files changed, 115 insertions(+), 32 deletions(-) diff --git a/src/db.js b/src/db.js index db0bf4d..fc40a72 100644 --- a/src/db.js +++ b/src/db.js @@ -57,6 +57,18 @@ export async function listComments(projectId) { return rows; } +// 여러 프로젝트의 댓글을 한 번에 (대시보드 모달 저장소용). parent_id 포함. +export async function listCommentsByProjects(ids) { + if (!ids || !ids.length) return []; + 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 = ANY($1::bigint[]) ORDER BY c.created_at ASC`, + [ids] + ); + return rows; +} + export async function addProject({ owner, title, description, repoUrl, appUrl, tags, isPublic, lang }) { const { rows } = await q( `INSERT INTO projects (owner_sabun, title, description, repo_url, app_url, tags, is_public, lang) diff --git a/src/server.js b/src/server.js index afa8269..c70c35b 100644 --- a/src/server.js +++ b/src/server.js @@ -4,8 +4,9 @@ import cookieParser from "cookie-parser"; import { fileURLToPath } from "url"; import path from "path"; import { config } from "./config.js"; -import { siteGroups } from "./sites.js"; +import { toolsForUser } from "./sites.js"; import { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady } from "./auth.js"; +import { icon, identicon, langColor, langFromTags } from "./helpers.js"; import * as db from "./db.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -26,22 +27,30 @@ app.use( }) ); -// 모든 뷰에 공통 노출 +// 모든 뷰 공통 노출 (브랜드·사용자·뷰 헬퍼) app.use((req, res, next) => { res.locals.brand = config.brand; res.locals.user = req.session.user || null; + res.locals.icon = icon; + res.locals.identicon = identicon; + res.locals.langColor = langColor; next(); }); app.get("/healthz", (_req, res) => res.json({ ok: true })); +// 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(커스텀 테마) 로그인으로 보낸다. -// (OIDC 초기화 실패 등으로 로그인 불가하면 안내용 login.ejs 로 폴백) +// 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 }); - // OIDC 준비됐으면 중간 화면 없이 곧장 Keycloak 로그인 폼으로 if (oidcReady()) return loginRedirect(req, res); return res.render("login", { error: "로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요." }); }); @@ -50,7 +59,7 @@ app.get("/auth/callback", async (req, res) => { try { const u = await handleCallback(req); await db.upsertUser(u); - req.session.user = u; + req.session.user = u; // { sabun, name, email, roles, isAdmin } res.redirect("/"); } catch (e) { console.error("oidc callback error", e); @@ -61,49 +70,111 @@ 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 }); + 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("/"); + if (!title || !title.trim()) return res.redirect("/?toast=" + encodeURIComponent("제목을 입력해주세요")); + const tagArr = String(tags || "") + .split(",") + .map((t) => t.trim().replace(/^#/, "")) + .filter(Boolean) + .slice(0, 5); await db.addProject({ owner: req.session.user.sabun, title: title.trim(), - description, - repoUrl: repo_url, - appUrl: app_url, - tags, + description: (description || "").trim(), + repoUrl: (repo_url || "").trim(), + appUrl: (app_url || "").trim(), + tags: tagArr.join(", "), + isPublic: req.body.is_public !== "0", + lang: langFromTags(tagArr), }); - res.redirect("/"); + const msg = req.body.is_public === "0" ? "비공개로 등록됐습니다" : "등록 완료 · 피드에 공유됐습니다"; + res.redirect("/?toast=" + encodeURIComponent(msg)); }); +// --- 단독 상세 페이지 (모달의 비-JS 폴백 / 공유 링크) --- 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 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); - const starred = await db.isStarred({ projectId: project.id, sabun: req.session.user.sabun }); - res.render("project", { project, comments, starred }); + res.render("project", { project, comments, starred: project.starred }); }); +// --- 반응: ❤️ 좋아요 / ⭐ 즐겨찾기 --- +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 ? "공개로 전환됐습니다 · 피드에 표시됩니다" : "비공개로 전환됐습니다"; + res.redirect("/?toast=" + encodeURIComponent(msg)); +}); + +// --- 프로젝트 삭제 (본인) --- +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) => { - 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}`); + 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("/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}`); +// --- 댓글 삭제 (본인) --- +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") || "/")); }); // --- 부팅 --- diff --git a/views/_header.ejs b/views/_header.ejs index 98887e5..8cc6532 100644 --- a/views/_header.ejs +++ b/views/_header.ejs @@ -15,7 +15,7 @@ })(); - + data-toast="<%= toast %>"<% } %><% if (typeof egg !== "undefined" && egg) { %> data-egg="<%= egg %>"<% } %><% if (typeof openModal !== "undefined" && openModal) { %> data-open-modal="<%= openModal %>"<% } %>>