- 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
|
*.md
|
||||||
!README.md
|
!README.md
|
||||||
content/crowd
|
content/crowd
|
||||||
|
docker/auth
|
||||||
|
docker/drops
|
||||||
|
content/drops
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user