init: video-share — minimal no-signup file/video share with TTL
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
|
data
|
||||||
|
uploads
|
||||||
|
.DS_Store
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
data/
|
||||||
|
uploads/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
PORT=3000 \
|
||||||
|
HOST=0.0.0.0 \
|
||||||
|
DATA_DIR=/data/uploads \
|
||||||
|
MAX_FILE_SIZE=524288000
|
||||||
|
|
||||||
|
RUN mkdir -p /data/uploads && chown -R node:node /data
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:3000/healthz >/dev/null || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
79
README.md
Normal file
79
README.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# share
|
||||||
|
|
||||||
|
Minimalist no-signup file/video drop. Upload a file, get a private UUID link, share it. Files self-destruct after a configurable TTL.
|
||||||
|
|
||||||
|
- No accounts, no DB. Metadata is a tiny JSON sidecar per upload.
|
||||||
|
- Streamed uploads (no in-memory buffering).
|
||||||
|
- HTTP `Range` requests so HTML5 video can seek.
|
||||||
|
- Background sweeper deletes expired files every 5 min.
|
||||||
|
- Per-upload `deleteToken` lets the uploader revoke early.
|
||||||
|
|
||||||
|
## Local dev
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
# http://127.0.0.1:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Files land in `./data/uploads`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Env | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `PORT` | `3000` | HTTP port |
|
||||||
|
| `HOST` | `0.0.0.0` | Bind host |
|
||||||
|
| `DATA_DIR` | `/data/uploads` | Persisted directory (mount a volume here) |
|
||||||
|
| `MAX_FILE_SIZE` | `524288000` | 500 MB. Bytes. |
|
||||||
|
| `LOG_LEVEL` | `info` | Fastify logger level |
|
||||||
|
|
||||||
|
TTL options exposed in the UI: 30m / 1h / 6h / 24h. Edit `TTL_OPTIONS` in `src/server.js` to change.
|
||||||
|
|
||||||
|
## Deploy on Coolify (montlab.dev)
|
||||||
|
|
||||||
|
Two ways. Pick one.
|
||||||
|
|
||||||
|
### Option A — Dockerfile build pack (matches existing apps)
|
||||||
|
|
||||||
|
1. Push this repo to Gitea: `git.montlab.dev/JosLe/video-share`.
|
||||||
|
2. In Coolify, create a new app:
|
||||||
|
- Build pack: **Dockerfile**
|
||||||
|
- Source: Public Repository → `https://git.montlab.dev/JosLe/video-share.git`
|
||||||
|
- Branch: `main`
|
||||||
|
- Port: `3000`
|
||||||
|
- Domain: `share.montlab.dev` (Caddy + Let's Encrypt is automatic)
|
||||||
|
3. **Persistent storage** → add a volume:
|
||||||
|
- Source: `share_data` (named volume, or a host path you prefer)
|
||||||
|
- Destination: `/data`
|
||||||
|
4. **Environment variables** (optional override):
|
||||||
|
- `MAX_FILE_SIZE=524288000`
|
||||||
|
5. Set up the Gitea webhook exactly like the other apps in `INFRASTRUCTURE.md`.
|
||||||
|
6. Deploy. First time may need "Force redeploy without cache".
|
||||||
|
|
||||||
|
### Option B — Docker Compose build pack
|
||||||
|
|
||||||
|
Use the bundled `docker-compose.yaml`. Pick build pack **Docker Compose** in Coolify, point at this repo, set the domain on the `app` service to `share.montlab.dev`, port `3000`. The named volume `share_data` is declared in the compose file.
|
||||||
|
|
||||||
|
## Caddy / proxy notes
|
||||||
|
|
||||||
|
Coolify's Caddy fronts the app, so HTTPS, HTTP/2 and request body limits are handled there. caddy-docker-proxy v2.9 does **not** cap upload size by default for HTTP/2, but if a future config sets `request_body { max_size ... }`, bump it above `MAX_FILE_SIZE`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET` | `/` | Upload page |
|
||||||
|
| `POST` | `/upload` | multipart form: `file` + `ttl` (`30m`/`1h`/`6h`/`24h`) |
|
||||||
|
| `GET` | `/v/:id` | Viewer (HTML, video player) |
|
||||||
|
| `GET` | `/f/:id` | Raw file stream, supports `Range` |
|
||||||
|
| `GET` | `/f/:id?dl=1` | Force download |
|
||||||
|
| `GET` | `/api/info/:id` | JSON metadata |
|
||||||
|
| `DELETE` | `/api/:id?token=...` | Revoke with the delete token |
|
||||||
|
| `GET` | `/healthz` | Liveness |
|
||||||
|
|
||||||
|
Links use UUID v4. They are unguessable but **not authenticated** — anyone with the link can view. Send the link over a private channel.
|
||||||
|
|
||||||
|
## Disk usage
|
||||||
|
|
||||||
|
A 500 MB upload at 1 h TTL uses ~500 MB until the sweep cycle picks it up (≤5 min after expiry). For peak sizing assume `users × max_concurrent_uploads × MAX_FILE_SIZE × longest_TTL/sweep_interval`. Coolify's CX22 has 40 GB SSD — keep an eye on it or lower `MAX_FILE_SIZE`.
|
||||||
16
docker-compose.yaml
Normal file
16
docker-compose.yaml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PORT: 3000
|
||||||
|
DATA_DIR: /data/uploads
|
||||||
|
MAX_FILE_SIZE: ${MAX_FILE_SIZE:-524288000}
|
||||||
|
LOG_LEVEL: info
|
||||||
|
volumes:
|
||||||
|
- share_data:/data
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
share_data:
|
||||||
1214
package-lock.json
generated
Normal file
1214
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "video-share",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Minimalist no-signup video/file share with private UUID links and TTL",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "node --watch src/server.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/multipart": "^8.3.0",
|
||||||
|
"@fastify/static": "^7.0.4",
|
||||||
|
"fastify": "^4.28.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/public/index.html
Normal file
77
src/public/index.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
<title>share — quick file drop</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="upload">
|
||||||
|
<header>
|
||||||
|
<h1 class="brand">share</h1>
|
||||||
|
<p class="muted">no signup · ephemeral · private link</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form id="form" autocomplete="off">
|
||||||
|
<label id="drop" for="file" class="drop">
|
||||||
|
<input type="file" id="file" name="file" hidden required>
|
||||||
|
<div class="drop-inner">
|
||||||
|
<div class="drop-title" id="dropTitle">drop a file here</div>
|
||||||
|
<div class="muted" id="dropHint">or click to choose · max {{MAX_FILE_SIZE_HUMAN}}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<label class="ttl">
|
||||||
|
<span class="muted">expires in</span>
|
||||||
|
<select id="ttl" name="ttl">
|
||||||
|
<option value="30m">30 min</option>
|
||||||
|
<option value="1h" selected>1 hour</option>
|
||||||
|
<option value="6h">6 hours</option>
|
||||||
|
<option value="24h">24 hours</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="submit" class="btn primary" type="submit" disabled>upload</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress" id="progress" hidden>
|
||||||
|
<div class="bar"><div class="fill" id="bar"></div></div>
|
||||||
|
<div class="muted" id="progressText">0%</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section id="result" class="result" hidden>
|
||||||
|
<div class="result-title">link ready</div>
|
||||||
|
<div class="link-row">
|
||||||
|
<input id="link" type="text" readonly>
|
||||||
|
<button id="copy" class="btn" type="button">copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="muted" id="resultMeta"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<a id="open" class="btn" target="_blank" rel="noopener">open</a>
|
||||||
|
<button id="reset" class="btn ghost" type="button">new upload</button>
|
||||||
|
</div>
|
||||||
|
<details class="extra">
|
||||||
|
<summary class="muted">delete link (keep private)</summary>
|
||||||
|
<div class="link-row">
|
||||||
|
<input id="delLink" type="text" readonly>
|
||||||
|
<button id="copyDel" class="btn" type="button">copy</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="error" class="error" hidden></section>
|
||||||
|
|
||||||
|
<footer class="foot muted">
|
||||||
|
<span>files self-destruct after expiry</span>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.MAX_FILE_SIZE = {{MAX_FILE_SIZE}};
|
||||||
|
</script>
|
||||||
|
<script src="/static/upload.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
215
src/public/style.css
Normal file
215
src/public/style.css
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0e0f12;
|
||||||
|
--fg: #e8e8e8;
|
||||||
|
--muted: #8a8f98;
|
||||||
|
--accent: #7cf2c4;
|
||||||
|
--accent-fg: #0e0f12;
|
||||||
|
--line: #23262d;
|
||||||
|
--card: #15171c;
|
||||||
|
--danger: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font: 15px/1.5 ui-monospace, "JetBrains Mono", Menlo, Consolas, monospace;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6vh 20px 40px;
|
||||||
|
}
|
||||||
|
body.center { align-items: center; }
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.brand:hover { text-decoration: none; color: var(--accent); }
|
||||||
|
|
||||||
|
main.upload {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
main.upload header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
main.upload h1 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop {
|
||||||
|
display: block;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
padding: 48px 24px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.drop:hover, .drop.over {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: #181b22;
|
||||||
|
}
|
||||||
|
.drop-title { font-size: 17px; margin-bottom: 4px; }
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.ttl { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
select, input[type="text"] {
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
select:focus, input[type="text"]:focus { outline: none; border-color: var(--accent); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--fg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-fg);
|
||||||
|
border-color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn.primary:hover { color: var(--accent-fg); filter: brightness(1.05); }
|
||||||
|
.btn.primary:disabled { opacity: .4; cursor: not-allowed; filter: none; }
|
||||||
|
.btn.ghost { background: transparent; }
|
||||||
|
|
||||||
|
.progress { margin-top: 16px; }
|
||||||
|
.bar {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--line);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.fill {
|
||||||
|
height: 100%;
|
||||||
|
width: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width .15s;
|
||||||
|
}
|
||||||
|
#progressText { margin-top: 6px; font-size: 13px; }
|
||||||
|
|
||||||
|
.result {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.result-title { font-weight: 600; margin-bottom: 12px; }
|
||||||
|
.link-row { display: flex; gap: 8px; }
|
||||||
|
.link-row input {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.actions { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.extra { margin-top: 14px; }
|
||||||
|
.extra summary { cursor: pointer; user-select: none; }
|
||||||
|
.extra .link-row { margin-top: 8px; }
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: #2a1416;
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
margin-top: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* viewer */
|
||||||
|
main.viewer {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
main.viewer header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.player {
|
||||||
|
background: #000;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
.player video, .player audio, .player img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 80vh;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.player audio { width: 100%; }
|
||||||
|
.filebox {
|
||||||
|
padding: 40px;
|
||||||
|
color: var(--fg);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.filebox .filename { font-size: 18px; margin-bottom: 6px; word-break: break-all; }
|
||||||
|
|
||||||
|
footer.meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
footer.meta .filename { word-break: break-all; }
|
||||||
|
|
||||||
|
main.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px 40px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
main.card h1 { margin: 0 0 8px; font-size: 48px; }
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
body { padding: 24px 12px; }
|
||||||
|
.drop { padding: 32px 16px; }
|
||||||
|
.row { flex-direction: column; align-items: stretch; }
|
||||||
|
.btn.primary { width: 100%; }
|
||||||
|
}
|
||||||
147
src/public/upload.js
Normal file
147
src/public/upload.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
(() => {
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const form = $('form');
|
||||||
|
const drop = $('drop');
|
||||||
|
const fileInput = $('file');
|
||||||
|
const dropTitle = $('dropTitle');
|
||||||
|
const dropHint = $('dropHint');
|
||||||
|
const submitBtn = $('submit');
|
||||||
|
const ttlSel = $('ttl');
|
||||||
|
const progress = $('progress');
|
||||||
|
const bar = $('bar');
|
||||||
|
const progressText = $('progressText');
|
||||||
|
const result = $('result');
|
||||||
|
const link = $('link');
|
||||||
|
const open = $('open');
|
||||||
|
const copyBtn = $('copy');
|
||||||
|
const resetBtn = $('reset');
|
||||||
|
const errorBox = $('error');
|
||||||
|
const resultMeta = $('resultMeta');
|
||||||
|
const delLink = $('delLink');
|
||||||
|
const copyDel = $('copyDel');
|
||||||
|
|
||||||
|
const MAX = window.MAX_FILE_SIZE || (500 * 1024 * 1024);
|
||||||
|
|
||||||
|
function human(n) {
|
||||||
|
if (!Number.isFinite(n)) return '?';
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0;
|
||||||
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||||
|
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setFile(f) {
|
||||||
|
if (!f) return;
|
||||||
|
if (f.size > MAX) {
|
||||||
|
showError(`file too large (${human(f.size)} > ${human(MAX)})`);
|
||||||
|
fileInput.value = '';
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
dropTitle.textContent = 'drop a file here';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideError();
|
||||||
|
dropTitle.textContent = f.name;
|
||||||
|
dropHint.textContent = human(f.size);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => setFile(fileInput.files[0]));
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(ev =>
|
||||||
|
drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('over'); }));
|
||||||
|
['dragleave', 'drop'].forEach(ev =>
|
||||||
|
drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('over'); }));
|
||||||
|
drop.addEventListener('drop', e => {
|
||||||
|
const f = e.dataTransfer?.files?.[0];
|
||||||
|
if (f) {
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(f);
|
||||||
|
fileInput.files = dt.files;
|
||||||
|
setFile(f);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errorBox.textContent = msg;
|
||||||
|
errorBox.hidden = false;
|
||||||
|
}
|
||||||
|
function hideError() {
|
||||||
|
errorBox.hidden = true;
|
||||||
|
errorBox.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = fileInput.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
hideError();
|
||||||
|
progress.hidden = false;
|
||||||
|
bar.style.width = '0%';
|
||||||
|
progressText.textContent = 'uploading 0%';
|
||||||
|
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('ttl', ttlSel.value);
|
||||||
|
fd.append('file', f);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', '/upload');
|
||||||
|
xhr.upload.addEventListener('progress', (ev) => {
|
||||||
|
if (!ev.lengthComputable) return;
|
||||||
|
const pct = Math.round((ev.loaded / ev.total) * 100);
|
||||||
|
bar.style.width = pct + '%';
|
||||||
|
progressText.textContent = `uploading ${pct}% (${human(ev.loaded)} / ${human(ev.total)})`;
|
||||||
|
});
|
||||||
|
xhr.onload = () => {
|
||||||
|
progress.hidden = true;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
let body = {};
|
||||||
|
try { body = JSON.parse(xhr.responseText); } catch {}
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
showResult(body, f);
|
||||||
|
} else {
|
||||||
|
showError(body.error || `upload failed (${xhr.status})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => {
|
||||||
|
progress.hidden = true;
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
showError('network error');
|
||||||
|
};
|
||||||
|
xhr.send(fd);
|
||||||
|
});
|
||||||
|
|
||||||
|
function showResult(body, f) {
|
||||||
|
form.hidden = true;
|
||||||
|
result.hidden = false;
|
||||||
|
link.value = body.url;
|
||||||
|
open.href = body.url;
|
||||||
|
delLink.value = body.deleteUrl || '';
|
||||||
|
const exp = new Date(body.expiresAt);
|
||||||
|
resultMeta.textContent = `${f.name} · ${human(f.size)} · expires ${exp.toLocaleString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyValue(input, btn) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(input.value);
|
||||||
|
} catch {
|
||||||
|
input.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
}
|
||||||
|
const orig = btn.textContent;
|
||||||
|
btn.textContent = 'copied';
|
||||||
|
setTimeout(() => { btn.textContent = orig; }, 1200);
|
||||||
|
}
|
||||||
|
copyBtn.addEventListener('click', () => copyValue(link, copyBtn));
|
||||||
|
copyDel.addEventListener('click', () => copyValue(delLink, copyDel));
|
||||||
|
|
||||||
|
resetBtn.addEventListener('click', () => {
|
||||||
|
fileInput.value = '';
|
||||||
|
form.reset();
|
||||||
|
form.hidden = false;
|
||||||
|
result.hidden = true;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
dropTitle.textContent = 'drop a file here';
|
||||||
|
dropHint.textContent = `or click to choose · max ${human(MAX)}`;
|
||||||
|
});
|
||||||
|
})();
|
||||||
28
src/public/viewer.js
Normal file
28
src/public/viewer.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
(() => {
|
||||||
|
const el = document.getElementById('expiry');
|
||||||
|
if (!el) return;
|
||||||
|
const expiresAt = parseInt(el.dataset.expires, 10);
|
||||||
|
if (!expiresAt) return;
|
||||||
|
|
||||||
|
function fmt(ms) {
|
||||||
|
if (ms <= 0) return 'expired';
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h > 0) return `expires in ${h}h ${m}m`;
|
||||||
|
if (m > 0) return `expires in ${m}m ${sec}s`;
|
||||||
|
return `expires in ${sec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
const left = expiresAt - Date.now();
|
||||||
|
el.textContent = fmt(left);
|
||||||
|
if (left <= 0) {
|
||||||
|
setTimeout(() => location.reload(), 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 1000);
|
||||||
|
})();
|
||||||
329
src/server.js
Normal file
329
src/server.js
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
import Fastify from 'fastify';
|
||||||
|
import multipart from '@fastify/multipart';
|
||||||
|
import staticPlugin from '@fastify/static';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data', 'uploads');
|
||||||
|
const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE || String(500 * 1024 * 1024), 10);
|
||||||
|
const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
const TTL_OPTIONS = {
|
||||||
|
'30m': 30 * 60 * 1000,
|
||||||
|
'1h': 60 * 60 * 1000,
|
||||||
|
'6h': 6 * 60 * 60 * 1000,
|
||||||
|
'24h': 24 * 60 * 60 * 1000,
|
||||||
|
};
|
||||||
|
const DEFAULT_TTL = '1h';
|
||||||
|
|
||||||
|
await fsp.mkdir(DATA_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const app = Fastify({
|
||||||
|
logger: { level: process.env.LOG_LEVEL || 'info' },
|
||||||
|
bodyLimit: MAX_FILE_SIZE + 5 * 1024 * 1024,
|
||||||
|
trustProxy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(multipart, {
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_FILE_SIZE,
|
||||||
|
files: 1,
|
||||||
|
fields: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.register(staticPlugin, {
|
||||||
|
root: path.join(__dirname, 'public'),
|
||||||
|
prefix: '/static/',
|
||||||
|
decorateReply: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const metaPath = (id) => path.join(DATA_DIR, `${id}.json`);
|
||||||
|
|
||||||
|
async function readMeta(id) {
|
||||||
|
if (!/^[a-f0-9-]{36}$/i.test(id)) return null;
|
||||||
|
try {
|
||||||
|
const buf = await fsp.readFile(metaPath(id), 'utf8');
|
||||||
|
return JSON.parse(buf);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUpload(id, meta) {
|
||||||
|
const m = meta || (await readMeta(id));
|
||||||
|
if (!m) return;
|
||||||
|
await Promise.allSettled([
|
||||||
|
fsp.rm(path.join(DATA_DIR, m.storedName), { force: true }),
|
||||||
|
fsp.rm(metaPath(id), { force: true }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sweep() {
|
||||||
|
try {
|
||||||
|
const entries = await fsp.readdir(DATA_DIR);
|
||||||
|
const now = Date.now();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.endsWith('.json')) continue;
|
||||||
|
const id = entry.slice(0, -5);
|
||||||
|
const meta = await readMeta(id);
|
||||||
|
if (!meta) continue;
|
||||||
|
if (now > meta.expiresAt) {
|
||||||
|
app.log.info({ id }, 'sweep: deleting expired');
|
||||||
|
await deleteUpload(id, meta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
app.log.warn({ err }, 'sweep error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(() => { sweep().catch(() => {}); }, SWEEP_INTERVAL_MS).unref();
|
||||||
|
sweep().catch(() => {});
|
||||||
|
|
||||||
|
function publicBase(req) {
|
||||||
|
const proto = (req.headers['x-forwarded-proto'] || req.protocol || 'https')
|
||||||
|
.toString().split(',')[0].trim();
|
||||||
|
const host = (req.headers['x-forwarded-host'] || req.headers.host || '')
|
||||||
|
.toString().split(',')[0].trim();
|
||||||
|
return `${proto}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||||
|
}[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanSize(bytes) {
|
||||||
|
if (!Number.isFinite(bytes)) return '?';
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let i = 0; let n = bytes;
|
||||||
|
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
|
||||||
|
return `${n.toFixed(n >= 10 || i === 0 ? 0 : 1)} ${u[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/healthz', async () => 'ok');
|
||||||
|
|
||||||
|
app.get('/robots.txt', async (req, reply) => {
|
||||||
|
reply.type('text/plain').send('User-agent: *\nDisallow: /\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/', async (req, reply) => {
|
||||||
|
const html = await fsp.readFile(path.join(__dirname, 'public', 'index.html'), 'utf8');
|
||||||
|
reply.type('text/html; charset=utf-8').send(
|
||||||
|
html.replace('{{MAX_FILE_SIZE_HUMAN}}', humanSize(MAX_FILE_SIZE))
|
||||||
|
.replace('{{MAX_FILE_SIZE}}', String(MAX_FILE_SIZE))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/upload', async (req, reply) => {
|
||||||
|
const data = await req.file();
|
||||||
|
if (!data) return reply.code(400).send({ error: 'no file' });
|
||||||
|
|
||||||
|
const ttlField = data.fields?.ttl;
|
||||||
|
const ttlKey = (ttlField?.value || DEFAULT_TTL).toString();
|
||||||
|
const ttlMs = TTL_OPTIONS[ttlKey] ?? TTL_OPTIONS[DEFAULT_TTL];
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const deleteToken = crypto.randomBytes(16).toString('hex');
|
||||||
|
const rawExt = path.extname(data.filename || '').slice(0, 16);
|
||||||
|
const safeExt = /^\.[A-Za-z0-9]{1,15}$/.test(rawExt) ? rawExt.toLowerCase() : '';
|
||||||
|
const storedName = `${id}${safeExt}`;
|
||||||
|
const storedPath = path.join(DATA_DIR, storedName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pipeline(data.file, fs.createWriteStream(storedPath));
|
||||||
|
} catch (err) {
|
||||||
|
await fsp.rm(storedPath, { force: true });
|
||||||
|
if (data.file.truncated) {
|
||||||
|
return reply.code(413).send({ error: 'file too large' });
|
||||||
|
}
|
||||||
|
app.log.error({ err }, 'upload failed');
|
||||||
|
return reply.code(500).send({ error: 'upload failed' });
|
||||||
|
}
|
||||||
|
if (data.file.truncated) {
|
||||||
|
await fsp.rm(storedPath, { force: true });
|
||||||
|
return reply.code(413).send({ error: 'file too large' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = await fsp.stat(storedPath);
|
||||||
|
const meta = {
|
||||||
|
id,
|
||||||
|
originalName: data.filename || `file${safeExt}`,
|
||||||
|
storedName,
|
||||||
|
mime: data.mimetype || 'application/octet-stream',
|
||||||
|
size: stat.size,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + ttlMs,
|
||||||
|
ttl: ttlKey,
|
||||||
|
deleteToken,
|
||||||
|
};
|
||||||
|
await fsp.writeFile(metaPath(id), JSON.stringify(meta));
|
||||||
|
|
||||||
|
const base = publicBase(req);
|
||||||
|
return reply.send({
|
||||||
|
id,
|
||||||
|
url: `${base}/v/${id}`,
|
||||||
|
expiresAt: meta.expiresAt,
|
||||||
|
deleteToken,
|
||||||
|
deleteUrl: `${base}/api/${id}?token=${deleteToken}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/info/:id', async (req, reply) => {
|
||||||
|
const meta = await readMeta(req.params.id);
|
||||||
|
if (!meta || Date.now() > meta.expiresAt) {
|
||||||
|
return reply.code(404).send({ error: 'not found' });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: meta.id,
|
||||||
|
originalName: meta.originalName,
|
||||||
|
mime: meta.mime,
|
||||||
|
size: meta.size,
|
||||||
|
expiresAt: meta.expiresAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/:id', async (req, reply) => {
|
||||||
|
const meta = await readMeta(req.params.id);
|
||||||
|
if (!meta) return reply.code(404).send({ error: 'not found' });
|
||||||
|
const token = req.query?.token;
|
||||||
|
if (!token || token !== meta.deleteToken) {
|
||||||
|
return reply.code(403).send({ error: 'forbidden' });
|
||||||
|
}
|
||||||
|
await deleteUpload(meta.id, meta);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/v/:id', async (req, reply) => {
|
||||||
|
const meta = await readMeta(req.params.id);
|
||||||
|
if (!meta || Date.now() > meta.expiresAt) {
|
||||||
|
if (meta) await deleteUpload(meta.id, meta);
|
||||||
|
reply.code(404).type('text/html; charset=utf-8').send(notFoundHtml());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reply.type('text/html; charset=utf-8').send(viewerHtml(meta));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/f/:id', async (req, reply) => {
|
||||||
|
const meta = await readMeta(req.params.id);
|
||||||
|
if (!meta || Date.now() > meta.expiresAt) {
|
||||||
|
if (meta) await deleteUpload(meta.id, meta);
|
||||||
|
return reply.code(404).send('not found');
|
||||||
|
}
|
||||||
|
const filePath = path.join(DATA_DIR, meta.storedName);
|
||||||
|
let stat;
|
||||||
|
try { stat = await fsp.stat(filePath); }
|
||||||
|
catch { return reply.code(404).send('not found'); }
|
||||||
|
|
||||||
|
const fileSize = stat.size;
|
||||||
|
const range = req.headers.range;
|
||||||
|
reply.header('Accept-Ranges', 'bytes');
|
||||||
|
reply.header('Content-Type', meta.mime || 'application/octet-stream');
|
||||||
|
reply.header('Cache-Control', 'private, no-store');
|
||||||
|
|
||||||
|
const dispoMode = req.query?.dl === '1' ? 'attachment' : 'inline';
|
||||||
|
const safeName = meta.originalName.replace(/[\r\n"]/g, '');
|
||||||
|
reply.header(
|
||||||
|
'Content-Disposition',
|
||||||
|
`${dispoMode}; filename*=UTF-8''${encodeURIComponent(safeName)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
const m = /^bytes=(\d*)-(\d*)$/.exec(range);
|
||||||
|
if (!m) {
|
||||||
|
return reply.code(416).header('Content-Range', `bytes */${fileSize}`).send();
|
||||||
|
}
|
||||||
|
let start = m[1] === '' ? undefined : parseInt(m[1], 10);
|
||||||
|
let end = m[2] === '' ? undefined : parseInt(m[2], 10);
|
||||||
|
if (start === undefined && end === undefined) {
|
||||||
|
return reply.code(416).header('Content-Range', `bytes */${fileSize}`).send();
|
||||||
|
}
|
||||||
|
if (start === undefined) {
|
||||||
|
start = Math.max(0, fileSize - end);
|
||||||
|
end = fileSize - 1;
|
||||||
|
}
|
||||||
|
if (end === undefined || end >= fileSize) end = fileSize - 1;
|
||||||
|
if (start > end || start >= fileSize) {
|
||||||
|
return reply.code(416).header('Content-Range', `bytes */${fileSize}`).send();
|
||||||
|
}
|
||||||
|
reply.code(206);
|
||||||
|
reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`);
|
||||||
|
reply.header('Content-Length', end - start + 1);
|
||||||
|
return reply.send(fs.createReadStream(filePath, { start, end }));
|
||||||
|
}
|
||||||
|
reply.header('Content-Length', fileSize);
|
||||||
|
return reply.send(fs.createReadStream(filePath));
|
||||||
|
});
|
||||||
|
|
||||||
|
function notFoundHtml() {
|
||||||
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||||
|
<title>gone</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head><body class="center">
|
||||||
|
<main class="card">
|
||||||
|
<h1>404</h1>
|
||||||
|
<p class="muted">This link has expired or never existed.</p>
|
||||||
|
<p><a href="/">← new upload</a></p>
|
||||||
|
</main></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewerHtml(meta) {
|
||||||
|
const mime = meta.mime || '';
|
||||||
|
const name = escapeHtml(meta.originalName);
|
||||||
|
const size = humanSize(meta.size);
|
||||||
|
const fileUrl = `/f/${meta.id}`;
|
||||||
|
const dlUrl = `/f/${meta.id}?dl=1`;
|
||||||
|
|
||||||
|
let player;
|
||||||
|
if (mime.startsWith('video/')) {
|
||||||
|
player = `<video controls preload="metadata" playsinline src="${fileUrl}"></video>`;
|
||||||
|
} else if (mime.startsWith('audio/')) {
|
||||||
|
player = `<audio controls preload="metadata" src="${fileUrl}"></audio>`;
|
||||||
|
} else if (mime.startsWith('image/')) {
|
||||||
|
player = `<img alt="${name}" src="${fileUrl}">`;
|
||||||
|
} else {
|
||||||
|
player = `<div class="filebox"><div class="filename">${name}</div>
|
||||||
|
<div class="muted">${escapeHtml(mime || 'unknown')} · ${size}</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<!doctype html><html lang="en"><head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
<title>${name}</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head><body>
|
||||||
|
<main class="viewer">
|
||||||
|
<header>
|
||||||
|
<a class="brand" href="/">share</a>
|
||||||
|
<span class="muted" id="expiry" data-expires="${meta.expiresAt}">expires …</span>
|
||||||
|
</header>
|
||||||
|
<section class="player">${player}</section>
|
||||||
|
<footer class="meta">
|
||||||
|
<div><span class="filename">${name}</span> <span class="muted">· ${size}</span></div>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn" href="${dlUrl}" download>download</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
<script src="/static/viewer.js"></script>
|
||||||
|
</body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.listen({ port: PORT, host: HOST });
|
||||||
|
app.log.info({ DATA_DIR, MAX_FILE_SIZE, MAX_HUMAN: humanSize(MAX_FILE_SIZE) }, 'ready');
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user