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;
|
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 }) {
|
export async function addProject({ owner, title, description, repoUrl, appUrl, tags, isPublic, lang }) {
|
||||||
const { rows } = await q(
|
const { rows } = await q(
|
||||||
`INSERT INTO projects (owner_sabun, title, description, repo_url, app_url, tags, is_public, lang)
|
`INSERT INTO projects (owner_sabun, title, description, repo_url, app_url, tags, is_public, lang)
|
||||||
|
|||||||
133
src/server.js
133
src/server.js
@@ -4,8 +4,9 @@ import cookieParser from "cookie-parser";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { config } from "./config.js";
|
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 { initOidc, loginRedirect, handleCallback, requireAuth, oidcReady } from "./auth.js";
|
||||||
|
import { icon, identicon, langColor, langFromTags } from "./helpers.js";
|
||||||
import * as db from "./db.js";
|
import * as db from "./db.js";
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -26,22 +27,30 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// 모든 뷰에 공통 노출
|
// 모든 뷰 공통 노출 (브랜드·사용자·뷰 헬퍼)
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.locals.brand = config.brand;
|
res.locals.brand = config.brand;
|
||||||
res.locals.user = req.session.user || null;
|
res.locals.user = req.session.user || null;
|
||||||
|
res.locals.icon = icon;
|
||||||
|
res.locals.identicon = identicon;
|
||||||
|
res.locals.langColor = langColor;
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/healthz", (_req, res) => res.json({ ok: true }));
|
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(커스텀 테마) 로그인으로 보낸다.
|
// SSO 세션이 없으면 중간 페이지 없이 바로 Keycloak 로그인으로. (실패 시 login.ejs 폴백)
|
||||||
// (OIDC 초기화 실패 등으로 로그인 불가하면 안내용 login.ejs 로 폴백)
|
|
||||||
app.get("/login", (req, res) => {
|
app.get("/login", (req, res) => {
|
||||||
if (req.session.user) return res.redirect("/");
|
if (req.session.user) return res.redirect("/");
|
||||||
if (req.query.error) return res.render("login", { error: req.query.error });
|
if (req.query.error) return res.render("login", { error: req.query.error });
|
||||||
// OIDC 준비됐으면 중간 화면 없이 곧장 Keycloak 로그인 폼으로
|
|
||||||
if (oidcReady()) return loginRedirect(req, res);
|
if (oidcReady()) return loginRedirect(req, res);
|
||||||
return res.render("login", { error: "로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요." });
|
return res.render("login", { error: "로그인 서비스를 사용할 수 없습니다. 잠시 후 다시 시도해 주세요." });
|
||||||
});
|
});
|
||||||
@@ -50,7 +59,7 @@ app.get("/auth/callback", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const u = await handleCallback(req);
|
const u = await handleCallback(req);
|
||||||
await db.upsertUser(u);
|
await db.upsertUser(u);
|
||||||
req.session.user = u;
|
req.session.user = u; // { sabun, name, email, roles, isAdmin }
|
||||||
res.redirect("/");
|
res.redirect("/");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("oidc callback error", e);
|
console.error("oidc callback error", e);
|
||||||
@@ -61,49 +70,111 @@ app.get("/logout", (req, res) => {
|
|||||||
req.session.destroy(() => res.redirect("/login"));
|
req.session.destroy(() => res.redirect("/login"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- 대시보드 (로그인 필요) ---
|
// --- 대시보드 ---
|
||||||
app.get("/", requireAuth, async (req, res) => {
|
app.get("/", requireAuth, async (req, res) => {
|
||||||
const projects = await db.listProjects();
|
const me = req.session.user.sabun;
|
||||||
res.render("dashboard", { siteGroups, projects });
|
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) => {
|
app.post("/projects", requireAuth, async (req, res) => {
|
||||||
const { title, description, repo_url, app_url, tags } = req.body;
|
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({
|
await db.addProject({
|
||||||
owner: req.session.user.sabun,
|
owner: req.session.user.sabun,
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description,
|
description: (description || "").trim(),
|
||||||
repoUrl: repo_url,
|
repoUrl: (repo_url || "").trim(),
|
||||||
appUrl: app_url,
|
appUrl: (app_url || "").trim(),
|
||||||
tags,
|
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) => {
|
app.get("/projects/:id", requireAuth, async (req, res) => {
|
||||||
const project = await db.getProject(req.params.id);
|
const me = req.session.user.sabun;
|
||||||
if (!project) return res.status(404).send("not found");
|
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 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: project.starred });
|
||||||
res.render("project", { project, comments, 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) => {
|
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}`);
|
||||||
await db.addComment({
|
const body = (req.body.body || "").trim();
|
||||||
projectId: req.params.id,
|
if (!body) return res.redirect(back);
|
||||||
author: req.session.user.sabun,
|
const parentId = req.body.parent_id ? Number(req.body.parent_id) : null;
|
||||||
body: req.body.body.trim(),
|
await db.addComment({
|
||||||
});
|
projectId: req.params.id,
|
||||||
}
|
author: req.session.user.sabun,
|
||||||
res.redirect(`/projects/${req.params.id}`);
|
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 });
|
app.post("/comments/:id/delete", requireAuth, async (req, res) => {
|
||||||
res.redirect(`/projects/${req.params.id}`);
|
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>
|
</script>
|
||||||
</head>
|
</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">
|
<header class="hdr">
|
||||||
<div class="hdr-inner">
|
<div class="hdr-inner">
|
||||||
<a class="brand" href="/" id="brandLink">
|
<a class="brand" href="/" id="brandLink">
|
||||||
|
|||||||
Reference in New Issue
Block a user