Upload a video, get a streamable link and embed code back immediately. A Vue 3 portal and admin panel, a zero-npm-dependency Node server (SQLite built in), and an ffmpeg media pipeline โ€” all in one Docker container.

English ยท ็ฎ€ไฝ“ไธญๆ–‡

โœจ Features

Upload & storage

  • Streaming upload with a rolling SHA-256 โ€” nothing is buffered in memory, and content-addressed storage means an identical file is deduplicated instantly
  • Batch uploads, size and extension allowlists, minimum-resolution rejection
  • Video, images, and optionally any file type

Media pipeline (ffmpeg)

  • Transcode everything to MP4 (H.264) or WebM (VP9), with an adjustable CRF
  • Text and image watermarks on a 3ร—3 position grid, proportional or forced scaling, upper and lower size bounds
  • Automatic thumbnails; real HTTP Range/206 streaming, so seeking is instant

Content safety

  • ๐Ÿ›ก๏ธ NSFW moderation: a local skin-tone frame heuristic (free) or an external moderation API, quarantining or deleting on a hit
  • IP allow/block lists (1.2.3.* and CIDR), file-hash blocklist, hotlink protection
  • Upload log with IP geolocation, per-IP and per-user daily caps, a site-wide storage quota, and login rate limiting
  • Uploads are served with nosniff and a sandbox CSP; anything that is not video or image is forced to download, so an uploaded HTML or SVG file can never run script on your origin

Multi-user & API

  • Two roles โ€” administrator and uploader โ€” with per-user daily caps, account disabling, and password resets
  • Optional self-service registration behind a stroke-drawn arithmetic CAPTCHA
  • Per-file public / unlisted / link-only visibility, with a per-account default
  • Share links with an optional password, expiry and view limit โ€” revocable one by one
  • Full English/Chinese localisation of the UI and the API
  • API keys (Authorization: Bearer vh_xxx) for third-party upload tools
  • Tags and ordered collections, each collection with its own shareable page
  • Bulk select to delete, retag, re-scope or file away many items at once
  • Sort any list by date, size, duration, views or filename
  • Recycle bin (soft delete, restore, permanent delete)
  • Scheduled database backups with retention, downloadable from the admin panel

Portal

  • Gallery with thumbnails and search, statistics page, player pages with OG tags
  • Top/bottom/player ad slots, site-wide notice, custom head and footer code
  • Configurable link domain, for serving through a CDN or a separate origin

๐Ÿš€ Deployment

docker build -t vidhub .
docker run -d --name vidhub -p 8081:8080 \
  -v vidhub-data:/app/data \
  -e ADMIN_PASSWORD="choose-a-strong-one" \
  vidhub
# portal โ†’ http://localhost:8081/   admin โ†’ sign in as "admin"

Or docker compose up -d.

Variable Default Purpose
ADMIN_PASSWORD change-me Password for the admin account created on first boot (change it)
PORT 8080 Listen port
DATA_DIR /app/data Data directory (videos, thumbnails, SQLite)
TRUST_PROXY 1 Read the real client IP from X-Forwarded-For behind a reverse proxy
FFMPEG_PATH ffmpeg Path to a custom ffmpeg binary

Without ffmpeg the server falls back to storing originals as-is. Transcoding, watermarking and moderation switch off; everything else keeps working.

๐Ÿ”Œ API

Public

Endpoint Purpose
GET /api/health Liveness probe (used by the Docker healthcheck)
GET /api/config/public Site settings safe to expose (title, limits, toggles)
POST /api/login ยท POST /api/logout Sign in (rate limited) / sign out
GET /api/captcha Fetch a challenge โ†’ {id, svg, ttl} (rate limited, single use)
POST /api/register Self-registration โ€” must be enabled in admin; rate limited + CAPTCHA
GET /api/public/videos Gallery. Can be closed; never includes uploader IP or username
GET /v/<name> ยท /t/<name> ยท /p/<name> ยท /d/<name> Stream (Range/206) / thumbnail / player page / download
GET/POST /s/<token> Share link โ€” player page, or the password prompt when one is set
GET /c/<id> Collection page โ€” the ordered set, playable and embeddable

Signed in

