A Puzzle game made from your own photos.
  • TypeScript 96.8%
  • Shell 2.3%
  • Dockerfile 0.7%
Find a file
Brian McGonagill e136160145 fix: gate puzzle render loop to stop iPad overheating
The Pixi ticker rendered every rAF forever (idle included) at full DPR
with MSAA, pinning the GPU. Now the canvas uses an activity-gated loop
(autoStart:false + wake-on-mutation, auto-stop after ~1.2s idle) with a
maxFPS cap, and touch devices get an automatic render-budget trim
(resolution capped to 1.5x, MSAA off, 30fps). Frontend render-loop only;
no backend/schema/texture changes. Ships as 2.1.1-beta.
2026-09-26 10:38:25 -05:00
backend feat: replace MinIO with Garage (open-source S3) as object storage 2026-09-13 19:01:46 -05:00
docker fix: allow MinIO->Garage migration from bind-mount data folders 2026-09-15 09:00:23 -05:00
docs fix: allow MinIO->Garage migration from bind-mount data folders 2026-09-15 09:00:23 -05:00
frontend fix: gate puzzle render loop to stop iPad overheating 2026-09-26 10:38:25 -05:00
.dockerignore Add Docker production deployment and improve E2E tests 2026-07-22 19:56:17 -05:00
.env.example feat: replace MinIO with Garage (open-source S3) as object storage 2026-09-13 19:01:46 -05:00
.env.production.example feat: replace MinIO with Garage (open-source S3) as object storage 2026-09-13 19:01:46 -05:00
.gitignore feat: replace MinIO with Garage (open-source S3) as object storage 2026-09-13 19:01:46 -05:00
AGENTS.md fix: gate puzzle render loop to stop iPad overheating 2026-09-26 10:38:25 -05:00
docker-compose.migrate.yml fix: allow MinIO->Garage migration from bind-mount data folders 2026-09-15 09:00:23 -05:00
docker-compose.prod.yml feat: replace MinIO with Garage (open-source S3) as object storage 2026-09-13 19:01:46 -05:00
docker-compose.yml feat: replace MinIO with Garage (open-source S3) as object storage 2026-09-13 19:01:46 -05:00
Dockerfile Add automatic database migration system 2026-08-23 13:30:07 -05:00
encrypt_work_checklist.md Update encryption checklist: phases 4-5 complete 2026-08-23 07:23:47 -05:00
init.sql Add piece outlines to Wall of Glory frames 2026-08-23 08:23:49 -05:00
LICENSE Initial commit 2026-07-17 20:49:22 +00:00
product_requirements.md feat: replace MinIO with Garage (open-source S3) as object storage 2026-09-13 19:01:46 -05:00
README.md fix: gate puzzle render loop to stop iPad overheating 2026-09-26 10:38:25 -05:00
social_sharing_build_plan.md Update social sharing build plan to mark as complete 2026-08-23 08:10:06 -05:00
start.sh feat: replace MinIO with Garage (open-source S3) as object storage 2026-09-13 19:01:46 -05:00

Privacy-First Custom Jigsaw Puzzle Platform

A privacy-focused web application that lets you create custom jigsaw puzzles from your own photos. Built with zero-knowledge encryption to ensure your photos remain private.

Features

  • Custom Puzzles: Create jigsaw puzzles from your own photos with adjustable difficulty (4-5000 pieces)
  • Zero-Knowledge Encryption: Optional client-side encryption ensures only you can view your encrypted photos
  • Progress Saving: Auto-saves puzzle progress every 15 seconds
  • Upload Progress Tracking: Real-time progress feedback with speed and ETA during photo uploads
  • Full-Board Image Display: Puzzle image fills the entire board area for an immersive experience
  • Guide Overlay: Optional ghost image overlay to help with piece placement (adjustable opacity)
  • Responsive Design: Works seamlessly on desktop and mobile devices with automatic tray repositioning on orientation change
  • Full Screen Mode: Immersive fullscreen puzzle solving with touch-optimized controls
  • Dark Mode: Full dark mode support
  • Admin Dashboard: User management, site settings, and IP blocking
  • Wall of Glory: Showcase completed puzzles in a customizable gallery with piece outlines, drag-and-drop positioning, and resize handles
  • Social Sharing: Share completed puzzles and galleries with public links, with optional gallery access control
  • Photo Encryption: Full encryption/decryption flow with passphrase management and re-encryption support
  • Photo Usage Badges: Puzzle-piece and checkmark count badges on photos that have been used to create puzzles, shown on both the upload page and puzzle creation page
  • Piece Groups: Adjoining pieces lock together wherever you place them, move as a single unit, and tray as one compact block — "Tray All Unlocked" and drag-over-tray keep groups intact. A group only locks in place after it snaps as a whole unit into its solved position; an off-target group always stays draggable
  • Zoom & Pan: Zoom grows both the puzzle and the tray 1:1 up to 6x (tray capped at 25% of the canvas width, oversized trays fit-to-tray). Mouse wheel, trackpad pinch (ctrl+wheel), and multi-touch two-finger pinch are all anchored at the pointer; locked pieces and piece groups are never disturbed by zooming or panning. The tray also captures wheel/scroll input in a small margin to its left, so aiming a few pixels off the tray still scrolls the tray instead of zooming the board
  • Puzzle Card Thumbnails: Each card on the My Puzzles page shows the thumbnail of the photo the puzzle is built from; encrypted photos show a lock placeholder unless a decrypted copy is cached in the browser session
  • Puzzle Rotation: Rotate the puzzle board 90° clockwise during solving to better fit portrait or landscape photos; rotation persists with your progress and works for both in-progress and already-solved puzzles
  • Cool-Running Rendering: The puzzle canvas no longer repaints nonstop. The render loop is activity-gated — it runs only while you drag/zoom/pan/scroll (and for a short settle window after), then fully stops so the GPU idles while you think. On touch devices it also auto-trims the render budget (caps backing resolution and drops MSAA anti-aliasing, ~30fps) so phones and iPads stay cool during long sessions
  • Audit Logging: Track all user and admin actions

