Files
sapl-admin 0c0a84f8cc feat(present): deck-scoped image upload + replace for any slot (closes #1561)
PowerPoint-grade editing: the slot picker now takes a device upload for ANY
slide/slot — not just the garment's stored shots — and the fresh image is
adjustable like any placed image (slot_adjust reads the same file).

Storage decision (owner: deck-scoped): uploads belong to the deck and never
touch the library. POST /api/presentations/uploads stores the file under
<data>/deck_uploads/<draft_id>/ (the wizard's client-generated uuid) after
validating content type (jpeg/png/webp), the real payload (Pillow) and a
10 MB cap; the returned `upload:<draft>/<file>` ref rides `overrides` like a
shot ref (services.deck_uploads + presentation_service._match_shot_ref).
/publish copies the referenced files into the board's own dir and rewrites
the refs, so the deck owns its uploads; a board hard-delete removes that dir
(routers/collections.delete_board). Files are served by a /deck-uploads
static mount (backend main.py + nginx), resolved on the frontend through
imageUrl()'s new upload-ref branch.

Frontend: new DeckImageUpload component (file pick, progress, inline errors,
uploaded thumbnails as re-pickable candidates kept per session) hooked into
SlotEditor with a few lines; app-native UI only.

Verified: backend tests/test_presentations_uploads.py (12 passed, docker
python:3.11-slim + requirements-test + router deps); sibling presentations +
board-delete suites 101 passed/2 pre-existing skips; frontend tsc -b, eslint
(zero findings), vite build, component suite 31 passed (incl. 3 new);
fastapi-route-check clean. Test file registered in ci.yml + deploy-dev.yml.
2026-10-13 13:47:55 +05:30

141 lines
9.8 KiB
Nginx Configuration File

server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ── Security headers (2026-09-18 pilot audit) ──────────────────────────
# Declared at server level AND repeated inside every location that defines
# its own add_header: nginx does NOT inherit a parent's add_header once a
# child block declares one, so a single server-level block would silently
# skip /assets/, /index.html, /sw.js and the manifest.
#
# CSP (baseline 2026-09-20; script-src tightened for #1346): origin
# allow-list with NO 'unsafe-inline' in script-src — verified against the
# built bundle (dist/index.html carries zero inline scripts; only
# /assets/*.js and /registerSW.js, both 'self') and Google's documented GSI
# policy, which needs no inline script. 'wasm-unsafe-eval' + the two CDNs
# stay: the Lottie player fetches and instantiates its wasm there.
# style-src KEEPS 'unsafe-inline' (React style attributes, the login page's
# <style> block, and GSI's injected styles); img-src keeps https: for the
# scraper's external-image fallback and the moodboard samples.
add_header Strict-Transport-Security "max-age=15552000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(self), camera=(self), microphone=(), payment=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://accounts.google.com https://apis.google.com https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline' https://accounts.google.com https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' blob:; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; frame-src 'self' https://accounts.google.com; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests" always;
server_tokens off;
# Allow large file uploads (moodboard images, PDFs, bulk capture batches).
# Bulk garment capture accepts up to 50 shots x 10MB, so the aggregate body
# can far exceed any per-file bound — 512M covers the worst case.
client_max_body_size 512M;
# Docker's embedded DNS. Combined with a *variable* in proxy_pass below, this
# makes nginx RE-RESOLVE the backend container's IP at request time (every
# ~10s) instead of caching it once at startup. Without this, each backend
# redeploy gives the container a new IP and nginx keeps dialing the old, dead
# one → "upstream timed out while connecting" → 504 on every /api call.
resolver 127.0.0.11 valid=10s ipv6=off;
# Hashed build assets (index-XXXX.js / .css) — the filename changes every
# build, so it's safe to cache them forever.
location /assets/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Strict-Transport-Security "max-age=15552000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(self), camera=(self), microphone=(), payment=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://accounts.google.com https://apis.google.com https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline' https://accounts.google.com https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' blob:; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; frame-src 'self' https://accounts.google.com; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests" always;
}
# index.html must NEVER be cached, or browsers keep loading an old bundle
# after a deploy (the blank-page-after-update problem). Always revalidate.
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Strict-Transport-Security "max-age=15552000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(self), camera=(self), microphone=(), payment=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://accounts.google.com https://apis.google.com https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline' https://accounts.google.com https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' blob:; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; frame-src 'self' https://accounts.google.com; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests" always;
expires -1;
}
# Service worker must NEVER be cached, or updates stall and users get a
# stale SW controlling a stale app shell.
location = /sw.js {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Strict-Transport-Security "max-age=15552000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(self), camera=(self), microphone=(), payment=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://accounts.google.com https://apis.google.com https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline' https://accounts.google.com https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' blob:; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; frame-src 'self' https://accounts.google.com; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests" always;
expires -1;
}
# Web app manifest must ALWAYS revalidate so icon/theme/display changes
# reach browsers within 24h.
location = /manifest.webmanifest {
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Strict-Transport-Security "max-age=15552000" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(self), camera=(self), microphone=(), payment=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' https://accounts.google.com https://apis.google.com https://cdn.jsdelivr.net https://unpkg.com; style-src 'self' 'unsafe-inline' https://accounts.google.com https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; media-src 'self' blob:; connect-src 'self' https://accounts.google.com https://oauth2.googleapis.com https://cdn.jsdelivr.net https://unpkg.com; frame-src 'self' https://accounts.google.com; worker-src 'self' blob:; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests" always;
default_type application/manifest+json;
expires -1;
}
# SPA routing — serve index.html for non-file routes
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API calls to backend. The `set` + variable forces runtime DNS
# resolution (see resolver above); $request_uri preserves the exact path+query.
location /api/ {
set $backend backend;
proxy_pass http://$backend:8000$request_uri;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Fail fast if the backend is genuinely down (don't hang 60s); but allow
# long-running AI calls (KAARIGAR, scraper) to complete.
proxy_connect_timeout 10s;
proxy_read_timeout 300s;
}
# Proxy static uploads to backend
location /images/ {
set $backend backend;
proxy_pass http://$backend:8000$request_uri;
proxy_set_header Host $host;
}
location /scraped/ {
set $backend backend;
proxy_pass http://$backend:8000$request_uri;
proxy_set_header Host $host;
}
location /concepts/ {
set $backend backend;
proxy_pass http://$backend:8000$request_uri;
proxy_set_header Host $host;
}
# Deck-scoped device uploads (#1561) — the wizard's uploaded slide images.
location /deck-uploads/ {
set $backend backend;
proxy_pass http://$backend:8000$request_uri;
proxy_set_header Host $host;
}
}