# Streaming vhost for the betting stream app.
#
# Layout assumed by this file:
#   app root      /var/www/stream
#   web root      /var/www/stream/public
#   private files /var/www/stream/storage/app/private
#                     videos/{uuid}/original.mp4   <- never web reachable
#                     videos/{uuid}/hls/master.m3u8
#                     videos/{uuid}/hls/{360p,480p,720p}/index.m3u8
#                     videos/{uuid}/hls/{360p,480p,720p}/seg_000.ts
#
# Install:
#   cp hls-mime.conf stream.conf /etc/nginx/conf.d/
#   nginx -t && systemctl reload nginx
#
# Laravel .env for this setup:
#   APP_URL=https://stream.example.com
#   STREAM_DOMAIN=https://stream.example.com
#   FORCE_HTTPS=true
#   TRUSTED_PROXIES=127.0.0.1,::1
#   VIDEO_X_ACCEL=true
#   VIDEO_X_ACCEL_LOCATION=protected-hls
#   CORS_ALLOWED_ORIGINS=https://example.com,https://partner.com,https://site.com

# ---------------------------------------------------------------------------
# Approved CORS origins. Anything not listed resolves to "" and therefore gets
# no Access-Control-Allow-Origin header at all.
# ---------------------------------------------------------------------------
map $http_origin $hls_cors_origin {
    default                                 "";
    "~^https://stream\.example\.com$"       $http_origin;
    "~^https://(www\.)?example\.com$"       $http_origin;
    "~^https://(www\.)?partner\.com$"       $http_origin;
    "~^https://(www\.)?site\.com$"          $http_origin;
}

# ---------------------------------------------------------------------------
# 7. HTTPS is required: plain HTTP only answers the ACME challenge and redirects.
# ---------------------------------------------------------------------------
server {
    listen 80;
    listen [::]:80;
    server_name stream.example.com;

    location ^~ /.well-known/acme-challenge/ {
        root /var/www/letsencrypt;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;   # nginx >= 1.25.1; older builds use "listen 443 ssl http2;"
    server_name stream.example.com;

    root /var/www/stream/public;
    index index.php;
    charset utf-8;

    ssl_certificate     /etc/letsencrypt/live/stream.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/stream.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_session_cache   shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;
    ssl_stapling        on;
    ssl_stapling_verify on;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options    "nosniff" always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;

    # Chunked uploads post 8 MB at a time; raise only if that changes.
    client_max_body_size 32m;
    client_body_timeout  120s;

    access_log /var/log/nginx/stream.access.log;
    error_log  /var/log/nginx/stream.error.log;

    # -----------------------------------------------------------------------
    # 3. Original source files can never be downloaded.
    #
    # storage/ lives outside the web root already; these rules are the second
    # line of defence in case of a stray symlink or a misconfigured alias.
    # -----------------------------------------------------------------------
    location ~ ^/storage/(app|videos)/ {
        return 403;
    }

    location ~* /original\.[a-z0-9]+$ {
        return 403;
    }

    location ~* \.(mp4|mkv|mov|avi|webm|flv|wmv)$ {
        return 403;
    }

    location ~ /\.(?!well-known) {
        deny all;
    }

    # -----------------------------------------------------------------------
    # 4. HLS playlists and segments require valid authorization.
    #
    # /videos/{id}/hls/... always reaches Laravel, which validates the signed
    # playback token, the video id, expiry and availability. Playlists are
    # returned by PHP because each line is rewritten with the caller's token.
    # Segments come back as an empty 200 carrying X-Accel-Redirect, and Nginx
    # streams the file from the internal location below.
    #
    # No token, expired token, wrong video -> Laravel answers 403 and Nginx
    # never touches the file.
    # -----------------------------------------------------------------------
    location ~ ^/videos/[^/]+/hls/ {
        # 6. Preflight for approved origins only. An unlisted origin resolves to
        # "" and the browser rejects the response before playback starts.
        if ($request_method = OPTIONS) {
            add_header Strict-Transport-Security    "max-age=31536000; includeSubDomains" always;
            add_header Access-Control-Allow-Origin  $hls_cors_origin always;
            add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
            add_header Access-Control-Allow-Headers "Origin, Accept, Content-Type, Range" always;
            add_header Access-Control-Max-Age       3600 always;
            add_header Vary                         "Origin" always;
            add_header Content-Length               0;
            return 204;
        }

        try_files $uri /index.php?$query_string;
    }

    # Defined after the route above, so it only catches playlists and segments
    # that someone tries to reach outside the authorized path.
    location ~* \.(m3u8|ts)$ {
        return 403;
    }

    # -----------------------------------------------------------------------
    # 2. Nginx handles HLS file delivery.
    #
    # internal: unreachable from outside, only via X-Accel-Redirect from PHP.
    # -----------------------------------------------------------------------
    location ^~ /protected-hls/ {
        internal;
        alias /var/www/stream/storage/app/private/;

        # 3. Even here, the original is off limits.
        location ~* /original\.[a-z0-9]+$ {
            internal;
            return 403;
        }

        # 5. Correct MIME types for playlists and segments.
        types {
            application/vnd.apple.mpegurl m3u8;
            video/mp2t                    ts;
        }
        default_type application/octet-stream;

        # Content-Type and Cache-Control arrive with the X-Accel-Redirect
        # response and are passed through, so they are not repeated here.
        #
        # add_header does not inherit once a location declares one of its own,
        # so the server-level security headers are repeated here.
        add_header Strict-Transport-Security   "max-age=31536000; includeSubDomains" always;
        add_header Referrer-Policy             "strict-origin-when-cross-origin" always;
        add_header X-Content-Type-Options      "nosniff" always;
        add_header Access-Control-Allow-Origin $hls_cors_origin always;
        add_header Vary                        "Origin" always;

        # Segments are small; send them without buffering the whole file.
        sendfile       on;
        tcp_nopush     on;
        output_buffers 2 256k;

        # Uncomment on a build with --with-threads and a configured thread_pool.
        # aio threads;
    }

    # -----------------------------------------------------------------------
    # 1. Laravel handles application requests.
    # -----------------------------------------------------------------------
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ ^/index\.php(/|$) {
        include fastcgi_params;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT   $realpath_root;
        fastcgi_param HTTPS           on;
        fastcgi_hide_header X-Powered-By;

        # Transcoding is queued, but playlists still go through PHP.
        fastcgi_read_timeout 120s;
        fastcgi_buffers      16 16k;
        fastcgi_buffer_size  32k;

        internal;
    }

    # Any other .php file is not an entry point.
    location ~ \.php$ {
        return 404;
    }

    location ~* \.(css|js|woff2?|svg|png|jpe?g|webp|ico)$ {
        access_log off;
        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options    "nosniff" always;
        add_header Cache-Control             "public, max-age=2592000, immutable" always;
        try_files $uri =404;
    }
}
