Compare commits
39 Commits
ae61c02868
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 58f16c1dd6 | |||
| 14f7f33023 | |||
| 8aec7abd01 | |||
| 5cb2919e84 | |||
| 31e0f8e5ca | |||
| f9634b37a1 | |||
| 290cebd2cc | |||
| ad04e1b5c7 | |||
| c626f392a8 | |||
| 2ec0ab6697 | |||
| da1b761975 | |||
| 02711a5593 | |||
| b395184e49 | |||
| 5553903e71 | |||
| 735c05ed1e | |||
| 648974960e | |||
| 7f4e814d94 | |||
| 2a01704b02 | |||
| 23b3e1ea4e | |||
| 9489bfa5d6 | |||
| 8722149499 | |||
| ed4a27d0ab | |||
| 31316f1bd9 | |||
| 2b88f4bfab | |||
| 24564d6c35 | |||
| 49766d149e | |||
| 4525476945 | |||
| 6943e6ef3e | |||
| 4e149a500d | |||
| f326643f24 | |||
| 210e88e21d | |||
| d892fe9c2c | |||
| a046e66339 | |||
| 1c42fb144e | |||
| 59bdfbc76f | |||
| 328065db18 | |||
| 3a797694b2 | |||
| 35dad1c9f4 | |||
| c1829189f2 |
@@ -0,0 +1 @@
|
|||||||
|
*.sh text eol=lf
|
||||||
@@ -32,8 +32,9 @@ The server cron runs this once per minute.
|
|||||||
- DB: `data/db.json`
|
- DB: `data/db.json`
|
||||||
- Deploy log: `data/deploy.log`
|
- Deploy log: `data/deploy.log`
|
||||||
- Uploads: `/volume2/AKA Drive/사진 받기`
|
- 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 template: `src/public/upload.html`
|
||||||
- Upload page style: `src/public/upload.css`
|
- Upload page style: `src/public/upload.css`
|
||||||
- Upload page image: `src/public/hero-wedding.png`
|
- Upload page illustration: `src/public/wedding-illustration.png`
|
||||||
|
|
||||||
Each request creates its own folder under the upload directory.
|
Each request creates its own folder under the upload directory.
|
||||||
|
|||||||
+9
-3
@@ -1,16 +1,22 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_PATH="$0"
|
||||||
|
case "$SCRIPT_PATH" in
|
||||||
|
/*) ;;
|
||||||
|
*) SCRIPT_PATH="$(command -v "$0")" ;;
|
||||||
|
esac
|
||||||
|
APP_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||||||
STATE_FILE="$APP_DIR/data/deployed-rev"
|
STATE_FILE="$APP_DIR/data/deployed-rev"
|
||||||
LOG_FILE="$APP_DIR/data/deploy.log"
|
LOG_FILE="$APP_DIR/data/deploy.log"
|
||||||
|
|
||||||
mkdir -p "$APP_DIR/data"
|
mkdir -p "$APP_DIR/data"
|
||||||
cd "$APP_DIR"
|
cd "$APP_DIR"
|
||||||
|
|
||||||
remote_rev="$(git -c credential.helper="store --file=/var/services/homes/whitekomani/.git-credentials" ls-remote origin refs/heads/main | awk '{print $1}')"
|
remote_output="$(git -c credential.helper="store --file=/var/services/homes/whitekomani/.git-credentials" ls-remote origin refs/heads/main 2>&1 || true)"
|
||||||
|
remote_rev="$(printf '%s\n' "$remote_output" | awk '/refs\/heads\/main$/ {print $1}')"
|
||||||
if [ -z "$remote_rev" ]; then
|
if [ -z "$remote_rev" ]; then
|
||||||
echo "$(date '+%Y-%m-%d %H:%M:%S') no remote revision found" >> "$LOG_FILE"
|
echo "$(date '+%Y-%m-%d %H:%M:%S') no remote revision found: $remote_output" >> "$LOG_FILE"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_PATH="$0"
|
||||||
|
case "$SCRIPT_PATH" in
|
||||||
|
/*) ;;
|
||||||
|
*) SCRIPT_PATH="$(command -v "$0")" ;;
|
||||||
|
esac
|
||||||
|
APP_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||||||
LOG_FILE="$APP_DIR/data/deploy.log"
|
LOG_FILE="$APP_DIR/data/deploy.log"
|
||||||
LOCK_DIR="$APP_DIR/data/deploy.lock"
|
LOCK_DIR="$APP_DIR/data/deploy.lock"
|
||||||
|
|
||||||
@@ -19,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 -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
|
git reset --hard origin/main >> "$LOG_FILE" 2>&1
|
||||||
|
|
||||||
/usr/local/bin/docker build -t photoreq:latest . >> "$LOG_FILE" 2>&1
|
/usr/local/bin/docker rm -f photoreq >> "$LOG_FILE" 2>&1 || true
|
||||||
./run.sh >> "$LOG_FILE" 2>&1
|
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@@ -2,16 +2,6 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
APP_DIR="$(cd "$(dirname "$0")" && pwd)"
|
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 rm -f photoreq >/dev/null 2>&1 || true
|
||||||
/usr/local/bin/docker run -d \
|
echo "photoreq is closed; container removed from $APP_DIR"
|
||||||
--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
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
+426
-125
@@ -1,208 +1,509 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--ink: #070707;
|
--ink: #4b4b4b;
|
||||||
--muted: #777777;
|
--deep: #174f49;
|
||||||
|
--deep-dark: #103f3a;
|
||||||
--paper: #ffffff;
|
--paper: #ffffff;
|
||||||
--warm: #fff7ca;
|
--line: rgba(35, 35, 35, 0.14);
|
||||||
--button: #c90000;
|
|
||||||
--button-dark: #9e0000;
|
|
||||||
--line: #d9d9d9;
|
|
||||||
--teal: #0d6a70;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #1f1f1f;
|
background: #ffffff;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: "Arial", "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
|
font-family: "Times New Roman", "Batang", "Apple SD Gothic Neo", serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-page {
|
.upload-page {
|
||||||
width: min(100%, 512px);
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 4px 9px 8px;
|
padding: 0;
|
||||||
background: #1f1f1f;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-card {
|
.wedding-card {
|
||||||
position: relative;
|
width: min(100%, 430px);
|
||||||
overflow: hidden;
|
min-height: 100vh;
|
||||||
min-height: calc(100vh - 12px);
|
margin: 0 auto;
|
||||||
padding: clamp(250px, 52vh, 555px) 18px 28px;
|
padding: 0 0 38px;
|
||||||
background:
|
background: #ffffff;
|
||||||
linear-gradient(180deg, var(--warm) 0, #fffdf0 14%, var(--paper) 38%, var(--paper) 100%);
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-card__glow {
|
.poster {
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
top: 430px;
|
|
||||||
z-index: 1;
|
|
||||||
height: 190px;
|
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0), #ffffff 70%);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.archive-card__art {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: clamp(185px, 35vh, 330px);
|
|
||||||
z-index: 0;
|
|
||||||
width: min(84%, 390px);
|
|
||||||
height: auto;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
filter: drop-shadow(0 12px 16px rgba(0, 0, 0, 0.08));
|
|
||||||
}
|
|
||||||
|
|
||||||
.archive-card__copy,
|
|
||||||
.upload-panel {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 2;
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
aspect-ratio: 538 / 795;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-card__copy {
|
.poster::before {
|
||||||
margin: 0 auto;
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster__art {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center top;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(29px, 7vw, 36px);
|
color: #565656;
|
||||||
line-height: 1.12;
|
font-size: clamp(32px, 7.5vw, 43px);
|
||||||
font-weight: 900;
|
line-height: 1.1;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-card__subtitle {
|
.poster__date,
|
||||||
margin: 8px 0 0;
|
.poster__subtitle {
|
||||||
font-size: clamp(27px, 6.2vw, 34px);
|
margin: 0;
|
||||||
line-height: 1.1;
|
color: #555555;
|
||||||
font-weight: 900;
|
letter-spacing: 0.24em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster__date {
|
||||||
|
margin-top: clamp(12px, 2.8vw, 20px);
|
||||||
|
font-size: clamp(15px, 3.3vw, 19px);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster__subtitle {
|
||||||
|
margin-top: clamp(14px, 3vw, 22px);
|
||||||
|
font-size: clamp(14px, 3.3vw, 19px);
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-panel {
|
.upload-panel {
|
||||||
width: min(100%, 270px);
|
width: min(100%, 430px);
|
||||||
margin: 60px 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 {
|
.upload-panel__today {
|
||||||
margin: 0 0 14px;
|
display: flex;
|
||||||
color: var(--muted);
|
align-items: center;
|
||||||
font-size: clamp(27px, 6vw, 33px);
|
justify-content: center;
|
||||||
line-height: 1.12;
|
gap: clamp(10px, 2.4vw, 16px);
|
||||||
font-weight: 900;
|
margin: 0 auto clamp(14px, 2.8vw, 20px);
|
||||||
|
padding: 6px clamp(8px, 2vw, 16px) 5px;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
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;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-picker {
|
.upload-panel__today::before,
|
||||||
display: block;
|
.upload-panel__today::after {
|
||||||
width: 100%;
|
content: "";
|
||||||
margin-bottom: 8px;
|
flex: 0 0 clamp(28px, 6vw, 42px);
|
||||||
padding: 11px 12px;
|
height: clamp(16px, 3.4vw, 24px);
|
||||||
border: 1px solid var(--line);
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 36'%3E%3Cg fill='none' stroke='%2389a69e' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 28c13-12 27-20 47-23'/%3E%3Cpath d='M25 22c-9-1-15-7-17-15 10 1 17 6 20 14' fill='%2389a69e' fill-opacity='.5'/%3E%3Cpath d='M42 13c-7-1-12-5-14-12 8 0 14 4 17 11' fill='%2389a69e' fill-opacity='.55'/%3E%3C/g%3E%3C/svg%3E") center / contain no-repeat;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
opacity: 0.78;
|
||||||
color: #333333;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-picker__label {
|
.upload-panel__today::after {
|
||||||
display: block;
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-picker__label {
|
.upload-panel__notice {
|
||||||
font-size: 15px;
|
margin: -6px auto 16px;
|
||||||
font-weight: 800;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="file"] {
|
.select-button,
|
||||||
width: 100%;
|
.upload-button,
|
||||||
margin-top: 10px;
|
.upload-again {
|
||||||
color: #3a3a3a;
|
position: relative;
|
||||||
font: inherit;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-button {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 99px;
|
min-height: clamp(60px, 12vw, 80px);
|
||||||
border: 2px solid #280000;
|
border: 0;
|
||||||
border-radius: 0;
|
border-radius: 7px;
|
||||||
padding: 14px 18px;
|
padding: 15px 24px;
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0) 43%),
|
radial-gradient(ellipse at 50% 0%, rgba(255, 255, 255, 0.13), transparent 58%),
|
||||||
linear-gradient(180deg, var(--button) 0%, #ca0000 52%, var(--button-dark) 100%);
|
linear-gradient(90deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0) 30% 70%, rgba(255, 255, 255, 0.05)),
|
||||||
box-shadow:
|
linear-gradient(180deg, #1c5d57 0%, #155149 100%);
|
||||||
inset 0 2px 0 rgba(255, 255, 255, 0.2),
|
box-shadow: none;
|
||||||
inset 0 -16px 24px rgba(90, 0, 0, 0.26);
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 26px;
|
font-size: clamp(27px, 5.2vw, 37px);
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
font-weight: 900;
|
font-weight: 400;
|
||||||
text-shadow: 0 2px 1px rgba(0, 0, 0, 0.25);
|
letter-spacing: 0.08em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-button {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
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;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-summary {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border: 1px solid rgba(137, 166, 158, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
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;
|
||||||
|
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
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: 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;
|
||||||
|
place-items: center;
|
||||||
|
width: 80px;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f7f8f6;
|
||||||
|
color: #8aa69f;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
||||||
|
min-height: 22px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #64736f;
|
||||||
|
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status[data-type="error"] {
|
||||||
|
color: #a64635;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-button:hover {
|
.upload-button:hover {
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0) 43%),
|
radial-gradient(ellipse at 50% 0%, rgba(255, 255, 255, 0.16), transparent 58%),
|
||||||
linear-gradient(180deg, #dc0000 0%, #c60000 50%, #8e0000 100%);
|
linear-gradient(90deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0) 30% 70%, rgba(255, 255, 255, 0.07)),
|
||||||
|
linear-gradient(180deg, #216d65 0%, var(--deep-dark) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-button:hover {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at 50% 0%, rgba(255, 255, 255, 0.26), transparent 58%),
|
||||||
|
linear-gradient(180deg, #ffffff 0%, #e9e9e3 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button:disabled {
|
||||||
|
opacity: 0.48;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-panel[data-uploading="true"] .upload-button span {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-panel[data-uploading="true"] .upload-button::after {
|
||||||
|
content: "";
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.35);
|
||||||
|
border-top-color: #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-button:focus-visible,
|
.upload-button:focus-visible,
|
||||||
|
.upload-again:focus-visible,
|
||||||
.file-picker:focus-within {
|
.file-picker:focus-within {
|
||||||
outline: 3px solid var(--teal);
|
outline: 3px solid #89aaa3;
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-panel__note {
|
.thank-you {
|
||||||
margin: 10px auto 0;
|
width: min(100%, 430px);
|
||||||
color: #898989;
|
margin: clamp(18px, 4vw, 28px) auto 0;
|
||||||
font-size: 12px;
|
padding: 26px 24px 28px;
|
||||||
line-height: 1.4;
|
border: 1px solid rgba(137, 166, 158, 0.32);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
box-shadow: 0 14px 30px rgba(55, 55, 55, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thank-you__eyebrow {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #8aa69f;
|
||||||
|
font-family: "Times New Roman", "Batang", serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thank-you h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: #4f4f4f;
|
||||||
|
font-size: clamp(31px, 6vw, 42px);
|
||||||
|
line-height: 1.15;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thank-you p:last-of-type {
|
||||||
|
margin: 14px 0 22px;
|
||||||
|
color: #626262;
|
||||||
|
font-family: "Apple SD Gothic Neo", "Malgun Gothic", system-ui, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
word-break: keep-all;
|
word-break: keep-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 680px) {
|
.thank-you--error {
|
||||||
.upload-page {
|
border-color: rgba(166, 70, 53, 0.28);
|
||||||
padding-top: 10px;
|
}
|
||||||
padding-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.archive-card {
|
.thank-you--error .thank-you__eyebrow,
|
||||||
min-height: 1068px;
|
.thank-you--error h2 {
|
||||||
padding-top: 585px;
|
color: #8c4d43;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-card__art {
|
.upload-again {
|
||||||
top: 365px;
|
min-height: 58px;
|
||||||
}
|
margin: 0 auto;
|
||||||
|
font-size: clamp(22px, 4.4vw, 29px);
|
||||||
|
}
|
||||||
|
|
||||||
.archive-card__glow {
|
@keyframes spin {
|
||||||
top: 535px;
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@media (max-width: 760px) {
|
||||||
.upload-page {
|
.upload-page {
|
||||||
padding-left: 6px;
|
background: #ffffff;
|
||||||
padding-right: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-card {
|
.wedding-card {
|
||||||
padding-left: 14px;
|
padding: 0 0 30px;
|
||||||
padding-right: 14px;
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
aspect-ratio: 538 / 795;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poster::before {
|
||||||
|
content: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-panel {
|
.upload-panel {
|
||||||
margin-top: 48px;
|
width: min(calc(100% - 48px), 340px);
|
||||||
|
margin-top: -54px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thank-you {
|
||||||
|
width: min(calc(100% - 48px), 340px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-panel__today {
|
||||||
|
font-size: clamp(23px, 6.5vw, 29px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-panel__notice {
|
||||||
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-15
@@ -8,31 +8,187 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="upload-page">
|
<main class="upload-page">
|
||||||
<section class="archive-card" aria-labelledby="archive-title">
|
<section class="wedding-card" aria-label="{{title}}">
|
||||||
<div class="archive-card__glow" aria-hidden="true"></div>
|
<div class="poster">
|
||||||
<img class="archive-card__art" src="/assets/hero-wedding.png" alt="">
|
<img class="poster__art" src="/assets/wedding-illustration.png?v=single-illustration-1" alt="">
|
||||||
|
|
||||||
<div class="archive-card__copy">
|
|
||||||
<h1 id="archive-title">{{title}}</h1>
|
|
||||||
<p class="archive-card__subtitle">WEDDING ARCHIVE</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="upload-panel" method="post" action="/r/{{slug}}" enctype="multipart/form-data">
|
<form class="upload-panel" method="post" action="/r/{{slug}}" enctype="multipart/form-data">
|
||||||
<p class="upload-panel__today">당신이 본 오늘</p>
|
<p class="upload-panel__today">오늘의 순간</p>
|
||||||
|
<p class="upload-panel__notice">※ 연락처는 상품 전달 목적 외에는 사용되지 않으며, 전달 후 삭제됩니다.</p>
|
||||||
|
|
||||||
<label class="file-picker">
|
<div class="guest-fields" aria-label="업로드하시는 분 정보">
|
||||||
<span class="file-picker__label">사진 선택</span>
|
<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>
|
<input type="file" name="files" accept="{{accept}}" multiple required>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button class="upload-button" type="submit" aria-label="업로드">
|
<div class="file-summary">
|
||||||
<span>축하 사진</span>
|
<p class="file-summary__title">선택한 파일</p>
|
||||||
<span>올리기</span>
|
<p class="file-summary__empty">등록된 사진이 없습니다.</p>
|
||||||
</button>
|
<ul class="file-list"></ul>
|
||||||
|
<button class="file-list__top" type="button" aria-label="선택한 파일 목록 맨 위로 이동" hidden></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p class="upload-panel__note">총 {{maxMb}} MB까지 · {{allowedExts}}</p>
|
<p class="upload-status" aria-live="polite"></p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<section class="thank-you" hidden>
|
||||||
|
<p class="thank-you__eyebrow">THANK YOU</p>
|
||||||
|
<h2>감사합니다</h2>
|
||||||
|
<p>소중한 오늘을 함께 남겨주셔서 감사드립니다.</p>
|
||||||
|
<a class="upload-again" href="/r/{{slug}}">사진 더 올리기</a>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
<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`;
|
||||||
|
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${bytes} B`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = (message, type = "") => {
|
||||||
|
status.textContent = message;
|
||||||
|
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) {
|
||||||
|
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 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("업로드 중이에요. 화면을 닫지 말아 주세요.");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(form.action, {
|
||||||
|
method: "POST",
|
||||||
|
body: new FormData(form),
|
||||||
|
headers: { "Accept": "application/json" }
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || !result.ok) throw new Error(result.message || "업로드 중 오류가 났어요.");
|
||||||
|
|
||||||
|
form.hidden = true;
|
||||||
|
thanks.hidden = false;
|
||||||
|
thanks.querySelector("p:last-of-type").innerHTML =
|
||||||
|
`${result.count || input.files.length}개의 파일 잘 받았습니다.<br>소중한 오늘을 함께 남겨주셔서 감사드립니다.`;
|
||||||
|
} catch (error) {
|
||||||
|
submit.disabled = false;
|
||||||
|
form.dataset.uploading = "false";
|
||||||
|
setStatus(error.message || "업로드 중 오류가 났어요. 다시 시도해 주세요.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 379 KiB |
+491
-46
@@ -15,17 +15,38 @@ const publicDir = path.resolve("src", "public");
|
|||||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
const sessionSecret = process.env.SESSION_SECRET;
|
const sessionSecret = process.env.SESSION_SECRET;
|
||||||
const publicBaseUrl = (process.env.PUBLIC_BASE_URL || "").replace(/\/$/, "");
|
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) {
|
if (!adminPassword || !sessionSecret) {
|
||||||
throw new Error("ADMIN_PASSWORD and SESSION_SECRET are required.");
|
throw new Error("ADMIN_PASSWORD and SESSION_SECRET are required.");
|
||||||
}
|
}
|
||||||
|
|
||||||
app.set("trust proxy", true);
|
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(cookieParser(sessionSecret));
|
||||||
app.use("/assets", express.static(publicDir, { fallthrough: true }));
|
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 videoExts = ["mp4", "mov", "m4v", "avi", "mkv", "webm"];
|
||||||
const defaultExts = [...imageExts, ...videoExts];
|
const defaultExts = [...imageExts, ...videoExts];
|
||||||
|
|
||||||
@@ -51,16 +72,36 @@ function sanitizeName(value, fallback = "request") {
|
|||||||
function sanitizeFileName(value) {
|
function sanitizeFileName(value) {
|
||||||
const parsed = path.parse(sanitizeName(value, "upload"));
|
const parsed = path.parse(sanitizeName(value, "upload"));
|
||||||
const base = sanitizeName(parsed.name, "upload").replace(/^\.+$/, "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}`;
|
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) {
|
function normalizeExts(value) {
|
||||||
return String(value || "")
|
return String(value || "")
|
||||||
.split(/[\s,;]+/)
|
.split(/[\s,;]+/)
|
||||||
.map((item) => item.trim().replace(/^\./, "").toLowerCase())
|
.map((item) => item.trim().replace(/^\./, "").toLowerCase())
|
||||||
.filter(Boolean)
|
.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() {
|
async function ensureDb() {
|
||||||
@@ -87,7 +128,10 @@ function sign(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeSession() {
|
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");
|
const encoded = Buffer.from(payload).toString("base64url");
|
||||||
return `${encoded}.${sign(encoded)}`;
|
return `${encoded}.${sign(encoded)}`;
|
||||||
}
|
}
|
||||||
@@ -97,7 +141,9 @@ function isAuthed(req) {
|
|||||||
const [encoded, mac] = token.split(".");
|
const [encoded, mac] = token.split(".");
|
||||||
if (!encoded || !mac || sign(encoded) !== mac) return false;
|
if (!encoded || !mac || sign(encoded) !== mac) return false;
|
||||||
try {
|
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();
|
return payload.admin === true && payload.exp > Date.now();
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
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; }
|
:root { color-scheme: light; --ink:#17202a; --muted:#667085; --line:#d6dde7; --bg:#f6f8fb; --panel:#fff; --accent:#0f766e; --danger:#b42318; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin:0; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background:var(--bg); color:var(--ink); }
|
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; }
|
.panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:20px; }
|
||||||
h1 { font-size:24px; margin:0 0 18px; }
|
h1 { font-size:24px; margin:0 0 18px; }
|
||||||
h2 { font-size:18px; margin:26px 0 12px; }
|
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, textarea, select { width:100%; border:1px solid var(--line); border-radius:6px; padding:10px 12px; font:inherit; background:#fff; }
|
||||||
input[type=file] { padding:9px; }
|
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, .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); }
|
button.danger { background:var(--danger); }
|
||||||
.row { display:grid; grid-template-columns: 1fr 1fr; gap:14px; }
|
.row { display:grid; grid-template-columns: 1fr 1fr; gap:14px; }
|
||||||
.actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:16px; }
|
.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; }
|
.notice { border:1px solid #bae6fd; background:#ecfeff; padding:12px; border-radius:6px; margin:12px 0; }
|
||||||
.error { border-color:#fecaca; background:#fff1f2; }
|
.error { border-color:#fecaca; background:#fff1f2; }
|
||||||
.upload-box { border:2px dashed var(--line); border-radius:8px; background:#fff; padding:18px; }
|
.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; } }
|
@media (max-width:720px) { main { width:calc(100% - 20px); margin:16px auto; } .row { grid-template-columns:1fr; } .table { font-size:14px; } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -146,7 +200,112 @@ function page(title, body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderTemplate(template, values) {
|
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 slug = escapeHtml(request.slug);
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${escapeHtml(heading)}</title>
|
||||||
|
<link rel="stylesheet" href="/assets/upload.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="upload-page">
|
||||||
|
<section class="wedding-card" aria-label="${escapeHtml(request.title)}">
|
||||||
|
<div class="poster">
|
||||||
|
<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>
|
||||||
|
<h2>${escapeHtml(heading)}</h2>
|
||||||
|
<p>${escapeHtml(message)}</p>
|
||||||
|
<a class="upload-again" href="/r/${slug}">${
|
||||||
|
isError ? "다시 올리기" : "사진 더 올리기"
|
||||||
|
}</a>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadPage(request) {
|
async function uploadPage(request) {
|
||||||
@@ -159,28 +318,33 @@ async function uploadPage(request) {
|
|||||||
accept: escapeHtml(accept),
|
accept: escapeHtml(accept),
|
||||||
allowedExts: escapeHtml(request.allowedExts.join(", ")),
|
allowedExts: escapeHtml(request.allowedExts.join(", ")),
|
||||||
maxMb: escapeHtml(Math.round(request.maxBytes / 1024 / 1024)),
|
maxMb: escapeHtml(Math.round(request.maxBytes / 1024 / 1024)),
|
||||||
cssPath: "/assets/upload.css"
|
cssPath: "/assets/upload.css",
|
||||||
};
|
};
|
||||||
if (template) return renderTemplate(template, values);
|
if (template) return renderTemplate(template, values);
|
||||||
return page(request.title, `
|
return page(
|
||||||
|
request.title,
|
||||||
|
`
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>${values.title}</h1>
|
<h1>${values.title}</h1>
|
||||||
<p class="muted">이 링크 전체에서 총 ${values.maxMb} MB까지 업로드할 수 있어.</p>
|
|
||||||
<form method="post" action="/r/${values.slug}" enctype="multipart/form-data">
|
<form method="post" action="/r/${values.slug}" enctype="multipart/form-data">
|
||||||
|
<label>연락처</label>
|
||||||
|
<input type="tel" name="guestPhone" autocomplete="tel">
|
||||||
<div class="upload-box">
|
<div class="upload-box">
|
||||||
<label>사진/영상 선택</label>
|
<label>사진/영상 선택</label>
|
||||||
<input type="file" name="files" accept="${values.accept}" multiple required>
|
<input type="file" name="files" accept="${values.accept}" multiple required>
|
||||||
<p class="muted">허용 형식: ${values.allowedExts}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="actions"><button type="submit">업로드</button></div>
|
<div class="actions"><button type="submit">업로드</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
`);
|
`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function directorySize(dir) {
|
async function directorySize(dir) {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
const entries = await fsp.readdir(dir, { withFileTypes: true }).catch((error) => {
|
const entries = await fsp
|
||||||
|
.readdir(dir, { withFileTypes: true })
|
||||||
|
.catch((error) => {
|
||||||
if (error.code === "ENOENT") return [];
|
if (error.code === "ENOENT") return [];
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
@@ -208,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) => {
|
app.get("/admin/login", (req, res) => {
|
||||||
res.send(page("관리자 로그인", `
|
res.send(
|
||||||
|
page(
|
||||||
|
"관리자 로그인",
|
||||||
|
`
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1>사진 요청 관리자</h1>
|
<h1>사진 요청 관리자</h1>
|
||||||
<form method="post" action="/admin/login">
|
<form method="post" action="/admin/login">
|
||||||
@@ -220,18 +463,34 @@ app.get("/admin/login", (req, res) => {
|
|||||||
<div class="actions"><button type="submit">로그인</button></div>
|
<div class="actions"><button type="submit">로그인</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
`));
|
`
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post("/admin/login", (req, res) => {
|
app.post("/admin/login", (req, res) => {
|
||||||
const supplied = Buffer.from(req.body.password || "");
|
const supplied = Buffer.from(req.body.password || "");
|
||||||
const expected = Buffer.from(adminPassword);
|
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) {
|
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;
|
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");
|
res.redirect("/admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -248,19 +507,34 @@ app.get("/admin", requireAdmin, async (req, res) => {
|
|||||||
.map((item) => {
|
.map((item) => {
|
||||||
const url = `${publicBaseUrl}/r/${item.slug}`;
|
const url = `${publicBaseUrl}/r/${item.slug}`;
|
||||||
return `<tr>
|
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><a href="${escapeHtml(url)}">${escapeHtml(url)}</a></td>
|
||||||
<td>${Math.round(item.maxBytes / 1024 / 1024)} MB</td>
|
<td>${Math.round(item.maxBytes / 1024 / 1024)} MB</td>
|
||||||
<td>${escapeHtml(item.allowedExts.join(", "))}</td>
|
<td>${escapeHtml(item.allowedExts.join(", "))}</td>
|
||||||
<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>
|
<button class="danger" type="submit">삭제</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join("");
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
res.send(page("사진 요청 관리자", `
|
res.send(
|
||||||
|
page(
|
||||||
|
"사진 요청 관리자",
|
||||||
|
`
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<form method="post" action="/admin/logout" style="float:right"><button type="submit">로그아웃</button></form>
|
<form method="post" action="/admin/logout" style="float:right"><button type="submit">로그아웃</button></form>
|
||||||
<h1>사진 요청 관리자</h1>
|
<h1>사진 요청 관리자</h1>
|
||||||
@@ -276,16 +550,90 @@ app.get("/admin", requireAdmin, async (req, res) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label>허용 확장자</label>
|
<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>
|
<div class="actions"><button type="submit">링크 만들기</button></div>
|
||||||
</form>
|
</form>
|
||||||
<h2>활성 링크</h2>
|
<h2>활성 링크</h2>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>요청</th><th>링크</th><th>총 용량 제한</th><th>확장자</th><th></th></tr></thead>
|
<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>
|
</table>
|
||||||
</section>
|
</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) => {
|
app.post("/admin/requests", requireAdmin, async (req, res) => {
|
||||||
@@ -294,11 +642,20 @@ app.post("/admin/requests", requireAdmin, async (req, res) => {
|
|||||||
const allowedExts = normalizeExts(req.body.allowedExts);
|
const allowedExts = normalizeExts(req.body.allowedExts);
|
||||||
const maxMb = Math.max(1, Math.min(20480, Number(req.body.maxMb || 500)));
|
const maxMb = Math.max(1, Math.min(20480, Number(req.body.maxMb || 500)));
|
||||||
if (allowedExts.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const slug = crypto.randomBytes(16).toString("base64url");
|
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 });
|
await fsp.mkdir(path.join(uploadsDir, folderName), { recursive: true });
|
||||||
db.requests.push({
|
db.requests.push({
|
||||||
slug,
|
slug,
|
||||||
@@ -306,7 +663,7 @@ app.post("/admin/requests", requireAdmin, async (req, res) => {
|
|||||||
folderName,
|
folderName,
|
||||||
maxBytes: maxMb * 1024 * 1024,
|
maxBytes: maxMb * 1024 * 1024,
|
||||||
allowedExts,
|
allowedExts,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
await writeDb(db);
|
await writeDb(db);
|
||||||
res.redirect("/admin");
|
res.redirect("/admin");
|
||||||
@@ -314,7 +671,9 @@ app.post("/admin/requests", requireAdmin, async (req, res) => {
|
|||||||
|
|
||||||
app.post("/admin/requests/:slug/delete", requireAdmin, async (req, res) => {
|
app.post("/admin/requests/:slug/delete", requireAdmin, async (req, res) => {
|
||||||
const db = await readDb();
|
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();
|
if (item) item.deletedAt = new Date().toISOString();
|
||||||
await writeDb(db);
|
await writeDb(db);
|
||||||
res.redirect("/admin");
|
res.redirect("/admin");
|
||||||
@@ -322,7 +681,9 @@ app.post("/admin/requests/:slug/delete", requireAdmin, async (req, res) => {
|
|||||||
|
|
||||||
async function findRequest(slug) {
|
async function findRequest(slug) {
|
||||||
const db = await readDb();
|
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) {
|
function uploadMiddleware(request) {
|
||||||
@@ -337,25 +698,40 @@ function uploadMiddleware(request) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const safe = sanitizeFileName(Buffer.from(file.originalname, "latin1").toString("utf8"));
|
const safe = sanitizeFileName(
|
||||||
cb(null, `${Date.now()}_${crypto.randomBytes(4).toString("hex")}_${safe}`);
|
Buffer.from(file.originalname, "latin1").toString("utf8")
|
||||||
}
|
);
|
||||||
|
cb(
|
||||||
|
null,
|
||||||
|
`${Date.now()}_${crypto.randomBytes(4).toString("hex")}_${safe}`
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return multer({
|
return multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { files: 100 },
|
limits: { files: 100 },
|
||||||
fileFilter: (req, file, cb) => {
|
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));
|
cb(null, request.allowedExts.includes(ext));
|
||||||
}
|
},
|
||||||
}).array("files", 100);
|
}).array("files", 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get("/r/:slug", async (req, res) => {
|
app.get("/r/:slug", async (req, res) => {
|
||||||
const request = await findRequest(req.params.slug);
|
const request = await findRequest(req.params.slug);
|
||||||
if (!request) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
res.send(await uploadPage(request));
|
res.send(await uploadPage(request));
|
||||||
@@ -364,7 +740,14 @@ app.get("/r/:slug", async (req, res) => {
|
|||||||
app.post("/r/:slug", async (req, res) => {
|
app.post("/r/:slug", async (req, res) => {
|
||||||
const request = await findRequest(req.params.slug);
|
const request = await findRequest(req.params.slug);
|
||||||
if (!request) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const upload = uploadMiddleware(request);
|
const upload = uploadMiddleware(request);
|
||||||
@@ -372,25 +755,87 @@ app.post("/r/:slug", async (req, res) => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
await removeFiles(req.files);
|
await removeFiles(req.files);
|
||||||
const message = "업로드 중 오류가 났어. 파일 형식과 용량을 확인해줘.";
|
const message = "업로드 중 오류가 났어. 파일 형식과 용량을 확인해줘.";
|
||||||
res.status(400).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">${escapeHtml(message)}</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`));
|
if (wantsJson(req)) {
|
||||||
|
res.status(400).json({ ok: false, message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.send(
|
||||||
|
uploadResultPage(request, {
|
||||||
|
heading: "업로드 실패",
|
||||||
|
message,
|
||||||
|
isError: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const files = req.files || [];
|
const files = req.files || [];
|
||||||
const targetDir = path.join(uploadsDir, request.folderName);
|
const guestPhone = cleanGuestField(req.body.guestPhone);
|
||||||
const existingBytes = await directorySize(targetDir);
|
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);
|
const incomingBytes = files.reduce((total, file) => total + file.size, 0);
|
||||||
if (existingBytes + incomingBytes > request.maxBytes) {
|
if (existingBytes + incomingBytes > request.maxBytes) {
|
||||||
await removeFiles(files);
|
await removeFiles(files);
|
||||||
const remainMb = Math.max(0, Math.floor((request.maxBytes - existingBytes) / 1024 / 1024));
|
const remainMb = Math.max(
|
||||||
res.status(400).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">이 링크의 총 업로드 용량 제한을 넘었어. 남은 용량은 약 ${escapeHtml(remainMb)} MB야.</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`));
|
0,
|
||||||
|
Math.floor((request.maxBytes - existingBytes) / 1024 / 1024)
|
||||||
|
);
|
||||||
|
if (wantsJson(req)) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({
|
||||||
|
ok: false,
|
||||||
|
message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.send(
|
||||||
|
uploadResultPage(request, {
|
||||||
|
heading: "업로드 실패",
|
||||||
|
message: `업로드 가능한 용량을 넘었어요. 남은 용량은 약 ${remainMb} MB예요.`,
|
||||||
|
isError: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await moveUploadedFiles(files, targetDir);
|
await moveUploadedFiles(files, targetDir);
|
||||||
res.send(page("업로드 완료", `<section class="panel"><h1>업로드 완료</h1><div class="notice">${escapeHtml(files.length)}개 파일을 받았어.</div><a class="button" href="/r/${escapeHtml(request.slug)}">더 올리기</a></section>`));
|
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}개의 파일 잘 받았습니다. 소중한 오늘을 함께 남겨주셔서 감사드립니다.`,
|
||||||
|
})
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
await removeFiles(req.files);
|
await removeFiles(req.files);
|
||||||
res.status(500).send(page("업로드 실패", `<section class="panel"><h1>업로드 실패</h1><div class="notice error">파일을 저장하는 중 오류가 났어.</div><a class="button" href="/r/${escapeHtml(request.slug)}">다시 올리기</a></section>`));
|
if (wantsJson(req)) {
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.json({
|
||||||
|
ok: false,
|
||||||
|
message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
.status(500)
|
||||||
|
.send(
|
||||||
|
uploadResultPage(request, {
|
||||||
|
heading: "업로드 실패",
|
||||||
|
message: "파일을 저장하는 중 오류가 났어요. 다시 시도해 주세요.",
|
||||||
|
isError: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user