A full-stack, white-label photo gallery application built with React + Vite + Tailwind (frontend) and Cloudflare Workers (backend), with D1 database and R2 storage.
Open Source & Self-Hosted: Deploy your own branded photo sharing platform with complete control over your data.
- Configuration Guide - Complete setup and deployment instructions
- API Reference - REST API endpoint documentation
- Architecture - Technical architecture and design
- Features - Feature descriptions and usage examples
- Image Processing - Preview/derivative generation and current limitations
- Contact Form - Configuring the landing page contact form
- Mobile Guide - Building and running the Android app
- Mobile OAuth Setup - Cloudflare Access OAuth for the mobile app
- Android Deployment - Google Play Store release process
- Contributing - Contribution guidelines and development workflow
- Framework: React 19 + TypeScript
- Build Tool: Vite 8
- Styling: Tailwind CSS 4
- Routing: React Router v7
- State: React hooks + Context API
- Storage: IndexedDB (via Dexie) for upload queue persistence
- Maps: Leaflet + React-Leaflet (with marker clustering)
- Image Processing: Client-side Canvas API for preview/blur-placeholder generation, RAW decoding (
libraw-wasm), and in-browser image editing (react-filerobot-image-editor+react-konva) - Video: Client-side trimming/processing via
@ffmpeg/ffmpeg(single-threaded, no COOP/COEP required) - Mobile: Capacitor 8 (Android native wrapper; iOS not currently packaged)
- Runtime: Cloudflare Workers
- Framework: Hono 4
- Database: Cloudflare D1 (SQLite)
- Storage: Cloudflare R2 (S3-compatible)
- Auth: Cloudflare Access (web) + JWT bearer tokens (mobile), HTTP-only session cookies for per-event passwords
- Image Processing: Preview/blur-placeholder generation happens client-side at upload time; the worker only streams original/preview files from R2 and does not apply server-side watermarking or resizing (see docs/image-processing.md)
- 🏠 Landing Page: Featured photo slideshow with auto-rotation
- 📋 Event List: Browse all public events with preview images, tags, and locations
- 🔐 Password Protection: Optional per-event password protection
- 🖼️ Gallery View: Masonry layout with responsive cards preserving aspect ratios
- 📅 Photo Sorting: By date (asc/desc), filename (asc/desc), or featured status
- 🏷️ Tag Filtering: Browse events by tags (e.g., "Schaatsen", "Skeeleren")
- 🌍 City Filtering: Filter events by location/city
- 📷 Direct Photo Links: Share individual photos with
/p/:eventSlug/:photoId - 🎯 Photo Navigation: Seamless browsing without page reload (pushState)
- ⌨️ Keyboard Navigation: Arrow keys, Escape to close, slideshow mode
- 📱 Mobile-Friendly: Touch gestures, native share API, and responsive design
- 📸 EXIF Metadata: Full camera, lens, and settings display
- 🗺️ Map View: Browse all photos with GPS coordinates on interactive map (marker clustering)
- 🕒 Timeline View: Chronological, infinite-scroll view across all accessible events
- 🎥 Video Support: Upload and play
.mp4/.movvideos alongside photos - 📡 Chromecast: Cast photos/videos to a TV from the web app
- 📶 Offline-Aware: Offline banner + pull-to-refresh, with network status detection on native
- ⬇️ Download Options:
- Original full-resolution file
- Preview (1920px @ 85% quality JPEG)
- Batch download selected photos as ZIP (max 50)
- ⭐ Favorites: Add photos to favorites, view in dedicated favorites page
- 🎨 Progressive Images: Blur placeholder → full image transition
- 🔄 Smart Caching: Preview images cached by browser
- 📄 Photo Usage & Privacy Pages: Dedicated
/usage(rights/licensing) and/privacypages - 🔗 Invite Links: Join an event as a collaborator via a shareable, expirable link (
/invite/:token)
- 📊 Analytics Cards: Total events, photos, storage usage, favorites
- 📈 Public/Private Split: Track event visibility at a glance
- 🎯 Event Management: Create, edit, and delete events
- 🏷️ Tag System: Assign tags to events from edit modal
- 🗑️ Safe Deletion: Confirmation modals for all destructive actions
- 📝 Event Details: Edit name, description, password, and tags
- 🔗 Quick Access: Direct links to upload, photo manager, and public gallery
- 📤 Drag & Drop: Upload multiple photos and videos at once
- 🔄 Persistent Queue: IndexedDB-backed upload queue survives page reloads
- 📦 Multipart Upload: Efficient large file uploads (5MB chunks) to R2
- 🚦 Concurrency Capping: Limits concurrent uploads to avoid exhausting the browser's connection pool on large batches
- 📊 EXIF Extraction: Automatic metadata extraction (camera, lens, settings, GPS)
- 🖼️ RAW Photo Support: Client-side RAW decoding (
.cr2,.nef,.arw,.dng, etc. vialibraw-wasm) with a generated JPEG preview, while the original RAW file is preserved - 🖼️ Client-Side Preview Generation: Creates 1920px previews in browser (85% quality)
- 🎨 Blur Placeholders: Generates tiny blurred placeholders for progressive loading
- 📈 Event Analytics:
- Photo count and GPS coverage
- Top 5 favorited photos with thumbnails
- Camera models used
- Featured photo count
- 🗺️ GPS Location Setter: Interactive map to view/verify photo locations
- 🖼️ Photo Manager Link: Quick access to manage all photos
- 🎯 Grid View: Visual overview of all event photos
- ☑️ Bulk Selection: Checkbox selection with Select All/Deselect All
- ⭐ Featured Toggle: Mark photos as featured (shows on landing page)
- 🗑️ Trash: Move photos to Trash (30-day retention, restorable) instead of deleting outright
- 📦 Archive: Hide a photo from the Timeline without deleting it
- ✨ Auto Enhance: One-tap client-side white balance + contrast fix
- 👁️ Preview Modal: Preview photo before deleting
- 🏷️ Featured Badge: Visual indicator for featured photos
- 💝 Favorite Count: See how many users favorited each photo
- ✂️ Image Editing: Crop, rotate, and adjust curves/levels in-browser (Filerobot-based editor); native Android uses the same editor with same-origin blob fetching to avoid canvas tainting
- 🎬 Video Editing: Trim, crop, and adjust playback speed client-side via FFmpeg WASM
- 📤 Replace Original/Preview: Save edits back to R2 without re-uploading from scratch
Organization, Search & AI (/admin/trash, /admin/duplicates, /admin/albums, /admin/people, /admin/activity, /search)
- 🗑️ Trash: View, restore, or permanently delete trashed photos; empty the whole trash at once
- 🔁 Duplicate Detection: Groups photos with byte-identical content (SHA-256), even across events
- 📁 Albums: Cross-event photo collections, independent of the event structure
- 🧑🤝🧑 People: Faces are detected client-side at upload (
@vladmandic/human) and grouped into named people via an hourly server-side clustering job — no Workers AI face model needed - 🔍 Unified Search: Full-text search (filenames, location, AI captions) with optional semantic re-ranking, powered by Cloudflare Workers AI's free daily allocation (captions + embeddings)
- 🕰️ Memories: "On this day" carousel on the Timeline page (pure SQL, no AI)
- 📜 Activity Feed: Polling-based log of favorites, event/album creation, and photo trashing
- 📲 Android Auto-Backup: Pick a device folder to automatically back up new photos/videos to an event in the background (Storage Access Framework)
- 🏷️ Tag CRUD: Create, edit, and delete tags
- 📊 Usage Stats: See how many events use each tag
- 🔗 Auto-Slugs: Automatic URL-friendly slug generation
- 📝 Descriptions: Add descriptions to tags for context
⚠️ Safe Deletion: Warns before deleting tags in use
- 👥 Invite Users: Invite collaborators by email to upload photos to specific events
- 🔗 Shareable Invite Links: Generate expirable, revocable links so people can self-join as collaborators without an email invite
- 📧 Email Notifications: Automatic invitation emails with event details (requires Mailgun)
- ⚡ Upload Permissions: Collaborators can upload photos/videos without admin access
- 🧑🤝🧑 Collaborator Roles: Fine-grained capabilities (e.g. invite, remove collaborator, change role) rather than a single flat role
- 📊 Status Tracking: See pending, accepted, or declined invitations
- 🕓 Collaboration History: Audit log of collaborator/invite actions per event
- 🔒 Secure Access: Uses Cloudflare Access authentication (web) and JWT bearer tokens (mobile)
- 🗑️ Easy Management: Add or remove collaborators anytime from admin dashboard
- 📚 Documentation: See CONTRIBUTING.md and docs/features.md for a detailed guide
- 🔐 Cloudflare Access: Secure admin authentication
- 🔒 Admin-Only APIs: X-Admin-Access header validation
- 🎨 Responsive Design: Works on desktop, tablet, and mobile
- ✅ Real-time Feedback: Success/error messages for all actions
- 🚀 Optimized Performance: Efficient queries and caching
.
├── apps/
│ ├── web/ # React + Vite + Tailwind frontend
│ ├── worker/ # Cloudflare Worker backend (TypeScript + Hono)
│ └── android/ # Android native app (Capacitor)
├── migrations/ # D1 database migrations
├── wrangler.toml # Worker configuration
└── README.md # This file
- Node.js 18+ and npm
- Cloudflare account
- Wrangler CLI (
npm install -g wrangler)
./scripts/setup-dev.shThis will install all dependencies and show you the next steps.
# Install worker dependencies
cd apps/worker
npm install
# Install web dependencies
cd ../web
npm install# Create local D1 database
wrangler d1 create photos-db-local
# Run ALL migrations
for file in ./migrations/*.sql; do wrangler d1 execute photos-db-local --local --file="$file"; doneFor local development, you don't need to create an R2 bucket - Wrangler will automatically simulate R2 storage locally.
For production deployment, you need to:
- Enable R2 in your Cloudflare Dashboard (Settings → R2)
- Create the bucket:
wrangler r2 bucket create photos-storageFor local development, create a .dev.vars file in the same directory as wrangler.toml:
# Create .dev.vars file with your secrets
cat > .dev.vars << 'EOF'
# Required: Secret for signing event session cookies
EVENT_COOKIE_SECRET=dev-secret-change-in-production-123456789
# Optional: Shared secret for admin API in development
# ADMIN_SHARED_SECRET=your-admin-secret
EOFNote: The
.dev.varsfile should be gitignored and never committed to version control.
Terminal 1 - Worker:
npm --prefix apps/worker run dev
# Worker runs on http://localhost:8787Terminal 2 - Web App:
cd apps/web
npm run dev
# Web app runs on http://localhost:5173
# API requests are proxied to the worker- Public site: http://localhost:5173
- Admin dashboard: http://localhost:5173/admin
- Worker API: http://localhost:8787 (direct access)
Once setup is complete, you need two terminal windows running simultaneously:
Terminal 1 - Worker (Backend API):
cd apps/worker
npm run dev- Runs on http://localhost:8787
- Handles all
/api/*and/media/*requests - Auto-reloads on code changes
- Access to local D1 and simulated R2 storage
Terminal 2 - Web App (Frontend):
cd apps/web
npm run dev- Runs on http://localhost:5173
- Proxies
/apiand/mediarequests to the worker on port 8787 - Hot module replacement (instant updates)
- React DevTools compatible
Using the Admin Panel Locally:
In development, admin access uses a shared secret:
- Set the
ADMIN_SHARED_SECRETin.dev.vars(e.g.,dev-secret-123) - Add
X-Admin-Secret: dev-secret-123header to requests - Or use browser extension to add the header
- The admin pages will automatically include this header
Development Workflow:
- Start both terminals (worker + web)
- Create an event at http://localhost:5173/admin
- Upload photos to the event
- Mark some photos as featured
- View the public gallery and landing page
Build the frontend:
cd apps/web
npm run build
npm run preview # Serves the production buildTest the worker locally:
cd apps/worker
npm run build
wrangler dev # Uses production-like environment# Create production database
wrangler d1 create photos-db
# Copy the database_id from output and update wrangler.toml
# Update both [[d1_databases]] and [[env.production.d1_databases]] with the same database_id
# Run ALL migrations on production database, in order (there are 21 files in migrations/ as of this writing)
for file in ./migrations/*.sql; do wrangler d1 execute photos-db --remote --file="$file"; done
# Verify migrations were successful:
wrangler d1 execute photos-db --remote --command "SELECT name FROM sqlite_master WHERE type='table';"
# Should show: events, photos, tags, event_tags, event_collaborators, collaboration_history,
# invite_links, and other supporting tableswrangler r2 bucket create photos-storage# Set cookie secret
wrangler secret put EVENT_COOKIE_SECRET
# Enter a strong random string (use: openssl rand -base64 32)
# Admin access is handled by Cloudflare Access (see below)cd apps/worker
npm install
cd ../..
npx wrangler deploy --env productionCreate Pages Project:
- Go to Cloudflare Dashboard → Workers & Pages
- Click Create → Select Pages tab
- Connect to your Git repository
- Configure build settings:
- Project name:
photos(or your choice) - Production branch:
main(or your default branch) - Root directory:
apps/web - Build command:
npm install && npm run build - Build output directory:
dist - Deploy command:
echo "Deploy complete"
- Project name:
- Save and Deploy
Important: The apps/web/public/_routes.json file configures routing:
{
"version": 1,
"include": ["/*"],
"exclude": ["/api/*", "/media/*"]
}This tells Pages to serve all routes except /api/* and /media/*, which are handled by the Worker.
-
Add routes to wrangler.toml (should already be configured):
[env.production] name = "photos-worker" routes = [ { pattern = "photos.yourdomain.com/api/*", zone_name = "yourdomain.com" }, { pattern = "photos.yourdomain.com/media/*", zone_name = "yourdomain.com" } ]
-
Add custom domain to Pages:
- In Pages project → Custom domains
- Click Set up a custom domain
- Enter
photos.yourdomain.com - Wait for DNS to propagate
-
How it works:
- Pages serves the React app at
photos.yourdomain.com - Worker handles
photos.yourdomain.com/api/*andphotos.yourdomain.com/media/*via routes _routes.jsonprevents Pages from interfering with Worker routes
- Pages serves the React app at
Configure Cloudflare Access:
- Go to Cloudflare Dashboard → Zero Trust → Access → Applications
- Click Add an application → Select Self-hosted
- Configure application:
- Application name: Photos Admin
- Session Duration: 24 hours (or your preference)
- Application domain:
- Subdomain:
photos - Domain:
yourdomain.com - Path:
/admin*(protects all admin routes)
- Subdomain:
- Click Next
- Add a Policy:
- Policy name: Admin Access
- Action: Allow
- Configure rules:
- Include: Emails ending in
@yourdomain.com - Or: Include specific email addresses
- Include: Emails ending in
- Click Next → Add application
How it works:
- Users visiting
/admin*must authenticate via Cloudflare Access - Cloudflare adds
Cf-Access-Jwt-Assertionheader for authenticated users - Worker validates this header and sets
X-Admin-Access: 1 - Admin API endpoints require this header
Accessing Admin in Production:
- Visit https://photos.yourdomain.com/admin
- Cloudflare Access will prompt for authentication
- Log in with your authorized email
- You'll be redirected to the admin dashboard
- Session lasts 24 hours (or configured duration)
The schema is defined incrementally across 21 files in migrations/ (from 001_init.sql
through 021_add_file_hash.sql). Key tables beyond the original events/photos/tags/event_tags:
event_collaborators- per-event collaborator records with roles/capabilitiescollaboration_history- audit log of invite/role/removal actionsinvite_links- shareable, expirable self-service invite linksusers(email as primary key) - profile data, admin status derived fromADMIN_EMAILSfavorites- per-user favorited photos
Notable events columns: visibility (public | private | collaborators_only), description,
is_archived. Notable photos columns: latitude/longitude, favorites_count,
blur_placeholder, is_featured, media_type, uploaded_by, source_photo_id,
is_notified, preview_complete, upload_complete, file_hash, cache_version.
For the full, current schema see docs/architecture.md or inspect
migrations/*.sql directly - this README intentionally doesn't duplicate the exhaustive column list
to avoid drifting out of sync as migrations are added.
photos-storage/
├── original/
│ └── {eventSlug}/
│ └── {photoId}.{jpg|mp4|cr2|nef|...} (original file, extension follows source file type)
└── preview/
└── {eventSlug}/
└── {photoId}.jpg (client-generated, max 1920px, 85% quality JPEG)
Note: Preview/original files are not watermarked and there is no server-side resizing - both derivatives are produced client-side at upload time. A legacy
ig/{eventSlug}/{photoId}.jpgkey space still exists only for cleanup purposes (deleting a photo also attempts to remove any leftoverig/object from older uploads); no code path writes newig/files. See docs/image-processing.md for details.
See docs/api-reference.md for the full, endpoint-by-endpoint REST API reference (public, media, admin, collaborators, favorites, mobile auth, and SEO routes).
EVENT_COOKIE_SECRET: Secret for signing session cookies (required)ADMIN_SHARED_SECRET: Optional shared secret for admin API in developmentJWT_SECRET: Signs/verifies mobile OAuth bearer tokens (required for mobile app auth)MAILGUN_API_KEY/MAILGUN_DOMAIN: Optional, enables collaborator invitation emailsGA_MEASUREMENT_ID: Optional, enables Google Analytics 4 tracking
ENVIRONMENT: Set to "development" or "production"ADMIN_EMAILS: Comma-separated list of admin email addresses
See docs/configuration.md for the complete environment variable reference.
- Password Protection: SHA-256 hashed with random salt
- Session Cookies: HttpOnly, Secure, SameSite=Lax, session-only (no expiry)
- Admin Protection: Cloudflare Access + header validation
- Mobile Auth: JWT bearer tokens issued after Cloudflare Access verification (see docs/mobile-oauth.md)
- CORS: Configured for same-origin requests
- Input Validation: All API endpoints validate inputs
- No Server-Side Watermarking: Previews are generated client-side (resized JPEG only); there is no watermark overlay applied anywhere in the current codebase, despite some older code comments/route names implying one.
- No Video Transcoding: Videos are stored and served as uploaded (
.mp4/.mov); editing (trim/crop/speed) happens client-side via FFmpeg WASM, but there's no server-side transcoding pipeline. - Thumbnail Optimization: Could add smaller thumbnails for the gallery grid instead of reusing the 1920px preview.
- iOS: Only the Android native app is currently packaged/released; the web app itself works in iOS Safari.
Worker not connecting to D1:
- Ensure
database_idin wrangler.toml matches your D1 database - Check that ALL migrations have been run (21 files in
migrations/, run in filename order) - Verify bindings in
wrangler devoutput - Run:
wrangler d1 execute photos-db-local --local --command "SELECT name FROM sqlite_master WHERE type='table';"- Should show: events, photos, tags, event_tags, event_collaborators, invite_links, and others
Missing columns error:
- You probably didn't run all migrations
- Run each migration file in order (see setup instructions)
- Common missing columns:
is_featured,description,latitude,favorites_count
Upload failing:
- Check browser console for CORS errors
- Verify R2 bucket exists and is bound correctly
- Check that files are valid JPEG images
- Ensure multipart upload is supported by R2 binding
- Check network tab for 413 errors (file too large)
Upload queue not persisting:
- Check IndexedDB in browser DevTools → Application tab
- Should see
PhotoUploadQueuedatabase - Clear IndexedDB and try again if corrupted
Admin access denied in development:
- Check
.dev.varsfile exists in project root - Verify
ADMIN_SHARED_SECRETis set - Check browser Network tab for
X-Admin-Secretheader - Try clearing browser cache and cookies
Admin access denied in production:
- Verify Cloudflare Access is configured for
/admin*path - Check you're logged in with authorized email
- Look for
Cf-Access-Jwt-Assertionheader in Network tab - Ensure
X-Admin-Access: 1header is being sent to API - Check worker logs:
wrangler tail --env production
Photos not showing in gallery:
- Verify event password is correct (if private event)
- Check browser cookies are enabled
- Look for authentication errors in browser console
- Ensure photos exist in R2 storage
- Check Network tab for 403/404 errors on image requests
Featured photos not showing on landing page:
- Verify photos are marked as featured (⭐ badge in photo manager)
- Check that featured photos are in PUBLIC events (no password)
- Run:
wrangler d1 execute photos-db --remote --command "SELECT COUNT(*) FROM photos WHERE is_featured = 1;" - Landing page falls back to recent photos if no featured photos exist
Tags not showing:
- Check that migration 004 was run (creates tags table)
- Verify tags exist:
wrangler d1 execute photos-db --remote --command "SELECT * FROM tags;" - Create default tags via admin tag manager
Slow photo loading:
- Check R2 bucket is in same region as worker
- Verify thumbnails are being generated (not serving originals)
- Check browser Network tab for slow requests
- Consider enabling Cloudflare Cache for
/media/*routes
Admin dashboard slow:
- Large number of photos can slow down stats queries
- Check D1 query performance in worker logs
- Consider adding database indexes (already included in migrations)
"No events found":
- Database is empty - create an event in admin dashboard
- Or check that events are public (for event list page)
"Failed to load photos":
- Event might not exist
- Event might be password-protected (need to login first)
- Check worker logs for database errors
"Upload failed" or "Network error":
- Worker might not be running
- R2 binding might be misconfigured
- Check CORS headers in worker response
Debugging:
- Use
wrangler tailto see Worker logs in real-time - Add
--env productionto tail production worker:wrangler tail --env production - Check browser IndexedDB (Application → Storage) to see upload queue state
- Use browser Network tab to debug API calls and check headers
- React DevTools extension for component debugging
Testing:
- Test with various JPEG files to ensure EXIF extraction works
- Test with photos with/without GPS data
- Test with different camera makes/models
- Test password-protected events
- Test admin features with multiple events and photos
Database Management:
- Query local database:
wrangler d1 execute photos-db-local --local --command "SELECT * FROM events;" - Query production database:
wrangler d1 execute photos-db --remote --command "SELECT * FROM events;" - Export database:
wrangler d1 export photos-db --remote --output=backup.sql - Check table schema:
wrangler d1 execute photos-db --remote --command "PRAGMA table_info(photos);"
Performance:
- Use
wrangler dev --remoteto test against production D1/R2 (faster than local) - Monitor D1 query performance in worker logs
- Check R2 bandwidth usage in Cloudflare dashboard
Workflow Tips:
- Create test events with different configurations (public, private, tagged)
- Keep a set of test photos with various EXIF data
- Use browser profiles for testing public vs admin views
- Test mobile responsiveness with browser DevTools device emulation
This project is licensed under the MIT License. See the LICENSE file for details.
Built with:
- React - UI framework
- Vite - Build tool
- Tailwind CSS - Styling
- Hono - Backend framework
- Cloudflare Workers - Serverless platform
For issues, questions, or contributions, please see contributing.md.