- 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:
@@ -8,3 +8,6 @@ content/*.pdf
|
||||
*.md
|
||||
!README.md
|
||||
content/crowd
|
||||
docker/auth
|
||||
docker/drops
|
||||
content/drops
|
||||
|
||||
@@ -53,3 +53,8 @@ content/*.mov
|
||||
# previz source renders (web versions in public/previz)
|
||||
content/crowd/
|
||||
|
||||
|
||||
# /drops/ login and clips: server only, never committed (see DEPLOY.md)
|
||||
docker/auth/
|
||||
docker/drops/
|
||||
content/drops/
|
||||
|
||||
@@ -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
|
||||
`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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,6 +5,11 @@ services:
|
||||
|
||||
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:
|
||||
- npm_default
|
||||
|
||||
|
||||
@@ -30,6 +30,22 @@ server {
|
||||
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
|
||||
location = /set/sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
|
||||
Reference in New Issue
Block a user