Endpoint Purpose
POST /api/videos?name=โ€ฆ&visibility=โ€ฆ Upload (binary body). Guests too, if guest uploads are enabled
GET /api/videos?page=&q=&status=&visibility= Own videos; admins add all=1 for the whole site
GET /api/videos/<name> One item (owner or admin)
PATCH /api/videos/<name> Change visibility, {visibility:"public"|"private"}
DELETE /api/videos/<name> Move to the recycle bin
POST /api/videos/bulk One action over many names; each is authorised on its own
GET/POST /api/videos/<name>/shares List / issue share links (link-only files)
POST/DELETE /api/videos/<name>/tags Attach / remove one tag
GET/POST /api/tags ยท PATCH/DELETE /api/tags/<id> Your tags โ€” rename merges, delete unlinks
GET/POST /api/collections ยท PATCH/DELETE /api/collections/<id> Collections
POST/DELETE /api/collections/<id>/items ยท POST โ€ฆ/order Membership and ordering
DELETE /api/shares/<token> Revoke one share link, immediately
POST /api/videos/<name>/restore ยท DELETE โ€ฆ/force Restore / delete permanently
GET /api/recycle Recycle bin; admins add all=1 for the whole site
DELETE /api/recycle Purge the bin โ€” own, or site-wide for an admin with all=1
GET/PATCH /api/me Read or update personal preferences (default visibility)
POST /api/me/password Change own password (invalidates every session)
GET/POST /api/me/keys ยท PATCH/DELETE /api/me/keys/<key> API key management (session only)
POST /api/uploads Start a resumable upload โ†’ {id, offset, chunk_size}
GET /api/uploads/<id> How many bytes the server holds, for resuming
PATCH /api/uploads/<id>?offset=N Append one chunk at a known offset
POST /api/uploads/<id>/finish ยท DELETE /api/uploads/<id> Finalise / abandon
GET /api/stats Site-wide for admins, own figures for uploaders, anonymous only if stats_public

Admin

Endpoint Purpose
POST /api/videos/<name>/ban ยท /unban Quarantine / release
GET/PUT /api/admin/settings All site settings
GET /api/admin/check Environment probe โ†’ {ok, ffmpeg}
GET /api/admin/stats Site figures plus users, quarantined, bin size, top IPs
GET /api/admin/jobs The 50 most recent pipeline jobs
GET/POST /api/admin/backups List backups / take one now
GET/DELETE /api/admin/backups/<file> Download / delete a snapshot
GET/POST /api/admin/users ยท PATCH/DELETE /api/admin/users/<id> User management
GET /api/admin/logs?ip= Upload log with IP geolocation
GET/POST /api/admin/iprules ยท DELETE โ€ฆ/<id> IP allow/block rules
GET/POST /api/admin/hashblack ยท DELETE โ€ฆ/<sha256> File-hash blocklist
GET/POST /api/admin/webhooks ยท PATCH/DELETE โ€ฆ/<id> Webhook endpoints
POST /api/admin/webhooks/<id>/test ยท GET โ€ฆ/webhooks/log Send a test ping / delivery log

๐Ÿ“ Layout

โ”œโ”€โ”€ frontend/       # Vue 3 + TS + Vite portal and admin panel
โ”œโ”€โ”€ server/
โ”‚   โ”œโ”€โ”€ server.js   # entry point: API / media streaming / player pages / static
โ”‚   โ””โ”€โ”€ lib/        # db ยท config ยท auth ยท security ยท upload ยท media ยท moderate ยท player
โ”‚                   # shares ยท organize ยท backup ยท webhooks ยท resumable ยท i18n ยท captcha
โ”œโ”€โ”€ test/           # smoke.sh ยท pipeline.sh ยท captcha.test.mjs
โ”œโ”€โ”€ Dockerfile      # multi-stage build, single container, ffmpeg included
โ””โ”€โ”€ docker-compose.yml

๐Ÿ’ป Local development (no Docker)

Needs Node 22+ for node:sqlite. ffmpeg is optional.

cd frontend && npm install && npm run build && cp -r dist ../server/wwwroot && cd ..
DATA_DIR=./data PORT=8080 ADMIN_PASSWORD=dev-password node --no-warnings server/server.js

For frontend hot reload use npm run dev โ€” /api is already proxied to 8080.

Installing ffmpeg on Windows (no administrator rights needed): download ffmpeg-release-essentials.zip from https://www.gyan.dev/ffmpeg/builds/, check it against the .sha256 published alongside it, extract to %LOCALAPPDATA%\Programs\ffmpeg, and add that folder's bin to your user PATH.

๐Ÿงช Tests

Both scripts really do upload, ban and rewrite settings, so always point them at a throwaway data directory:

DATA_DIR=/tmp/vh-test PORT=8098 ADMIN_PASSWORD=TestPass123 node server/server.js &
BASE=http://localhost:8098 ADMIN_PASSWORD=TestPass123 bash test/smoke.sh

