How We Added Per-Video Download Controls
You share a product demo with a prospect. They download the MP4, re-upload it somewhere, and now your half-finished UI is floating around the internet without context. Or you record a confidential client review and the recipient saves it locally — outside your control, outside your audit trail.
The fix is simple: let the sender decide whether a video can be downloaded. We added a per-video download toggle to SendRec that removes the download button, blocks the download API, and tells the browser to hide its built-in save option.
Three layers of enforcement
Disabling downloads requires blocking three paths:
1. The download button. The watch page has a “Download” button that calls the download API. When downloads are disabled, the button doesn’t render at all — it’s not hidden with CSS or disabled with JavaScript, it’s absent from the HTML:
{{if .DownloadEnabled}}
<button class="download-btn" id="download-btn">Download</button>
{{end}}
The download click handler is similarly wrapped:
{{if .DownloadEnabled}}
document.getElementById('download-btn').addEventListener('click', function() {
fetch('/api/watch/{{.ShareToken}}/download')
.then(function(r) { return r.json(); })
.then(function(data) { if (data.downloadUrl) window.location.href = data.downloadUrl; });
});
{{end}}
No button, no handler, no client-side code to tamper with.
2. The download API. Even without the button, someone could call the download endpoint directly. The WatchDownload handler checks the download_enabled flag before generating a presigned S3 URL:
if !downloadEnabled {
httputil.WriteError(w, http.StatusForbidden,
"downloads are disabled for this video")
return
}
This runs before expiry checks and password verification — there’s no point validating the rest if downloads are off.
3. The browser’s built-in controls. Browsers let users right-click a video and choose “Save video as.” HTML has two mechanisms to discourage this:
<video {{if not .DownloadEnabled}}
controlsList="nodownload"
oncontextmenu="return false;"
{{end}}>
controlsList="nodownload" removes the download option from the video player’s built-in menu (Chrome, Edge). oncontextmenu="return false;" disables the right-click menu entirely.
These are hints, not guarantees — a determined user can always use browser DevTools to find the video URL. But they handle the casual case and remove the obvious download paths.
The toggle
The Library page shows a “Downloads on” / “Downloads off” button for each video:
<button onClick={() => toggleDownload(video)}
style={{ color: video.downloadEnabled ? "var(--color-accent)" : undefined }}>
{video.downloadEnabled ? "Downloads on" : "Downloads off"}
</button>
One click, immediate toggle. The function calls PUT /api/videos/{id}/download-enabled with {downloadEnabled: true/false} and updates local state. No confirmation dialog — the action is easily reversible.
The handler
The SetDownloadEnabled handler follows the same pattern as our other per-video settings (comment mode, notifications). It’s a simple UPDATE with ownership verification:
func (h *Handler) SetDownloadEnabled(w http.ResponseWriter, r *http.Request) {
userID := auth.UserIDFromContext(r.Context())
videoID := chi.URLParam(r, "id")
var req setDownloadEnabledRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httputil.WriteError(w, http.StatusBadRequest, "invalid request body")
return
}
tag, err := h.db.Exec(r.Context(),
`UPDATE videos SET download_enabled = $1
WHERE id = $2 AND user_id = $3 AND status != 'deleted'`,
req.DownloadEnabled, videoID, userID,
)
if err != nil {
httputil.WriteError(w, http.StatusInternalServerError,
"could not update download setting")
return
}
if tag.RowsAffected() == 0 {
httputil.WriteError(w, http.StatusNotFound, "video not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
The AND user_id = $3 clause ensures you can only toggle downloads on your own videos. The RowsAffected() check distinguishes “video doesn’t exist” from “update succeeded” without a separate SELECT query.
The data model
One column:
ALTER TABLE videos ADD COLUMN download_enabled BOOLEAN NOT NULL DEFAULT true;
Default true preserves backward compatibility — all existing videos keep downloads enabled. The column is NOT NULL because there’s no “inherit” semantic here (unlike branding, where NULL means “use the default”). A video either allows downloads or it doesn’t.
OG video tag
One subtle detail: when downloads are disabled, we also suppress the OpenGraph video meta tag:
{{if .DownloadEnabled}}
<meta property="og:video" content="{{.VideoURL}}">
<meta property="og:video:type" content="{{.ContentType}}">
{{end}}
Without this, platforms like Slack and Discord would show a direct video embed in link previews, which is effectively a download path. When downloads are disabled, the preview falls back to the thumbnail image via og:image.
What this doesn’t do
DRM. This isn’t digital rights management. A viewer can always screen-record, use DevTools to extract the video URL, or capture the stream. The goal is removing the obvious download paths, not making downloading impossible. True DRM requires encrypted media extensions (EME) and a license server — complexity that doesn’t match our use case.
Revocation. Disabling downloads on a video doesn’t affect copies already downloaded. If someone downloaded the file yesterday and you disable downloads today, they still have their copy.
Streaming protection. The video itself still streams in the browser. The S3 presigned URL for playback is in the page source. Blocking this would require a proxy that serves video chunks without exposing the source URL — a significant architectural change for marginal benefit.
The feature solves the practical problem: a client opens your video link, watches it, and has no download button to click. That covers 99% of the use case.
Try it
SendRec is open source (AGPL-3.0) and self-hostable. The download toggle is live at app.sendrec.eu — upload a video, click “Downloads on” in the Library to switch it off, and share the link to verify the button is gone.