Compare commits

..

30 Commits

Author SHA1 Message Date
DESKTOP-KSVGT20\shkim 58f16c1dd6 사진 모집 서버 종료 2026-06-05 12:53:43 +09:00
Codex 14f7f33023 Add photoreq admin allowed IP 2026-06-01 10:28:54 +09:00
DESKTOP-KSVGT20\shkim 8aec7abd01 Cloudflare 프록시 환경의 관리자 IP 판별 보정 2026-05-18 02:00:38 +09:00
DESKTOP-KSVGT20\shkim 5cb2919e84 안내 변경 2026-05-17 11:52:32 +09:00
DESKTOP-KSVGT20\shkim 31e0f8e5ca 이상한 접근 금지 2026-05-17 11:13:01 +09:00
DESKTOP-KSVGT20\shkim f9634b37a1 관리자 페이지 IP 허용 목록 적용 2026-05-17 11:07:28 +09:00
DESKTOP-KSVGT20\shkim 290cebd2cc 루트 경로 관리자 리다이렉트 제거 2026-05-14 00:34:03 +09:00
DESKTOP-KSVGT20\shkim ad04e1b5c7 익명 제출 안내 표시 위치 조정 2026-05-13 23:21:25 +09:00
DESKTOP-KSVGT20\shkim c626f392a8 연락처 입력 기본값 설정 2026-05-13 23:19:28 +09:00
DESKTOP-KSVGT20\shkim 2ec0ab6697 업로드 연락처 라벨 크기 조정 2026-05-13 23:16:40 +09:00
DESKTOP-KSVGT20\shkim da1b761975 업로드 연락처 안내 문구 개선 2026-05-13 23:13:54 +09:00
DESKTOP-KSVGT20\shkim 02711a5593 모바일 업로드 안내 문구 크기 조정 2026-05-13 23:10:37 +09:00
DESKTOP-KSVGT20\shkim b395184e49 업로드 빈 사진 문구 수정 2026-05-13 23:07:30 +09:00
DESKTOP-KSVGT20\shkim 5553903e71 업로드 개인정보 안내 문구 수정 2026-05-13 23:06:57 +09:00
DESKTOP-KSVGT20\shkim 735c05ed1e 업로드 연락처 입력과 저장 폴더 정리 2026-05-13 23:03:44 +09:00
DESKTOP-KSVGT20\shkim 648974960e Add admin upload submissions view 2026-05-11 23:03:15 +09:00
DESKTOP-KSVGT20\shkim 7f4e814d94 Require guest contact for uploads 2026-05-11 22:50:55 +09:00
DESKTOP-KSVGT20\shkim 2a01704b02 Enlarge selected file thumbnails 2026-05-11 00:44:48 +09:00
DESKTOP-KSVGT20\shkim 23b3e1ea4e Adjust upload button height 2026-05-11 00:24:55 +09:00
DESKTOP-KSVGT20\shkim 9489bfa5d6 Refine empty file selection state 2026-05-11 00:20:53 +09:00
DESKTOP-KSVGT20\shkim 8722149499 Show upload action after file selection 2026-05-11 00:15:17 +09:00
DESKTOP-KSVGT20\shkim ed4a27d0ab Tighten selected file rows 2026-05-11 00:08:22 +09:00
DESKTOP-KSVGT20\shkim 31316f1bd9 Add selected file thumbnails 2026-05-11 00:01:48 +09:00
DESKTOP-KSVGT20\shkim 2b88f4bfab Use single wedding illustration 2026-05-10 23:43:52 +09:00
DESKTOP-KSVGT20\shkim 24564d6c35 Restore select button border 2026-05-10 23:29:50 +09:00
DESKTOP-KSVGT20\shkim 49766d149e Clean up wedding upload background 2026-05-10 23:27:51 +09:00
DESKTOP-KSVGT20\shkim 4525476945 Use mobile-style poster on desktop 2026-05-10 23:14:52 +09:00
DESKTOP-KSVGT20\shkim 6943e6ef3e Lift today label over poster 2026-05-10 22:55:14 +09:00
DESKTOP-KSVGT20\shkim 4e149a500d Recolor wedding gate shadows 2026-05-10 22:52:41 +09:00
DESKTOP-KSVGT20\shkim f326643f24 Soften wedding gate background 2026-05-10 22:43:40 +09:00
11 changed files with 735 additions and 246 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+2 -2
View File
@@ -32,9 +32,9 @@ The server cron runs this once per minute.
- DB: `data/db.json`
- Deploy log: `data/deploy.log`
- Uploads: `/volume2/AKA Drive/사진 받기`
- Admin allowlist: `ADMIN_ALLOWED_IPS` comma-separated IPs, default `112.159.64.27,162.12.184.41,192.168.219.100,192.168.219.1`
- Upload page template: `src/public/upload.html`
- Upload page style: `src/public/upload.css`
- Upload page images: `src/public/wedding-gate-bg.png`, `src/public/wedding-couple.png`
- Optional thank-you illustration: `src/public/wedding-thanks-couple.png`
- Upload page illustration: `src/public/wedding-illustration.png`
Each request creates its own folder under the upload directory.
+2 -3
View File
@@ -24,7 +24,6 @@ echo "$(date '+%Y-%m-%d %H:%M:%S') deploy start" >> "$LOG_FILE"
git -c credential.helper="store --file=/var/services/homes/whitekomani/.git-credentials" fetch origin main >> "$LOG_FILE" 2>&1
git reset --hard origin/main >> "$LOG_FILE" 2>&1
/usr/local/bin/docker build -t photoreq:latest . >> "$LOG_FILE" 2>&1
./run.sh >> "$LOG_FILE" 2>&1
/usr/local/bin/docker rm -f photoreq >> "$LOG_FILE" 2>&1 || true
echo "$(date '+%Y-%m-%d %H:%M:%S') deploy done $(git rev-parse --short HEAD)" >> "$LOG_FILE"
echo "$(date '+%Y-%m-%d %H:%M:%S') deploy stopped photoreq $(git rev-parse --short HEAD)" >> "$LOG_FILE"
+1 -11
View File
@@ -2,16 +2,6 @@
set -eu
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
UPLOAD_DIR="/volume2/AKA Drive/사진 받기"
/usr/local/bin/docker rm -f photoreq >/dev/null 2>&1 || true
/usr/local/bin/docker run -d \
--name photoreq \
--restart unless-stopped \
-p 18082:8080 \
--env-file "$APP_DIR/.env" \
-e PUBLIC_BASE_URL="https://photoreq.mokaya.org" \
-e MAX_FIELDS_SIZE_MB="2" \
-v "$APP_DIR/data:/data" \
-v "$UPLOAD_DIR:/uploads" \
photoreq:latest
echo "photoreq is closed; container removed from $APP_DIR"
+193 -145
View File
@@ -11,6 +11,10 @@
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
body {
margin: 0;
min-height: 100vh;
@@ -24,106 +28,46 @@ body {
min-height: 100vh;
margin: 0 auto;
padding: 0;
background:
radial-gradient(ellipse at 50% 22%, rgba(214, 214, 208, 0.28), transparent 56%),
radial-gradient(ellipse at 50% 54%, rgba(235, 235, 230, 0.34), transparent 62%),
linear-gradient(180deg, #ffffff 0%, #fbfbfa 74%, #ffffff 100%);
background: #ffffff;
}
.wedding-card {
width: min(100%, 720px);
width: min(100%, 430px);
min-height: 100vh;
margin: 0 auto;
padding: 0 clamp(12px, 3.6vw, 34px) 38px;
background:
radial-gradient(ellipse at 50% 30%, rgba(218, 218, 212, 0.28), transparent 58%),
radial-gradient(circle at 50% 35%, rgba(255, 255, 255, 0.7), transparent 48%),
linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #fbfbfa 82%, #ffffff 100%);
padding: 0 0 38px;
background: #ffffff;
text-align: center;
}
.poster {
position: relative;
width: min(100%, 900px);
width: 100%;
margin: 0 auto;
aspect-ratio: 3 / 2;
aspect-ratio: 538 / 795;
overflow: hidden;
padding: 0;
}
.poster::before {
content: "";
content: none;
}
.poster__art {
position: absolute;
inset: 0;
z-index: 0;
border-radius: 0 0 22px 22px;
background: rgba(232, 232, 226, 0.38);
filter: blur(24px);
transform: translateY(12px) scale(0.96);
pointer-events: none;
}
.poster__gate,
.poster__portrait,
.poster__couple {
position: relative;
display: block;
}
.poster__gate {
position: absolute;
inset: 0 50% 0 auto;
z-index: 3;
z-index: 1;
width: 100%;
height: 100%;
max-width: none;
object-fit: cover;
object-fit: contain;
object-position: center top;
transform: translateX(50%);
opacity: 1;
filter: saturate(0.88) contrast(0.92) brightness(1.04) drop-shadow(0 18px 30px rgba(88, 88, 80, 0.05));
}
.poster__portrait {
position: absolute;
z-index: 2;
left: 34%;
right: 34%;
top: 13%;
bottom: 16.5%;
overflow: hidden;
background: #ffffff;
border-radius: 50% 50% 4px 4px / 14% 14% 4px 4px;
}
.poster__couple {
position: absolute;
left: 51.5%;
top: 13%;
width: 148%;
height: auto;
max-width: none;
transform: translateX(-50%);
filter: drop-shadow(0 12px 18px rgba(65, 65, 58, 0.08));
}
.poster__plate {
position: absolute;
z-index: 5;
left: 8.5%;
right: 8.5%;
bottom: 10.5%;
padding: clamp(16px, 3.5vw, 25px) 12px clamp(13px, 2.6vw, 20px);
background: rgba(255, 255, 255, 0.78);
border-top: 1px solid rgba(55, 55, 55, 0.12);
border-bottom: 1px solid rgba(55, 55, 55, 0.12);
backdrop-filter: blur(2px);
}
h1 {
margin: 0;
color: #565656;
font-size: clamp(32px, 7.5vw, 50px);
font-size: clamp(32px, 7.5vw, 43px);
line-height: 1.1;
font-weight: 400;
letter-spacing: 0.12em;
@@ -140,19 +84,33 @@ h1 {
.poster__date {
margin-top: clamp(12px, 2.8vw, 20px);
font-size: clamp(16px, 3.3vw, 24px);
font-size: clamp(15px, 3.3vw, 19px);
font-weight: 700;
}
.poster__subtitle {
margin-top: clamp(14px, 3vw, 22px);
font-size: clamp(15px, 3.3vw, 24px);
font-size: clamp(14px, 3.3vw, 19px);
font-weight: 700;
}
.upload-panel {
width: min(100%, 430px);
margin: clamp(-24px, -3vw, -12px) auto 0;
position: relative;
z-index: 8;
margin: clamp(-58px, -6vw, -34px) auto 0;
}
.upload-panel::before {
content: "";
position: absolute;
z-index: -1;
left: 0;
right: 0;
top: -18px;
bottom: -28px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92) 0%, #ffffff 34%, #ffffff 100%);
pointer-events: none;
}
.upload-panel__today {
@@ -161,13 +119,14 @@ h1 {
justify-content: center;
gap: clamp(10px, 2.4vw, 16px);
margin: 0 auto clamp(14px, 2.8vw, 20px);
padding: 11px clamp(12px, 3vw, 22px) 9px;
padding: 6px clamp(8px, 2vw, 16px) 5px;
width: fit-content;
max-width: 100%;
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 8px 22px rgba(120, 120, 112, 0.08);
backdrop-filter: blur(3px);
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
color: #4f4f4f;
font-size: clamp(24px, 4.8vw, 35px);
line-height: 1.15;
@@ -189,6 +148,15 @@ h1 {
transform: scaleX(-1);
}
.upload-panel__notice {
margin: -6px auto 16px;
color: #5f6f6b;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
font-size: 14px;
line-height: 1.55;
word-break: keep-all;
}
.select-button,
.upload-button,
.upload-again {
@@ -196,7 +164,7 @@ h1 {
display: grid;
place-items: center;
width: 100%;
min-height: clamp(68px, 12vw, 86px);
min-height: clamp(60px, 12vw, 80px);
border: 0;
border-radius: 7px;
padding: 15px 24px;
@@ -204,10 +172,7 @@ h1 {
radial-gradient(ellipse at 50% 0%, rgba(255, 255, 255, 0.13), transparent 58%),
linear-gradient(90deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0) 30% 70%, rgba(255, 255, 255, 0.05)),
linear-gradient(180deg, #1c5d57 0%, #155149 100%);
box-shadow:
0 14px 24px rgba(22, 72, 67, 0.17),
inset 0 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -8px 16px rgba(0, 0, 0, 0.09);
box-shadow: none;
color: #ffffff;
font: inherit;
font-size: clamp(27px, 5.2vw, 37px);
@@ -220,16 +185,62 @@ h1 {
.select-button {
margin-bottom: 14px;
background:
radial-gradient(ellipse at 50% 0%, rgba(255, 255, 255, 0.2), transparent 58%),
linear-gradient(180deg, #f8f8f6 0%, #eeeee9 100%);
box-shadow:
0 9px 18px rgba(85, 85, 85, 0.11),
inset 0 0 0 1px rgba(83, 83, 83, 0.14);
background: #ffffff;
box-shadow: inset 0 0 0 1.5px rgba(137, 166, 158, 0.55);
color: #565656;
font-size: clamp(24px, 4.6vw, 32px);
}
.upload-button {
margin-bottom: 14px;
}
.guest-fields {
display: grid;
gap: 10px;
margin: 0 0 14px;
text-align: left;
}
.guest-fields label {
display: grid;
gap: 6px;
color: #45615c;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
font-size: 16px;
font-weight: 700;
}
.guest-fields input {
width: 100%;
min-height: 46px;
border: 1px solid rgba(137, 166, 158, 0.38);
border-radius: 7px;
padding: 0 14px;
background: #ffffff;
color: #3f3f3f;
font: inherit;
font-size: 15px;
font-weight: 500;
}
.guest-fields input::placeholder {
color: #9aa4a1;
}
.guest-fields input:focus {
outline: 2px solid rgba(137, 170, 163, 0.6);
outline-offset: 2px;
}
.guest-fields__anonymous {
color: #a64635;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
font-size: 13px;
font-weight: 700;
line-height: 1.45;
}
.file-picker input[type="file"] {
position: absolute;
inset: 0;
@@ -240,15 +251,24 @@ h1 {
}
.file-summary {
position: relative;
margin: 0 0 14px;
padding: 16px 18px;
border: 1px solid rgba(137, 166, 158, 0.35);
border-radius: 8px;
background: rgba(255, 255, 255, 0.86);
box-shadow: 0 8px 18px rgba(55, 55, 55, 0.06);
background: #ffffff;
box-shadow: none;
text-align: left;
}
.upload-panel:not([data-has-files="true"]) .file-summary {
padding: 16px 0 6px;
border: 0;
border-radius: 0;
background: transparent;
text-align: center;
}
.file-summary__title {
margin: 0 0 10px;
color: #45615c;
@@ -257,33 +277,87 @@ h1 {
font-weight: 700;
}
.upload-panel:not([data-has-files="true"]) .file-summary__title {
display: none;
}
.file-summary__empty {
margin: 0;
padding: 8px 0 4px;
color: #242424;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
font-size: 21px;
font-weight: 800;
line-height: 1.35;
text-align: center;
}
.file-list {
display: grid;
gap: 8px;
display: flex;
flex-wrap: wrap;
gap: 7px;
margin: 0;
padding: 0;
list-style: none;
max-height: 301px;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 4px;
}
.file-list li {
flex: 0 0 80px;
width: 80px;
height: 80px;
padding: 0;
}
.file-list__preview {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
color: #4f4f4f;
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
font-size: 13px;
}
.file-list span:first-child {
place-items: center;
width: 80px;
aspect-ratio: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border-radius: 6px;
background: #f7f8f6;
color: #8aa69f;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.file-list span:last-child {
color: #87928f;
font-size: 12px;
.file-list__preview img,
.file-list__preview video {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.file-list__top {
position: absolute;
right: 12px;
bottom: 12px;
width: 42px;
height: 42px;
border: 0;
border-radius: 50%;
background: var(--deep);
box-shadow: none;
cursor: pointer;
}
.file-list__top::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-bottom: 10px solid #ffffff;
transform: translate(-50%, -62%);
}
.upload-status {
@@ -398,57 +472,27 @@ h1 {
@media (max-width: 760px) {
.upload-page {
background:
radial-gradient(ellipse at 50% 18%, rgba(214, 214, 208, 0.26), transparent 58%),
radial-gradient(ellipse at 50% 52%, rgba(235, 235, 230, 0.32), transparent 64%),
linear-gradient(180deg, #ffffff 0%, #fbfbfa 78%, #ffffff 100%);
background: #ffffff;
}
.wedding-card {
padding: 0 0 30px;
background:
radial-gradient(ellipse at 50% 30%, rgba(218, 218, 212, 0.24), transparent 58%),
radial-gradient(circle at 50% 33%, rgba(255, 255, 255, 0.66), transparent 48%),
linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #fbfbfa 82%, #ffffff 100%);
background: #ffffff;
}
.poster {
width: 100%;
padding: 0;
aspect-ratio: 390 / 545;
aspect-ratio: 538 / 795;
}
.poster::before {
inset: 0 8px;
filter: blur(18px);
}
.poster__gate {
width: 212%;
opacity: 1;
}
.poster__portrait {
left: 18%;
right: 18%;
top: 13%;
bottom: 24.8%;
}
.poster__couple {
top: 14%;
left: 51.5%;
width: 145%;
}
.poster__plate {
left: 8%;
right: 8%;
bottom: 10.5%;
content: none;
}
.upload-panel {
width: min(calc(100% - 48px), 340px);
margin-top: -54px;
}
.thank-you {
@@ -458,4 +502,8 @@ h1 {
.upload-panel__today {
font-size: clamp(23px, 6.5vw, 29px);
}
.upload-panel__notice {
font-size: 15px;
}
}
+84 -23
View File
@@ -8,37 +8,39 @@
</head>
<body>
<main class="upload-page">
<section class="wedding-card" aria-labelledby="archive-title">
<section class="wedding-card" aria-label="{{title}}">
<div class="poster">
<div class="poster__portrait" aria-hidden="true">
<img class="poster__couple" src="/assets/wedding-couple.png" alt="">
</div>
<img class="poster__gate" src="/assets/wedding-gate-bg.png" alt="">
<div class="poster__plate">
<h1 id="archive-title">{{title}}</h1>
<p class="poster__date">2026.05.24 SUN</p>
<p class="poster__subtitle">OUR WEDDING ARCHIVE</p>
</div>
<img class="poster__art" src="/assets/wedding-illustration.png?v=single-illustration-1" alt="">
</div>
<form class="upload-panel" method="post" action="/r/{{slug}}" enctype="multipart/form-data">
<p class="upload-panel__today">오늘의 순간</p>
<p class="upload-panel__notice">※ 연락처는 상품 전달 목적 외에는 사용되지 않으며, 전달 후 삭제됩니다.</p>
<div class="guest-fields" aria-label="업로드하시는 분 정보">
<label>
<span>연락처</span>
<input type="tel" name="guestPhone" autocomplete="tel" inputmode="tel" value="010-">
<span class="guest-fields__anonymous" hidden>※ 익명 제출 시 추첨 대상에서 제외됩니다.</span>
</label>
</div>
<button class="upload-button" type="submit" aria-label="업로드" hidden>
<span>업로드하기</span>
</button>
<label class="select-button file-picker">
<span>사진 선택하기</span>
<input type="file" name="files" accept="{{accept}}" multiple required>
</label>
<div class="file-summary" hidden>
<div class="file-summary">
<p class="file-summary__title">선택한 파일</p>
<p class="file-summary__empty">등록된 사진이 없습니다.</p>
<ul class="file-list"></ul>
<button class="file-list__top" type="button" aria-label="선택한 파일 목록 맨 위로 이동" hidden></button>
</div>
<button class="upload-button" type="submit" aria-label="업로드">
<span>업로드하기</span>
</button>
<p class="upload-status" aria-live="polite"></p>
</form>
@@ -53,12 +55,18 @@
<script>
document.querySelectorAll(".upload-panel").forEach((form) => {
const input = form.querySelector('input[type="file"][name="files"]');
const guestPhone = form.querySelector('input[name="guestPhone"]');
const anonymousNotice = form.querySelector(".guest-fields__anonymous");
const submit = form.querySelector('button[type="submit"][aria-label="업로드"]');
const summary = form.querySelector(".file-summary");
const empty = form.querySelector(".file-summary__empty");
const list = form.querySelector(".file-list");
const scrollTopButton = form.querySelector(".file-list__top");
const status = form.querySelector(".upload-status");
const thanks = document.querySelector(".thank-you");
let previewUrls = [];
submit.disabled = true;
submit.hidden = true;
const formatBytes = (bytes) => {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
@@ -71,39 +79,92 @@
status.dataset.type = type;
};
const clearPreviews = () => {
previewUrls.forEach((url) => URL.revokeObjectURL(url));
previewUrls = [];
};
const updateScrollTopButton = () => {
if (!scrollTopButton) return;
scrollTopButton.hidden = list.scrollHeight <= list.clientHeight || list.scrollTop < 8;
};
const hasValidPhone = () => /^010\d{8}$/.test((guestPhone?.value || "").replace(/\D/g, ""));
const updateAnonymousNotice = () => {
if (!anonymousNotice) return;
const hasFiles = Boolean(input?.files && input.files.length > 0);
anonymousNotice.hidden = !hasFiles || hasValidPhone();
};
input?.addEventListener("change", () => {
const files = Array.from(input.files || []);
clearPreviews();
list.replaceChildren();
if (files.length === 0) {
summary.hidden = true;
form.dataset.hasFiles = "false";
summary.hidden = false;
if (empty) empty.hidden = false;
submit.disabled = true;
submit.hidden = true;
updateScrollTopButton();
updateAnonymousNotice();
setStatus("");
return;
}
files.forEach((file) => {
const item = document.createElement("li");
const name = document.createElement("span");
const size = document.createElement("span");
name.textContent = file.name;
size.textContent = formatBytes(file.size);
item.append(name, size);
const preview = document.createElement("div");
const previewUrl = URL.createObjectURL(file);
previewUrls.push(previewUrl);
preview.className = "file-list__preview";
if (file.type.startsWith("video/")) {
const video = document.createElement("video");
video.src = previewUrl;
video.muted = true;
video.playsInline = true;
video.preload = "metadata";
preview.append(video);
} else if (file.type.startsWith("image/")) {
const image = document.createElement("img");
image.src = previewUrl;
image.alt = "";
preview.append(image);
} else {
preview.textContent = "FILE";
}
item.append(preview);
list.append(item);
});
summary.hidden = false;
form.dataset.hasFiles = "true";
if (empty) empty.hidden = true;
submit.disabled = false;
submit.hidden = false;
list.scrollTop = 0;
requestAnimationFrame(updateScrollTopButton);
updateAnonymousNotice();
setStatus(`${files.length}개 파일을 선택했어요.`);
});
guestPhone?.addEventListener("input", updateAnonymousNotice);
list.addEventListener("scroll", updateScrollTopButton);
scrollTopButton?.addEventListener("click", () => {
list.scrollTo({ top: 0, behavior: "smooth" });
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!form.reportValidity()) return;
if (!input.files || input.files.length === 0) {
setStatus("먼저 사진을 선택해 주세요.", "error");
return;
}
submit.disabled = true;
form.dataset.uploading = "true";
setStatus("업로드 중이에요. 화면을 닫지 말아 주세요.");
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

+452 -62
View File
@@ -15,17 +15,38 @@ const publicDir = path.resolve("src", "public");
const adminPassword = process.env.ADMIN_PASSWORD;
const sessionSecret = process.env.SESSION_SECRET;
const publicBaseUrl = (process.env.PUBLIC_BASE_URL || "").replace(/\/$/, "");
const adminAllowedIps = parseIpList(
process.env.ADMIN_ALLOWED_IPS ||
"112.159.64.27,162.12.184.41,192.168.219.100,192.168.219.1"
);
if (!adminPassword || !sessionSecret) {
throw new Error("ADMIN_PASSWORD and SESSION_SECRET are required.");
}
app.set("trust proxy", true);
app.use(express.urlencoded({ extended: false, limit: `${Number(process.env.MAX_FIELDS_SIZE_MB || 2)}mb` }));
app.use(
express.urlencoded({
extended: false,
limit: `${Number(process.env.MAX_FIELDS_SIZE_MB || 2)}mb`,
})
);
app.use(cookieParser(sessionSecret));
app.use("/assets", express.static(publicDir, { fallthrough: true }));
const imageExts = ["jpg", "jpeg", "png", "gif", "webp", "heic", "heif", "tif", "tiff", "bmp", "avif"];
const imageExts = [
"jpg",
"jpeg",
"png",
"gif",
"webp",
"heic",
"heif",
"tif",
"tiff",
"bmp",
"avif",
];
const videoExts = ["mp4", "mov", "m4v", "avi", "mkv", "webm"];
const defaultExts = [...imageExts, ...videoExts];
@@ -51,16 +72,36 @@ function sanitizeName(value, fallback = "request") {
function sanitizeFileName(value) {
const parsed = path.parse(sanitizeName(value, "upload"));
const base = sanitizeName(parsed.name, "upload").replace(/^\.+$/, "upload");
const ext = parsed.ext.replace(/[^\w.]/g, "").slice(0, 16).toLowerCase();
const ext = parsed.ext
.replace(/[^\w.]/g, "")
.slice(0, 16)
.toLowerCase();
return `${base}${ext}`;
}
function cleanGuestField(value) {
return String(value || "")
.normalize("NFKC")
.replace(/[\r\n\t]/g, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80);
}
function submissionFolderName(guestPhone) {
const phone = cleanGuestField(guestPhone).replace(/[^\d]/g, "");
return /^010\d{8}$/.test(phone) ? phone : "익명";
}
function normalizeExts(value) {
return String(value || "")
.split(/[\s,;]+/)
.map((item) => item.trim().replace(/^\./, "").toLowerCase())
.filter(Boolean)
.filter((item, index, list) => /^[a-z0-9]+$/.test(item) && list.indexOf(item) === index);
.filter(
(item, index, list) =>
/^[a-z0-9]+$/.test(item) && list.indexOf(item) === index
);
}
async function ensureDb() {
@@ -87,7 +128,10 @@ function sign(value) {
}
function makeSession() {
const payload = JSON.stringify({ admin: true, exp: Date.now() + 1000 * 60 * 60 * 12 });
const payload = JSON.stringify({
admin: true,
exp: Date.now() + 1000 * 60 * 60 * 12,
});
const encoded = Buffer.from(payload).toString("base64url");
return `${encoded}.${sign(encoded)}`;
}
@@ -97,7 +141,9 @@ function isAuthed(req) {
const [encoded, mac] = token.split(".");
if (!encoded || !mac || sign(encoded) !== mac) return false;
try {
const payload = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8"));
const payload = JSON.parse(
Buffer.from(encoded, "base64url").toString("utf8")
);
return payload.admin === true && payload.exp > Date.now();
} catch {
return false;
@@ -120,7 +166,7 @@ function page(title, body) {
:root { color-scheme: light; --ink:#17202a; --muted:#667085; --line:#d6dde7; --bg:#f6f8fb; --panel:#fff; --accent:#0f766e; --danger:#b42318; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--ink); }
main { width:min(960px, calc(100% - 32px)); margin:32px auto; }
main { width:min(1100px, calc(100% - 32px)); margin:32px auto; }
.panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:20px; }
h1 { font-size:24px; margin:0 0 18px; }
h2 { font-size:18px; margin:26px 0 12px; }
@@ -128,6 +174,7 @@ function page(title, body) {
input, textarea, select { width:100%; border:1px solid var(--line); border-radius:6px; padding:10px 12px; font:inherit; background:#fff; }
input[type=file] { padding:9px; }
button, .button { display:inline-flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:6px; padding:10px 14px; background:var(--accent); color:#fff; font-weight:650; cursor:pointer; text-decoration:none; }
.button.secondary { background:#475467; }
button.danger { background:var(--danger); }
.row { display:grid; grid-template-columns: 1fr 1fr; gap:14px; }
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:16px; }
@@ -138,6 +185,13 @@ function page(title, body) {
.notice { border:1px solid #bae6fd; background:#ecfeff; padding:12px; border-radius:6px; margin:12px 0; }
.error { border-color:#fecaca; background:#fff1f2; }
.upload-box { border:2px dashed var(--line); border-radius:8px; background:#fff; padding:18px; }
.stack { display:flex; flex-direction:column; gap:8px; }
.submission { border:1px solid var(--line); border-radius:8px; padding:14px; margin-top:12px; background:#fff; }
.submission h3 { margin:0 0 8px; font-size:16px; }
.submission dl { display:grid; grid-template-columns:90px 1fr; gap:6px 12px; margin:0; }
.submission dt { color:var(--muted); }
.submission dd { margin:0; }
.file-list-admin { margin:10px 0 0; padding-left:18px; color:var(--muted); }
@media (max-width:720px) { main { width:calc(100% - 20px); margin:16px auto; } .row { grid-template-columns:1fr; } .table { font-size:14px; } }
</style>
</head>
@@ -146,15 +200,83 @@ function page(title, body) {
}
function renderTemplate(template, values) {
return template.replaceAll(/\{\{(\w+)\}\}/g, (match, key) => values[key] ?? match);
return template.replaceAll(
/\{\{(\w+)\}\}/g,
(match, key) => values[key] ?? match
);
}
function wantsJson(req) {
return String(req.get("accept") || "").includes("application/json");
}
function parseIpList(value) {
return String(value || "")
.split(/[\s,;]+/)
.map(normalizeIp)
.filter(Boolean);
}
function normalizeIp(value) {
let ip = String(value || "").trim();
if (!ip) return "";
if (ip.startsWith("[") && ip.endsWith("]")) ip = ip.slice(1, -1);
if (ip.startsWith("::ffff:")) ip = ip.slice("::ffff:".length);
return ip;
}
function clientIp(req) {
const cloudflareIp = normalizeIp(req.get("cf-connecting-ip") || "");
if (cloudflareIp) return cloudflareIp;
const forwarded = String(req.get("x-forwarded-for") || "")
.split(",")
.map(normalizeIp)
.filter(Boolean);
const closestPublicIp =
forwarded.toReversed().find((ip) => !isPrivateIp(ip)) || "";
return normalizeIp(
closestPublicIp ||
forwarded[0] ||
req.get("x-real-ip") ||
req.ip ||
req.socket.remoteAddress ||
""
);
}
function isPrivateIp(ip) {
if (ip === "localhost" || ip === "::1") return true;
if (ip.startsWith("fe80:") || ip.startsWith("fc") || ip.startsWith("fd"))
return true;
const parts = ip.split(".").map((part) => Number(part));
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part)))
return false;
const [a, b] = parts;
return (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
);
}
function requireAdminIp(req, res, next) {
const ip = clientIp(req);
if (adminAllowedIps.includes(ip)) {
next();
return;
}
console.warn(
`blocked admin path from ${ip || "unknown"}: ${req.method} ${
req.originalUrl
}`
);
res.status(404).send(page("잘못된 접근", ""));
}
function uploadResultPage(request, { heading, message, isError = false }) {
const title = escapeHtml(request.title);
const slug = escapeHtml(request.slug);
return `<!doctype html>
<html lang="ko">
@@ -166,23 +288,19 @@ function uploadResultPage(request, { heading, message, isError = false }) {
</head>
<body>
<main class="upload-page">
<section class="wedding-card" aria-labelledby="archive-title">
<section class="wedding-card" aria-label="${escapeHtml(request.title)}">
<div class="poster">
<div class="poster__portrait" aria-hidden="true">
<img class="poster__couple" src="/assets/wedding-couple.png" alt="">
</div>
<img class="poster__gate" src="/assets/wedding-gate-bg.png" alt="">
<div class="poster__plate">
<h1 id="archive-title">${title}</h1>
<p class="poster__date">2026.05.24 SUN</p>
<p class="poster__subtitle">OUR WEDDING ARCHIVE</p>
</div>
<img class="poster__art" src="/assets/wedding-illustration.png?v=single-illustration-1" alt="">
</div>
<section class="thank-you${isError ? " thank-you--error" : ""}">
<p class="thank-you__eyebrow">${isError ? "PLEASE TRY AGAIN" : "THANK YOU"}</p>
<p class="thank-you__eyebrow">${
isError ? "PLEASE TRY AGAIN" : "THANK YOU"
}</p>
<h2>${escapeHtml(heading)}</h2>
<p>${escapeHtml(message)}</p>
<a class="upload-again" href="/r/${slug}">${isError ? "다시 올리기" : "사진 더 올리기"}</a>
<a class="upload-again" href="/r/${slug}">${
isError ? "다시 올리기" : "사진 더 올리기"
}</a>
</section>
</section>
</main>
@@ -200,13 +318,17 @@ async function uploadPage(request) {
accept: escapeHtml(accept),
allowedExts: escapeHtml(request.allowedExts.join(", ")),
maxMb: escapeHtml(Math.round(request.maxBytes / 1024 / 1024)),
cssPath: "/assets/upload.css"
cssPath: "/assets/upload.css",
};
if (template) return renderTemplate(template, values);
return page(request.title, `
return page(
request.title,
`
<section class="panel">
<h1>${values.title}</h1>
<form method="post" action="/r/${values.slug}" enctype="multipart/form-data">
<label>연락처</label>
<input type="tel" name="guestPhone" autocomplete="tel">
<div class="upload-box">
<label>사진/영상 선택</label>
<input type="file" name="files" accept="${values.accept}" multiple required>
@@ -214,15 +336,18 @@ async function uploadPage(request) {
<div class="actions"><button type="submit">업로드</button></div>
</form>
</section>
`);
`
);
}
async function directorySize(dir) {
let total = 0;
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch((error) => {
if (error.code === "ENOENT") return [];
throw error;
});
const entries = await fsp
.readdir(dir, { withFileTypes: true })
.catch((error) => {
if (error.code === "ENOENT") return [];
throw error;
});
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
@@ -247,10 +372,89 @@ async function moveUploadedFiles(files, targetDir) {
}
}
app.get("/", (req, res) => res.redirect("/admin"));
async function appendUploadMetadata(
targetDir,
{ guestPhone, folderName, files }
) {
const entry = {
uploadedAt: new Date().toISOString(),
guestPhone,
folderName,
files: files.map((file) => ({
storedName: file.filename,
originalName: Buffer.from(file.originalname, "latin1").toString("utf8"),
size: file.size,
})),
};
await fsp.appendFile(
path.join(targetDir, "upload-metadata.jsonl"),
`${JSON.stringify(entry)}\n`,
"utf8"
);
}
async function readUploadMetadata(folderName) {
const requestDir = path.join(uploadsDir, folderName);
const paths = [path.join(requestDir, "upload-metadata.jsonl")];
const entries = await fsp
.readdir(requestDir, { withFileTypes: true })
.catch((error) => {
if (error.code === "ENOENT") return [];
throw error;
});
for (const entry of entries) {
if (entry.isDirectory()) {
paths.push(path.join(requestDir, entry.name, "upload-metadata.jsonl"));
}
}
const submissions = [];
for (const metadataPath of paths) {
const content = await fsp.readFile(metadataPath, "utf8").catch((error) => {
if (error.code === "ENOENT") return "";
throw error;
});
for (const line of content.split(/\r?\n/).filter(Boolean)) {
try {
submissions.push(JSON.parse(line));
} catch {
// Ignore malformed legacy lines so one bad record does not hide the list.
}
}
}
return submissions.sort((a, b) =>
String(a.uploadedAt || "").localeCompare(String(b.uploadedAt || ""))
);
}
function formatDateTime(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return new Intl.DateTimeFormat("ko-KR", {
timeZone: "Asia/Seoul",
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
app.get("/", (req, res) => {
res
.status(404)
.send(
page(
"페이지 없음",
`error`
)
);
});
app.use("/admin", requireAdminIp);
app.get("/admin/login", (req, res) => {
res.send(page("관리자 로그인", `
res.send(
page(
"관리자 로그인",
`
<section class="panel">
<h1>사진 요청 관리자</h1>
<form method="post" action="/admin/login">
@@ -259,18 +463,34 @@ app.get("/admin/login", (req, res) => {
<div class="actions"><button type="submit">로그인</button></div>
</form>
</section>
`));
`
)
);
});
app.post("/admin/login", (req, res) => {
const supplied = Buffer.from(req.body.password || "");
const expected = Buffer.from(adminPassword);
const ok = supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected);
const ok =
supplied.length === expected.length &&
crypto.timingSafeEqual(supplied, expected);
if (!ok) {
res.status(401).send(page("로그인 실패", `<section class="panel"><h1>로그인 실패</h1><p class="muted">비밀번호가 맞지 않아.</p><a class="button" href="/admin/login">다시 시도</a></section>`));
res
.status(401)
.send(
page(
"로그인 실패",
`<section class="panel"><h1>로그인 실패</h1><p class="muted">비밀번호가 맞지 않아.</p><a class="button" href="/admin/login">다시 시도</a></section>`
)
);
return;
}
res.cookie("session", makeSession(), { httpOnly: true, sameSite: "lax", secure: req.secure, maxAge: 1000 * 60 * 60 * 12 });
res.cookie("session", makeSession(), {
httpOnly: true,
sameSite: "lax",
secure: req.secure,
maxAge: 1000 * 60 * 60 * 12,
});
res.redirect("/admin");
});
@@ -287,19 +507,34 @@ app.get("/admin", requireAdmin, async (req, res) => {
.map((item) => {
const url = `${publicBaseUrl}/r/${item.slug}`;
return `<tr>
<td><strong>${escapeHtml(item.title)}</strong><br><span class="muted">${escapeHtml(item.folderName)}</span></td>
<td><strong>${escapeHtml(
item.title
)}</strong><br><span class="muted">${escapeHtml(
item.folderName
)}</span></td>
<td><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></td>
<td>${Math.round(item.maxBytes / 1024 / 1024)} MB</td>
<td>${escapeHtml(item.allowedExts.join(", "))}</td>
<td>
<form method="post" action="/admin/requests/${escapeHtml(item.slug)}/delete" onsubmit="return confirm('링크를 삭제할까? 업로드된 파일은 유지돼.');">
<div class="stack">
<a class="button secondary" href="/admin/requests/${escapeHtml(
item.slug
)}/submissions">제출 내역</a>
<form method="post" action="/admin/requests/${escapeHtml(
item.slug
)}/delete" onsubmit="return confirm('링크를 삭제할까? 업로드된 파일은 유지돼.');">
<button class="danger" type="submit">삭제</button>
</form>
</div>
</td>
</tr>`;
}).join("");
})
.join("");
res.send(page("사진 요청 관리자", `
res.send(
page(
"사진 요청 관리자",
`
<section class="panel">
<form method="post" action="/admin/logout" style="float:right"><button type="submit">로그아웃</button></form>
<h1>사진 요청 관리자</h1>
@@ -315,16 +550,90 @@ app.get("/admin", requireAdmin, async (req, res) => {
</div>
</div>
<label>허용 확장자</label>
<input name="allowedExts" value="${escapeHtml(defaultExts.join(", "))}" required>
<input name="allowedExts" value="${escapeHtml(
defaultExts.join(", ")
)}" required>
<div class="actions"><button type="submit">링크 만들기</button></div>
</form>
<h2>활성 링크</h2>
<table class="table">
<thead><tr><th>요청</th><th>링크</th><th>총 용량 제한</th><th>확장자</th><th></th></tr></thead>
<tbody>${rows || `<tr><td colspan="5" class="muted">아직 만든 링크가 없어.</td></tr>`}</tbody>
<tbody>${
rows ||
`<tr><td colspan="5" class="muted">아직 만든 링크가 없어.</td></tr>`
}</tbody>
</table>
</section>
`));
`
)
);
});
app.get("/admin/requests/:slug/submissions", requireAdmin, async (req, res) => {
const db = await readDb();
const item = db.requests.find((request) => request.slug === req.params.slug);
if (!item) {
res
.status(404)
.send(
page(
"제출 내역 없음",
`<section class="panel"><h1>제출 내역 없음</h1><p class="muted">해당 링크를 찾을 수 없어.</p><a class="button" href="/admin">돌아가기</a></section>`
)
);
return;
}
const submissions = await readUploadMetadata(item.folderName);
const rows = submissions
.map((submission, index) => {
const files = Array.isArray(submission.files) ? submission.files : [];
const fileItems = files
.map((file) => {
const name = file.originalName || file.storedName || "파일";
const size = Number(file.size || 0);
const sizeText = size > 0 ? ` (${Math.round(size / 1024)} KB)` : "";
return `<li>${escapeHtml(name)}${escapeHtml(sizeText)}</li>`;
})
.join("");
return `<article class="submission">
<h3>제출 ${index + 1}</h3>
<dl>
<dt>연락처</dt><dd>${escapeHtml(submission.guestPhone || "-")}</dd>
<dt>저장 폴더</dt><dd>${escapeHtml(submission.folderName || "-")}</dd>
<dt>제출 시간</dt><dd>${escapeHtml(
formatDateTime(submission.uploadedAt)
)}</dd>
<dt>파일 수</dt><dd>${files.length}개</dd>
</dl>
<ul class="file-list-admin">${
fileItems || `<li class="muted">파일 기록이 없어.</li>`
}</ul>
</article>`;
})
.join("");
res.send(
page(
`${item.title} 제출 내역`,
`
<section class="panel">
<div class="actions" style="float:right">
<a class="button secondary" href="/admin">관리자 홈</a>
</div>
<h1>${escapeHtml(item.title)} 제출 내역</h1>
<p class="muted">저장 위치: ${escapeHtml(
path.join(uploadsDir, item.folderName)
)}</p>
<p class="muted">제출 정보 파일: 각 연락처/익명 폴더의 upload-metadata.jsonl</p>
${
rows ||
`<div class="notice">아직 이 링크로 제출된 내역이 없어. 예전 업로드는 메타데이터가 없으면 여기에는 표시되지 않을 수 있어.</div>`
}
</section>
`
)
);
});
app.post("/admin/requests", requireAdmin, async (req, res) => {
@@ -333,11 +642,20 @@ app.post("/admin/requests", requireAdmin, async (req, res) => {
const allowedExts = normalizeExts(req.body.allowedExts);
const maxMb = Math.max(1, Math.min(20480, Number(req.body.maxMb || 500)));
if (allowedExts.length === 0) {
res.status(400).send(page("입력 오류", `<section class="panel"><h1>입력 오류</h1><p class="muted">허용 확장자를 하나 이상 넣어야 해.</p><a class="button" href="/admin">돌아가기</a></section>`));
res
.status(400)
.send(
page(
"입력 오류",
`<section class="panel"><h1>입력 오류</h1><p class="muted">허용 확장자를 하나 이상 넣어야 해.</p><a class="button" href="/admin">돌아가기</a></section>`
)
);
return;
}
const slug = crypto.randomBytes(16).toString("base64url");
const folderName = `${new Date().toISOString().slice(0, 10)}_${title}_${slug.slice(0, 6)}`;
const folderName = `${new Date()
.toISOString()
.slice(0, 10)}_${title}_${slug.slice(0, 6)}`;
await fsp.mkdir(path.join(uploadsDir, folderName), { recursive: true });
db.requests.push({
slug,
@@ -345,7 +663,7 @@ app.post("/admin/requests", requireAdmin, async (req, res) => {
folderName,
maxBytes: maxMb * 1024 * 1024,
allowedExts,
createdAt: new Date().toISOString()
createdAt: new Date().toISOString(),
});
await writeDb(db);
res.redirect("/admin");
@@ -353,7 +671,9 @@ app.post("/admin/requests", requireAdmin, async (req, res) => {
app.post("/admin/requests/:slug/delete", requireAdmin, async (req, res) => {
const db = await readDb();
const item = db.requests.find((request) => request.slug === req.params.slug && !request.deletedAt);
const item = db.requests.find(
(request) => request.slug === req.params.slug && !request.deletedAt
);
if (item) item.deletedAt = new Date().toISOString();
await writeDb(db);
res.redirect("/admin");
@@ -361,7 +681,9 @@ app.post("/admin/requests/:slug/delete", requireAdmin, async (req, res) => {
async function findRequest(slug) {
const db = await readDb();
return db.requests.find((request) => request.slug === slug && !request.deletedAt);
return db.requests.find(
(request) => request.slug === slug && !request.deletedAt
);
}
function uploadMiddleware(request) {
@@ -376,25 +698,40 @@ function uploadMiddleware(request) {
}
},
filename: (req, file, cb) => {
const safe = sanitizeFileName(Buffer.from(file.originalname, "latin1").toString("utf8"));
cb(null, `${Date.now()}_${crypto.randomBytes(4).toString("hex")}_${safe}`);
}
const safe = sanitizeFileName(
Buffer.from(file.originalname, "latin1").toString("utf8")
);
cb(
null,
`${Date.now()}_${crypto.randomBytes(4).toString("hex")}_${safe}`
);
},
});
return multer({
storage,
limits: { files: 100 },
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).replace(".", "").toLowerCase();
const ext = path
.extname(file.originalname)
.replace(".", "")
.toLowerCase();
cb(null, request.allowedExts.includes(ext));
}
},
}).array("files", 100);
}
app.get("/r/:slug", async (req, res) => {
const request = await findRequest(req.params.slug);
if (!request) {
res.status(404).send(page("링크 없음", `<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`));
res
.status(404)
.send(
page(
"링크 없음",
`<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`
)
);
return;
}
res.send(await uploadPage(request));
@@ -403,7 +740,14 @@ app.get("/r/:slug", async (req, res) => {
app.post("/r/:slug", async (req, res) => {
const request = await findRequest(req.params.slug);
if (!request) {
res.status(404).send(page("링크 없음", `<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`));
res
.status(404)
.send(
page(
"링크 없음",
`<section class="panel"><h1>링크가 없거나 삭제됐어</h1></section>`
)
);
return;
}
const upload = uploadMiddleware(request);
@@ -415,37 +759,83 @@ app.post("/r/:slug", async (req, res) => {
res.status(400).json({ ok: false, message });
return;
}
res.status(400).send(uploadResultPage(request, { heading: "업로드 실패", message, isError: true }));
res
.status(400)
.send(
uploadResultPage(request, {
heading: "업로드 실패",
message,
isError: true,
})
);
return;
}
try {
const files = req.files || [];
const targetDir = path.join(uploadsDir, request.folderName);
const existingBytes = await directorySize(targetDir);
const guestPhone = cleanGuestField(req.body.guestPhone);
const requestDir = path.join(uploadsDir, request.folderName);
const folderName = submissionFolderName(guestPhone);
const targetDir = path.join(requestDir, folderName);
const existingBytes = await directorySize(requestDir);
const incomingBytes = files.reduce((total, file) => total + file.size, 0);
if (existingBytes + incomingBytes > request.maxBytes) {
await removeFiles(files);
const remainMb = Math.max(0, Math.floor((request.maxBytes - existingBytes) / 1024 / 1024));
const remainMb = Math.max(
0,
Math.floor((request.maxBytes - existingBytes) / 1024 / 1024)
);
if (wantsJson(req)) {
res.status(400).json({ ok: false, message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.` });
res
.status(400)
.json({
ok: false,
message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`,
});
return;
}
res.status(400).send(uploadResultPage(request, { heading: "업로드 실패", message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`, isError: true }));
res
.status(400)
.send(
uploadResultPage(request, {
heading: "업로드 실패",
message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`,
isError: true,
})
);
return;
}
await moveUploadedFiles(files, targetDir);
await appendUploadMetadata(targetDir, { guestPhone, folderName, files });
if (wantsJson(req)) {
res.json({ ok: true, count: files.length });
return;
}
res.send(uploadResultPage(request, { heading: "감사합니다", message: `${files.length}개의 파일 잘 받았습니다. 소중한 오늘을 함께 남겨주셔서 감사드립니다.` }));
res.send(
uploadResultPage(request, {
heading: "감사합니다",
message: `${files.length}개의 파일 잘 받았습니다. 소중한 오늘을 함께 남겨주셔서 감사드립니다.`,
})
);
} catch {
await removeFiles(req.files);
if (wantsJson(req)) {
res.status(500).json({ ok: false, message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요." });
res
.status(500)
.json({
ok: false,
message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.",
});
return;
}
res.status(500).send(uploadResultPage(request, { heading: "업로드 실패", message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.", isError: true }));
res
.status(500)
.send(
uploadResultPage(request, {
heading: "업로드 실패",
message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.",
isError: true,
})
);
}
});
});