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:
122
public/index.html
Normal file
122
public/index.html
Normal 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) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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>
|
||||||
29
src/db.js
29
src/db.js
@@ -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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user