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:
@@ -45,9 +45,14 @@ export async function handleCallback(req) {
|
||||
// 그룹 기반 관리자 판별. Keycloak group-membership 매퍼가 ID 토큰에 groups 를 넣는다.
|
||||
// full.path=false 라 보통 "DevOps" 형태지만, 설정에 따라 "/DevOps" 일 수 있어 앞 슬래시는 떼고 비교.
|
||||
const groups = (Array.isArray(claims.groups) ? claims.groups : []).map((g) => g.replace(/^\//, ""));
|
||||
// 표시 이름: 한국식 성+이름 순서(예: 홍길동). Keycloak `name` 클레임은 보통 이름+성("길동 홍")이라
|
||||
// family_name(성)+given_name(이름) 을 공백 없이 조합해 교정한다. 둘 다 없으면 name/사번으로 폴백.
|
||||
const fam = (claims.family_name || "").trim();
|
||||
const giv = (claims.given_name || "").trim();
|
||||
const displayName = fam || giv ? fam + giv : claims.name || claims.preferred_username;
|
||||
return {
|
||||
sabun: claims.preferred_username, // 사번
|
||||
name: claims.name || claims.given_name || claims.preferred_username,
|
||||
name: displayName,
|
||||
email: claims.email,
|
||||
groups,
|
||||
isAdmin: groups.includes(config.adminGroup),
|
||||
|
||||
38
src/db.js
38
src/db.js
@@ -17,6 +17,32 @@ export async function upsertUser({ sabun, name, email }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 프로필 사진 저장(본인). 브라우저에서 1:1 크롭·축소한 작은 이미지 버퍼를 그대로 보관.
|
||||
export async function setAvatar({ sabun, buf, mime }) {
|
||||
await q(
|
||||
`UPDATE users SET avatar=$2, avatar_mime=$3, avatar_updated_at=now() WHERE sabun=$1`,
|
||||
[sabun, buf, mime]
|
||||
);
|
||||
}
|
||||
|
||||
// 프로필 사진 조회 → { avatar(Buffer|null), avatar_mime }. 없으면 호출부에서 identicon 폴백.
|
||||
export async function getAvatar(sabun) {
|
||||
const { rows } = await q(`SELECT avatar, avatar_mime FROM users WHERE sabun=$1`, [sabun]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// 프로필 페이지용 사용자 정보 + 프로젝트 수(공개만 카운트가 아니라 전체 — 뷰에서 필요 시 구분).
|
||||
export async function getUserProfile(sabun) {
|
||||
const { rows } = await q(
|
||||
`SELECT u.sabun, u.name, u.email, u.created_at,
|
||||
(u.avatar IS NOT NULL) AS has_avatar,
|
||||
(SELECT count(*) FROM projects p WHERE p.owner_sabun=u.sabun)::int AS project_count
|
||||
FROM users u WHERE u.sabun=$1`,
|
||||
[sabun]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
// 카드/피드에 필요한 카운트 + 현재 사용자(viewer)의 ❤️/⭐ 여부를 함께 계산하는 공통 SELECT 절.
|
||||
// $1 = viewer 사번.
|
||||
const PROJECT_SELECT = `
|
||||
@@ -40,6 +66,18 @@ export async function listProjects(viewer) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 특정 사용자(owner)의 프로젝트 — 프로필 페이지용.
|
||||
// 본인($1===owner)이면 비공개 포함 전체, 타인이면 공개 프로젝트만.
|
||||
export async function listProjectsByOwner(owner, viewer) {
|
||||
const { rows } = await q(
|
||||
`${PROJECT_SELECT}
|
||||
WHERE p.owner_sabun=$2 AND (p.is_public OR p.owner_sabun=$1)
|
||||
ORDER BY p.created_at DESC`,
|
||||
[viewer, owner]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
// 단일 프로젝트 (가시성 검사는 호출부에서: is_public 이거나 본인일 때만 노출)
|
||||
export async function getProject(id, viewer) {
|
||||
const { rows } = await q(`${PROJECT_SELECT} WHERE p.id=$2`, [viewer, id]);
|
||||
|
||||
@@ -101,12 +101,10 @@ function hashSeed(seed) {
|
||||
return h;
|
||||
}
|
||||
|
||||
// 사번 기반 identicon 아바타(5x5 대칭 격자). 디자인과 동일한 시각.
|
||||
export function identicon(seed, size = 28) {
|
||||
// 사번 기반 identicon(5x5 대칭 격자)의 rects+color 계산 코어.
|
||||
function identiconCore(seed) {
|
||||
const h = hashSeed(seed);
|
||||
const hue = h % 360;
|
||||
const sat = 48 + (h % 22);
|
||||
const color = `hsl(${hue},${sat}%,52%)`;
|
||||
const color = `hsl(${h % 360},${48 + (h % 22)}%,52%)`;
|
||||
const grid = 5;
|
||||
const on = new Array(25).fill(false);
|
||||
for (let col = 0; col < 3; col++) {
|
||||
@@ -116,8 +114,7 @@ export function identicon(seed, size = 28) {
|
||||
on[row * 5 + (4 - col)] = !!bit;
|
||||
}
|
||||
}
|
||||
const inner = Math.round(size * 0.74);
|
||||
const cell = inner / grid;
|
||||
const cell = 100 / grid; // viewBox 0..100 기준
|
||||
let rects = "";
|
||||
for (let r = 0; r < grid; r++) {
|
||||
for (let c = 0; c < grid; c++) {
|
||||
@@ -126,9 +123,37 @@ export function identicon(seed, size = 28) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { color, rects };
|
||||
}
|
||||
|
||||
// 사번 기반 identicon 아바타(인라인 span). 디자인과 동일한 시각.
|
||||
export function identicon(seed, size = 28) {
|
||||
const { color, rects } = identiconCore(seed);
|
||||
const inner = Math.round(size * 0.74);
|
||||
const radius = Math.max(5, Math.round(size * 0.2));
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${inner}" height="${inner}" viewBox="0 0 ${inner} ${inner}" fill="${color}" style="display:block">${rects}</svg>`;
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${inner}" height="${inner}" viewBox="0 0 100 100" fill="${color}" style="display:block">${rects}</svg>`;
|
||||
return (
|
||||
`<span class="avatar" style="width:${size}px;height:${size}px;border-radius:${radius}px;">${svg}</span>`
|
||||
);
|
||||
}
|
||||
|
||||
// /avatar/:sabun 엔드포인트가 업로드 사진이 없을 때 응답하는 독립 SVG 문서 문자열.
|
||||
export function identiconSvg(seed, size = 128) {
|
||||
const { color, rects } = identiconCore(seed);
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100" fill="${color}">${rects}</svg>`;
|
||||
}
|
||||
|
||||
// 프로필 사진 <img>. 실제 이미지는 /avatar/:sabun 엔드포인트가 업로드본 또는 identicon SVG 로 응답한다.
|
||||
// (모든 아바타 렌더 지점을 이 헬퍼로 통일 → 뷰마다 업로드 여부를 조회할 필요가 없다.)
|
||||
function escapeAttr(s) {
|
||||
return String(s).replace(/[&"'<>]/g, (c) => ({ "&": "&", '"': """, "'": "'", "<": "<", ">": ">" }[c]));
|
||||
}
|
||||
export function avatar(sabun, size = 28) {
|
||||
const radius = Math.max(5, Math.round(size * 0.2));
|
||||
const src = `/avatar/${encodeURIComponent(sabun)}`;
|
||||
return (
|
||||
`<img class="avatar" src="${src}" alt="" loading="lazy" width="${size}" height="${size}" ` +
|
||||
`style="width:${size}px;height:${size}px;border-radius:${radius}px;object-fit:cover;display:block" ` +
|
||||
`data-avatar="${escapeAttr(sabun)}" />`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user