feat: 프로필 사진 업로드·프로필 페이지 + 모달/이름 개선

- 프로필 페이지 /u/:sabun 신설 (본인=비공개 포함, 타인=공개만).
  헤더 "내 프로필", 카드·모달·댓글 작성자 클릭 시 프로필로 이동.
- 프로필 사진 업로드: users.avatar(bytea) 저장, GET /avatar/:sabun
  (업로드본 없으면 identicon SVG 폴백), POST /profile/avatar(express.raw).
  브라우저 canvas 크롭(256px webp) — 서버 이미지 라이브러리/멀터 불필요.
  identicon 6개 렌더 지점을 avatar() 헬퍼로 통일.
- 상세 모달 폭 640→760px, 소유자 액션(비공개 전환·삭제)을 우측 그룹으로
  묶어 한 줄 정렬.
- 표시 이름을 한국식 성+이름(family_name+given_name)으로 교정.
- .project-env.example ADMIN_ROLE→ADMIN_GROUP 정리.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:41:51 +09:00
parent b4c9b4a395
commit 6c2bb888b4
14 changed files with 446 additions and 35 deletions

View File

@@ -6,7 +6,7 @@ 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 { icon, identicon, identiconSvg, avatar, langColor, langFromTags, renderMarkdown, mdPlain } from "./helpers.js";
import { fetchMainLang, fetchReadme } from "./gitea.js";
import * as db from "./db.js";
@@ -34,6 +34,7 @@ app.use((req, res, next) => {
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;
@@ -82,6 +83,50 @@ app.get("/logout", (req, res) => {
});
});
// --- 아바타 이미지 ---
// 업로드한 프로필 사진이 있으면 그 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 });
}
);
// --- 프로필 페이지 (본인/타인) — 프로필 + 해당 사용자의 프로젝트 목록 ---
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);
res.render("profile", { profile, projects, isSelf: profile.sabun === me });
});
// --- 대시보드 ---
app.get("/", requireAuth, async (req, res) => {
const me = req.session.user.sabun;