ㅌㅌㅌ
This commit is contained in:
@@ -1,30 +0,0 @@
|
|||||||
# .project-env.example — 프로젝트 설정 예시(참조용).
|
|
||||||
#
|
|
||||||
# 실제 사용: `new-project <이름>` 명령으로 sample을 복사하면 .project-env 가
|
|
||||||
# DB(본인 schema 자동계산)+S3 값이 채워진 채로 자동 생성됩니다.
|
|
||||||
# 이 example 을 직접 복사해 쓸 일은 거의 없습니다(수동 생성 시 참고용).
|
|
||||||
#
|
|
||||||
# 동작: 프로젝트 폴더에 cd 하면 로그인 셸이 .project-env 를 자동 export → 앱은
|
|
||||||
# process.env 로 바로 읽습니다(별도 dotenv 불필요). git에는 올리지 마세요(.gitignore).
|
|
||||||
|
|
||||||
# --- DB (본인 전용 Postgres schema) ---
|
|
||||||
# host 는 클러스터 내부 OpenEverest(postgresql-6ox) pgbouncer. 워크스페이스와 Kubero 배포 앱은
|
|
||||||
# 둘 다 클러스터 내부라 같은 k8s DNS 로 도달(그대로 복붙 가능).
|
|
||||||
DATABASE_URL=postgresql://emp_<사번>:<pw>@postgresql-6ox-pgbouncer.everest.svc.cluster.local:5432/appdb?options=-c%20search_path%3Demp_<사번>
|
|
||||||
PGHOST=postgresql-6ox-pgbouncer.everest.svc.cluster.local
|
|
||||||
PGPORT=5432
|
|
||||||
PGDATABASE=appdb
|
|
||||||
PGUSER=emp_<사번>
|
|
||||||
PGPASSWORD=<pw>
|
|
||||||
PGOPTIONS=-c search_path=emp_<사번>
|
|
||||||
|
|
||||||
# --- MinIO / S3 ---
|
|
||||||
# endpoint 는 콘솔(minioc)이 아니라 API 호스트(minio)입니다.
|
|
||||||
S3_ENDPOINT=https://minio.bokdev.in
|
|
||||||
S3_REGION=us-east-1
|
|
||||||
S3_BUCKET=coolify-user-data
|
|
||||||
S3_ACCESS_KEY_ID=REPLACE_WITH_YOUR_MINIO_ACCESS_KEY
|
|
||||||
S3_SECRET_ACCESS_KEY=REPLACE_WITH_YOUR_MINIO_SECRET_KEY
|
|
||||||
|
|
||||||
# --- 앱 ---
|
|
||||||
PORT=3000
|
|
||||||
1540
package-lock.json
generated
Normal file
1540
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
328
public/index.html
Normal file
328
public/index.html
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>계산기 & 메모장</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0f172a; --panel: #1e293b; --panel2: #334155;
|
||||||
|
--txt: #e2e8f0; --muted: #94a3b8; --accent: #38bdf8; --accent2: #f59e0b;
|
||||||
|
--danger: #ef4444; --border: #334155;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
background: var(--bg); color: var(--txt); line-height: 1.5;
|
||||||
|
}
|
||||||
|
header { padding: 18px 24px; border-bottom: 1px solid var(--border); }
|
||||||
|
header h1 { margin: 0; font-size: 20px; }
|
||||||
|
header p { margin: 4px 0 0; color: var(--muted); font-size: 13px; }
|
||||||
|
.grid {
|
||||||
|
display: grid; gap: 18px; padding: 24px; max-width: 1100px; margin: 0 auto;
|
||||||
|
grid-template-columns: 320px 1fr;
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
.card {
|
||||||
|
background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 16px;
|
||||||
|
}
|
||||||
|
.card h2 { margin: 0 0 12px; font-size: 15px; color: var(--accent); }
|
||||||
|
|
||||||
|
/* 계산기 */
|
||||||
|
.calc-display {
|
||||||
|
background: #0b1220; border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 12px 14px; text-align: right; font-size: 28px; min-height: 48px;
|
||||||
|
overflow-x: auto; white-space: nowrap; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.calc-keys { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||||
|
.calc-keys button {
|
||||||
|
padding: 14px 0; font-size: 18px; border: none; border-radius: 8px;
|
||||||
|
background: var(--panel2); color: var(--txt); cursor: pointer;
|
||||||
|
}
|
||||||
|
.calc-keys button:hover { filter: brightness(1.2); }
|
||||||
|
.calc-keys .op { background: #1d4ed8; }
|
||||||
|
.calc-keys .eq { background: var(--accent); color: #06283d; font-weight: 700; }
|
||||||
|
.calc-keys .fn { background: #475569; color: var(--muted); }
|
||||||
|
.calc-keys .span2 { grid-column: span 2; }
|
||||||
|
|
||||||
|
/* 메모장 */
|
||||||
|
.row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||||
|
input[type=text], textarea {
|
||||||
|
width: 100%; background: #0b1220; color: var(--txt);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px;
|
||||||
|
font-size: 14px; font-family: inherit;
|
||||||
|
}
|
||||||
|
textarea { min-height: 140px; resize: vertical; }
|
||||||
|
button.btn {
|
||||||
|
border: none; border-radius: 8px; padding: 9px 14px; font-size: 14px;
|
||||||
|
cursor: pointer; background: var(--accent); color: #06283d; font-weight: 600;
|
||||||
|
}
|
||||||
|
button.btn.ghost { background: var(--panel2); color: var(--txt); font-weight: 500; }
|
||||||
|
button.btn.danger { background: var(--danger); color: #fff; }
|
||||||
|
button.btn:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
.list { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||||
|
.list li {
|
||||||
|
border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px;
|
||||||
|
margin-bottom: 8px; cursor: pointer; background: #0b1220;
|
||||||
|
}
|
||||||
|
.list li:hover { border-color: var(--accent); }
|
||||||
|
.list li.active { border-color: var(--accent); background: #102132; }
|
||||||
|
.list .t { font-weight: 600; }
|
||||||
|
.list .m { color: var(--muted); font-size: 12px; margin-top: 2px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.file-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 8px; border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 8px 12px; margin-bottom: 8px; background: #0b1220;
|
||||||
|
}
|
||||||
|
.file-row .meta { color: var(--muted); font-size: 12px; }
|
||||||
|
.file-row a { color: var(--accent); text-decoration: none; }
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: var(--panel2); border: 1px solid var(--border); color: var(--txt);
|
||||||
|
padding: 10px 16px; border-radius: 8px; opacity: 0; transition: opacity .2s;
|
||||||
|
pointer-events: none; font-size: 14px;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; }
|
||||||
|
.muted { color: var(--muted); font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>🧮 계산기 & 📝 메모장</h1>
|
||||||
|
<p>계산기 · 메모는 DB · 파일은 오브젝트 스토리지에 저장됩니다.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<!-- 계산기 -->
|
||||||
|
<section class="card" aria-label="계산기">
|
||||||
|
<h2>계산기</h2>
|
||||||
|
<div id="display" class="calc-display">0</div>
|
||||||
|
<div class="calc-keys" id="keys">
|
||||||
|
<button class="fn" data-act="clear">C</button>
|
||||||
|
<button class="fn" data-act="back">⌫</button>
|
||||||
|
<button class="fn" data-val="%">%</button>
|
||||||
|
<button class="op" data-val="/">÷</button>
|
||||||
|
|
||||||
|
<button data-val="7">7</button>
|
||||||
|
<button data-val="8">8</button>
|
||||||
|
<button data-val="9">9</button>
|
||||||
|
<button class="op" data-val="*">×</button>
|
||||||
|
|
||||||
|
<button data-val="4">4</button>
|
||||||
|
<button data-val="5">5</button>
|
||||||
|
<button data-val="6">6</button>
|
||||||
|
<button class="op" data-val="-">−</button>
|
||||||
|
|
||||||
|
<button data-val="1">1</button>
|
||||||
|
<button data-val="2">2</button>
|
||||||
|
<button data-val="3">3</button>
|
||||||
|
<button class="op" data-val="+">+</button>
|
||||||
|
|
||||||
|
<button class="span2" data-val="0">0</button>
|
||||||
|
<button data-val=".">.</button>
|
||||||
|
<button class="eq" data-act="eq">=</button>
|
||||||
|
</div>
|
||||||
|
<p class="muted" style="margin-top:10px">키보드 입력도 가능합니다 (Enter = , Esc = C).</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<!-- 메모장 -->
|
||||||
|
<section class="card" aria-label="메모장" style="margin-bottom:18px">
|
||||||
|
<h2>메모장</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input type="text" id="noteTitle" placeholder="제목" />
|
||||||
|
</div>
|
||||||
|
<textarea id="noteBody" placeholder="메모 내용을 입력하세요…"></textarea>
|
||||||
|
<div class="row" style="margin-top:8px">
|
||||||
|
<button class="btn" id="saveNote">저장</button>
|
||||||
|
<button class="btn ghost" id="newNote">새 메모</button>
|
||||||
|
<button class="btn danger" id="delNote" style="margin-left:auto">삭제</button>
|
||||||
|
</div>
|
||||||
|
<ul class="list" id="noteList"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 파일 저장 -->
|
||||||
|
<section class="card" aria-label="파일">
|
||||||
|
<h2>파일 저장 (오브젝트 스토리지)</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input type="file" id="fileInput" />
|
||||||
|
<button class="btn" id="uploadBtn">업로드</button>
|
||||||
|
</div>
|
||||||
|
<div id="fileList"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const toast = (msg) => {
|
||||||
|
const t = $("toast"); t.textContent = msg; t.classList.add("show");
|
||||||
|
clearTimeout(toast._t); toast._t = setTimeout(() => t.classList.remove("show"), 2200);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- 계산기 ---------------- */
|
||||||
|
const display = $("display");
|
||||||
|
let expr = "";
|
||||||
|
const render = () => (display.textContent = expr || "0");
|
||||||
|
|
||||||
|
function inputVal(v) {
|
||||||
|
// 연산자 중복 방지
|
||||||
|
if (/[+\-*/%]/.test(v) && /[+\-*/%]$/.test(expr)) expr = expr.slice(0, -1);
|
||||||
|
expr += v; render();
|
||||||
|
}
|
||||||
|
function evaluate() {
|
||||||
|
if (!expr) return;
|
||||||
|
try {
|
||||||
|
// 숫자/연산자/소수점/괄호만 허용 후 평가
|
||||||
|
if (!/^[0-9+\-*/%.() ]+$/.test(expr)) throw new Error("invalid");
|
||||||
|
const result = Function('"use strict"; return (' + expr + ")")();
|
||||||
|
if (result === undefined || Number.isNaN(result) || !Number.isFinite(result))
|
||||||
|
throw new Error("invalid");
|
||||||
|
expr = String(result); render();
|
||||||
|
} catch {
|
||||||
|
toast("계산할 수 없는 식입니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$("keys").addEventListener("click", (e) => {
|
||||||
|
const b = e.target.closest("button"); if (!b) return;
|
||||||
|
if (b.dataset.val != null) inputVal(b.dataset.val);
|
||||||
|
else if (b.dataset.act === "clear") { expr = ""; render(); }
|
||||||
|
else if (b.dataset.act === "back") { expr = expr.slice(0, -1); render(); }
|
||||||
|
else if (b.dataset.act === "eq") evaluate();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (document.activeElement && /INPUT|TEXTAREA/.test(document.activeElement.tagName)) return;
|
||||||
|
if (/[0-9+\-*/%.]/.test(e.key)) inputVal(e.key);
|
||||||
|
else if (e.key === "Enter" || e.key === "=") { e.preventDefault(); evaluate(); }
|
||||||
|
else if (e.key === "Escape") { expr = ""; render(); }
|
||||||
|
else if (e.key === "Backspace") { expr = expr.slice(0, -1); render(); }
|
||||||
|
});
|
||||||
|
render();
|
||||||
|
|
||||||
|
/* ---------------- 메모장 ---------------- */
|
||||||
|
let notes = [];
|
||||||
|
let activeId = null;
|
||||||
|
|
||||||
|
async function api(path, opts) {
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
const ct = res.headers.get("content-type") || "";
|
||||||
|
const data = ct.includes("json") ? await res.json() : null;
|
||||||
|
if (!res.ok) throw new Error((data && data.error) || res.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotes() {
|
||||||
|
try {
|
||||||
|
notes = await api("/api/notes");
|
||||||
|
renderNotes();
|
||||||
|
} catch (e) { toast("메모 불러오기 실패: " + e.message); }
|
||||||
|
}
|
||||||
|
function renderNotes() {
|
||||||
|
const ul = $("noteList"); ul.innerHTML = "";
|
||||||
|
for (const n of notes) {
|
||||||
|
const li = document.createElement("li");
|
||||||
|
if (n.id === activeId) li.classList.add("active");
|
||||||
|
li.innerHTML = `<div class="t"></div><div class="m"></div>`;
|
||||||
|
li.querySelector(".t").textContent = n.title || "(제목 없음)";
|
||||||
|
li.querySelector(".m").textContent = (n.body || "").slice(0, 60) || " ";
|
||||||
|
li.onclick = () => selectNote(n.id);
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function selectNote(id) {
|
||||||
|
const n = notes.find((x) => x.id === id); if (!n) return;
|
||||||
|
activeId = id; $("noteTitle").value = n.title || ""; $("noteBody").value = n.body || "";
|
||||||
|
renderNotes();
|
||||||
|
}
|
||||||
|
$("newNote").onclick = () => {
|
||||||
|
activeId = null; $("noteTitle").value = ""; $("noteBody").value = "";
|
||||||
|
renderNotes(); $("noteTitle").focus();
|
||||||
|
};
|
||||||
|
$("saveNote").onclick = async () => {
|
||||||
|
const title = $("noteTitle").value, body = $("noteBody").value;
|
||||||
|
try {
|
||||||
|
if (activeId == null) {
|
||||||
|
const n = await api("/api/notes", {
|
||||||
|
method: "POST", headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ title, body }),
|
||||||
|
});
|
||||||
|
activeId = n.id;
|
||||||
|
} else {
|
||||||
|
await api("/api/notes/" + activeId, {
|
||||||
|
method: "PUT", headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ title, body }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await loadNotes(); toast("메모를 저장했습니다");
|
||||||
|
} catch (e) { toast("저장 실패: " + e.message); }
|
||||||
|
};
|
||||||
|
$("delNote").onclick = async () => {
|
||||||
|
if (activeId == null) { $("newNote").click(); return; }
|
||||||
|
try {
|
||||||
|
await api("/api/notes/" + activeId, { method: "DELETE" });
|
||||||
|
activeId = null; $("noteTitle").value = ""; $("noteBody").value = "";
|
||||||
|
await loadNotes(); toast("메모를 삭제했습니다");
|
||||||
|
} catch (e) { toast("삭제 실패: " + e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ---------------- 파일 ---------------- */
|
||||||
|
async function loadFiles() {
|
||||||
|
try {
|
||||||
|
const files = await api("/api/files");
|
||||||
|
const box = $("fileList"); box.innerHTML = "";
|
||||||
|
if (!files.length) { box.innerHTML = '<p class="muted">저장된 파일이 없습니다.</p>'; return; }
|
||||||
|
for (const f of files) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "file-row";
|
||||||
|
const left = document.createElement("div");
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = "/api/files/" + encodeURIComponent(f.name);
|
||||||
|
a.textContent = f.name; a.download = f.name;
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "meta";
|
||||||
|
meta.textContent = humanSize(f.size) + " · " + new Date(f.lastModified).toLocaleString();
|
||||||
|
left.appendChild(a); left.appendChild(meta);
|
||||||
|
const del = document.createElement("button");
|
||||||
|
del.className = "btn danger"; del.textContent = "삭제";
|
||||||
|
del.onclick = () => deleteFile(f.name);
|
||||||
|
row.appendChild(left); row.appendChild(del);
|
||||||
|
box.appendChild(row);
|
||||||
|
}
|
||||||
|
} catch (e) { toast("파일 목록 실패: " + e.message); }
|
||||||
|
}
|
||||||
|
function humanSize(n) {
|
||||||
|
if (n == null) return "";
|
||||||
|
const u = ["B", "KB", "MB", "GB"]; let i = 0;
|
||||||
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||||
|
return (i ? n.toFixed(1) : n) + u[i];
|
||||||
|
}
|
||||||
|
$("uploadBtn").onclick = async () => {
|
||||||
|
const input = $("fileInput");
|
||||||
|
const file = input.files && input.files[0];
|
||||||
|
if (!file) { toast("업로드할 파일을 선택하세요"); return; }
|
||||||
|
try {
|
||||||
|
await api("/api/files/" + encodeURIComponent(file.name), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": file.type || "application/octet-stream" },
|
||||||
|
body: file,
|
||||||
|
});
|
||||||
|
input.value = ""; await loadFiles(); toast("파일을 업로드했습니다");
|
||||||
|
} catch (e) { toast("업로드 실패: " + e.message); }
|
||||||
|
};
|
||||||
|
async function deleteFile(name) {
|
||||||
|
try {
|
||||||
|
await api("/api/files/" + encodeURIComponent(name), { method: "DELETE" });
|
||||||
|
await loadFiles(); toast("파일을 삭제했습니다");
|
||||||
|
} catch (e) { toast("삭제 실패: " + e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNotes();
|
||||||
|
loadFiles();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -12,6 +12,9 @@ export const config = {
|
|||||||
|
|
||||||
// Postgres — .project-env 의 DATABASE_URL. Kubero에선 본인 DB 접속정보를 넣을 것.
|
// Postgres — .project-env 의 DATABASE_URL. Kubero에선 본인 DB 접속정보를 넣을 것.
|
||||||
databaseUrl: process.env.DATABASE_URL || "",
|
databaseUrl: process.env.DATABASE_URL || "",
|
||||||
|
// pgbouncer 는 SSL 을 요구한다. 내부 클러스터 사설 인증서라 검증은 생략(require).
|
||||||
|
// 끄려면 PGSSLMODE=disable.
|
||||||
|
dbSslMode: (process.env.PGSSLMODE || "require").toLowerCase(),
|
||||||
|
|
||||||
// MinIO / S3 호환
|
// MinIO / S3 호환
|
||||||
s3: {
|
s3: {
|
||||||
|
|||||||
28
src/db.js
28
src/db.js
@@ -2,8 +2,34 @@
|
|||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
|
|
||||||
|
// SSL: pgbouncer 가 require 하지만 내부 사설 인증서라 검증은 생략한다.
|
||||||
|
const ssl =
|
||||||
|
config.dbSslMode === "disable" || config.dbSslMode === "off"
|
||||||
|
? false
|
||||||
|
: { rejectUnauthorized: false };
|
||||||
|
|
||||||
|
// pgbouncer 는 startup 의 `options`(=-c search_path=...) 파라미터를 거부한다.
|
||||||
|
// search_path 는 role 기본값으로 이미 본인 schema 에 고정돼 있으므로 options 는 불필요.
|
||||||
|
// 연결문자열에 섞여 있으면 떼어낸다.
|
||||||
|
function cleanConnectionString(url) {
|
||||||
|
if (!url) return url;
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
u.searchParams.delete("options");
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pg 는 config.options 가 비어 있으면 PGOPTIONS 환경변수로 폴백해 startup options 를
|
||||||
|
// 그대로 보낸다(.project-env 가 로그인 셸에서 자동 export 되므로 항상 새어든다).
|
||||||
|
// pgbouncer 가 이를 거부하므로 아예 제거한다. search_path 는 role 기본값으로 고정돼 있다.
|
||||||
|
delete process.env.PGOPTIONS;
|
||||||
|
|
||||||
export const pool = new pg.Pool({
|
export const pool = new pg.Pool({
|
||||||
connectionString: config.databaseUrl,
|
connectionString: cleanConnectionString(config.databaseUrl),
|
||||||
|
ssl,
|
||||||
max: 5,
|
max: 5,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
56
src/notes.js
Normal file
56
src/notes.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// 메모장 저장소 — Postgres(본인 schema)에 notes 테이블로 보관.
|
||||||
|
// DATABASE_URL 의 search_path 가 직원 전용 schema 로 고정되어 있으므로
|
||||||
|
// 테이블은 그 schema 안에 만들어진다.
|
||||||
|
import { pool } from "./db.js";
|
||||||
|
|
||||||
|
let ready;
|
||||||
|
|
||||||
|
// 최초 1회 테이블을 만든다(없으면). 이후 호출은 같은 Promise 를 재사용.
|
||||||
|
function ensureTable() {
|
||||||
|
if (!ready) {
|
||||||
|
ready = pool.query(`
|
||||||
|
create table if not exists notes (
|
||||||
|
id bigint generated always as identity primary key,
|
||||||
|
title text not null default '',
|
||||||
|
body text not null default '',
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listNotes() {
|
||||||
|
await ensureTable();
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"select id, title, body, created_at, updated_at from notes order by updated_at desc"
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createNote(title, body) {
|
||||||
|
await ensureTable();
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
"insert into notes (title, body) values ($1, $2) returning id, title, body, created_at, updated_at",
|
||||||
|
[title ?? "", body ?? ""]
|
||||||
|
);
|
||||||
|
return rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNote(id, title, body) {
|
||||||
|
await ensureTable();
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`update notes set title = $2, body = $3, updated_at = now()
|
||||||
|
where id = $1
|
||||||
|
returning id, title, body, created_at, updated_at`,
|
||||||
|
[id, title ?? "", body ?? ""]
|
||||||
|
);
|
||||||
|
return rows[0]; // 없으면 undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteNote(id) {
|
||||||
|
await ensureTable();
|
||||||
|
const { rowCount } = await pool.query("delete from notes where id = $1", [id]);
|
||||||
|
return rowCount > 0;
|
||||||
|
}
|
||||||
69
src/s3.js
69
src/s3.js
@@ -1,7 +1,17 @@
|
|||||||
// MinIO(S3 호환) 클라이언트. 자격증명은 env에서만 읽는다.
|
// MinIO(S3 호환) 클라이언트. 자격증명은 env에서만 읽는다.
|
||||||
import { S3Client, ListObjectsV2Command, HeadBucketCommand } from "@aws-sdk/client-s3";
|
import {
|
||||||
|
S3Client,
|
||||||
|
ListObjectsV2Command,
|
||||||
|
HeadBucketCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
DeleteObjectCommand,
|
||||||
|
} from "@aws-sdk/client-s3";
|
||||||
import { config } from "./config.js";
|
import { config } from "./config.js";
|
||||||
|
|
||||||
|
// 이 앱이 올린 파일은 버킷 안에서 이 prefix 아래에만 둔다(다른 데이터와 격리).
|
||||||
|
const FILE_PREFIX = "calc-files/";
|
||||||
|
|
||||||
export const s3 = new S3Client({
|
export const s3 = new S3Client({
|
||||||
endpoint: config.s3.endpoint,
|
endpoint: config.s3.endpoint,
|
||||||
region: config.s3.region,
|
region: config.s3.region,
|
||||||
@@ -22,3 +32,60 @@ export async function pingS3() {
|
|||||||
sampleKeys: (out.Contents || []).map((o) => o.Key),
|
sampleKeys: (out.Contents || []).map((o) => o.Key),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 파일명이 prefix 밖(상위 경로 등)으로 새지 않도록 안전한 키로 정규화한다.
|
||||||
|
function fileKey(name) {
|
||||||
|
const clean = String(name)
|
||||||
|
.replace(/^\/+/, "")
|
||||||
|
.replace(/\.{2,}/g, "")
|
||||||
|
.replace(/[/\\]/g, "_")
|
||||||
|
.trim();
|
||||||
|
if (!clean) throw new Error("파일명이 비어있습니다");
|
||||||
|
return FILE_PREFIX + clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 업로드된 파일 목록(이 앱 prefix 아래만).
|
||||||
|
export async function listFiles() {
|
||||||
|
const out = await s3.send(
|
||||||
|
new ListObjectsV2Command({ Bucket: config.s3.bucket, Prefix: FILE_PREFIX })
|
||||||
|
);
|
||||||
|
return (out.Contents || [])
|
||||||
|
.filter((o) => o.Key !== FILE_PREFIX)
|
||||||
|
.map((o) => ({
|
||||||
|
name: o.Key.slice(FILE_PREFIX.length),
|
||||||
|
size: o.Size,
|
||||||
|
lastModified: o.LastModified,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.lastModified > a.lastModified ? 1 : -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putFile(name, body, contentType) {
|
||||||
|
await s3.send(
|
||||||
|
new PutObjectCommand({
|
||||||
|
Bucket: config.s3.bucket,
|
||||||
|
Key: fileKey(name),
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentType || "application/octet-stream",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return { name: String(name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다운로드용 스트림과 메타데이터를 돌려준다.
|
||||||
|
export async function getFile(name) {
|
||||||
|
const out = await s3.send(
|
||||||
|
new GetObjectCommand({ Bucket: config.s3.bucket, Key: fileKey(name) })
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
body: out.Body, // Node Readable 스트림
|
||||||
|
contentType: out.ContentType,
|
||||||
|
contentLength: out.ContentLength,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFile(name) {
|
||||||
|
await s3.send(
|
||||||
|
new DeleteObjectCommand({ Bucket: config.s3.bucket, Key: fileKey(name) })
|
||||||
|
);
|
||||||
|
return { name: String(name) };
|
||||||
|
}
|
||||||
|
|||||||
101
src/server.js
101
src/server.js
@@ -1,33 +1,96 @@
|
|||||||
// 최소 Express 서버 — /healthz, /db, /s3 로 연결 확인.
|
// 계산기 + 메모장(파일 저장) 앱.
|
||||||
|
// - 정적 UI: public/index.html
|
||||||
|
// - 메모: Postgres (src/notes.js)
|
||||||
|
// - 파일: MinIO/S3 (src/s3.js)
|
||||||
|
// - 연결 확인: /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 } from "./db.js";
|
||||||
import { pingS3 } from "./s3.js";
|
import {
|
||||||
|
pingS3,
|
||||||
|
listFiles,
|
||||||
|
putFile,
|
||||||
|
getFile,
|
||||||
|
deleteFile,
|
||||||
|
} from "./s3.js";
|
||||||
|
import {
|
||||||
|
listNotes,
|
||||||
|
createNote,
|
||||||
|
updateNote,
|
||||||
|
deleteNote,
|
||||||
|
} from "./notes.js";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
|
||||||
|
// 핸들러를 감싸 에러를 500 JSON 으로 일관 처리.
|
||||||
|
const wrap = (fn) => (req, res) =>
|
||||||
|
Promise.resolve(fn(req, res)).catch((e) =>
|
||||||
|
res.status(500).json({ ok: false, error: String(e.message || e) })
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- 연결 확인 (기존 유지) ----
|
||||||
app.get("/healthz", (_req, res) => res.json({ ok: true }));
|
app.get("/healthz", (_req, res) => res.json({ ok: true }));
|
||||||
|
app.get("/db", wrap(async (_req, res) => res.json({ ok: true, ...(await pingDb()) })));
|
||||||
|
app.get("/s3", wrap(async (_req, res) => res.json({ ok: true, ...(await pingS3()) })));
|
||||||
|
|
||||||
app.get("/db", async (_req, res) => {
|
// ---- 메모장 API (Postgres) ----
|
||||||
try {
|
app.get("/api/notes", wrap(async (_req, res) => res.json(await listNotes())));
|
||||||
res.json({ ok: true, ...(await pingDb()) });
|
|
||||||
} catch (e) {
|
|
||||||
res.status(500).json({ ok: false, error: String(e.message || e) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/s3", async (_req, res) => {
|
app.post("/api/notes", wrap(async (req, res) => {
|
||||||
try {
|
const { title, body } = req.body || {};
|
||||||
res.json({ ok: true, ...(await pingS3()) });
|
res.status(201).json(await createNote(title, body));
|
||||||
} catch (e) {
|
}));
|
||||||
res.status(500).json({ ok: false, error: String(e.message || e) });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/", (_req, res) =>
|
app.put("/api/notes/:id", wrap(async (req, res) => {
|
||||||
res.json({ name: "sample", endpoints: ["/healthz", "/db", "/s3"] })
|
const { title, body } = req.body || {};
|
||||||
|
const note = await updateNote(req.params.id, title, body);
|
||||||
|
if (!note) return res.status(404).json({ ok: false, error: "메모를 찾을 수 없습니다" });
|
||||||
|
res.json(note);
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.delete("/api/notes/:id", wrap(async (req, res) => {
|
||||||
|
const ok = await deleteNote(req.params.id);
|
||||||
|
if (!ok) return res.status(404).json({ ok: false, error: "메모를 찾을 수 없습니다" });
|
||||||
|
res.json({ ok: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---- 파일 API (MinIO/S3) ----
|
||||||
|
app.get("/api/files", wrap(async (_req, res) => res.json(await listFiles())));
|
||||||
|
|
||||||
|
// 업로드: 파일 바이트를 raw body 로 받는다(브라우저 fetch 의 Blob 그대로).
|
||||||
|
app.put(
|
||||||
|
"/api/files/:name",
|
||||||
|
express.raw({ type: "*/*", limit: "50mb" }),
|
||||||
|
wrap(async (req, res) => {
|
||||||
|
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from([]);
|
||||||
|
res.status(201).json(await putFile(req.params.name, body, req.get("content-type")));
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get("/api/files/:name", wrap(async (req, res) => {
|
||||||
|
const f = await getFile(req.params.name);
|
||||||
|
res.setHeader("Content-Type", f.contentType || "application/octet-stream");
|
||||||
|
if (f.contentLength != null) res.setHeader("Content-Length", f.contentLength);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename*=UTF-8''${encodeURIComponent(req.params.name)}`
|
||||||
|
);
|
||||||
|
f.body.pipe(res);
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.delete("/api/files/:name", wrap(async (req, res) => {
|
||||||
|
await deleteFile(req.params.name);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ---- 정적 UI ----
|
||||||
|
app.use(express.static(join(__dirname, "..", "public")));
|
||||||
|
|
||||||
app.listen(config.port, "0.0.0.0", () => {
|
app.listen(config.port, "0.0.0.0", () => {
|
||||||
console.log(`sample listening on :${config.port}`);
|
console.log(`calc listening on :${config.port}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user