test/smoke.sh โ€” 278 assertions, no ffmpeg required. Covers registration and the CAPTCHA, visibility and gallery filtering, share-link formats, the bilingual API, uploads that must not be executable, the last administrator that must not be lockable, public endpoints that must not leak IPs, settings clamping, quarantined content that must not be re-uploadable, quotas and hotlink protection, authorization boundaries, and Range/path-traversal handling.

DATA_DIR=/tmp/vh-pipe PORT=8097 ADMIN_PASSWORD=TestPass123 node server/server.js &
BASE=http://localhost:8097 DATA_DIR=/tmp/vh-pipe bash test/pipeline.sh

test/pipeline.sh โ€” 33 assertions, needs ffmpeg. Generates its own footage, then checks that a public link survives a real transcode, that the watermark is genuinely burned into the pixels, that max_width scaling works, and that the minimum-resolution gate, image pipeline and local moderation all behave.

The CAPTCHA module has unit tests that need no running server:

node --test test/captcha.test.mjs

๐Ÿ”’ Security notes

  • Session tokens live in localStorage and travel in the Authorization header. No cookies, so there is no CSRF surface.
  • Uploads share the portal's origin, but anything that is not video or image is returned as application/octet-stream with Content-Disposition: attachment, and every /v/ response carries a sandbox CSP and nosniff.
  • The system always keeps at least one usable administrator โ€” demoting, disabling or deleting the last one is refused.
  • private is unlisted, not locked โ€” see Visibility. protected is the level with real access control: every direct URL is refused and a signed, short-lived, per-file grant minted by a share page is required to stream.
  • Share passwords are scrypt-hashed with a per-link salt and are never returned by the API. Revoking a link takes effect immediately; grants it already handed out lapse within hours rather than being individually recallable.
  • Backup snapshots contain everything โ€” password hashes, API keys, the share signing key. Treat a downloaded snapshot exactly as you would the live database.
  • Custom head/footer/ad fields are raw HTML injection points held by the administrator and are deliberately not escaped. Only grant admin to people you trust with that.
  • Self-registration is off by default. When enabled, new accounts are always uploaders. Rate limiting is split in two: attempts get a generous ceiling to stop brute-forcing, while successful sign-ups are capped strictly by register_rate_limit to stop bulk account creation โ€” so a human who fumbles the CAPTCHA is not locked out for an hour.
  • โš ๏ธ The built-in CAPTCHA is a simple measure. The arithmetic is drawn as line segments and the SVG contains no text, so a script cannot read the answer out of the response body โ€” but seven-segment digits do not hold up to real OCR. For a public commercial deployment, swap in Cloudflare Turnstile or hCaptcha.
  • โš ๏ธ Local skin-tone moderation is a heuristic with meaningful false-positive and false-negative rates (pale walls and beaches trip it easily). For commercial use, switch to the external moderation API mode and connect a real content safety service.

๐ŸŒ Bilingual

The UI language follows the browser (navigator.language) on a first visit, is then remembered in localStorage, and can be switched at any time from the header. API errors are returned in the language given by Accept-Language, and ?lang=zh|en overrides it explicitly. The server-rendered player page follows the same rules.

Error responses also carry a machine-readable code, so an integration can do its own localisation:

{ "error": "Incorrect username or password", "code": "auth.badCredentials" }

Upload logs store key|params rather than a finished sentence, so switching language re-renders historical log rows too.

To add a language, append a column to the tables in server/lib/i18n.js and frontend/src/i18n.ts.

๐Ÿ“Š Statistics

Figures are scoped to whoever is asking, because storage and upload volume are operational numbers rather than public showcase material:

Caller Sees
Administrator The whole site
Signed-in uploader Only their own uploads
Anonymous Nothing, unless stats_public is turned on

stats_public is off by default. Turning it on exposes site-wide totals to everyone, which is a deliberate choice for a public showcase.

๐Ÿ‘ Visibility

Every file sits at one of three levels:

Listed in the gallery Direct link / player / embed Share link
Public โœ… โœ… โ€”
Unlisted (private) โŒ โœ… โ€”
Link-only (protected) โŒ โŒ โœ…

โš ๏ธ "Unlisted" means unlisted, not locked. Filenames are the first 16 hex characters of the content's SHA-256 and cannot be enumerated, but anyone who has the URL keeps it forever. That is fine for most uploads. For anything that genuinely needs controlling, use link-only โ€” it is the level with real access control behind it.

