downloadonce

package module
v1.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Mar 18, 2026 License: MIT Imports: 1 Imported by: 0

README

DownloadOnce

Token-based file distribution with forensic watermarking. Each recipient gets a unique download link; their copy is invisibly watermarked so leaks can be traced back to the source.


Features

  • Forensic watermarking — visible overlay + invisible DWT-DCT steganographic embedding that survives JPEG re-compression
  • Token-based distribution — each recipient gets a unique link with optional download limits and expiry dates
  • Leak detection — decode a leaked file to identify which recipient's copy it was
  • Multi-user — admin and member roles; shared recipient/asset library
  • Recipient groups — organise recipients into named groups for bulk campaign creation
  • Resumable uploads — chunked upload with progress bar for large video files
  • Campaign management — draft → publish workflow; per-recipient watermarking jobs run in background
  • Email notifications — SMTP delivery of download links and campaign-complete alerts
  • Webhooks — outgoing HTTP hooks for campaign and download events
  • Audit log — append-only log of every action taken
  • Disk monitoring — configurable free-space warnings with admin dashboard
  • REST API — Bearer-token API for headless/CI automation
  • Single binary — SQLite embedded, no external queue or cache required

Quick Start

git clone https://github.com/YannKr/downloadonce
cd downloadonce
cp .env.example .env        # edit BASE_URL and SESSION_SECRET at minimum
docker compose up

Visit http://localhost:8080 — the first registered account becomes admin automatically.


Requirements

Deployment Requirements
Docker (recommended) Docker + Docker Compose — everything else (FFmpeg, ImageMagick, Python) is bundled in the image
Bare metal Go 1.22+, ffmpeg, imagemagick, python3, pip packages invisible-watermark + opencv-python-headless

Configuration

All configuration is via environment variables. Copy .env.example to .env and adjust:

Variable Default Description
BASE_URL http://localhost:8080 Public-facing URL used in download links
SESSION_SECRET Required. 32+ byte random secret. Generate: openssl rand -hex 32
LISTEN_ADDR :8080 HTTP listen address
DATA_DIR ./data Persistent storage root (assets, watermarked files, SQLite DB)
WORKER_COUNT 2 Concurrent watermark encoding workers
MAX_UPLOAD_BYTES 53687091200 Maximum upload file size (50 GB)
ALLOW_REGISTRATION false Allow public self-registration (off = invite-only via admin)
LOG_LEVEL info debug, info, warn, error
FONT_PATH /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf Font used for visible watermark overlay
VENV_PATH /opt/venv Python venv containing invisible-watermark
SMTP_HOST SMTP server hostname (leave empty to disable email)
SMTP_PORT 587 SMTP port
SMTP_USER SMTP username
SMTP_PASS SMTP password
SMTP_FROM Sender address (e.g. noreply@example.com)
CLEANUP_INTERVAL_MINS 60 How often the cleanup scheduler runs (minutes)
UPLOAD_SESSION_TTL_HOURS 24 How long an incomplete chunked upload is kept before expiry
DISK_WARN_YELLOW_PCT 20 Free-disk % below which a yellow warning is shown
DISK_WARN_RED_PCT 10 Free-disk % below which a red alert is shown
DISK_WARN_BLOCK_PCT 5 Free-disk % below which new uploads are blocked
MAX_STORAGE_BYTES 0 App-level storage cap in bytes (0 = unlimited)
WM_COMPRESSION_FACTOR 0.9 Estimated compression ratio used for disk-space estimates

Deployment

cp .env.example .env
# Edit .env — set BASE_URL and SESSION_SECRET at minimum
docker compose up -d

For HTTPS, uncomment the Caddy service in docker-compose.yml and point Caddyfile at your domain.

Bare metal (Debian/Ubuntu)

Download the latest binary from GitHub Releases, then:

scp downloadonce setup.sh root@your-server:~
ssh root@your-server 'BASE_URL=https://dl.example.com ./setup.sh'

setup.sh installs system dependencies, sets up a Python venv, installs a systemd service, and generates a random SESSION_SECRET automatically.

Reverse proxy

The app speaks plain HTTP on LISTEN_ADDR. Put it behind Caddy, nginx, or any TLS-terminating proxy.

Caddy

Caddy handles streaming, SSE, and large uploads correctly with zero extra configuration:

dl.example.com {
    reverse_proxy app:8080
}
nginx

nginx needs explicit configuration for three things:

  • SSE (/events endpoints) — buffering must be off or events are held until the buffer fills
  • File downloads — buffering off avoids nginx holding the entire watermarked file in memory before forwarding
  • Chunked uploadsproxy_request_buffering off streams the request body directly to the app instead of spooling it to disk first

Proxy header inheritance caveat: nginx only inherits proxy_set_header directives from a parent context into a location block when that block defines no proxy_set_header of its own. If even one proxy_set_header appears in a location, all parent-level ones are silently dropped for that block. The SSE and default locations below set Connection '' and must therefore repeat every header explicitly.

upstream downloadonce {
    server 127.0.0.1:8080;
    keepalive 32;
}