Tech Stack

Frontend

  • Next.js 16 (App Router)
  • TypeScript
  • Tailwind CSS
  • PixiJS (puzzle canvas rendering)

Backend

  • Node.js + Express
  • TypeScript
  • Lucia Auth (authentication)
  • PostgreSQL (database)
  • Garage (open-source S3-compatible object storage)
  • ClamAV (virus scanning)

Infrastructure

  • Docker Compose
  • Zero-knowledge encryption (Web Crypto API)

Quick Start

Prerequisites

  • Docker and Docker Compose
  • Node.js 20+ (for development)
  • npm or yarn (for development)

Option 1: Docker Hub Image (Easiest)

Pull and run the pre-built image with only the infrastructure services:

  1. Create a project directory and add the production compose file:
mkdir puzzle && cd puzzle
curl -sO https://raw.githubusercontent.com/<repo>/main/docker-compose.prod.yml
curl -sO https://raw.githubusercontent.com/<repo>/main/.env.production.example
cp .env.production.example .env
# Edit .env with your secure passwords
  1. Start everything:
docker compose -f docker-compose.prod.yml up -d
  1. Open http://localhost:8080 and register your first user (becomes admin automatically).

Services:

Option 2: Build from Source (Docker Compose)

Build and run the full stack locally with Docker:

  1. Clone the repository:
git clone <repository-url>
cd get_puzzles
  1. Start all services (builds backend and frontend images, starts Postgres, Garage, and ClamAV):
docker compose up -d --build
  1. Open http://localhost:3002 in your browser

  2. Register your first user - they will automatically become an admin

Services:

Stop all services:

docker compose down

Stop and remove data volumes:

docker compose down -v

Option 3: Development Setup

For local development with hot reload:

  1. Clone the repository:
git clone <repository-url>
cd get_puzzles
  1. Copy the backend environment file:
cp .env.example backend/.env
  1. Update environment variables in backend/.env as needed.

  2. Start infrastructure services (Postgres, Garage, ClamAV):

docker compose up -d postgres garage clamav
  1. Install and start the backend:
cd backend
npm install
npm run dev
  1. Install and start the frontend (in a new terminal):
cd frontend
npm install
npm run dev
  1. Open http://localhost:3000 in your browser

  2. Register your first user - they will automatically become an admin

View logs (production):

docker compose -f docker-compose.prod.yml logs -f

Stop all services:

docker compose -f docker-compose.prod.yml down

Stop and remove volumes:

docker compose -f docker-compose.prod.yml down -v

Starting from a Fresh Server

Deploy without cloning the whole repo — everything else is inside the images. You need 4 files + a .env, in this exact layout (the compose file uses relative paths):