Users set their default under My files โ†’ Account and can override it per upload. Administrators set the default for new accounts under Settings โ†’ Registration, and can change the visibility of any file from the video list.

A protected file refuses every direct URL โ€” /v/, /d/, /t/ and /p/ all answer 403. It is reachable only through a share link its owner issues, and each link can carry:

Password Prompted for on the share page; scrypt-hashed, never returned by the API
Expiry 1 hour to 30 days, or never
View limit One viewer counts once per hour, so a reload does not burn a view
Revocation Immediate, per link โ€” the others keep working

Wrong password guesses are budgeted per link rather than per IP: rotating addresses buys an attacker no fresh attempts, and one person fumbling a link cannot lock their colleagues out of every other one. Only failures count.

curl -X POST http://localhost:8081/api/videos/<name>/shares \
  -H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \
  -d '{"password":"letmein","expires_in_hours":24,"max_views":5,"note":"for review"}'

This does not break embedding, which is the whole reason authenticated access was rejected as the answer here. The token lives in the URL, so <iframe src="/s/<token>"> behaves exactly like the ordinary player page.

Streaming is authorised by a short-lived signed grant rather than a cookie or a session: the share page mints ?k=<exp>.<sig> into its own media URLs and /v/ verifies the HMAC. That keeps the no-cookie design intact (so still no CSRF surface), leaves Range requests untouched, and means a media URL copied out of the page stops working within hours instead of never. A grant is also clamped to its own link's expiry, so a ten-minute link cannot hand out six hours of video.

๐Ÿ’พ Backups

Two things here cannot be regenerated: the SQLite database and the media directory. Media are plain files that rsync or a volume snapshot already handles. The database is the part that needs care โ€” copying vidhub.db while the server is writing to it can catch a half-written page or miss the WAL, giving you a file that looks fine right up until you try to restore it.

vidhub uses SQLite's own VACUUM INTO, which writes a fresh, internally consistent, already-compacted database from inside a read transaction. No downtime, no write lock held for the duration, and no -wal/-shm sidecars to keep alongside the copy.

Turn it on under Settings โ†’ Database backups (off by default โ€” snapshots cost disk, so that should be a deliberate choice), or press Back up now under Security. Snapshots land in DATA_DIR/backups/ and the oldest are pruned past the retention count.

Restoring is a plain file operation:

docker stop vidhub
# put the snapshot in place as the live database
cp vidhub-20260818-030000.db /var/lib/docker/volumes/vidhub-data/_data/vidhub.db
# IMPORTANT: a stale WAL beside a restored database can corrupt it
rm -f /var/lib/docker/volumes/vidhub-data/_data/vidhub.db-wal \
      /var/lib/docker/volumes/vidhub-data/_data/vidhub.db-shm
docker start vidhub

The database holds metadata, not video bytes. Restoring it alone rolls back accounts, settings and the file index โ€” pair it with your media-directory backup, or the index will reference files that are no longer on disk.

๐Ÿ—‚ Organising

Three things beyond search, for when the library stops fitting on one screen.

Sorting. Every list โ€” your files, the admin list, the recycle bin, the public gallery โ€” takes ?sort=uploaded|size|duration|views|name and ?order=asc|desc. Sort keys are resolved through a lookup table rather than interpolated, because an ORDER BY is the one place a bound parameter cannot protect you.

Tags. Per-owner labels: two accounts can each have a "raw footage" and neither sees the other's. Renaming a tag onto an existing name merges into it rather than failing on the unique index, which is what fixing a typo actually means. Deleting one unlinks it everywhere and leaves the files alone.

Collections. An ordered set with its own page at /c/<id>, embeddable the same way a player page is. Only your own files can go in one, so a collection cannot be used to re-publish somebody else's upload under your name โ€” and link-only members are omitted from the page, since refusing direct URLs is exactly their purpose.

Bulk actions. Tick several rows and delete, restore, purge, re-scope, tag or file them away in one call. Every name is authorised individually and the reply reports what was skipped, so a selection that happens to include a file you do not own does the part it may and tells you the rest โ€” rather than failing the whole batch or quietly doing it anyway.

๐Ÿ”‘ API keys

Keys carry explicit scopes rather than the full weight of their owner's account:

Scope Grants
read List and read own files
upload Add files โ€” cannot list or delete
manage Visibility changes, deletion, emptying the bin

An optional expiry date closes the window on a leaked key, last_used shows whether a key is still live, and a key can be revoked without being deleted.

An API key can never reach the admin panel, create other keys, or change the account password โ€” even when its owner is an administrator. Those need a signed-in session, so a key that leaks cannot escalate itself.

