feat(server): 라우트 전면 개편 — 반응·가시성·댓글/대댓글·삭제
- 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) <noreply@anthropic.com>
This commit is contained in:
12
src/db.js
12
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)
|
||||
|
||||
125
src/server.js
125
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()) {
|
||||
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: req.body.body.trim(),
|
||||
body,
|
||||
parentId: parentId || null,
|
||||
});
|
||||
}
|
||||
res.redirect(`/projects/${req.params.id}`);
|
||||
// 이스터에그 신호
|
||||
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") || "/"));
|
||||
});
|
||||
|
||||
// --- 부팅 ---
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<body<% if (typeof toast !== "undefined" && toast) { %> data-toast="<%= toast %>"<% } %><% if (typeof egg !== "undefined" && egg) { %> data-egg="<%= egg %>"<% } %><% if (typeof openModal !== "undefined" && openModal) { %> data-open-modal="<%= openModal %>"<% } %>>
|
||||
<header class="hdr">
|
||||
<div class="hdr-inner">
|
||||
<a class="brand" href="/" id="brandLink">
|
||||
|
||||
Reference in New Issue
Block a user