/opt/get-puzzles/
├── docker-compose.prod.yml     # the stack definition
├── init.sql                    # MUST be a file, not a directory — on a fresh
│                               #   Postgres volume it creates the base schema;
│                               #   if the path is missing Docker bind-mounts an
│                               #   empty directory named "init.sql" instead
├── .env                        # created in step 3 below
└── docker/garage/
    ├── garage.toml             # Garage single-node config (mounted)
    └── preflight.sh            # validates secrets/config on every `up`
  1. Create the directories and place the four files (copy from a workstation or download the raw files from the repository web UI).

  2. Pin the app image tag in docker-compose.prod.yml: bmcgonag/get-puzzles:latest → bmcgonag/get-puzzles:2.1.0-beta (or newer). latest predates the Garage switch and will fail against this compose file.

  3. Create .env. Garage credentials are strict lowercase hex — base64 is rejected and the preflight check will refuse to start the stack:

    DB_USER=puzzle_user
    DB_PASSWORD=<strong password>
    DB_NAME=puzzle_db
    GARAGE_RPC_SECRET=<64 hex>    # openssl rand -hex 32
    S3_ACCESS_KEY=GK<24 hex>      # echo "GK$(openssl rand -hex 12)"
    S3_SECRET_KEY=<64 hex>        # openssl rand -hex 32
    S3_BUCKET=puzzle-uploads
    APP_PORT=8080
    
  4. Start the stack:

    docker compose -f docker-compose.prod.yml up -d
    

    Order it enforces: preflight (fails fast with the exact fix command if a secret or garage.toml is wrong) → garage self-bootstraps its layout, bucket, and access key on first boot → postgres/clamav become healthy → the app container waits for all three and runs database migrations itself. ClamAV's first start takes several minutes downloading virus definitions; the app waits for it.

  5. Verify and go:

    docker compose -f docker-compose.prod.yml ps    # garage: healthy, app: up
    docker compose -f docker-compose.prod.yml logs preflight garage | tail
    

    Open http://<server>:8080 — the first registered user becomes admin.

Full details: docs/DEPLOYMENT.md.

Migrating from a version before 2.1.0

Releases before 2.1.0 stored photo blobs in MinIO; 2.1.0+ uses Garage (open-source, S3-compatible). The migration copies every object preserving its key, so uploaded_photos.file_path rows stay valid — the database is not touched. Full operator guide: docs/MINIO-TO-GARAGE.md.

Files needed (same four as the fresh install, plus the migration overlay):

/opt/get-puzzles/
├── docker-compose.prod.yml          # updated version (garage + preflight services)
├── docker-compose.migrate.yml       # ← new: one-shot migration overlay
├── init.sql
├── .env                             # Garage values as in the fresh-server section, plus:
│                                    #   SOURCE_ACCESS_KEY / SOURCE_SECRET_KEY = the
│                                    #   OLD MinIO root credentials (from the old .env)
└── docker/garage/
    ├── garage.toml
    └── preflight.sh

Steps:

  1. Update the files above (pin image: bmcgonag/get-puzzles:2.1.0-beta) and add the Garage credentials to .env (hex format — see fresh-server section), plus the old MinIO root user/password as SOURCE_ACCESS_KEY/SOURCE_SECRET_KEY.

  2. Point the overlay at your old MinIO data. Bind-mount setup (a host folder whose subdirectory is the bucket) — the common case:

    ls ./minio_data                  # must show a subdirectory per bucket, e.g. puzzle-uploads/
    

    Docker-volume setup: docker volume ls | grep minio_data and use the volume's _data host path (docker volume inspect <vol> --format '{{.Mountpoint}}'). Also find the compose network name:

    docker network ls | grep puzzle-net    # e.g. <project>_puzzle-net
    

    Add to .env:

    SOURCE_MINIO_DIR=./minio_data          # host folder holding the old buckets
    MIGRATE_NETWORK=<project>_puzzle-net
    SOURCE_BUCKET=puzzle-uploads           # old MinIO bucket name
    
  3. Bring up the new stack (Garage starts with an empty bucket; app + Postgres run normally):

    docker compose -f docker-compose.prod.yml up -d
    
  4. Run the migration (boots the old MinIO from SOURCE_MINIO_DIR as minio-source and rclone-copies it into Garage over the shared network — no ports exposed):

    docker compose -f docker-compose.prod.yml -f docker-compose.migrate.yml \
      --profile migrate run --rm migrate
    

    Confirm the ==> Source bucket: line prints a non-zero object count; if it prints Total objects: 0, SOURCE_MINIO_DIR points at the wrong/empty folder and nothing was copied. The run is idempotent — safe to re-run; identical objects are skipped.

  5. Verify:

    docker compose -f docker-compose.prod.yml exec garage /garage bucket info puzzle-uploads
    

    Objects: must match the old MinIO count, then open a photo in the app.

  6. Once confirmed, clean up:

    # optional final backup of the old folder, then remove it (or the old volume)
    #   rm -rf ./minio_data        # bind-mount setup
    #   docker volume rm <project>_minio_data   # docker-volume setup
    

    and remove the SOURCE_*, SOURCE_MINIO_DIR, MIGRATE_NETWORK lines from .env (the app never uses them).

Note: the migration does not run automatically on docker compose up — the old MinIO must be reachable during the copy, so it is an explicit operator action.

Documentation

Testing

Unit tests (143 backend tests):

cd backend
npm test

