Compare commits

...

5 Commits

Author SHA1 Message Date
Gitea Actions Bot e343106af5 chore: bump version to 1.0.4
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 19:28:44 +00:00
paul 2f848eb602 fix: make create-admin script executable
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Successful in 4s
2025-07-13 21:24:40 +02:00
paul 1c7fa781ad fix: configure PostgreSQL for production and clean up deployment
- Fix database configuration to use PostgreSQL in production
- Add knexfile.js to support both SQLite (dev) and PostgreSQL (prod)
- Create admin user creation script (scripts/create-admin.js)
- Clean up docker-compose files:
  - Remove redundant docker-compose.yml and docker-compose.local.yml
  - Create docker-compose.dev.yml for development
  - Update docker-compose.prod.yml with proper DB configuration
- Clean up environment files:
  - Update .env.example for development
  - Update .env.production.example with proper settings
  - Remove redundant .env.local
- Update backend .env.example with database configuration options
- Create comprehensive DEPLOYMENT.md with admin setup instructions
- Fix production database name consistency (picpeak instead of photoapp)
2025-07-13 21:24:40 +02:00
Gitea Actions Bot 66940c2f5b chore: bump version to 1.0.3
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 19:18:49 +00:00
paul a3638fe954 fix: remove hardcoded localhost URLs for production deployment
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Successful in 3s
- Add URL utility functions for building resource URLs
- Update all components to use relative URLs in production
- Add production deployment documentation
- Update nginx config to proxy all required endpoints
- Add .env.production.example with proper configuration
2025-07-13 21:14:26 +02:00
31 changed files with 784 additions and 716 deletions
+28 -20
View File
@@ -1,26 +1,34 @@
# JWT Secret for authentication
# IMPORTANT: Generate a secure random secret with: openssl rand -hex 32
# NEVER use the default value or commit the actual secret to version control
JWT_SECRET=CHANGE_ME_TO_A_64_CHARACTER_SECURE_RANDOM_STRING_GENERATED_BY_OPENSSL
# Environment Configuration Template
# Copy this file to .env and adjust values for your environment
# URLs
ADMIN_URL=https://admin.photos.yourdomain.com
FRONTEND_URL=https://photos.yourdomain.com
# Development: Use docker-compose.dev.yml
# Production: Use docker-compose.prod.yml with .env.production.example
# Database (for PostgreSQL in production)
DB_USER=photoapp
DB_PASSWORD=secure-password-here
DB_NAME=photo_sharing
# JWT Secret (CRITICAL for production)
# Generate with: openssl rand -base64 32
JWT_SECRET=dev-secret-change-in-production
# Application URLs
ADMIN_URL=http://localhost:3005
FRONTEND_URL=http://localhost:3005
# Database Configuration
# SQLite is used for development by default
# For production PostgreSQL config, see .env.production.example
DATABASE_CLIENT=sqlite3
DATABASE_PATH=./data/photo_sharing.db
# Email Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
# Development: Uses Mailhog (included in docker-compose.dev.yml)
# Production: Configure real SMTP server
SMTP_HOST=mailhog
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com
SMTP_USER=
SMTP_PASS=
EMAIL_FROM=noreply@localhost
# Umami Analytics
UMAMI_URL=https://analytics.yourdomain.com
UMAMI_WEBSITE_ID=your-website-id
UMAMI_HASH_SALT=random-salt-here
# Optional: Umami Analytics
UMAMI_URL=
UMAMI_WEBSITE_ID=
UMAMI_HASH_SALT=
+18 -46
View File
@@ -1,60 +1,32 @@
# Production Environment Configuration Template
# Copy this file to .env and fill in your values
# Application URLs
FRONTEND_HOST=photos.yourdomain.com
BACKEND_HOST=api.photos.yourdomain.com
ADMIN_URL=https://admin.photos.yourdomain.com
FRONTEND_URL=https://photos.yourdomain.com
ADMIN_URL=https://yourdomain.com
FRONTEND_URL=https://yourdomain.com
# Database Configuration
DB_NAME=photo_sharing
DB_USER=photoapp
DB_PASSWORD=your-secure-password-here
# Security - CRITICAL: Generate a secure random JWT secret
# You can generate one with: openssl rand -base64 32
JWT_SECRET=your-secure-random-jwt-secret-here
# JWT Configuration
JWT_SECRET=your-jwt-secret-here
# Database Configuration (PostgreSQL)
DB_USER=picpeak
DB_PASSWORD=your-secure-database-password
DB_NAME=picpeak
# Email Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_SECURE=true
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com
# Umami Analytics
# Umami Analytics (Optional)
UMAMI_URL=https://analytics.yourdomain.com
UMAMI_HOST=analytics.yourdomain.com
UMAMI_WEBSITE_ID=your-website-id
UMAMI_HASH_SALT=your-random-salt
UMAMI_DB_PASSWORD=umami-db-password
UMAMI_HASH_SALT=your-random-hash-salt
# Traefik Configuration
TRAEFIK_HOST=traefik.yourdomain.com
ACME_EMAIL=admin@yourdomain.com
TRAEFIK_DASHBOARD_AUTH=admin:$2y$10$... # Use htpasswd to generate
# Docker Registry (optional)
REGISTRY_URL=registry.yourdomain.com
VERSION=latest
# Monitoring
DOMAIN=yourdomain.com
GRAFANA_USER=admin
GRAFANA_PASSWORD=your-grafana-password
# OAuth Configuration (optional)
OAUTH_AUTH_URL=https://auth.yourdomain.com/oauth2/auth
OAUTH_TOKEN_URL=https://auth.yourdomain.com/oauth2/token
OAUTH_USER_URL=https://auth.yourdomain.com/oauth2/userinfo
OAUTH_CLIENT_ID=photo-sharing
OAUTH_CLIENT_SECRET=your-oauth-secret
OAUTH_SECRET=your-random-secret
COOKIE_DOMAIN=.yourdomain.com
OAUTH_WHITELIST=admin@yourdomain.com
# Backup Configuration (optional)
S3_BACKUP_BUCKET=your-backup-bucket
# Drone CI Configuration
DRONE_RPC_SECRET=your-drone-secret
DRONE_GITHUB_CLIENT_ID=your-github-client-id
DRONE_GITHUB_CLIENT_SECRET=your-github-client-secret
# First Admin User (for initial setup)
# Run: docker-compose exec backend node scripts/create-admin.js --email admin@yourdomain.com
ADMIN_EMAIL=admin@yourdomain.com
+287 -424
View File
@@ -1,483 +1,346 @@
# Photo Sharing Platform - Production Deployment Guide
# PicPeak Deployment Guide
This guide covers deploying the photo sharing platform using Docker Swarm, Traefik, and Drone CI/CD.
This guide covers deploying PicPeak for development and production environments.
## Table of Contents
- [Prerequisites](#prerequisites)
- [Infrastructure Setup](#infrastructure-setup)
- [Docker Swarm Setup](#docker-swarm-setup)
- [Traefik Setup](#traefik-setup)
- [Application Deployment](#application-deployment)
- [CI/CD with Drone](#cicd-with-drone)
- [Monitoring](#monitoring)
- [Backup and Recovery](#backup-and-recovery)
- [Quick Start (Development)](#quick-start-development)
- [Production Deployment](#production-deployment)
- [Admin User Setup](#admin-user-setup)
- [Configuration Reference](#configuration-reference)
- [Troubleshooting](#troubleshooting)
## Prerequisites
## Quick Start (Development)
### Hardware Requirements
- **Manager Node**: 2 CPU cores, 4GB RAM, 50GB storage
- **Worker Nodes**: 2 CPU cores, 2GB RAM, 20GB storage
- **Storage**: SSD recommended for database and photo storage
### Software Requirements
- Ubuntu 20.04+ or similar Linux distribution
- Docker Engine 20.10+
- Docker Compose 2.0+
- Git
- SSL certificates (automated with Let's Encrypt)
### Network Requirements
- Ports 80, 443 open for web traffic
- Port 2377 for Swarm management
- Ports 7946, 4789 for Swarm networking
- Static IP or reliable dynamic DNS
## Infrastructure Setup
### 1. Install Docker
### 1. Clone and Setup
```bash
# Install Docker
curl -fsSL https://get.docker.com | sh
git clone https://github.com/yourusername/picpeak.git
cd picpeak
# Add user to docker group
sudo usermod -aG docker $USER
# Enable Docker service
sudo systemctl enable docker
sudo systemctl start docker
```
### 2. Configure Firewall
```bash
# Allow Docker Swarm ports
sudo ufw allow 2377/tcp
sudo ufw allow 7946/tcp
sudo ufw allow 7946/udp
sudo ufw allow 4789/udp
# Allow web traffic
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
```
## Docker Swarm Setup
### 1. Initialize Swarm
On the manager node:
```bash
cd deploy/scripts
sudo ./init-swarm.sh
```
This script will:
- Initialize Docker Swarm
- Create overlay networks
- Label nodes for service placement
- Create required directories
### 2. Join Worker Nodes
On each worker node, run the join command displayed by the init script:
```bash
docker swarm join --token SWMTKN-1-xxx... manager-ip:2377
```
### 3. Verify Swarm
```bash
docker node ls
```
## Application Configuration
### 1. Environment Setup
```bash
# Copy environment template
cp .env.production.example .env.production
cp .env.example .env
# Edit with your values
nano .env.production
```
Required configurations:
- Domain names for frontend, backend, and services
- SMTP credentials for email
- Database passwords
- JWT secrets
### 2. Create Docker Secrets
```bash
cd deploy/scripts
./create-secrets.sh
```
This will create all required secrets in Docker Swarm. Save the generated passwords!
## Traefik Setup
### 1. Deploy Traefik
```bash
cd deploy/traefik
# Create traefik network
docker network create --driver overlay traefik-public
# Deploy Traefik stack
docker stack deploy -c docker-compose.traefik.yml traefik
```
### 2. Verify Traefik
```bash
# Check service status
docker service ls | grep traefik
# View logs
docker service logs traefik_traefik
```
Access Traefik dashboard at: `https://traefik.yourdomain.com/dashboard/`
## Application Deployment
### 1. Build Images (if using local registry)
```bash
# Build frontend
cd frontend
docker build -t photo-sharing-frontend:latest .
# Build backend
cd ../backend
docker build -t photo-sharing-backend:latest .
```
### 2. Deploy Application Stack
```bash
cd deploy/scripts
./deploy.sh
```
Options:
- `--env FILE`: Specify environment file
- `--registry URL`: Docker registry URL
- `--version VERSION`: Image version to deploy
### 3. Verify Deployment
```bash
# Check all services
docker service ls
# Check specific service
docker service ps photo-sharing_backend
# View logs
docker service logs photo-sharing_backend -f
```
### 4. Run Database Migrations
The deploy script automatically runs migrations, but you can run manually:
```bash
docker exec $(docker ps -q -f name=photo-sharing_backend) npm run migrate
```
## CI/CD with Drone
### 1. Drone Server Setup
Deploy Drone server on your CI infrastructure:
```bash
docker run \
--volume=/var/lib/drone:/data \
--env=DRONE_GITHUB_CLIENT_ID=your-id \
--env=DRONE_GITHUB_CLIENT_SECRET=your-secret \
--env=DRONE_RPC_SECRET=your-rpc-secret \
--env=DRONE_SERVER_HOST=drone.yourdomain.com \
--env=DRONE_SERVER_PROTO=https \
--publish=80:80 \
--publish=443:443 \
--restart=always \
--detach=true \
--name=drone \
drone/drone:2
```
### 2. Drone Runner Setup
On build servers:
```bash
docker run -d \
-v /var/run/docker.sock:/var/run/docker.sock \
-e DRONE_RPC_PROTO=https \
-e DRONE_RPC_HOST=drone.yourdomain.com \
-e DRONE_RPC_SECRET=your-rpc-secret \
-e DRONE_RUNNER_CAPACITY=2 \
-e DRONE_RUNNER_NAME=runner-1 \
-p 3000:3000 \
--restart always \
--name runner \
drone/drone-runner-docker:1
```
### 3. Repository Setup
1. Enable repository in Drone UI
2. Add secrets in Drone:
- `docker_username`
- `docker_password`
- `docker_registry`
- `staging_swarm_host`
- `staging_swarm_user`
- `staging_swarm_key`
- `prod_swarm_host`
- `prod_swarm_user`
- `prod_swarm_key`
- `slack_webhook`
### 4. Deployment Workflow
- Push to `develop` → Deploy to staging
- Create tag → Deploy to production
- Automatic rollback on failure
## Monitoring
### 1. Deploy Monitoring Stack
```bash
cd deploy/monitoring
# Deploy monitoring services
docker stack deploy -c docker-compose.monitoring.yml monitoring
# Start development environment
docker-compose -f docker-compose.dev.yml up -d
```
### 2. Access Services
- Grafana: `https://grafana.yourdomain.com`
- Prometheus: `https://prometheus.yourdomain.com`
- Alertmanager: `https://alerts.yourdomain.com`
- Frontend: http://localhost:3005
- Backend API: http://localhost:3001
- MailHog (email testing): http://localhost:8025
### 3. Configure Alerts
### 3. Create Admin User
Create alert rules in `deploy/monitoring/alerts/`:
```bash
docker-compose -f docker-compose.dev.yml exec backend node scripts/create-admin.js \
--email admin@localhost \
--username admin \
--password admin123
```
## Production Deployment
### Prerequisites
- Docker and Docker Compose installed
- Domain with DNS configured
- SSL/TLS handled by reverse proxy (Traefik, Nginx, etc.)
### 1. Environment Setup
```bash
# Copy production template
cp .env.production.example .env
# Generate secure secrets
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
echo "DB_PASSWORD=$(openssl rand -base64 24)" >> .env
```
Edit `.env` with your configuration:
```env
# Your domain
ADMIN_URL=https://yourdomain.com
FRONTEND_URL=https://yourdomain.com
# Database (PostgreSQL)
DB_USER=picpeak
DB_NAME=picpeak
# DB_PASSWORD already generated above
# Email
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=true
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com
```
### 2. Frontend Configuration
```bash
# Configure frontend for production
echo "VITE_API_URL=/api" > frontend/.env.production
```
### 3. Deploy with Docker Compose
```bash
# Build and start services
docker-compose -f docker-compose.prod.yml up -d
# Check status
docker-compose -f docker-compose.prod.yml ps
# View logs
docker-compose -f docker-compose.prod.yml logs -f
```
### 4. Deploy with Traefik
If using Traefik, create `docker-compose.override.yml`:
```yaml
groups:
- name: photo-sharing
rules:
- alert: ServiceDown
expr: up{job="photo-sharing-backend"} == 0
for: 5m
annotations:
summary: "Photo sharing backend is down"
version: '3.8'
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
networks:
- traefik
- picpeak
networks:
traefik:
external: true
```
## Backup and Recovery
## Admin User Setup
### 1. Automated Backups
### Create First Admin
Set up cron job for automated backups:
After deployment, create your admin user:
```bash
# Edit crontab
crontab -e
# Production
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
--email admin@yourdomain.com \
--username admin \
--password yourSecurePassword
# Add daily backup at 2 AM
0 2 * * * /opt/photo-sharing/deploy/scripts/backup.sh
# Auto-generate password
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
--email admin@yourdomain.com
```
### 2. Manual Backup
The script will display:
- ✅ Admin user created successfully!
- Email: admin@yourdomain.com
- Username: admin
- Login URL: https://yourdomain.com/admin/login
- Password: (save this if auto-generated!)
### Managing Admin Users
```bash
cd deploy/scripts
./backup.sh
# List admin users
docker-compose -f docker-compose.prod.yml exec backend \
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
-c "SELECT id, username, email, is_active, last_login FROM admin_users;"
# Deactivate user
docker-compose -f docker-compose.prod.yml exec backend \
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
-c "UPDATE admin_users SET is_active = false WHERE email = 'user@example.com';"
```
### 3. Restore from Backup
## Configuration Reference
### Database Configuration
PicPeak automatically detects the environment and uses:
- **Development**: SQLite (`./data/photo_sharing.db`)
- **Production**: PostgreSQL (configured via environment variables)
### Environment Variables
#### Required for Production
| Variable | Description | Example |
|----------|-------------|---------|
| `JWT_SECRET` | JWT signing key | `openssl rand -base64 32` |
| `DB_PASSWORD` | PostgreSQL password | `openssl rand -base64 24` |
| `ADMIN_URL` | Admin panel URL | `https://yourdomain.com` |
| `FRONTEND_URL` | Frontend URL | `https://yourdomain.com` |
| `EMAIL_FROM` | Sender email | `noreply@yourdomain.com` |
#### Email Configuration
| Variable | Description | Example |
|----------|-------------|---------|
| `SMTP_HOST` | SMTP server | `smtp.gmail.com` |
| `SMTP_PORT` | SMTP port | `587` |
| `SMTP_SECURE` | Use TLS | `true` |
| `SMTP_USER` | SMTP username | `your-email@gmail.com` |
| `SMTP_PASS` | SMTP password | App-specific password |
### Storage Paths
- Photos: `./storage/events/active/`
- Archives: `./storage/events/archived/`
- Thumbnails: `./storage/thumbnails/`
- Uploads: `./storage/uploads/`
## Backup and Restore
### Backup Database
```bash
# Extract backup
tar -xzf backup-20240615-020000.tar.gz
# PostgreSQL backup
docker-compose -f docker-compose.prod.yml exec db \
pg_dump -U picpeak picpeak > backup-$(date +%Y%m%d).sql
# Restore database
docker exec -i $(docker ps -q -f name=photo-sharing_db) \
psql -U postgres photo_sharing < backup-20240615-020000/database.sql
# Restore photos
tar -xzf backup-20240615-020000/photos.tar.gz -C /opt/photo-sharing/
# Restore volumes
docker run --rm \
-v photo-sharing_app-data:/data \
-v $(pwd)/backup-20240615-020000:/backup \
alpine tar -xzf /backup/volume-photo-sharing_app-data.tar.gz -C /data
# Backup storage
tar -czf storage-backup-$(date +%Y%m%d).tar.gz ./storage
```
## Maintenance
### 1. Scaling Services
### Restore Database
```bash
# Scale backend to 5 replicas
docker service scale photo-sharing_backend=5
# PostgreSQL restore
docker-compose -f docker-compose.prod.yml exec -T db \
psql -U picpeak picpeak < backup-20240115.sql
# Scale frontend to 3 replicas
docker service scale photo-sharing_frontend=3
# Restore storage
tar -xzf storage-backup-20240115.tar.gz
```
### 2. Rolling Updates
```bash
# Update backend image
docker service update \
--image registry.yourdomain.com/photo-sharing-backend:v2.0 \
photo-sharing_backend
```
### 3. Drain Node for Maintenance
```bash
# Drain node
docker node update --availability drain worker-1
# Perform maintenance...
# Activate node
docker node update --availability active worker-1
```
## Troubleshooting
### Common Issues
#### 1. Service Won't Start
```bash
# Check service status
docker service ps photo-sharing_backend --no-trunc
# View detailed logs
docker service logs photo-sharing_backend --details
```
#### 2. Database Connection Issues
```bash
# Check database logs
docker service logs photo-sharing_db
# Test connection
docker exec $(docker ps -q -f name=photo-sharing_db) \
pg_isready -U postgres
```
#### 3. Traefik Certificate Issues
```bash
# Check Traefik logs
docker service logs traefik_traefik | grep acme
# Remove and regenerate certificates
rm -rf /opt/traefik/letsencrypt/acme.json
docker service update --force traefik_traefik
```
#### 4. Storage Issues
```bash
# Check disk usage
df -h
# Clean up Docker
docker system prune -a
```
### Debug Mode
Enable debug logging:
```bash
# Update service with debug logging
docker service update \
--env-add LOG_LEVEL=debug \
photo-sharing_backend
```
## Monitoring
### Health Checks
```bash
# Check all endpoints
curl -f https://photos.yourdomain.com/health
curl -f https://api.photos.yourdomain.com/api/health
curl -f https://traefik.yourdomain.com/ping
# Backend health
curl https://yourdomain.com/api/health
# Frontend health
curl https://yourdomain.com/health
```
## Security Best Practices
### Logs
1. **Regular Updates**
- Keep Docker and system packages updated
- Update application dependencies regularly
- Monitor security advisories
```bash
# All services
docker-compose -f docker-compose.prod.yml logs -f
2. **Access Control**
- Use strong passwords for all services
- Enable 2FA where possible
- Restrict SSH access to specific IPs
- Use Docker secrets for sensitive data
# Specific service
docker-compose -f docker-compose.prod.yml logs -f backend
3. **Network Security**
- Use internal networks for service communication
- Enable firewall rules
- Use TLS for all external communication
- Regular security scans with Trivy
# Last 100 lines
docker-compose -f docker-compose.prod.yml logs --tail=100 backend
```
4. **Backup Security**
- Encrypt backups at rest
- Test restore procedures regularly
- Store backups in multiple locations
- Rotate old backups
## Troubleshooting
## Performance Tuning
### Backend Won't Start
1. **Database Optimization**
```sql
-- Add indexes for common queries
CREATE INDEX idx_photos_event_id ON photos(event_id);
CREATE INDEX idx_access_logs_event_id ON access_logs(event_id);
1. Check database connection:
```bash
docker-compose -f docker-compose.prod.yml logs db
```
2. **Image Optimization**
- Use CDN for static assets
- Enable aggressive caching
- Optimize image sizes before upload
3. **Service Limits**
```yaml
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
2. Verify environment variables:
```bash
docker-compose -f docker-compose.prod.yml exec backend env | grep DB_
```
## Support
### Can't Login as Admin
For issues and questions:
- Check logs: `docker service logs <service_name>`
- Review documentation: [README.md](README.md)
- Check monitoring dashboards
- Contact: admin@yourdomain.com
1. Verify admin user exists:
```bash
docker-compose -f docker-compose.prod.yml exec backend \
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
-c "SELECT * FROM admin_users;"
```
2. Reset admin password:
```bash
# Create new admin with different email
docker-compose -f docker-compose.prod.yml exec backend \
node scripts/create-admin.js --email newadmin@yourdomain.com
```
### Photos Not Loading
1. Check file permissions:
```bash
ls -la ./storage/events/active/
```
2. Verify nginx proxy configuration:
```bash
docker-compose -f docker-compose.prod.yml exec frontend \
cat /etc/nginx/conf.d/default.conf
```
### Email Not Sending
1. Check email configuration:
```bash
docker-compose -f docker-compose.prod.yml exec backend env | grep SMTP_
```
2. View email queue:
```bash
docker-compose -f docker-compose.prod.yml exec backend \
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
-c "SELECT * FROM email_queue WHERE status = 'failed';"
```
## Maintenance
### Update Application
```bash
# Pull latest changes
git pull
# Rebuild images
docker-compose -f docker-compose.prod.yml build
# Restart services
docker-compose -f docker-compose.prod.yml up -d
```
### Clean Up
```bash
# Remove unused images
docker image prune -a
# Clean up logs
docker-compose -f docker-compose.prod.yml logs --tail=0 -f
# Remove old archives
find ./storage/events/archived -name "*.zip" -mtime +90 -delete
```
## Security Checklist
- [ ] Generated secure `JWT_SECRET`
- [ ] Generated secure `DB_PASSWORD`
- [ ] HTTPS enabled via reverse proxy
- [ ] Changed default admin credentials
- [ ] Configured real SMTP server
- [ ] Set file permissions: `chmod 600 .env`
- [ ] Firewall configured
- [ ] Regular backups scheduled
- [ ] Monitoring enabled
+98
View File
@@ -0,0 +1,98 @@
# Production Deployment Guide
This guide explains how to deploy PicPeak in production behind a reverse proxy like Traefik.
## Environment Configuration
### Frontend Configuration
For production deployment behind a reverse proxy (Traefik, Nginx, etc.), the frontend should use relative URLs to automatically inherit the protocol (HTTPS) and domain.
1. Copy the production environment template:
```bash
cp frontend/.env.production.example frontend/.env.production
```
2. Set the API URL to use relative path:
```env
# frontend/.env.production
VITE_API_URL=/api
```
This ensures all API calls will use the same domain and protocol as the frontend.
### Backend Configuration
Ensure your backend `.env` file has the correct URLs:
```env
# backend/.env
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
```
## Docker Compose Production
When using Docker Compose in production:
1. Build with production environment:
```bash
docker-compose -f docker-compose.prod.yml build --build-arg NODE_ENV=production
```
2. The frontend nginx configuration already includes proper proxy settings for:
- `/api` → Backend API
- `/photos` → Protected photo access
- `/thumbnails` → Thumbnail images
- `/uploads` → Public uploads (logos, favicons)
## Traefik Configuration
Example Traefik labels for docker-compose:
```yaml
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
```
## Important Notes
1. **No Hardcoded URLs**: The application uses environment variables with relative URL fallbacks, making it production-ready.
2. **HTTPS Only**: When `VITE_API_URL=/api`, all requests will use the same protocol as the page (HTTPS in production).
3. **CORS Configuration**: The backend CORS is configured to accept requests from the URLs specified in `FRONTEND_URL` and `ADMIN_URL`.
4. **Static Assets**: All static assets (photos, thumbnails, uploads) are served through the nginx proxy, inheriting authentication headers.
## Verification
After deployment, verify:
1. Check browser console for any localhost URLs (there should be none)
2. Verify all API calls use HTTPS
3. Check that images load correctly with authentication
4. Test favicon and logo display
## Troubleshooting
If you see console errors about localhost:
1. Ensure `VITE_API_URL=/api` in frontend environment
2. Clear browser cache
3. Rebuild frontend with production environment:
```bash
cd frontend
npm run build
```
If images don't load:
1. Check that nginx proxy locations are configured
2. Verify authentication tokens are being sent
3. Check backend logs for authentication errors
+10 -1
View File
@@ -24,10 +24,19 @@ ARCHIVE_PATH=./storage/events/archived
# Logging
LOG_LEVEL=info
# Database (for production, consider PostgreSQL)
# Database Configuration
# Development: Use SQLite
DATABASE_CLIENT=sqlite3
DATABASE_PATH=./data/photo_sharing.db
# Production: Use PostgreSQL
# DATABASE_CLIENT=pg
# DB_HOST=localhost
# DB_PORT=5432
# DB_USER=picpeak
# DB_PASSWORD=your-secure-password
# DB_NAME=picpeak
# Umami Analytics (optional)
UMAMI_URL=
UMAMI_WEBSITE_ID=
+46
View File
@@ -0,0 +1,46 @@
require('dotenv').config();
const path = require('path');
// Database configuration for different environments
const config = {
development: {
client: process.env.DATABASE_CLIENT || 'sqlite3',
connection: process.env.DATABASE_CLIENT === 'pg' ? {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'photo_sharing'
} : {
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
},
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
migrations: {
directory: './migrations'
},
seeds: {
directory: './seeds'
}
},
production: {
client: process.env.DATABASE_CLIENT || 'pg',
connection: {
host: process.env.DB_HOST || 'postgres',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'photo_sharing'
},
pool: {
min: 2,
max: 10
},
migrations: {
directory: './migrations'
}
}
};
module.exports = config[process.env.NODE_ENV || 'development'];
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.2",
"version": "1.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.2",
"version": "1.0.4",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.2",
"version": "1.0.4",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
/**
* Script to create an admin user
* Usage: node scripts/create-admin.js --email admin@example.com --username admin --password yourpassword
*
* If no password is provided, a random one will be generated and displayed
*/
require('dotenv').config();
const bcrypt = require('bcryptjs');
const { db } = require('../src/database/db');
const crypto = require('crypto');
// Parse command line arguments
const args = process.argv.slice(2);
const getArg = (name) => {
const index = args.findIndex(arg => arg === `--${name}`);
return index !== -1 && args[index + 1] ? args[index + 1] : null;
};
const email = getArg('email');
const username = getArg('username') || email?.split('@')[0] || 'admin';
let password = getArg('password');
// Validate email
if (!email) {
console.error('Error: Email is required. Use --email admin@example.com');
process.exit(1);
}
// Generate password if not provided
if (!password) {
password = crypto.randomBytes(12).toString('base64').slice(0, 16);
console.log(`Generated password: ${password}`);
console.log('Please save this password securely!');
}
async function createAdmin() {
try {
// Check if user already exists
const existingUser = await db('admin_users')
.where('email', email)
.orWhere('username', username)
.first();
if (existingUser) {
console.error(`Error: User with email "${email}" or username "${username}" already exists`);
process.exit(1);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create admin user
await db('admin_users').insert({
username,
email,
password_hash: passwordHash,
is_active: true,
created_at: new Date(),
updated_at: new Date()
});
console.log(`✅ Admin user created successfully!`);
console.log(` Email: ${email}`);
console.log(` Username: ${username}`);
console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`);
process.exit(0);
} catch (error) {
console.error('Error creating admin user:', error.message);
process.exit(1);
}
}
createAdmin();
+39 -40
View File
@@ -1,13 +1,7 @@
const knex = require('knex');
const path = require('path');
const knexConfig = require('../../knexfile');
const db = knex({
client: 'sqlite3',
connection: {
filename: path.join(__dirname, '../../data/photo_sharing.db')
},
useNullAsDefault: true
});
const db = knex(knexConfig);
async function initializeDatabase() {
// Events table
@@ -35,37 +29,42 @@ async function initializeDatabase() {
} else {
// Check if color_theme needs to be updated to TEXT type
// This is needed for larger theme configurations
try {
await db.raw(`
CREATE TABLE IF NOT EXISTS events_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
event_name TEXT NOT NULL,
event_date DATE NOT NULL,
host_email TEXT NOT NULL,
admin_email TEXT NOT NULL,
password_hash TEXT NOT NULL,
welcome_message TEXT,
color_theme TEXT,
share_link TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
is_active BOOLEAN DEFAULT 1,
is_archived BOOLEAN DEFAULT 0,
archive_path TEXT,
archived_at DATETIME,
allow_user_uploads BOOLEAN DEFAULT 0,
upload_category_id INTEGER
)
`);
await db.raw(`INSERT INTO events_new SELECT * FROM events`);
await db.raw(`DROP TABLE events`);
await db.raw(`ALTER TABLE events_new RENAME TO events`);
} catch (error) {
// If the migration fails, it might already have been applied
console.log('Color theme migration may have already been applied');
const isPostgres = knexConfig.client === 'pg';
if (!isPostgres) {
// SQLite-specific migration
try {
await db.raw(`
CREATE TABLE IF NOT EXISTS events_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
event_name TEXT NOT NULL,
event_date DATE NOT NULL,
host_email TEXT NOT NULL,
admin_email TEXT NOT NULL,
password_hash TEXT NOT NULL,
welcome_message TEXT,
color_theme TEXT,
share_link TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
is_active BOOLEAN DEFAULT 1,
is_archived BOOLEAN DEFAULT 0,
archive_path TEXT,
archived_at DATETIME,
allow_user_uploads BOOLEAN DEFAULT 0,
upload_category_id INTEGER
)
`);
await db.raw(`INSERT INTO events_new SELECT * FROM events`);
await db.raw(`DROP TABLE events`);
await db.raw(`ALTER TABLE events_new RENAME TO events`);
} catch (error) {
// If the migration fails, it might already have been applied
console.log('Color theme migration may have already been applied');
}
}
}
@@ -225,4 +224,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
}
}
module.exports = { db, initializeDatabase, logActivity };
module.exports = { db, initializeDatabase, logActivity };
+61
View File
@@ -0,0 +1,61 @@
# Development Docker Compose Configuration
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3001:3000"
environment:
- NODE_ENV=development
- PORT=3000
- JWT_SECRET=dev-secret-change-in-production
- ADMIN_URL=http://localhost:3005
- FRONTEND_URL=http://localhost:3005
- DATABASE_CLIENT=sqlite3
- DATABASE_PATH=./data/photo_sharing.db
# Email - uses Mailhog
- SMTP_HOST=mailhog
- SMTP_PORT=1025
- SMTP_SECURE=false
- EMAIL_FROM=noreply@photo-sharing.local
volumes:
- ./backend:/app
- /app/node_modules
- ./storage:/app/storage
- ./data:/app/data
- ./logs:/app/logs
depends_on:
- mailhog
command: sh -c "npm install && npm run dev"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3005:80"
environment:
- NODE_ENV=development
volumes:
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
mailhog:
image: mailhog/mailhog:latest
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
-109
View File
@@ -1,109 +0,0 @@
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile.dev
ports:
- "3001:3000"
environment:
- NODE_ENV=development
- PORT=3000
- JWT_SECRET=b375996f704bb1541ad9297e7710a215fce0b59ecf1c43236aeea32ec01e7b27
- ADMIN_URL=http://localhost:3005
- FRONTEND_URL=http://localhost:3005
# Email - uses Mailhog
- SMTP_HOST=mailhog
- SMTP_PORT=1025
- SMTP_SECURE=false
- SMTP_USER=
- SMTP_PASS=
- EMAIL_FROM=noreply@photo-sharing.local
# Storage path
- STORAGE_PATH=/app/storage
# Umami Analytics (optional)
- UMAMI_URL=
- UMAMI_WEBSITE_ID=
volumes:
- ./backend:/app
- /app/node_modules
- ./storage:/app/storage
- ./data:/app/data
- ./logs:/app/logs
depends_on:
- mailhog
command: sh -c "npm install && npm run dev"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile.dev
args:
- VITE_API_URL=http://localhost:3001
- VITE_UMAMI_URL=
- VITE_UMAMI_WEBSITE_ID=
ports:
- "3005:80"
environment:
- NODE_ENV=development
volumes:
- ./frontend/dist:/usr/share/nginx/html
- ./frontend/nginx.dev.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
# Development frontend with hot reload
frontend-dev:
image: node:18-alpine
working_dir: /app
ports:
- "3002:5173"
environment:
- NODE_ENV=development
- VITE_API_URL=http://localhost:3001
- VITE_UMAMI_URL=
- VITE_UMAMI_WEBSITE_ID=
volumes:
- ./frontend:/app
- /app/node_modules
command: sh -c "npm install --legacy-peer-deps && npm run dev -- --host"
depends_on:
- backend
mailhog:
image: mailhog/mailhog:latest
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
# Optional: File watcher service
file-watcher:
build:
context: ./backend
dockerfile: Dockerfile.dev
environment:
- NODE_ENV=development
- STORAGE_PATH=/app/storage
volumes:
- ./backend:/app
- /app/node_modules
- ./storage:/app/storage
- ./data:/app/data
command: node src/services/fileWatcher.js
depends_on:
- backend
volumes:
node_modules_backend:
node_modules_frontend:
+11 -2
View File
@@ -14,12 +14,21 @@ services:
- JWT_SECRET=${JWT_SECRET}
- ADMIN_URL=${ADMIN_URL}
- FRONTEND_URL=${FRONTEND_URL}
# Database
- DATABASE_CLIENT=pg
- DB_HOST=db
- DB_PORT=5432
- DB_USER=${DB_USER:-picpeak}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME:-picpeak}
# Email
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
# Analytics
- UMAMI_URL=${UMAMI_URL}
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
volumes:
@@ -70,7 +79,7 @@ services:
image: postgres:14-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER:-photoapp}
- POSTGRES_USER=${DB_USER:-picpeak}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-picpeak}
volumes:
@@ -82,7 +91,7 @@ services:
image: ghcr.io/umami-software/umami:postgresql-latest
restart: unless-stopped
environment:
DATABASE_URL: postgresql://${DB_USER:-photoapp}:${DB_PASSWORD}@db:5432/umami
DATABASE_URL: postgresql://${DB_USER:-picpeak}:${DB_PASSWORD}@db:5432/umami
DATABASE_TYPE: postgresql
HASH_SALT: ${UMAMI_HASH_SALT}
depends_on:
-49
View File
@@ -1,49 +0,0 @@
# docker-compose.yml - Development configuration
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3001:3000"
environment:
- NODE_ENV=development
- PORT=3000
- JWT_SECRET=b55e4b3e9f212e1d1836c2ee2ec5ac47672350acdf1391c894d3433646579ad0
- ADMIN_URL=http://localhost:3001
- FRONTEND_URL=http://localhost:3005
- SMTP_HOST=mailhog
- SMTP_PORT=1025
- SMTP_SECURE=false
- SMTP_USER=
- SMTP_PASS=
- EMAIL_FROM=noreply@localhost
volumes:
- ./backend:/app
- /app/node_modules
- ./storage:/app/storage
- ./data:/app/data
- ./logs:/app/logs
depends_on:
- mailhog
command: node server.js
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3005:80"
environment:
- REACT_APP_API_URL=http://localhost:3001
volumes:
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
mailhog:
image: mailhog/mailhog:latest
ports:
- "1025:1025"
- "8025:8025"
+14
View File
@@ -0,0 +1,14 @@
# Production Environment Configuration
# When running behind a reverse proxy like Traefik, use relative URLs
# Backend API URL
# For production behind reverse proxy, use relative URL:
VITE_API_URL=/api
# For development or if frontend/backend are on different domains:
# VITE_API_URL=https://api.yourdomain.com
# Umami Analytics Configuration (optional)
# VITE_UMAMI_URL=https://analytics.yourdomain.com
# VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami
# VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing
+14
View File
@@ -79,6 +79,20 @@ server {
proxy_cache_valid 404 1m;
}
# Uploads serving proxy (logos, favicons, watermarks)
location /uploads {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
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;
# Cache uploads
proxy_cache_valid 200 302 7d;
proxy_cache_valid 404 1m;
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.2",
"version": "1.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.2",
"version": "1.0.4",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-link": "^2.25.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.2",
"version": "1.0.4",
"type": "module",
"scripts": {
"dev": "vite",
+2 -1
View File
@@ -27,6 +27,7 @@ import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
import { getApiBaseUrl } from './utils/url';
// Create a client
const queryClient = new QueryClient({
@@ -48,7 +49,7 @@ function App() {
if (umamiUrl && umamiWebsiteId) {
try {
// Fetch public settings to check if analytics is enabled
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
const settings = await response.json();
// Only initialize if analytics is enabled in settings
+2 -1
View File
@@ -3,6 +3,7 @@ import { AlertTriangle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { api } from '../config/api';
import { buildResourceUrl } from '../utils/url';
interface BrandingSettings {
branding_company_name?: string;
@@ -50,7 +51,7 @@ export const MaintenanceMode: React.FC = () => {
src={settings?.branding_logo_url ?
(settings.branding_logo_url.startsWith('http')
? settings.branding_logo_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_logo_url}`)
: buildResourceUrl(settings.branding_logo_url))
: '/picpeak-logo-transparent.png'
}
alt={settings?.branding_company_name || 'PicPeak'}
@@ -4,6 +4,7 @@ import { Button, Card, Input } from '../common';
import { GALLERY_THEME_PRESETS, type ThemeConfig } from '../../contexts/ThemeContext';
import { settingsService } from '../../services/settings.service';
import { toast } from 'react-toastify';
import { buildResourceUrl } from '../../utils/url';
interface ThemeCustomizerProps {
value: ThemeConfig;
@@ -269,7 +270,7 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
<div className="flex items-center gap-4">
{localTheme.logoUrl && (
<img
src={localTheme.logoUrl.startsWith('http') ? localTheme.logoUrl : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${localTheme.logoUrl}`}
src={localTheme.logoUrl.startsWith('http') ? localTheme.logoUrl : buildResourceUrl(localTheme.logoUrl)}
alt="Custom logo"
className="h-16 w-auto object-contain"
/>
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { getAuthToken } from '../../config/api';
import { buildResourceUrl } from '../../utils/url';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string;
@@ -60,9 +61,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
// Use the src as-is since it should already be the correct endpoint
let imageUrl = src;
// Prepend API URL for absolute paths
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
const fullImageUrl = imageUrl.startsWith('/') ? `${apiUrl}${imageUrl}` : imageUrl;
// Build full URL for the image
const fullImageUrl = imageUrl.startsWith('/') ? buildResourceUrl(imageUrl) : imageUrl;
console.log('Fetching authenticated image:', fullImageUrl);
const response = await fetch(fullImageUrl, {
@@ -1,12 +1,13 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
export const DynamicFavicon: React.FC = () => {
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) {
return response.json();
}
@@ -30,7 +31,7 @@ export const DynamicFavicon: React.FC = () => {
link.type = 'image/png';
link.href = settings.branding_favicon_url.startsWith('http')
? settings.branding_favicon_url
: `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settings.branding_favicon_url}`;
: buildResourceUrl(settings.branding_favicon_url);
document.head.appendChild(link);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl } from '../../utils/url';
interface ReCaptchaProps {
onChange: (token: string | null) => void;
@@ -20,7 +21,7 @@ export const ReCaptcha: React.FC<ReCaptchaProps> = ({
const { data: settings } = useQuery({
queryKey: ['public-settings'],
queryFn: async () => {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
return response.json();
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
@@ -7,6 +7,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext';
import { buildResourceUrl } from '../../utils/url';
interface GalleryLayoutProps {
event: {
@@ -122,7 +123,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<div className="flex-shrink-0">
<img
src={brandingSettings?.logo_url ?
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` :
buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png'
}
alt={brandingSettings?.company_name || 'PicPeak'}
@@ -277,7 +278,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<div className="mb-6">
<img
src={brandingSettings?.logo_url ?
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` :
buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png'
}
alt={brandingSettings?.company_name || 'PicPeak'}
@@ -7,6 +7,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { buildResourceUrl } from '../../../utils/url';
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
eventName?: string;
@@ -97,7 +98,7 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
<div className="mb-6">
<img
src={eventLogo ?
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${eventLogo}` :
buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png'
}
alt="Event logo"
+1 -1
View File
@@ -14,7 +14,7 @@ export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void)
// Create axios instance
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001',
baseURL: import.meta.env.VITE_API_URL || '/api',
headers: {
'Content-Type': 'application/json',
},
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { setMaintenanceModeCallback } from '../config/api';
import { getApiBaseUrl } from '../utils/url';
interface MaintenanceContextType {
isMaintenanceMode: boolean;
@@ -29,7 +30,7 @@ export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ childr
queryKey: ['public-settings-maintenance'],
queryFn: async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}/api/public/settings`);
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.status === 503) {
setIsMaintenanceMode(true);
return null;
+4 -3
View File
@@ -13,6 +13,7 @@ import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url';
export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>();
@@ -164,7 +165,7 @@ export const GalleryPage: React.FC = () => {
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
src={buildResourceUrl(settingsData.branding_logo_url)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
@@ -220,7 +221,7 @@ export const GalleryPage: React.FC = () => {
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}`}
src={buildResourceUrl(settingsData.branding_logo_url)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
@@ -282,7 +283,7 @@ export const GalleryPage: React.FC = () => {
<div className="text-center mb-4 sm:mb-6">
<img
src={settingsData?.branding_logo_url ?
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${settingsData.branding_logo_url}` :
buildResourceUrl(settingsData.branding_logo_url) :
'/picpeak-logo-transparent.png'
}
alt={settingsData?.branding_company_name || 'PicPeak'}
+3 -2
View File
@@ -7,6 +7,7 @@ import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../context
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService, type BrandingSettings } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { buildResourceUrl } from '../../utils/url';
export const BrandingPage: React.FC = () => {
const { t } = useTranslation();
@@ -279,7 +280,7 @@ export const BrandingPage: React.FC = () => {
{brandingSettings.favicon_url && (
<div className="flex items-center gap-2">
<img
src={brandingSettings.favicon_url.startsWith('http') ? brandingSettings.favicon_url : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.favicon_url}`}
src={brandingSettings.favicon_url.startsWith('http') ? brandingSettings.favicon_url : buildResourceUrl(brandingSettings.favicon_url)}
alt="Current favicon"
className="w-8 h-8"
/>
@@ -344,7 +345,7 @@ export const BrandingPage: React.FC = () => {
{brandingSettings.watermark_logo_url && (
<div className="flex items-center gap-2">
<img
src={brandingSettings.watermark_logo_url.startsWith('http') ? brandingSettings.watermark_logo_url : `${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.watermark_logo_url}`}
src={brandingSettings.watermark_logo_url.startsWith('http') ? brandingSettings.watermark_logo_url : buildResourceUrl(brandingSettings.watermark_logo_url)}
alt="Current watermark"
className="h-16 w-auto object-contain bg-neutral-100 p-2 rounded"
/>
+46
View File
@@ -0,0 +1,46 @@
/**
* Utility functions for URL handling in production environments
*/
/**
* Get the base API URL, preferring relative URLs for production
* @returns The API base URL
*/
export const getApiBaseUrl = (): string => {
// If VITE_API_URL is explicitly set, use it
if (import.meta.env.VITE_API_URL && import.meta.env.VITE_API_URL !== '/api') {
return import.meta.env.VITE_API_URL;
}
// In production, use relative URL
return '/api';
};
/**
* Build a full URL for resources (images, files, etc.)
* In production, this will use the current origin
* @param path - The resource path
* @returns The full URL
*/
export const buildResourceUrl = (path: string): string => {
// Remove leading slash if present
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
// If we have an explicit API URL that's not relative, use it
const apiUrl = import.meta.env.VITE_API_URL;
if (apiUrl && apiUrl !== '/api' && apiUrl.startsWith('http')) {
const baseUrl = apiUrl.replace(/\/api\/?$/, ''); // Remove /api suffix if present
return `${baseUrl}/${cleanPath}`;
}
// In production (relative API), use current origin
return `${window.location.origin}/${cleanPath}`;
};
/**
* Check if we're in production mode (using relative URLs)
* @returns True if in production mode
*/
export const isProductionMode = (): boolean => {
return !import.meta.env.VITE_API_URL || import.meta.env.VITE_API_URL === '/api';
};