Compare commits

...

7 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
Gitea Actions Bot 0934695a69 chore: bump version to 1.0.2
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-13 18:13:24 +00:00
paul 77ece5c5f1 fix: add missing translations for admin activities
Test and Lint / backend-test (push) Successful in 1m11s
Test and Lint / frontend-test (push) Successful in 2m15s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
continuous-integration/drone/push Build is passing
2025-07-13 20:09:08 +02:00
33 changed files with 788 additions and 716 deletions
+28 -20
View File
@@ -1,26 +1,34 @@
# JWT Secret for authentication # Environment Configuration Template
# IMPORTANT: Generate a secure random secret with: openssl rand -hex 32 # Copy this file to .env and adjust values for your environment
# 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
# URLs # Development: Use docker-compose.dev.yml
ADMIN_URL=https://admin.photos.yourdomain.com # Production: Use docker-compose.prod.yml with .env.production.example
FRONTEND_URL=https://photos.yourdomain.com
# Database (for PostgreSQL in production) # JWT Secret (CRITICAL for production)
DB_USER=photoapp # Generate with: openssl rand -base64 32
DB_PASSWORD=secure-password-here JWT_SECRET=dev-secret-change-in-production
DB_NAME=photo_sharing
# 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 # Email Configuration
SMTP_HOST=smtp.gmail.com # Development: Uses Mailhog (included in docker-compose.dev.yml)
SMTP_PORT=587 # Production: Configure real SMTP server
SMTP_HOST=mailhog
SMTP_PORT=1025
SMTP_SECURE=false SMTP_SECURE=false
SMTP_USER=your-email@gmail.com SMTP_USER=
SMTP_PASS=your-app-password SMTP_PASS=
EMAIL_FROM=noreply@yourdomain.com EMAIL_FROM=noreply@localhost
# Umami Analytics # Optional: Umami Analytics
UMAMI_URL=https://analytics.yourdomain.com UMAMI_URL=
UMAMI_WEBSITE_ID=your-website-id UMAMI_WEBSITE_ID=
UMAMI_HASH_SALT=random-salt-here 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 # Application URLs
FRONTEND_HOST=photos.yourdomain.com ADMIN_URL=https://yourdomain.com
BACKEND_HOST=api.photos.yourdomain.com FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://admin.photos.yourdomain.com
FRONTEND_URL=https://photos.yourdomain.com
# Database Configuration # Security - CRITICAL: Generate a secure random JWT secret
DB_NAME=photo_sharing # You can generate one with: openssl rand -base64 32
DB_USER=photoapp JWT_SECRET=your-secure-random-jwt-secret-here
DB_PASSWORD=your-secure-password-here
# JWT Configuration # Database Configuration (PostgreSQL)
JWT_SECRET=your-jwt-secret-here DB_USER=picpeak
DB_PASSWORD=your-secure-database-password
DB_NAME=picpeak
# Email Configuration # Email Configuration
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.gmail.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_SECURE=false SMTP_SECURE=true
SMTP_USER=your-email@gmail.com SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com EMAIL_FROM=noreply@yourdomain.com
# Umami Analytics # Umami Analytics (Optional)
UMAMI_URL=https://analytics.yourdomain.com UMAMI_URL=https://analytics.yourdomain.com
UMAMI_HOST=analytics.yourdomain.com
UMAMI_WEBSITE_ID=your-website-id UMAMI_WEBSITE_ID=your-website-id
UMAMI_HASH_SALT=your-random-salt UMAMI_HASH_SALT=your-random-hash-salt
UMAMI_DB_PASSWORD=umami-db-password
# Traefik Configuration # First Admin User (for initial setup)
TRAEFIK_HOST=traefik.yourdomain.com # Run: docker-compose exec backend node scripts/create-admin.js --email admin@yourdomain.com
ACME_EMAIL=admin@yourdomain.com ADMIN_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
+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 ## Table of Contents
- [Prerequisites](#prerequisites) - [Quick Start (Development)](#quick-start-development)
- [Infrastructure Setup](#infrastructure-setup) - [Production Deployment](#production-deployment)
- [Docker Swarm Setup](#docker-swarm-setup) - [Admin User Setup](#admin-user-setup)
- [Traefik Setup](#traefik-setup) - [Configuration Reference](#configuration-reference)
- [Application Deployment](#application-deployment)
- [CI/CD with Drone](#cicd-with-drone)
- [Monitoring](#monitoring)
- [Backup and Recovery](#backup-and-recovery)
- [Troubleshooting](#troubleshooting) - [Troubleshooting](#troubleshooting)
## Prerequisites ## Quick Start (Development)
### Hardware Requirements ### 1. Clone and Setup
- **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
```bash ```bash
# Install Docker git clone https://github.com/yourusername/picpeak.git
curl -fsSL https://get.docker.com | sh 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 # Copy environment template
cp .env.production.example .env.production cp .env.example .env
# Edit with your values # Start development environment
nano .env.production docker-compose -f docker-compose.dev.yml up -d
```
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
``` ```
### 2. Access Services ### 2. Access Services
- Grafana: `https://grafana.yourdomain.com` - Frontend: http://localhost:3005
- Prometheus: `https://prometheus.yourdomain.com` - Backend API: http://localhost:3001
- Alertmanager: `https://alerts.yourdomain.com` - 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 ```yaml
groups: version: '3.8'
- name: photo-sharing
rules: services:
- alert: ServiceDown frontend:
expr: up{job="photo-sharing-backend"} == 0 labels:
for: 5m - "traefik.enable=true"
annotations: - "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
summary: "Photo sharing backend is down" - "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 ```bash
# Edit crontab # Production
crontab -e 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 # Auto-generate password
0 2 * * * /opt/photo-sharing/deploy/scripts/backup.sh 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 ```bash
cd deploy/scripts # List admin users
./backup.sh 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 ```bash
# Extract backup # PostgreSQL backup
tar -xzf backup-20240615-020000.tar.gz docker-compose -f docker-compose.prod.yml exec db \
pg_dump -U picpeak picpeak > backup-$(date +%Y%m%d).sql
# Restore database # Backup storage
docker exec -i $(docker ps -q -f name=photo-sharing_db) \ tar -czf storage-backup-$(date +%Y%m%d).tar.gz ./storage
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
``` ```
## Maintenance ### Restore Database
### 1. Scaling Services
```bash ```bash
# Scale backend to 5 replicas # PostgreSQL restore
docker service scale photo-sharing_backend=5 docker-compose -f docker-compose.prod.yml exec -T db \
psql -U picpeak picpeak < backup-20240115.sql
# Scale frontend to 3 replicas # Restore storage
docker service scale photo-sharing_frontend=3 tar -xzf storage-backup-20240115.tar.gz
``` ```
### 2. Rolling Updates ## Monitoring
```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
```
### Health Checks ### Health Checks
```bash ```bash
# Check all endpoints # Backend health
curl -f https://photos.yourdomain.com/health curl https://yourdomain.com/api/health
curl -f https://api.photos.yourdomain.com/api/health
curl -f https://traefik.yourdomain.com/ping # Frontend health
curl https://yourdomain.com/health
``` ```
## Security Best Practices ### Logs
1. **Regular Updates** ```bash
- Keep Docker and system packages updated # All services
- Update application dependencies regularly docker-compose -f docker-compose.prod.yml logs -f
- Monitor security advisories
2. **Access Control** # Specific service
- Use strong passwords for all services docker-compose -f docker-compose.prod.yml logs -f backend
- Enable 2FA where possible
- Restrict SSH access to specific IPs
- Use Docker secrets for sensitive data
3. **Network Security** # Last 100 lines
- Use internal networks for service communication docker-compose -f docker-compose.prod.yml logs --tail=100 backend
- Enable firewall rules ```
- Use TLS for all external communication
- Regular security scans with Trivy
4. **Backup Security** ## Troubleshooting
- Encrypt backups at rest
- Test restore procedures regularly
- Store backups in multiple locations
- Rotate old backups
## Performance Tuning ### Backend Won't Start
1. **Database Optimization** 1. Check database connection:
```sql ```bash
-- Add indexes for common queries docker-compose -f docker-compose.prod.yml logs db
CREATE INDEX idx_photos_event_id ON photos(event_id);
CREATE INDEX idx_access_logs_event_id ON access_logs(event_id);
``` ```
2. **Image Optimization** 2. Verify environment variables:
- Use CDN for static assets ```bash
- Enable aggressive caching docker-compose -f docker-compose.prod.yml exec backend env | grep DB_
- Optimize image sizes before upload
3. **Service Limits**
```yaml
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
``` ```
## Support ### Can't Login as Admin
For issues and questions: 1. Verify admin user exists:
- Check logs: `docker service logs <service_name>` ```bash
- Review documentation: [README.md](README.md) docker-compose -f docker-compose.prod.yml exec backend \
- Check monitoring dashboards psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
- Contact: admin@yourdomain.com -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 # Logging
LOG_LEVEL=info LOG_LEVEL=info
# Database (for production, consider PostgreSQL) # Database Configuration
# Development: Use SQLite
DATABASE_CLIENT=sqlite3 DATABASE_CLIENT=sqlite3
DATABASE_PATH=./data/photo_sharing.db 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 Analytics (optional)
UMAMI_URL= UMAMI_URL=
UMAMI_WEBSITE_ID= 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", "name": "picpeak-backend",
"version": "1.0.1", "version": "1.0.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.1", "version": "1.0.4",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^5.3.1", "archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.1", "version": "1.0.4",
"description": "Backend for PicPeak event photo sharing platform", "description": "Backend for PicPeak event photo sharing platform",
"main": "server.js", "main": "server.js",
"scripts": { "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 knex = require('knex');
const path = require('path'); const knexConfig = require('../../knexfile');
const db = knex({ const db = knex(knexConfig);
client: 'sqlite3',
connection: {
filename: path.join(__dirname, '../../data/photo_sharing.db')
},
useNullAsDefault: true
});
async function initializeDatabase() { async function initializeDatabase() {
// Events table // Events table
@@ -35,37 +29,42 @@ async function initializeDatabase() {
} else { } else {
// Check if color_theme needs to be updated to TEXT type // Check if color_theme needs to be updated to TEXT type
// This is needed for larger theme configurations // This is needed for larger theme configurations
try { const isPostgres = knexConfig.client === 'pg';
await db.raw(`
CREATE TABLE IF NOT EXISTS events_new ( if (!isPostgres) {
id INTEGER PRIMARY KEY AUTOINCREMENT, // SQLite-specific migration
slug TEXT UNIQUE NOT NULL, try {
event_type TEXT NOT NULL, await db.raw(`
event_name TEXT NOT NULL, CREATE TABLE IF NOT EXISTS events_new (
event_date DATE NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT,
host_email TEXT NOT NULL, slug TEXT UNIQUE NOT NULL,
admin_email TEXT NOT NULL, event_type TEXT NOT NULL,
password_hash TEXT NOT NULL, event_name TEXT NOT NULL,
welcome_message TEXT, event_date DATE NOT NULL,
color_theme TEXT, host_email TEXT NOT NULL,
share_link TEXT UNIQUE NOT NULL, admin_email TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, password_hash TEXT NOT NULL,
expires_at DATETIME NOT NULL, welcome_message TEXT,
is_active BOOLEAN DEFAULT 1, color_theme TEXT,
is_archived BOOLEAN DEFAULT 0, share_link TEXT UNIQUE NOT NULL,
archive_path TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
archived_at DATETIME, expires_at DATETIME NOT NULL,
allow_user_uploads BOOLEAN DEFAULT 0, is_active BOOLEAN DEFAULT 1,
upload_category_id INTEGER is_archived BOOLEAN DEFAULT 0,
) archive_path TEXT,
`); archived_at DATETIME,
allow_user_uploads BOOLEAN DEFAULT 0,
await db.raw(`INSERT INTO events_new SELECT * FROM events`); upload_category_id INTEGER
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 await db.raw(`INSERT INTO events_new SELECT * FROM events`);
console.log('Color theme migration may have already been applied'); 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} - JWT_SECRET=${JWT_SECRET}
- ADMIN_URL=${ADMIN_URL} - ADMIN_URL=${ADMIN_URL}
- FRONTEND_URL=${FRONTEND_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_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT} - SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE} - SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER=${SMTP_USER} - SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS} - SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM} - EMAIL_FROM=${EMAIL_FROM}
# Analytics
- UMAMI_URL=${UMAMI_URL} - UMAMI_URL=${UMAMI_URL}
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID} - UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
volumes: volumes:
@@ -70,7 +79,7 @@ services:
image: postgres:14-alpine image: postgres:14-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
- POSTGRES_USER=${DB_USER:-photoapp} - POSTGRES_USER=${DB_USER:-picpeak}
- POSTGRES_PASSWORD=${DB_PASSWORD} - POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-picpeak} - POSTGRES_DB=${DB_NAME:-picpeak}
volumes: volumes:
@@ -82,7 +91,7 @@ services:
image: ghcr.io/umami-software/umami:postgresql-latest image: ghcr.io/umami-software/umami:postgresql-latest
restart: unless-stopped restart: unless-stopped
environment: 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 DATABASE_TYPE: postgresql
HASH_SALT: ${UMAMI_HASH_SALT} HASH_SALT: ${UMAMI_HASH_SALT}
depends_on: 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; 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 # SPA fallback
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.0.1", "version": "1.0.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.0.1", "version": "1.0.4",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"@tiptap/extension-link": "^2.25.0", "@tiptap/extension-link": "^2.25.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"private": true, "private": true,
"version": "1.0.1", "version": "1.0.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "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 { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { MaintenanceWrapper } from './components/MaintenanceWrapper';
import { GlobalThemeProvider } from './components/GlobalThemeProvider'; import { GlobalThemeProvider } from './components/GlobalThemeProvider';
import { getApiBaseUrl } from './utils/url';
// Create a client // Create a client
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -48,7 +49,7 @@ function App() {
if (umamiUrl && umamiWebsiteId) { if (umamiUrl && umamiWebsiteId) {
try { try {
// Fetch public settings to check if analytics is enabled // 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(); const settings = await response.json();
// Only initialize if analytics is enabled in settings // 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 { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { api } from '../config/api'; import { api } from '../config/api';
import { buildResourceUrl } from '../utils/url';
interface BrandingSettings { interface BrandingSettings {
branding_company_name?: string; branding_company_name?: string;
@@ -50,7 +51,7 @@ export const MaintenanceMode: React.FC = () => {
src={settings?.branding_logo_url ? src={settings?.branding_logo_url ?
(settings.branding_logo_url.startsWith('http') (settings.branding_logo_url.startsWith('http')
? settings.branding_logo_url ? 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' : '/picpeak-logo-transparent.png'
} }
alt={settings?.branding_company_name || 'PicPeak'} 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 { GALLERY_THEME_PRESETS, type ThemeConfig } from '../../contexts/ThemeContext';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { buildResourceUrl } from '../../utils/url';
interface ThemeCustomizerProps { interface ThemeCustomizerProps {
value: ThemeConfig; value: ThemeConfig;
@@ -269,7 +270,7 @@ export const ThemeCustomizer: React.FC<ThemeCustomizerProps> = ({
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{localTheme.logoUrl && ( {localTheme.logoUrl && (
<img <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" alt="Custom logo"
className="h-16 w-auto object-contain" className="h-16 w-auto object-contain"
/> />
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { getAuthToken } from '../../config/api'; import { getAuthToken } from '../../config/api';
import { buildResourceUrl } from '../../utils/url';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string; 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 // Use the src as-is since it should already be the correct endpoint
let imageUrl = src; let imageUrl = src;
// Prepend API URL for absolute paths // Build full URL for the image
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001'; const fullImageUrl = imageUrl.startsWith('/') ? buildResourceUrl(imageUrl) : imageUrl;
const fullImageUrl = imageUrl.startsWith('/') ? `${apiUrl}${imageUrl}` : imageUrl;
console.log('Fetching authenticated image:', fullImageUrl); console.log('Fetching authenticated image:', fullImageUrl);
const response = await fetch(fullImageUrl, { const response = await fetch(fullImageUrl, {
@@ -1,12 +1,13 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
export const DynamicFavicon: React.FC = () => { export const DynamicFavicon: React.FC = () => {
const { data: settings } = useQuery({ const { data: settings } = useQuery({
queryKey: ['public-settings'], queryKey: ['public-settings'],
queryFn: async () => { queryFn: async () => {
try { 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) { if (response.ok) {
return response.json(); return response.json();
} }
@@ -30,7 +31,7 @@ export const DynamicFavicon: React.FC = () => {
link.type = 'image/png'; link.type = 'image/png';
link.href = settings.branding_favicon_url.startsWith('http') link.href = settings.branding_favicon_url.startsWith('http')
? settings.branding_favicon_url ? 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); document.head.appendChild(link);
} }
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import ReCAPTCHA from 'react-google-recaptcha'; import ReCAPTCHA from 'react-google-recaptcha';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getApiBaseUrl } from '../../utils/url';
interface ReCaptchaProps { interface ReCaptchaProps {
onChange: (token: string | null) => void; onChange: (token: string | null) => void;
@@ -20,7 +21,7 @@ export const ReCaptcha: React.FC<ReCaptchaProps> = ({
const { data: settings } = useQuery({ const { data: settings } = useQuery({
queryKey: ['public-settings'], queryKey: ['public-settings'],
queryFn: async () => { 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(); return response.json();
}, },
staleTime: 5 * 60 * 1000, // Cache for 5 minutes staleTime: 5 * 60 * 1000, // Cache for 5 minutes
@@ -7,6 +7,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button } from '../common'; import { Button } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon'; import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { buildResourceUrl } from '../../utils/url';
interface GalleryLayoutProps { interface GalleryLayoutProps {
event: { event: {
@@ -122,7 +123,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<img <img
src={brandingSettings?.logo_url ? src={brandingSettings?.logo_url ?
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` : buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png' '/picpeak-logo-transparent.png'
} }
alt={brandingSettings?.company_name || 'PicPeak'} alt={brandingSettings?.company_name || 'PicPeak'}
@@ -277,7 +278,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
<div className="mb-6"> <div className="mb-6">
<img <img
src={brandingSettings?.logo_url ? src={brandingSettings?.logo_url ?
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${brandingSettings.logo_url}` : buildResourceUrl(brandingSettings.logo_url) :
'/picpeak-logo-transparent.png' '/picpeak-logo-transparent.png'
} }
alt={brandingSettings?.company_name || 'PicPeak'} alt={brandingSettings?.company_name || 'PicPeak'}
@@ -7,6 +7,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common'; import { AuthenticatedImage } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout'; import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types'; import type { Photo } from '../../../types';
import { buildResourceUrl } from '../../../utils/url';
interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps { interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
eventName?: string; eventName?: string;
@@ -97,7 +98,7 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
<div className="mb-6"> <div className="mb-6">
<img <img
src={eventLogo ? src={eventLogo ?
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${eventLogo}` : buildResourceUrl(eventLogo) :
'/picpeak-logo-transparent.png' '/picpeak-logo-transparent.png'
} }
alt="Event logo" alt="Event logo"
+1 -1
View File
@@ -14,7 +14,7 @@ export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void)
// Create axios instance // Create axios instance
export const api = axios.create({ export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3001', baseURL: import.meta.env.VITE_API_URL || '/api',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { setMaintenanceModeCallback } from '../config/api'; import { setMaintenanceModeCallback } from '../config/api';
import { getApiBaseUrl } from '../utils/url';
interface MaintenanceContextType { interface MaintenanceContextType {
isMaintenanceMode: boolean; isMaintenanceMode: boolean;
@@ -29,7 +30,7 @@ export const MaintenanceProvider: React.FC<MaintenanceProviderProps> = ({ childr
queryKey: ['public-settings-maintenance'], queryKey: ['public-settings-maintenance'],
queryFn: async () => { queryFn: async () => {
try { 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) { if (response.status === 503) {
setIsMaintenanceMode(true); setIsMaintenanceMode(true);
return null; return null;
+2
View File
@@ -704,6 +704,8 @@
"category_created": "Kategorie erstellt: {{categoryName}}", "category_created": "Kategorie erstellt: {{categoryName}}",
"category_updated": "Kategorie aktualisiert: {{categoryName}}", "category_updated": "Kategorie aktualisiert: {{categoryName}}",
"category_deleted": "Kategorie gelöscht: {{categoryName}}", "category_deleted": "Kategorie gelöscht: {{categoryName}}",
"general_settings_updated": "Allgemeine Einstellungen aktualisiert",
"favicon_uploaded": "Favicon hochgeladen",
"unknown": "Unbekannte Aktivität" "unknown": "Unbekannte Aktivität"
} }
}, },
+2
View File
@@ -755,6 +755,8 @@
"category_created": "Category created: {{categoryName}}", "category_created": "Category created: {{categoryName}}",
"category_updated": "Category updated: {{categoryName}}", "category_updated": "Category updated: {{categoryName}}",
"category_deleted": "Category deleted: {{categoryName}}", "category_deleted": "Category deleted: {{categoryName}}",
"general_settings_updated": "General settings updated",
"favicon_uploaded": "Favicon uploaded",
"unknown": "Unknown activity" "unknown": "Unknown activity"
} }
}, },
+4 -3
View File
@@ -13,6 +13,7 @@ import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service'; import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api'; import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types'; import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url';
export const GalleryPage: React.FC = () => { export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>(); const { slug, token } = useParams<{ slug: string; token?: string }>();
@@ -164,7 +165,7 @@ export const GalleryPage: React.FC = () => {
{settingsData?.branding_logo_url && ( {settingsData?.branding_logo_url && (
<div className="p-8 text-center"> <div className="p-8 text-center">
<img <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'} alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto" className="h-16 w-auto object-contain mx-auto"
/> />
@@ -220,7 +221,7 @@ export const GalleryPage: React.FC = () => {
{settingsData?.branding_logo_url && ( {settingsData?.branding_logo_url && (
<div className="p-8 text-center"> <div className="p-8 text-center">
<img <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'} alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto" 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"> <div className="text-center mb-4 sm:mb-6">
<img <img
src={settingsData?.branding_logo_url ? 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' '/picpeak-logo-transparent.png'
} }
alt={settingsData?.branding_company_name || 'PicPeak'} 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService, type BrandingSettings } from '../../services/settings.service'; import { settingsService, type BrandingSettings } from '../../services/settings.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { buildResourceUrl } from '../../utils/url';
export const BrandingPage: React.FC = () => { export const BrandingPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -279,7 +280,7 @@ export const BrandingPage: React.FC = () => {
{brandingSettings.favicon_url && ( {brandingSettings.favicon_url && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <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" alt="Current favicon"
className="w-8 h-8" className="w-8 h-8"
/> />
@@ -344,7 +345,7 @@ export const BrandingPage: React.FC = () => {
{brandingSettings.watermark_logo_url && ( {brandingSettings.watermark_logo_url && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<img <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" alt="Current watermark"
className="h-16 w-auto object-contain bg-neutral-100 p-2 rounded" 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';
};