Backend E2E tests (31 tests — requires the full stack running on http://localhost:3002):

cd backend
npx playwright test

Frontend E2E tests (63 tests x 5 browsers, 315 total — starts its own dev server):

cd frontend
npx playwright test

The project includes:

  • 124 frontend unit tests covering auth, uploads, puzzle creation, hints, admin, user settings, gallery, social sharing, photo usage badges, piece groups, zoom/tray layout math (including the tray capture margin so wheel-scroll a few px left of the tray still scrolls the tray), puzzle rotation coordinate transforms, texture-frame clamping (piece bounding boxes from float-ulp overshoots of the photo edge never overflow the base texture), and the idle-render loop (touch/desktop render-budget matrix plus the activity-gated start/stop controller: it does not spin on idle, restarts on wake, and disposes cleanly)
  • Backend tests cover auth, uploads, storage (real Garage S3 round-trip: put/get/delete/batch-delete), puzzle creation, hints, admin, user settings, gallery, sharing, photo usage badges, and jigsaw-cut frame correctness
  • Frontend E2E tests covering puzzle layout, tray behavior, piece-group behavior (group formation, tray-all-unlocked unit behavior, drag-over-tray grouping, group solve-snap/lock correctness), zoom controls (tray growth, locked/group invariance, zoom-aware dragging, fit-on-reload, tray pieces staying in the tray across a reload while zoomed), puzzle rotation (button exists, rotation persists, four rotations return to original, locked pieces maintain relative positions, works on existing puzzles), piece locking regression (snap→merge→re-snap→lock ordering), piece texture-frame fit (creating puzzles from real-size photos at counts like 100/275/350 where float rounding overshot the photo edge), idle/cool rendering (board paints then the render loop freezes when idle and wakes on input, group drag + rotate still correct under the gated ticker), My Puzzles card photo thumbnails, and mobile responsiveness

Security Features

  • Zero-Knowledge Encryption: Photos can be encrypted client-side before upload
  • Virus Scanning: All uploads scanned with ClamAV
  • Audit Logging: All actions logged for security review
  • IP Blocking: Admin can block suspicious IP addresses
  • Password Hashing: Argon2id password hashing
  • Session Management: Secure session handling with Lucia Auth

Project Structure

get_puzzles/
├── backend/              # Express API server
│   ├── src/
│   │   ├── controllers/  # Request handlers
│   │   ├── services/     # Business logic
│   │   ├── middleware/   # Express middleware
│   │   └── config/       # Configuration
│   ├── migrations/       # Database migration scripts
│   └── e2e/              # Playwright E2E tests
├── frontend/             # Next.js application
│   ├── app/              # App router pages
│   ├── components/       # React components
│   └── lib/              # Utilities and API client
├── docker/               # Docker support files
│   ├── nginx.conf        # Reverse proxy config
│   ├── entrypoint.sh     # Container startup script
│   └── run-migrations.sh # Automatic migration runner
├── docs/                 # Documentation
├── Dockerfile            # Multi-stage production build
├── docker-compose.yml    # Development compose
├── docker-compose.prod.yml # Production compose
├── init.sql              # Database schema
└── .env.production.example

Development Phases

  • ✅ Phase 1: Authentication (Login/Registration)
  • ✅ Phase 2: Photo Upload (with virus scanning)
  • ✅ Phase 3: Puzzle Creation (jigsaw cut algorithm)
  • ✅ Phase 4: Puzzle Solving (drag-and-drop, hints)
  • ✅ Phase 5: Admin Dashboard (user management, settings)
  • ✅ Phase 6: User Settings (profile, password, encryption)
  • ✅ Phase 7: Wall of Glory (gallery showcase with piece outlines)
  • ✅ Phase 8: Social Sharing (share puzzles and galleries publicly)
  • ✅ Phase 9: Photo Encryption (full encryption/decryption flow)

Database Migrations

The application includes an automatic migration system that runs on container startup:

  • The base schema (users, sessions, uploaded_photos, puzzles, ...) lives in init.sql and is applied idempotently on every boot by docker/run-migrations.sh — it does not depend on the Postgres volume being fresh (Docker's /docker-entrypoint-initdb.d hook runs only once on an empty data volume)
  • Migration changes are stored in backend/migrations/
  • The docker/run-migrations.sh script tracks applied migrations in a schema_migrations table
  • Migrations are applied in alphabetical order and are idempotent
  • No manual intervention required - just pull the new image and restart

To create a new migration:

  1. Add a new .sql file to backend/migrations/ with a numbered prefix (e.g., 010_add_feature.sql)
  2. Use IF NOT EXISTS or IF EXISTS clauses to make migrations idempotent
  3. The migration will run automatically on next container startup

Contributing

Contributions are welcome! Please ensure:

  • All tests pass (npm test)
  • Code follows existing patterns
  • New features include tests
  • Documentation is updated

License

MIT License - see LICENSE file for details

Support

For issues and feature requests, please use the GitHub issue tracker.