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