โฑ Sessions

Sessions slide: once past halfway, activity pushes the expiry out again, so someone working through the day is never signed out mid-task while an abandoned session still dies on schedule. session_max_days is a hard ceiling measured from first sign-in, so no session lives forever. "Keep me signed in" swaps the session_hours window for session_remember_days.

โธ Resumable uploads

Files at or above 16 MB use a resumable session instead of one long POST, since a transfer that dies at 90% otherwise starts over. The client asks the server how many bytes it holds and continues from there โ€” across a dropped connection, a page reload, or a new tab.

POST   /api/uploads              {name, size}          -> {id, offset, chunk_size}
GET    /api/uploads/<id>                               -> {offset, size}
PATCH  /api/uploads/<id>?offset=N  <binary chunk>      -> {offset}
POST   /api/uploads/<id>/finish                        -> the usual upload result

The offset is authoritative on the server. A chunk that does not begin exactly at the current offset is refused with the real offset attached, so a confused client resynchronises instead of corrupting the file. Sessions nobody returns to are swept after 24 hours.

๐Ÿ”” Webhooks

Configured under Admin โ†’ Webhooks. Events are queued and delivered out of band, so a slow or dead endpoint never holds up an upload.

Event Fires when
upload.completed A file finished processing and went live
upload.rejected An upload was refused โ€” type, size, quota, hash blocklist, resolution
moderation.flagged Moderation quarantined or deleted something
video.deleted A file was permanently removed
user.registered Someone signed up
backup.completed A database snapshot was written
backup.failed A snapshot could not be written โ€” the one worth alerting on

Every delivery carries:

X-Vidhub-Event: upload.completed
X-Vidhub-Delivery: <uuid>
X-Vidhub-Timestamp: 1735689600000
X-Vidhub-Signature: sha256=<hex>

The signature is HMAC-SHA256(secret, "<timestamp>.<raw body>"). Verify it before trusting the payload โ€” the URL alone is not a secret. Including the timestamp in the signed string is what stops a captured delivery from being replayed later.

const want = 'sha256=' + createHmac('sha256', SECRET)
  .update(`${req.headers['x-vidhub-timestamp']}.${rawBody}`).digest('hex')
// compare with timingSafeEqual, not ===

Failures retry with exponential backoff (webhook_retries, default 3). Twenty consecutive failures disable the hook rather than hammering a dead endpoint forever; re-enabling clears the streak. Every attempt lands in the delivery log, so an integration that quietly stopped working is visible instead of being noticed weeks later.

Webhook targets are checked against loopback, link-local, RFC1918 and CGNAT ranges โ€” including IPv4-mapped IPv6 forms such as ::ffff:127.0.0.1 โ€” both when a hook is saved and again at delivery time, which closes the DNS-rebinding window between the two. A sub-millisecond 0-TTL rebind between the delivery re-check and the request itself remains theoretically possible; pin an egress allowlist at the network layer if that matters for your threat model.

Targets that resolve to loopback, link-local or RFC1918 addresses are refused by default. The URL comes from an administrator, but a compromised admin account should not also become a probe into your private network โ€” including 169.254.169.254, the cloud metadata endpoint. Set webhook_allow_private if your receiver genuinely is internal.

โ–ถ Instant start (faststart)

An MP4 carries its index โ€” the moov atom โ€” either before or after the media data, and most encoders write it after. A browser cannot render a frame until it has that index, so such a file has to be downloaded in full before anything appears: a 500MB clip means a 500MB wait. This is a file-layout problem, not a bandwidth one; a fast connection does not help.

vidhub checks every stored mp4/m4v/mov and, when the index is at the back, remuxes it to the front. Streams are copied, never re-encoded, so it takes seconds and the video is bit-for-bit identical โ€” the test suite asserts the video stream MD5 is unchanged. Turn it off with faststart if you have a reason to keep files exactly as uploaded.

๐Ÿ’พ Disk

storage_quota_gb is the policy you set. Underneath it sits the physical limit, which vidhub now watches directly:

Setting Default Effect
disk_reserve_gb 2 Refuse new uploads once free space would drop below this
disk_warn_gb 10 Below this the admin dashboard turns red and storage.low fires (at most hourly)

Without the reserve, a full disk shows up as half-written files and 500s from SQLite โ€” and the first sign of trouble is a user complaint. Refusing early with a clear message keeps enough headroom for the database to keep writing. Free and total space appear on the admin overview. Set either to 0 to disable.