zshot/cliDownload

Signed URL support

Requires a Enterprise license

Dave’s Dog Depot is a menu board rendered to a looping menu-board.webm for a screen. The animation loops on a 16-second cycle, so a 16-second capture plays seamlessly on repeat.

A sign-player on the wall should hold neither the render parameters nor the signing secret. It fetches one stable URL, and a small service mints a short-lived signed render and redirects to it. With --server-signed-url set, the zshot server rejects an unsigned GET / with 401 and a tampered or expired one with 403; only a URL signed with the shared secret renders.

zshot signs the query string by percent-encoding each value, sorting the pairs by key, joining them with &, and taking the HMAC-SHA256 with the secret. sign-url.py builds the same string, so the server accepts it, and 302s the player to a freshly signed render:

#!/usr/bin/env python3
"""Hands a sign-player a freshly signed zshot URL for the menu board."""
import hashlib
import hmac
import time
import urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer

ZSHOT = "http://127.0.0.1:3000/"          # the signed zshot server
SECRET = b"s3cr3t"                        # matches --server-signed-url
BOARD = "https://zshot-cli.com/example_assets/daves-dog-depot/"
TTL = 120                                 # signed URL lifetime, seconds


def signed_render_url():
    params = {
        "url": BOARD,
        "output_type": "webm",
        "video_duration": "16",
        "video_framerate": "24",
        "browser_width": "1920",
        "browser_height": "1128",
        "video_width": "1280",
        "video_height": "752",
        "wait_for": "js:menu:ready",
        "expires": str(int(time.time()) + TTL),
    }
    canonical = "&".join(
        "%s=%s" % (k, urllib.parse.quote(v, safe=""))
        for k, v in sorted(params.items())
    )
    signature = hmac.new(SECRET, canonical.encode(), hashlib.sha256).hexdigest()
    return ZSHOT + "?" + canonical + "&signature=" + signature


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(302)
        self.send_header("Location", signed_render_url())
        self.send_header("Cache-Control", "no-store")
        self.end_headers()


if __name__ == "__main__":
    HTTPServer(("127.0.0.1", 8080), Handler).serve_forever()

Start the render server with the signing secret:

zshot --server --server-bind-port 3000 \
  --server-signed-url hmac-sha256:s3cr3t --server-signed-url-max-age 300

Then run the signer beside it:

python3 sign-url.py            # listens on :8080

Visit http://localhost:8080: the signer 302s to a freshly signed render and the zshot server returns the looping board. --server-signed-url-max-age caps the lifetime, so a leaked URL stops rendering once it expires. See the API for the server endpoints and the other server flags.

Expected output