server {
    listen 80;
    server_name dl.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name dl.example.com;

    ssl_certificate     /etc/ssl/certs/dl.example.com.pem;
    ssl_certificate_key /etc/ssl/private/dl.example.com.key;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 10m;

    # Match app MAX_UPLOAD_BYTES default (50 GB)
    client_max_body_size 50g;

    proxy_http_version 1.1;

    # SSE (real-time progress) — buffering must be off or events never reach the browser.
    # proxy_set_header Connection '' must be repeated here because any proxy_set_header
    # in a location block stops inheritance of server-level headers.
    location ~ ^/(d/[^/]+|campaigns/[^/]+)/events$ {
        proxy_pass         http://downloadonce;
        proxy_buffering    off;
        proxy_cache        off;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_set_header   Connection        '';
        proxy_read_timeout 1h;
    }

    # File downloads — stream directly; buffering off avoids holding large files in memory.
    # No proxy_set_header here so server-level headers are inherited.
    location ~ ^/d/[^/]+/file$ {
        proxy_pass         http://downloadonce;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_buffering    off;
        proxy_read_timeout 1h;
    }

    # Chunked uploads — stream body directly to app, don't spool to nginx temp
    location /upload/chunks/ {
        proxy_pass              http://downloadonce;
        proxy_set_header        Host              $host;
        proxy_set_header        X-Real-IP         $remote_addr;
        proxy_set_header        X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header        X-Forwarded-Proto $scheme;
        proxy_request_buffering off;
        proxy_read_timeout      10m;
        client_body_timeout     10m;
    }

    # Everything else
    location / {
        proxy_pass         http://downloadonce;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_set_header   Connection        '';
        proxy_read_timeout 60s;
    }
}
Nginx Proxy Manager

NPM sets X-Real-IP and X-Forwarded-For only inside its own generated location / block, not at the server {} level. Custom location blocks added via the Advanced tab are siblings of that block and receive no IP headers by default. Each custom block must include them explicitly:

# Match app MAX_UPLOAD_BYTES (50 GB)
client_max_body_size 50g;

# SSE — real-time progress events
location ~ ^/(d/[^/]+|campaigns/[^/]+)/events$ {
    proxy_pass http://192.168.x.x:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Connection        '';
    proxy_buffering    off;
    proxy_cache        off;
    proxy_read_timeout 1h;
}

# File downloads — stream, no buffering
location ~ ^/d/[^/]+/file$ {
    proxy_pass http://192.168.x.x:8080;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_buffering    off;
    proxy_read_timeout 1h;
}

# Chunked uploads — don't spool to disk
location /upload/chunks/ {
    proxy_pass              http://192.168.x.x:8080;
    proxy_set_header        Host              $host;
    proxy_set_header        X-Real-IP         $remote_addr;
    proxy_set_header        X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header        X-Forwarded-Proto $scheme;
    proxy_request_buffering off;
    proxy_read_timeout      10m;
    client_body_timeout     10m;
}

Architecture

Single-binary, single-process — no external services required beyond the system tools.

Layer Technology
HTTP router chi
Database SQLite (WAL mode, modernc.org/sqlite — no CGO)
Auth Session cookies + CSRF; optional Bearer API keys
Background jobs In-process goroutine pool polling a jobs table
Real-time progress Server-Sent Events
Video watermarking FFmpeg subprocess with drawtext overlay
Image watermarking ImageMagick subprocess + Python DWT-DCT embedding
Invisible watermark invisible-watermark Python library
File delivery Pre-computed watermarked files served directly
File embedding Templates, static assets, migrations, and Python scripts compiled into the binary

The 16-byte watermark payload encodes: version (2 B) + truncated SHA-256 of token ID (8 B) + truncated SHA-256 of campaign ID (4 B) + CRC-16 checksum (2 B).

See CLAUDE.md for a full developer guide.


Releases

Releases follow Semantic Versioning. Binary builds are available on the GitHub Releases page.

Version Highlights
v1.1.2 Fix auto_publish API flag skipping watermark job enqueue
v1.1.1 Campaign archive, clone on published campaigns, add recipients after publish
v1.1.0 REST API with Bearer auth, OpenAPI spec, webhook delivery log + retry, campaign clone, export links, Go-native DWT-DCT-SVD watermarking
v1.0.1 Reduce Docker image size from ~2.3 GB to ~1.1 GB
v1.0.0 Initial release

Contributing

See CONTRIBUTING.md.

Security

See SECURITY.md for how to report vulnerabilities.

License

MIT © 2025 ypk

Documentation

Index

Constants

This section is empty.

Variables

View Source
var MigrationFS embed.FS
View Source
var ScriptFS embed.FS
View Source
var StaticFS embed.FS
View Source
var TemplateFS embed.FS

Functions

This section is empty.

Types

This section is empty.

Directories

Path Synopsis
cmd
server command
internal
app
db
sse
watermark/dct
Package dct implements a 2D Type-II DCT and its inverse (Type-III).
Package dct implements a 2D Type-II DCT and its inverse (Type-III).
watermark/dwt
Package dwt implements a single-level 2D Haar Discrete Wavelet Transform.
Package dwt implements a single-level 2D Haar Discrete Wavelet Transform.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL