feat: 방명록(guestbook) 웹사이트 추가

- src/db.js: initGuestbook/listMessages/addMessage (본인 schema에 테이블 생성)
- src/server.js: express.json + public 정적 서빙, GET/POST /api/messages, 시작 시 테이블 자동 생성
- public/index.html: 작성 폼 + 목록 UI (XSS 이스케이프, 길이 검증)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 14:04:13 +09:00
parent 179226b59a
commit 9026a47119
3 changed files with 192 additions and 8 deletions

122
public/index.html Normal file
View File

@@ -0,0 +1,122 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>방명록</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", "Apple SD Gothic Neo", sans-serif;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
color: #1e293b;
display: flex; justify-content: center; padding: 32px 16px;
}
.card {
width: 100%; max-width: 640px;
background: rgba(255,255,255,0.96);
border-radius: 20px; padding: 28px 26px;
box-shadow: 0 20px 60px rgba(0,0,0,0.25);
}
h1 { margin: 0 0 4px; font-size: 1.6rem; }
p.sub { margin: 0 0 20px; color: #64748b; font-size: .9rem; }
form { display: grid; gap: 10px; margin-bottom: 22px; }
input, textarea {
width: 100%; padding: 11px 13px; border: 1px solid #d1d5db;
border-radius: 11px; font-size: .95rem; font-family: inherit; background: #fff;
}
textarea { resize: vertical; min-height: 76px; }
button {
justify-self: end; padding: 10px 22px; border: 0; border-radius: 11px;
background: #6366f1; color: #fff; font-weight: 600; font-size: .95rem; cursor: pointer;
}
button:hover { background: #4f46e5; }
button:disabled { opacity: .6; cursor: default; }
.msg { border-top: 1px solid #eef; padding: 13px 2px; }
.msg:first-child { border-top: 0; }
.msg .head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.msg .name { font-weight: 700; }
.msg .time { color: #94a3b8; font-size: .78rem; }
.msg .body { margin: 5px 0 0; white-space: pre-wrap; word-break: break-word; }
.empty { color: #94a3b8; text-align: center; padding: 24px 0; }
.error { color: #dc2626; font-size: .85rem; min-height: 1.2em; }
</style>
</head>
<body>
<div class="card">
<h1>📖 방명록</h1>
<p class="sub">한 줄 남기고 가세요. (Postgres 에 저장됩니다)</p>
<form id="form">
<input id="name" name="name" placeholder="이름" maxlength="40" required autocomplete="off" />
<textarea id="message" name="message" placeholder="메시지를 남겨주세요" maxlength="500" required></textarea>
<div class="error" id="error"></div>
<button type="submit" id="submit">남기기</button>
</form>
<div id="list"><div class="empty">불러오는 중…</div></div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const list = $("list"), errorEl = $("error");
const esc = (s) => s.replace(/[&<>"']/g, (c) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmt = (iso) => {
const d = new Date(iso);
return d.toLocaleString("ko-KR", { dateStyle: "medium", timeStyle: "short" });
};
function render(messages) {
if (!messages.length) {
list.innerHTML = '<div class="empty">아직 글이 없어요. 첫 글을 남겨보세요!</div>';
return;
}
list.innerHTML = messages.map((m) => `
<div class="msg">
<div class="head">
<span class="name">${esc(m.name)}</span>
<span class="time">${fmt(m.created_at)}</span>
</div>
<p class="body">${esc(m.message)}</p>
</div>`).join("");
}
async function load() {
try {
const r = await fetch("/api/messages");
const data = await r.json();
if (!data.ok) throw new Error(data.error);
render(data.messages);
} catch (e) {
list.innerHTML = `<div class="empty">불러오기 실패: ${esc(String(e.message || e))}</div>`;
}
}
$("form").addEventListener("submit", async (ev) => {
ev.preventDefault();
errorEl.textContent = "";
$("submit").disabled = true;
try {
const r = await fetch("/api/messages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: $("name").value, message: $("message").value }),
});
const data = await r.json();
if (!data.ok) throw new Error(data.error);
$("message").value = "";
await load();
} catch (e) {
errorEl.textContent = String(e.message || e);
} finally {
$("submit").disabled = false;
}
});
load();
</script>
</body>
</html>

View File

@@ -13,3 +13,32 @@ export async function pingDb() {
); );
return rows[0]; return rows[0];
} }
// --- 방명록(guestbook) ---
// 테이블은 search_path에 고정된 본인 schema에 생성된다(schema 접두어 불필요).
export async function initGuestbook() {
await pool.query(`
create table if not exists guestbook (
id bigint generated always as identity primary key,
name text not null,
message text not null,
created_at timestamptz not null default now()
)
`);
}
export async function listMessages(limit = 100) {
const { rows } = await pool.query(
"select id, name, message, created_at from guestbook order by id desc limit $1",
[limit]
);
return rows;
}
export async function addMessage(name, message) {
const { rows } = await pool.query(
"insert into guestbook (name, message) values ($1, $2) returning id, name, message, created_at",
[name, message]
);
return rows[0];
}

View File

@@ -1,10 +1,16 @@
// 최소 Express 서버 — /healthz, /db, /s3 로 연결 확인. // Express 서버 — 방명록(guestbook) 웹사이트 + 연결 확인용 /healthz, /db, /s3.
import express from "express"; import express from "express";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { config } from "./config.js"; import { config } from "./config.js";
import { pingDb } from "./db.js"; import { pingDb, initGuestbook, listMessages, addMessage } from "./db.js";
import { pingS3 } from "./s3.js"; import { pingS3 } from "./s3.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express(); const app = express();
app.use(express.json());
app.use(express.static(join(__dirname, "..", "public")));
app.get("/healthz", (_req, res) => res.json({ ok: true })); app.get("/healthz", (_req, res) => res.json({ ok: true }));
@@ -24,10 +30,37 @@ app.get("/s3", async (_req, res) => {
} }
}); });
app.get("/", (_req, res) => // --- 방명록 API ---
res.json({ name: "sample", endpoints: ["/healthz", "/db", "/s3"] }) app.get("/api/messages", async (_req, res) => {
); try {
res.json({ ok: true, messages: await listMessages() });
app.listen(config.port, "0.0.0.0", () => { } catch (e) {
console.log(`sample listening on :${config.port}`); res.status(500).json({ ok: false, error: String(e.message || e) });
}
});
app.post("/api/messages", async (req, res) => {
try {
const name = String(req.body?.name ?? "").trim();
const message = String(req.body?.message ?? "").trim();
if (!name || !message) {
return res
.status(400)
.json({ ok: false, error: "name 과 message 는 필수입니다." });
}
if (name.length > 40 || message.length > 500) {
return res
.status(400)
.json({ ok: false, error: "이름은 40자, 메시지는 500자 이내." });
}
res.json({ ok: true, message: await addMessage(name, message) });
} catch (e) {
res.status(500).json({ ok: false, error: String(e.message || e) });
}
});
// 앱 시작 전에 테이블 보장 후 리슨.
await initGuestbook();
app.listen(config.port, "0.0.0.0", () => {
console.log(`guestbook listening on :${config.port}`);
}); });