init: video-share — minimal no-signup file/video share with TTL

This commit is contained in:
2026-05-04 16:09:15 +02:00
commit 4ab1f87c1a
12 changed files with 2165 additions and 0 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
npm-debug.log
.git
.gitignore
.env
.env.*
*.md
data
uploads
.DS_Store

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
.env
.env.*
data/
uploads/
*.log
.DS_Store

24
Dockerfile Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View 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
View 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 &mdash; 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 &middot; ephemeral &middot; 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 &middot; 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
View 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
View 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
View 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
View 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) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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="/">&larr; 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')} &middot; ${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 &hellip;</span>
</header>
<section class="player">${player}</section>
<footer class="meta">
<div><span class="filename">${name}</span> <span class="muted">&middot; ${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);
}