Add /drops/: login-protected, view-only shot previews
Deploy / deploy (push) Successful in 39s

- nginx basic auth on /drops/ (page and clips); login file and clips live
  on the server only (docker/auth, docker/drops), never in git or the image
- Fails closed: missing login file returns 500; no-store, noindex
- Page reads manifest.json from the server, shows "taken down" when empty
- Player hides download/cast/PiP, blocks right-click, confidential overlay
- DEPLOY.md step 8: how to share a drop and take it down

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-20 14:10:02 +02:00
parent 613d46d067
commit 7150c3f3d2
7 changed files with 191 additions and 0 deletions
+3
View File
@@ -8,3 +8,6 @@ content/*.pdf
*.md *.md
!README.md !README.md
content/crowd content/crowd
docker/auth
docker/drops
content/drops
+5
View File
@@ -53,3 +53,8 @@ content/*.mov
# previz source renders (web versions in public/previz) # previz source renders (web versions in public/previz)
content/crowd/ content/crowd/
# /drops/ login and clips: server only, never committed (see DEPLOY.md)
docker/auth/
docker/drops/
content/drops/
+43
View File
@@ -112,6 +112,49 @@ The workflow is already in the repo (`.gitea/workflows/deploy.yml`). In Gitea, o
Test it: push any commit to `main` and watch **Actions** in the repo. The job should run Test it: push any commit to `main` and watch **Actions** in the repo. The job should run
`git pull` and `docker compose up -d --build`, then the site updates. `git pull` and `docker compose up -d --build`, then the site updates.
### 8. Private shot previews at /drops/
`/drops/` is a view-only screening page for vendors and clients. nginx asks for a login before
it serves the page or any clip. The login file and the clips stay on the server only, in
`docker/auth/` and `docker/drops/`, and are never committed. The page reads
`docker/drops/manifest.json` to know which clips to show.
**Share a drop.** On your PC, convert the clips to web MP4 without re-encoding, then copy them up:
```bash
ffmpeg -i SHOT.mov -map 0:v -map 0:a? -c copy -movflags +faststart -map_metadata -1 SHOT.mp4
scp SHOT.mp4 manifest.json <you>@178.104.56.62:/opt/ttvfx_web/docker/drops/
```
`manifest.json` looks like this (`src` is the file name in `docker/drops/`):
```json
{ "title": "DH2 shot previews", "note": "For review only.",
"files": [{ "name": "DH2_VFX_027_210_REF", "src": "DH2_VFX_027_210_REF.mp4" }] }
```
Then set a password on the server. Use a new password for each vendor and drop:
```bash
cd /opt/ttvfx_web
mkdir -p docker/auth
printf "vendor:$(openssl passwd -apr1)\n" > docker/auth/drops.htpasswd # prompts for the password
docker compose up -d --force-recreate # first time only, so the folders get mounted
```
Send `https://twotalesvfx.com/drops/`, the username `vendor` and the password through two
different channels (for example, the link by email and the password by WhatsApp).
**Take it down.** No deploy is needed. Delete the clips and the login, and the page returns
"taken down" or 500 straight away:
```bash
rm /opt/ttvfx_web/docker/drops/* /opt/ttvfx_web/docker/auth/drops.htpasswd
```
If the login file is missing, `/drops/` returns 500, so it is never left open. Nothing in
`/drops/` is cached by browsers or Cloudflare.
--- ---
## Day to day ## Day to day
+28
View File
@@ -0,0 +1,28 @@
import type { Metadata, Viewport } from "next";
import Fox from "@/components/Fox";
import DropViewer from "@/components/drops/DropViewer";
import { site } from "@/content/site";
// Private screening room for vendors and clients. nginx puts the whole of /drops/ behind a
// login, and the clips plus their list (manifest.json) live on the server only, never in git
// (see DEPLOY.md). Not linked from the site, kept out of search and the sitemap.
export const metadata: Metadata = {
title: "Shot previews",
robots: { index: false, follow: false },
};
export const viewport: Viewport = {
themeColor: "#0a0a0b",
};
export default function DropsPage() {
return (
<main className="grain min-h-dvh bg-ink pb-24">
<header className="mx-auto flex max-w-6xl items-center gap-3 px-4 pt-6 md:px-8">
<Fox className="h-6 w-6 text-accent" />
<span className="font-mono text-[11px] tracking-[0.25em] text-muted uppercase">{site.name} · Private</span>
</header>
<DropViewer />
</main>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useEffect, useState } from "react";
// Served by nginx from the server's docker/drops/ folder, behind the same login as the page.
const base = "/drops/files/";
type Drop = {
title: string;
note?: string;
files: { name: string; src: string }[];
};
export default function DropViewer() {
const [drop, setDrop] = useState<Drop | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
fetch(`${base}manifest.json`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
.then(setDrop)
.catch(() => setFailed(true));
}, []);
if (failed) {
return (
<section className="mx-auto max-w-6xl px-4 pt-16 md:px-8 md:pt-24">
<p className="font-mono text-xs tracking-[0.3em] text-accent uppercase">Nothing here</p>
<p className="mt-5 max-w-2xl text-xl leading-relaxed font-light text-muted">
These previews have been taken down. Ask your Two Tales contact if you still need them.
</p>
</section>
);
}
if (!drop) {
return <p className="mx-auto max-w-6xl px-4 pt-16 font-mono text-xs text-muted uppercase md:px-8">Loading…</p>;
}
return (
<>
<section className="mx-auto max-w-6xl px-4 pt-16 md:px-8 md:pt-24">
<p className="font-mono text-xs tracking-[0.3em] text-accent uppercase">Confidential · view only</p>
<h1 className="mt-5 font-display text-[clamp(2rem,7vw,4.5rem)] leading-[0.9] font-thin uppercase">
{drop.title}
</h1>
{drop.note && <p className="mt-6 max-w-3xl text-lg leading-relaxed font-light text-muted">{drop.note}</p>}
</section>
<ol className="mx-auto mt-12 max-w-6xl space-y-12 px-4 md:mt-16 md:px-8">
{drop.files.map((f, i) => (
<li key={f.src}>
<div className="mb-3 flex items-center gap-4 font-mono text-xs tracking-[0.2em] uppercase">
<span className="text-accent">{String(i + 1).padStart(2, "0")}</span>
<span className="h-px w-10 bg-line" />
<span className="break-all text-fg">{f.name}</span>
</div>
<Player src={`${base}${f.src}`} label={f.name} />
</li>
))}
</ol>
</>
);
}
// Plays inline with no download, cast or picture-in-picture options and no right-click menu.
// This only stops casual saving: anyone who can watch can still screen-record.
function Player({ src, label }: { src: string; label: string }) {
return (
<div className="relative overflow-hidden border border-line bg-black" onContextMenu={(e) => e.preventDefault()}>
<video
src={src}
controls
loop
playsInline
preload="metadata"
controlsList="nodownload noremoteplayback noplaybackrate"
disablePictureInPicture
disableRemotePlayback
className="block aspect-video w-full"
aria-label={label}
/>
<span
className="pointer-events-none absolute top-3 right-3 font-mono text-[10px] tracking-[0.25em] text-white/40 uppercase select-none md:text-xs"
aria-hidden="true"
>
Two Tales VFX · Confidential
</span>
</div>
);
}
+5
View File
@@ -5,6 +5,11 @@ services:
restart: unless-stopped restart: unless-stopped
# /drops/ login and clips (gitignored; created on the server, see DEPLOY.md)
volumes:
- ./docker/auth:/etc/nginx/auth:ro
- ./docker/drops:/srv/drops:ro
networks: networks:
- npm_default - npm_default
+16
View File
@@ -30,6 +30,22 @@ server {
try_files $uri $uri/ $uri.html =404; try_files $uri $uri/ $uri.html =404;
} }
# Private shot previews: login required for the page and the clips (see DEPLOY.md).
# The login file and the clips live on the server only. If the login file is missing,
# nginx answers 500, so /drops/ is never left open. ^~ beats the media regex above,
# so nothing in here is cached by the browser or Cloudflare.
location ^~ /drops/ {
auth_basic "Two Tales VFX";
auth_basic_user_file /etc/nginx/auth/drops.htpasswd;
add_header Cache-Control "private, no-store";
add_header X-Robots-Tag "noindex, nofollow";
try_files $uri $uri/ $uri.html =404;
location ^~ /drops/files/ {
alias /srv/drops/;
}
}
# Offline worker for /set must always be revalidated so updates roll out # Offline worker for /set must always be revalidated so updates roll out
location = /set/sw.js { location = /set/sw.js {
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache";