등록 모달의 '한 줄 설명' input 을 여러 줄 textarea 로 바꾸고, 설명을 markdown-it 로 렌더링한다(html:false 로 raw HTML 이스케이프·javascript: 링크 차단 — XSS 안전). 상세 화면은 마크다운으로, 카드 미리보기는 기호를 걷어낸 순수 텍스트(mdPlain) + 3줄 클램프로 표시. '저장소 README 가져오기' 버튼과 GET /api/readme 라우트를 추가해 repo_url 의 Gitea README 를 설명란으로 불러올 수 있다(gitea.js fetchReadme). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
218 lines
9.2 KiB
JavaScript
218 lines
9.2 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, 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.user = req.session.user || null;
|
|
res.locals.icon = icon;
|
|
res.locals.identicon = identicon;
|
|
res.locals.langColor = langColor;
|
|
res.locals.renderMarkdown = renderMarkdown;
|
|
res.locals.mdPlain = mdPlain;
|
|
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 로그인으로. (실패 시 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, roles, 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");
|
|
});
|
|
});
|
|
|
|
// --- 대시보드 ---
|
|
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));
|
|
});
|
|
|
|
// --- 단독 상세 페이지 (모달의 비-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);
|
|
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 ? "공개로 전환됐습니다 · 피드에 표시됩니다" : "비공개로 전환됐습니다";
|
|
// 모달에서 전환하면 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();
|