Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97bbb3c8e1 | |||
| ac1cd96ecd | |||
| 689861f671 | |||
| 41fb575e80 | |||
| 6ebc4f3fc4 | |||
| de973f5613 | |||
| 279c70b3d6 | |||
| 5a73f6963f | |||
| 5101a05bca | |||
| c82caf6539 |
@@ -0,0 +1,111 @@
|
|||||||
|
# Quick Fix for Migration Error
|
||||||
|
|
||||||
|
## Immediate Fix
|
||||||
|
|
||||||
|
The error "relation photo_categories already exists" occurs because the database already has tables but the migration tracking doesn't know they were applied.
|
||||||
|
|
||||||
|
### Option 1: Use Safe Migration Runner (Recommended)
|
||||||
|
|
||||||
|
Update your `docker-compose.prod.yml` to use the safe migration command:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
# ... other config ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the `wait-for-db.sh` (already done) to use `npm run migrate:safe` in production.
|
||||||
|
|
||||||
|
### Option 2: Quick Manual Fix
|
||||||
|
|
||||||
|
If you need to fix the running system immediately:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Enter the backend container
|
||||||
|
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||||
|
|
||||||
|
# 2. Run the safe migration script
|
||||||
|
npm run migrate:safe
|
||||||
|
|
||||||
|
# 3. If that fails, manually mark migrations as applied:
|
||||||
|
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
|
||||||
|
|
||||||
|
# In PostgreSQL:
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Mark existing migrations as applied
|
||||||
|
INSERT INTO migrations (filename) VALUES
|
||||||
|
('init.js'),
|
||||||
|
('004_add_categories_and_cms.js'),
|
||||||
|
('006_add_photo_counter_to_categories.js'),
|
||||||
|
('007_add_read_at_to_activity_logs.js'),
|
||||||
|
('008_add_language_support_to_email_templates.js'),
|
||||||
|
('009_update_german_email_templates.js'),
|
||||||
|
('010_add_missing_email_templates.js'),
|
||||||
|
('011_add_user_upload_settings.js'),
|
||||||
|
('012_add_hero_photo_id.js'),
|
||||||
|
('013_fix_email_links_and_date_format.js'),
|
||||||
|
('014_add_default_welcome_message.js'),
|
||||||
|
('014_add_host_name_to_events.js'),
|
||||||
|
('015_add_login_attempts_table.js'),
|
||||||
|
('016_add_auth_security_columns.js'),
|
||||||
|
('017_add_token_revocation_tables.js')
|
||||||
|
ON CONFLICT (filename) DO NOTHING;
|
||||||
|
|
||||||
|
\q
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Fresh Start (Nuclear Option)
|
||||||
|
|
||||||
|
If you don't have important data yet:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop everything
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# Remove database volume
|
||||||
|
docker volume rm wedding-photo-sharing_postgres_data
|
||||||
|
|
||||||
|
# Start fresh
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root Cause
|
||||||
|
|
||||||
|
The issue happens when:
|
||||||
|
1. Database volume persists between deployments
|
||||||
|
2. Migration tracking table gets out of sync
|
||||||
|
3. The original migration runner doesn't check for existing tables
|
||||||
|
|
||||||
|
## Permanent Solution
|
||||||
|
|
||||||
|
The new safe migration runner (`migrate:safe`) handles this by:
|
||||||
|
1. Checking if tables exist before creating them
|
||||||
|
2. Catching "already exists" errors gracefully
|
||||||
|
3. Auto-detecting existing schema and marking migrations as applied
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
After fixing the migration issue:
|
||||||
|
|
||||||
|
1. Create admin user:
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
|
||||||
|
--username admin \
|
||||||
|
--email admin@yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Check health:
|
||||||
|
```bash
|
||||||
|
curl http://yourdomain.com/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Monitor logs:
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||||
|
```
|
||||||
@@ -81,4 +81,20 @@ Expected response:
|
|||||||
Email service requires configuration in the database. If email is not configured:
|
Email service requires configuration in the database. If email is not configured:
|
||||||
- The service will log a warning but continue running
|
- The service will log a warning but continue running
|
||||||
- Emails will be queued but not sent
|
- Emails will be queued but not sent
|
||||||
- Configure email settings in the admin panel after deployment
|
- Configure email settings in the admin panel after deployment
|
||||||
|
|
||||||
|
## PostgreSQL Connection Fix
|
||||||
|
|
||||||
|
### Issue: "no pg_hba.conf entry for host"
|
||||||
|
This error occurs when PostgreSQL requires SSL but the client connects without encryption.
|
||||||
|
|
||||||
|
### Solution:
|
||||||
|
- Disabled SSL requirement for PostgreSQL in Docker environment (`ssl=off`)
|
||||||
|
- Added proper authentication method (`scram-sha-256`)
|
||||||
|
- This is acceptable for internal Docker networks where all traffic is isolated
|
||||||
|
|
||||||
|
### Security Note:
|
||||||
|
For production deployments exposed to the internet:
|
||||||
|
1. Use SSL certificates for PostgreSQL
|
||||||
|
2. Or ensure the database is only accessible within the Docker network
|
||||||
|
3. Never expose PostgreSQL port (5432) directly to the internet
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# Production Deployment Guide
|
||||||
|
|
||||||
|
This guide addresses all known production deployment issues and provides solutions.
|
||||||
|
|
||||||
|
## Pre-Deployment Checklist
|
||||||
|
|
||||||
|
### 1. Environment Variables
|
||||||
|
Create a `.env` file with ALL required variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
JWT_SECRET=<generate-with-openssl-rand-base64-32>
|
||||||
|
DB_PASSWORD=<strong-password>
|
||||||
|
ADMIN_URL=https://yourdomain.com
|
||||||
|
FRONTEND_URL=https://yourdomain.com
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_USER=picpeak
|
||||||
|
DB_NAME=picpeak
|
||||||
|
|
||||||
|
# Email (Optional but recommended)
|
||||||
|
SMTP_HOST=smtp.gmail.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_USER=your-email@gmail.com
|
||||||
|
SMTP_PASS=your-app-password
|
||||||
|
EMAIL_FROM=noreply@yourdomain.com
|
||||||
|
|
||||||
|
# Umami Analytics (Optional)
|
||||||
|
UMAMI_URL=https://analytics.yourdomain.com
|
||||||
|
UMAMI_WEBSITE_ID=your-website-id
|
||||||
|
UMAMI_HASH_SALT=<generate-random-string>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Generate Secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate JWT Secret
|
||||||
|
openssl rand -base64 32
|
||||||
|
|
||||||
|
# Generate Database Password
|
||||||
|
openssl rand -base64 24
|
||||||
|
|
||||||
|
# Generate Umami Hash Salt
|
||||||
|
openssl rand -hex 32
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
### 1. Initial Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Clone repository
|
||||||
|
git clone https://github.com/yourusername/wedding-photo-sharing.git
|
||||||
|
cd wedding-photo-sharing
|
||||||
|
|
||||||
|
# Create required directories
|
||||||
|
mkdir -p storage/events/active storage/events/archived storage/thumbnails storage/uploads
|
||||||
|
mkdir -p data logs
|
||||||
|
mkdir -p certbot/conf certbot/www
|
||||||
|
|
||||||
|
# Set permissions (important!)
|
||||||
|
chmod -R 755 storage data logs
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Fix Docker Volume Permissions
|
||||||
|
|
||||||
|
Create `docker-compose.override.yml` for local volume configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
volumes:
|
||||||
|
- ./storage:/app/storage:delegated
|
||||||
|
- ./data:/app/data:delegated
|
||||||
|
- ./logs:/app/logs:delegated
|
||||||
|
user: "1001:1001" # nodejs user
|
||||||
|
|
||||||
|
db:
|
||||||
|
volumes:
|
||||||
|
- ./postgres-data:/var/lib/postgresql/data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Build and Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build images
|
||||||
|
docker-compose -f docker-compose.prod.yml build
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create Admin User
|
||||||
|
|
||||||
|
After deployment, create the first admin user:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Enter backend container
|
||||||
|
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||||
|
|
||||||
|
# Create admin
|
||||||
|
node scripts/create-admin.js \
|
||||||
|
--username admin \
|
||||||
|
--email admin@yourdomain.com \
|
||||||
|
--password <your-secure-password>
|
||||||
|
|
||||||
|
# Exit container
|
||||||
|
exit
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Configure Email (if using database config)
|
||||||
|
|
||||||
|
1. Login to admin panel: https://yourdomain.com/admin
|
||||||
|
2. Go to Settings > Email Configuration
|
||||||
|
3. Enter SMTP details
|
||||||
|
4. Test email sending
|
||||||
|
|
||||||
|
## Common Issues and Solutions
|
||||||
|
|
||||||
|
### Issue 1: Migration Failures
|
||||||
|
|
||||||
|
**Error**: "relation already exists"
|
||||||
|
|
||||||
|
**Solution**: The safe migration runner handles this automatically. If issues persist:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Reset migrations tracking
|
||||||
|
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
|
||||||
|
|
||||||
|
# In PostgreSQL:
|
||||||
|
DROP TABLE IF EXISTS migrations;
|
||||||
|
\q
|
||||||
|
|
||||||
|
# Re-run migrations
|
||||||
|
docker-compose -f docker-compose.prod.yml exec backend npm run migrate:safe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 2: Permission Denied Errors
|
||||||
|
|
||||||
|
**Error**: "EACCES: permission denied"
|
||||||
|
|
||||||
|
**Solution**: Fix container permissions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop containers
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
|
||||||
|
# Fix permissions on host
|
||||||
|
sudo chown -R 1001:1001 storage data logs
|
||||||
|
|
||||||
|
# Restart
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue 3: Database Connection Failed
|
||||||
|
|
||||||
|
**Error**: "no pg_hba.conf entry"
|
||||||
|
|
||||||
|
**Solution**: Already fixed in docker-compose.prod.yml with:
|
||||||
|
- SSL disabled for internal Docker network
|
||||||
|
- Proper authentication method (scram-sha-256)
|
||||||
|
|
||||||
|
### Issue 4: Frontend Can't Connect to Backend
|
||||||
|
|
||||||
|
**Error**: CORS errors or connection refused
|
||||||
|
|
||||||
|
**Solution**: Ensure environment variables match:
|
||||||
|
- Backend: `FRONTEND_URL` must match your frontend URL
|
||||||
|
- Frontend: `VITE_API_URL` must be set during build
|
||||||
|
|
||||||
|
### Issue 5: Email Not Sending
|
||||||
|
|
||||||
|
**Solution**: Check email configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backend logs
|
||||||
|
docker-compose -f docker-compose.prod.yml logs backend | grep email
|
||||||
|
|
||||||
|
# Verify SMTP settings
|
||||||
|
# Gmail users: Use app password, not regular password
|
||||||
|
# Enable "Less secure app access" or use OAuth2
|
||||||
|
```
|
||||||
|
|
||||||
|
## SSL/HTTPS Setup
|
||||||
|
|
||||||
|
1. Update `nginx/sites-enabled/default` with your domain
|
||||||
|
2. Run certbot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Initial certificate
|
||||||
|
docker-compose -f docker-compose.prod.yml run --rm certbot certonly \
|
||||||
|
--webroot --webroot-path=/var/www/certbot \
|
||||||
|
-d yourdomain.com -d www.yourdomain.com
|
||||||
|
|
||||||
|
# Auto-renewal is handled by the certbot container
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend health
|
||||||
|
curl http://localhost/api/health
|
||||||
|
|
||||||
|
# Database connection
|
||||||
|
docker-compose -f docker-compose.prod.yml exec backend \
|
||||||
|
psql -U picpeak -d picpeak -c "SELECT 1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All services
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f
|
||||||
|
|
||||||
|
# Specific service
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup and Restore
|
||||||
|
|
||||||
|
### Backup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# backup.sh
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_DIR="./backups/$DATE"
|
||||||
|
|
||||||
|
mkdir -p $BACKUP_DIR
|
||||||
|
|
||||||
|
# Database
|
||||||
|
docker-compose -f docker-compose.prod.yml exec -T db \
|
||||||
|
pg_dump -U picpeak picpeak > $BACKUP_DIR/database.sql
|
||||||
|
|
||||||
|
# Files
|
||||||
|
tar -czf $BACKUP_DIR/storage.tar.gz storage/
|
||||||
|
|
||||||
|
echo "Backup completed: $BACKUP_DIR"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
docker-compose -f docker-compose.prod.yml exec -T db \
|
||||||
|
psql -U picpeak picpeak < ./backups/20240713_120000/database.sql
|
||||||
|
|
||||||
|
# Files
|
||||||
|
tar -xzf ./backups/20240713_120000/storage.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Best Practices
|
||||||
|
|
||||||
|
1. **Always use named volumes** in production for better data persistence
|
||||||
|
2. **Set up monitoring** with Prometheus/Grafana
|
||||||
|
3. **Enable backups** with automated scripts
|
||||||
|
4. **Use a reverse proxy** (Nginx) for SSL termination
|
||||||
|
5. **Implement rate limiting** at the Nginx level
|
||||||
|
6. **Regular updates** - Keep Docker images updated
|
||||||
|
7. **Log rotation** - Configure log rotation for application logs
|
||||||
|
|
||||||
|
## Troubleshooting Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check running containers
|
||||||
|
docker-compose -f docker-compose.prod.yml ps
|
||||||
|
|
||||||
|
# Restart a service
|
||||||
|
docker-compose -f docker-compose.prod.yml restart backend
|
||||||
|
|
||||||
|
# View real-time logs
|
||||||
|
docker-compose -f docker-compose.prod.yml logs -f --tail=100
|
||||||
|
|
||||||
|
# Execute commands in container
|
||||||
|
docker-compose -f docker-compose.prod.yml exec backend sh
|
||||||
|
|
||||||
|
# Database shell
|
||||||
|
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak
|
||||||
|
|
||||||
|
# Clean restart
|
||||||
|
docker-compose -f docker-compose.prod.yml down
|
||||||
|
docker-compose -f docker-compose.prod.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
- [ ] Strong JWT_SECRET (min 32 chars)
|
||||||
|
- [ ] Strong database password
|
||||||
|
- [ ] SSL/HTTPS enabled
|
||||||
|
- [ ] Firewall configured (only 80/443 open)
|
||||||
|
- [ ] Regular security updates
|
||||||
|
- [ ] Backup encryption
|
||||||
|
- [ ] Access logs monitored
|
||||||
|
- [ ] Rate limiting enabled
|
||||||
|
- [ ] File upload restrictions configured
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues not covered here:
|
||||||
|
1. Check application logs
|
||||||
|
2. Review error messages carefully
|
||||||
|
3. Ensure all environment variables are set
|
||||||
|
4. Verify file permissions
|
||||||
|
5. Check Docker daemon logs
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# Traefik Deployment Guide
|
||||||
|
|
||||||
|
This guide explains how to deploy the PicPeak application with Traefik as the reverse proxy.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The application consists of:
|
||||||
|
- **Frontend**: React app served by nginx (port 80)
|
||||||
|
- **Backend**: Node.js API (port 3000)
|
||||||
|
- **Database**: PostgreSQL (port 5432, internal only)
|
||||||
|
|
||||||
|
## Traefik Configuration
|
||||||
|
|
||||||
|
### 1. Docker Labels for Traefik
|
||||||
|
|
||||||
|
Add these labels to your `docker-compose.prod.yml` services:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.picpeak-frontend.rule=Host(`picpeak.yourdomain.com`)"
|
||||||
|
- "traefik.http.routers.picpeak-frontend.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.picpeak-frontend.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.picpeak-frontend.loadbalancer.server.port=80"
|
||||||
|
# Priority for catch-all route
|
||||||
|
- "traefik.http.routers.picpeak-frontend.priority=1"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.picpeak-api.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/api`)"
|
||||||
|
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3000"
|
||||||
|
# Higher priority for API routes
|
||||||
|
- "traefik.http.routers.picpeak-api.priority=10"
|
||||||
|
|
||||||
|
# Additional routes for backend static files
|
||||||
|
- "traefik.http.routers.picpeak-uploads.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/uploads`)"
|
||||||
|
- "traefik.http.routers.picpeak-uploads.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.picpeak-uploads.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.picpeak-uploads.service=picpeak-api"
|
||||||
|
- "traefik.http.routers.picpeak-uploads.priority=10"
|
||||||
|
|
||||||
|
- "traefik.http.routers.picpeak-images.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/images`)"
|
||||||
|
- "traefik.http.routers.picpeak-images.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.picpeak-images.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.picpeak-images.service=picpeak-api"
|
||||||
|
- "traefik.http.routers.picpeak-images.priority=10"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Network Configuration
|
||||||
|
|
||||||
|
Ensure your services are on the Traefik network:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
networks:
|
||||||
|
picpeak:
|
||||||
|
external: false
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
networks:
|
||||||
|
- picpeak
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
backend:
|
||||||
|
networks:
|
||||||
|
- picpeak
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
db:
|
||||||
|
networks:
|
||||||
|
- picpeak # Don't expose to traefik
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Remove Nginx Service
|
||||||
|
|
||||||
|
Since you're using Traefik, remove the nginx service from `docker-compose.prod.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Remove this entire service:
|
||||||
|
# nginx:
|
||||||
|
# image: nginx:alpine
|
||||||
|
# ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Configuration
|
||||||
|
|
||||||
|
The frontend is built with the API URL set to `/api`. This is important because:
|
||||||
|
|
||||||
|
1. All API calls will be relative to the same domain
|
||||||
|
2. Traefik will route `/api/*` to the backend service
|
||||||
|
3. No CORS issues since everything is on the same domain
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Ensure these are set correctly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend needs to know the public URLs
|
||||||
|
ADMIN_URL=https://picpeak.yourdomain.com
|
||||||
|
FRONTEND_URL=https://picpeak.yourdomain.com
|
||||||
|
|
||||||
|
# Backend API is accessed via /api path
|
||||||
|
API_URL=https://picpeak.yourdomain.com/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complete Example
|
||||||
|
|
||||||
|
Here's a complete `docker-compose.prod.yml` for Traefik:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
networks:
|
||||||
|
picpeak:
|
||||||
|
external: false
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: picpeak-backend:latest
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- ADMIN_URL=https://picpeak.yourdomain.com
|
||||||
|
- FRONTEND_URL=https://picpeak.yourdomain.com
|
||||||
|
- DATABASE_CLIENT=pg
|
||||||
|
- DB_HOST=db
|
||||||
|
- DB_PORT=5432
|
||||||
|
- DB_USER=${DB_USER:-picpeak}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
|
- DB_NAME=${DB_NAME:-picpeak}
|
||||||
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
|
- SMTP_PORT=${SMTP_PORT}
|
||||||
|
- SMTP_SECURE=${SMTP_SECURE}
|
||||||
|
- SMTP_USER=${SMTP_USER}
|
||||||
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
|
- EMAIL_FROM=${EMAIL_FROM}
|
||||||
|
- STORAGE_PATH=/app/storage
|
||||||
|
- EVENTS_PATH=/app/storage/events
|
||||||
|
- ARCHIVE_PATH=/app/storage/events/archived
|
||||||
|
volumes:
|
||||||
|
- ./storage:/app/storage
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./logs:/app/logs
|
||||||
|
networks:
|
||||||
|
- picpeak
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.picpeak-api.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/api`)"
|
||||||
|
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.http.routers.picpeak-api.priority=10"
|
||||||
|
|
||||||
|
- "traefik.http.routers.picpeak-uploads.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/uploads`)"
|
||||||
|
- "traefik.http.routers.picpeak-uploads.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.picpeak-uploads.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.picpeak-uploads.service=picpeak-api"
|
||||||
|
- "traefik.http.routers.picpeak-uploads.priority=10"
|
||||||
|
|
||||||
|
- "traefik.http.routers.picpeak-images.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/images`)"
|
||||||
|
- "traefik.http.routers.picpeak-images.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.picpeak-images.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.routers.picpeak-images.service=picpeak-api"
|
||||||
|
- "traefik.http.routers.picpeak-images.priority=10"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: picpeak-frontend:latest
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- VITE_API_URL=/api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
networks:
|
||||||
|
- picpeak
|
||||||
|
- traefik
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.picpeak-frontend.rule=Host(`picpeak.yourdomain.com`)"
|
||||||
|
- "traefik.http.routers.picpeak-frontend.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.picpeak-frontend.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.picpeak-frontend.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.routers.picpeak-frontend.priority=1"
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:14-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${DB_USER:-picpeak}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=${DB_NAME:-picpeak}
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=scram-sha-256
|
||||||
|
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- picpeak
|
||||||
|
command: postgres -c ssl=off
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### 502 Bad Gateway Errors
|
||||||
|
|
||||||
|
1. **Check if backend is running**:
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.prod.yml ps
|
||||||
|
docker-compose -f docker-compose.prod.yml logs backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Verify Traefik can reach the backend**:
|
||||||
|
- Ensure both services are on the same Docker network
|
||||||
|
- Check Traefik logs: `docker logs traefik`
|
||||||
|
|
||||||
|
3. **Check backend health**:
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.prod.yml exec backend curl http://localhost:3000/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Can't Reach API
|
||||||
|
|
||||||
|
1. **Verify API paths don't have double `/api`**:
|
||||||
|
- Frontend should call `/auth/admin/login`, not `/api/auth/admin/login`
|
||||||
|
- The base URL in axios should be `/api`
|
||||||
|
|
||||||
|
2. **Check browser console for actual URLs being called**
|
||||||
|
|
||||||
|
3. **Ensure Traefik routing rules are correct**:
|
||||||
|
- API routes should have higher priority than frontend catch-all
|
||||||
|
|
||||||
|
### CORS Issues
|
||||||
|
|
||||||
|
Should not occur since everything is on the same domain. If you see CORS errors:
|
||||||
|
1. Check that `FRONTEND_URL` and `ADMIN_URL` match your actual domain
|
||||||
|
2. Ensure you're not mixing HTTP and HTTPS
|
||||||
|
|
||||||
|
## Testing the Setup
|
||||||
|
|
||||||
|
1. **Test API directly**:
|
||||||
|
```bash
|
||||||
|
curl https://picpeak.yourdomain.com/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Test frontend**:
|
||||||
|
```bash
|
||||||
|
curl https://picpeak.yourdomain.com/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Test admin login**:
|
||||||
|
- Navigate to https://picpeak.yourdomain.com/admin/login
|
||||||
|
- Check browser console for any errors
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
1. **SSL/TLS**: Traefik handles SSL termination, so the backend doesn't need SSL
|
||||||
|
2. **Port Exposure**: Don't expose backend ports directly - let Traefik handle routing
|
||||||
|
3. **Health Checks**: Configure Traefik health checks for better reliability
|
||||||
|
4. **Rate Limiting**: Consider adding Traefik rate limiting middleware for API routes
|
||||||
Executable
+50
@@ -0,0 +1,50 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# init-production.sh - Production initialization script
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Initializing PicPeak Production Environment..."
|
||||||
|
|
||||||
|
# Wait for services to be ready
|
||||||
|
echo "⏳ Waiting for database to be fully ready..."
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
# Fix permissions if running as root (shouldn't happen with proper Dockerfile)
|
||||||
|
if [ "$(id -u)" = "0" ]; then
|
||||||
|
echo "🔧 Fixing file permissions..."
|
||||||
|
chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create required directories
|
||||||
|
echo "📁 Creating required directories..."
|
||||||
|
mkdir -p /app/storage/events/active \
|
||||||
|
/app/storage/events/archived \
|
||||||
|
/app/storage/thumbnails \
|
||||||
|
/app/storage/uploads/logos \
|
||||||
|
/app/storage/uploads/favicons \
|
||||||
|
/app/data \
|
||||||
|
/app/logs
|
||||||
|
|
||||||
|
# Run migrations with safe runner
|
||||||
|
echo "🗄️ Running database migrations (safe mode)..."
|
||||||
|
NODE_ENV=production npm run migrate:safe
|
||||||
|
|
||||||
|
# Create admin user if environment variables are set
|
||||||
|
if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then
|
||||||
|
echo "👤 Creating admin user..."
|
||||||
|
node scripts/create-admin.js \
|
||||||
|
--email "$ADMIN_EMAIL" \
|
||||||
|
--username "${ADMIN_USERNAME:-admin}" \
|
||||||
|
--password "$ADMIN_PASSWORD" || echo "Admin user might already exist"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Initialize email configuration if variables are set
|
||||||
|
if [ -n "$SMTP_HOST" ]; then
|
||||||
|
echo "📧 Email configuration detected via environment variables"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Production initialization complete!"
|
||||||
|
echo "🌐 Starting application server..."
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
exec node server.js
|
||||||
+2
-1
@@ -31,7 +31,8 @@ const config = {
|
|||||||
port: process.env.DB_PORT || 5432,
|
port: process.env.DB_PORT || 5432,
|
||||||
user: process.env.DB_USER || 'picpeak',
|
user: process.env.DB_USER || 'picpeak',
|
||||||
password: process.env.DB_PASSWORD,
|
password: process.env.DB_PASSWORD,
|
||||||
database: process.env.DB_NAME || 'picpeak'
|
database: process.env.DB_NAME || 'picpeak',
|
||||||
|
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false
|
||||||
},
|
},
|
||||||
pool: {
|
pool: {
|
||||||
min: 2,
|
min: 2,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Migration helper functions for production-safe migrations
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a table only if it doesn't already exist
|
||||||
|
*/
|
||||||
|
async function createTableIfNotExists(knex, tableName, callback) {
|
||||||
|
const exists = await knex.schema.hasTable(tableName);
|
||||||
|
if (!exists) {
|
||||||
|
console.log(`Creating table: ${tableName}`);
|
||||||
|
return knex.schema.createTable(tableName, callback);
|
||||||
|
} else {
|
||||||
|
console.log(`Table ${tableName} already exists, skipping...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add column to table only if it doesn't exist
|
||||||
|
*/
|
||||||
|
async function addColumnIfNotExists(knex, tableName, columnName, callback) {
|
||||||
|
const hasColumn = await knex.schema.hasColumn(tableName, columnName);
|
||||||
|
if (!hasColumn) {
|
||||||
|
console.log(`Adding column ${columnName} to table ${tableName}`);
|
||||||
|
return knex.schema.alterTable(tableName, (table) => {
|
||||||
|
callback(table);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`Column ${columnName} already exists in table ${tableName}, skipping...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert data only if it doesn't already exist
|
||||||
|
*/
|
||||||
|
async function insertIfNotExists(knex, tableName, data, uniqueField) {
|
||||||
|
const exists = await knex(tableName)
|
||||||
|
.where(uniqueField, data[uniqueField])
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
console.log(`Inserting ${uniqueField}: ${data[uniqueField]} into ${tableName}`);
|
||||||
|
return knex(tableName).insert(data);
|
||||||
|
} else {
|
||||||
|
console.log(`${uniqueField}: ${data[uniqueField]} already exists in ${tableName}, skipping...`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create index only if it doesn't exist
|
||||||
|
*/
|
||||||
|
async function createIndexIfNotExists(knex, tableName, columns, indexName) {
|
||||||
|
// This is database-specific, works for PostgreSQL
|
||||||
|
if (knex.client.config.client === 'pg') {
|
||||||
|
const result = await knex.raw(`
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE tablename = ? AND indexname = ?
|
||||||
|
`, [tableName, indexName]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
console.log(`Creating index ${indexName} on ${tableName}`);
|
||||||
|
return knex.schema.alterTable(tableName, (table) => {
|
||||||
|
table.index(columns, indexName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For SQLite, just try to create and ignore errors
|
||||||
|
try {
|
||||||
|
await knex.schema.alterTable(tableName, (table) => {
|
||||||
|
table.index(columns, indexName);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Index probably already exists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createTableIfNotExists,
|
||||||
|
addColumnIfNotExists,
|
||||||
|
insertIfNotExists,
|
||||||
|
createIndexIfNotExists
|
||||||
|
};
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const { db } = require('../src/database/db');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production-safe migration runner that handles existing schema
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Create or verify migrations tracking table
|
||||||
|
async function ensureMigrationsTable() {
|
||||||
|
const tableExists = await db.schema.hasTable('migrations');
|
||||||
|
if (!tableExists) {
|
||||||
|
await db.schema.createTable('migrations', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('filename').unique().notNullable();
|
||||||
|
table.timestamp('applied_at').defaultTo(db.fn.now());
|
||||||
|
});
|
||||||
|
console.log('Created migrations tracking table');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a migration has been applied
|
||||||
|
async function isMigrationApplied(filename) {
|
||||||
|
const result = await db('migrations').where('filename', filename).first();
|
||||||
|
return !!result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark migration as applied without running it (for existing schema)
|
||||||
|
async function markMigrationAsApplied(filename) {
|
||||||
|
await db('migrations').insert({ filename });
|
||||||
|
console.log(`Marked migration ${filename} as applied`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect existing schema and mark migrations as applied
|
||||||
|
async function detectExistingSchema() {
|
||||||
|
console.log('Detecting existing schema...');
|
||||||
|
|
||||||
|
const tableChecks = [
|
||||||
|
{ table: 'events', migration: 'init.js' },
|
||||||
|
{ table: 'photos', migration: 'init.js' },
|
||||||
|
{ table: 'photo_categories', migration: '004_add_categories_and_cms.js' },
|
||||||
|
{ table: 'cms_pages', migration: '004_add_categories_and_cms.js' },
|
||||||
|
{ table: 'login_attempts', migration: '015_add_login_attempts_table.js' },
|
||||||
|
{ table: 'token_blacklist', migration: '017_add_token_revocation_tables.js' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const check of tableChecks) {
|
||||||
|
const exists = await db.schema.hasTable(check.table);
|
||||||
|
if (exists) {
|
||||||
|
const isApplied = await isMigrationApplied(check.migration);
|
||||||
|
if (!isApplied) {
|
||||||
|
await markMigrationAsApplied(check.migration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run a single migration safely
|
||||||
|
async function runMigrationSafely(filename) {
|
||||||
|
try {
|
||||||
|
const migrationPath = path.join(__dirname, filename);
|
||||||
|
const migration = require(migrationPath);
|
||||||
|
|
||||||
|
if (migration.up) {
|
||||||
|
console.log(`Running migration: ${filename}`);
|
||||||
|
|
||||||
|
// Run migration in a transaction if possible
|
||||||
|
if (db.client.config.client === 'pg') {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
await migration.up(trx);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await migration.up(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('migrations').insert({ filename });
|
||||||
|
console.log(`Migration ${filename} completed successfully`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Check if error is because schema already exists
|
||||||
|
if (error.code === '42P07' || // PostgreSQL: relation already exists
|
||||||
|
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
|
||||||
|
console.log(`Migration ${filename} - schema already exists, marking as applied`);
|
||||||
|
await markMigrationAsApplied(filename);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main migration runner
|
||||||
|
async function runMigrations() {
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
console.log('Starting production-safe database migrations...');
|
||||||
|
|
||||||
|
// Ensure database connection is ready
|
||||||
|
await db.raw('SELECT 1');
|
||||||
|
console.log('Database connection verified');
|
||||||
|
|
||||||
|
// Create migrations tracking table
|
||||||
|
await ensureMigrationsTable();
|
||||||
|
|
||||||
|
// Detect and mark existing schema
|
||||||
|
await detectExistingSchema();
|
||||||
|
|
||||||
|
// Get all migration files
|
||||||
|
const files = await fs.readdir(__dirname);
|
||||||
|
const migrationFiles = files
|
||||||
|
.filter(f => f.match(/^\d{3}_.*\.js$/) || f === 'init.js')
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Ensure init.js runs first
|
||||||
|
if (a === 'init.js') return -1;
|
||||||
|
if (b === 'init.js') return 1;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Run pending migrations
|
||||||
|
let pendingCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
for (const file of migrationFiles) {
|
||||||
|
const isApplied = await isMigrationApplied(file);
|
||||||
|
if (!isApplied) {
|
||||||
|
await runMigrationSafely(file);
|
||||||
|
pendingCount++;
|
||||||
|
} else {
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nMigration Summary:`);
|
||||||
|
console.log(`- Applied: ${pendingCount} migration(s)`);
|
||||||
|
console.log(`- Skipped: ${skippedCount} migration(s) (already applied)`);
|
||||||
|
console.log(`- Total: ${migrationFiles.length} migration(s)`);
|
||||||
|
console.log('\nAll migrations completed successfully');
|
||||||
|
|
||||||
|
// Close database connection
|
||||||
|
await db.destroy();
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Migration failed:', error.message);
|
||||||
|
console.error('Error details:', error);
|
||||||
|
|
||||||
|
// Close database connection on error
|
||||||
|
try {
|
||||||
|
await db.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay for database readiness in production
|
||||||
|
async function waitAndRun() {
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
console.log('Waiting 2 seconds for database readiness...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
await runMigrations();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only run if called directly
|
||||||
|
if (require.main === module) {
|
||||||
|
waitAndRun();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runMigrations };
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.7",
|
"version": "1.0.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.7",
|
"version": "1.0.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.7",
|
"version": "1.0.12",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
"dev": "nodemon server.js",
|
"dev": "nodemon server.js",
|
||||||
"migrate": "node migrations/run-migrations.js",
|
"migrate": "node migrations/run-migrations.js",
|
||||||
|
"migrate:safe": "node migrations/run-migrations-safe.js",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcrypt');
|
||||||
const { db } = require('../src/database/db');
|
const { db } = require('../src/database/db');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ done
|
|||||||
|
|
||||||
>&2 echo "PostgreSQL is up - executing command"
|
>&2 echo "PostgreSQL is up - executing command"
|
||||||
|
|
||||||
# Run migrations
|
# Run migrations (use safe runner in production)
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
npm run migrate
|
if [ "$NODE_ENV" = "production" ]; then
|
||||||
|
npm run migrate:safe
|
||||||
|
else
|
||||||
|
npm run migrate
|
||||||
|
fi
|
||||||
|
|
||||||
# Execute the main command
|
# Execute the main command
|
||||||
exec "$@"
|
exec "$@"
|
||||||
@@ -49,6 +49,8 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- VITE_API_URL=/api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
@@ -88,10 +90,15 @@ services:
|
|||||||
- POSTGRES_USER=${DB_USER:-picpeak}
|
- POSTGRES_USER=${DB_USER:-picpeak}
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
- POSTGRES_DB=${DB_NAME:-picpeak}
|
- POSTGRES_DB=${DB_NAME:-picpeak}
|
||||||
|
# Allow connections from any host with password authentication
|
||||||
|
- POSTGRES_HOST_AUTH_METHOD=scram-sha-256
|
||||||
|
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- picpeak
|
- picpeak
|
||||||
|
# Allow connections without SSL requirement from Docker network
|
||||||
|
command: postgres -c ssl=off
|
||||||
|
|
||||||
umami:
|
umami:
|
||||||
image: ghcr.io/umami-software/umami:postgresql-latest
|
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||||
@@ -110,4 +117,4 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Build stage with dynamic API URL
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
# Accept build args for API URL
|
||||||
|
ARG VITE_API_URL=/api
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --legacy-peer-deps
|
||||||
|
|
||||||
|
# Copy source files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Set environment variable for build
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# Remove default nginx config
|
||||||
|
RUN rm -rf /etc/nginx/conf.d/*
|
||||||
|
|
||||||
|
# Copy custom nginx config
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Copy built application from builder stage
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Create a script to inject runtime config
|
||||||
|
RUN cat > /usr/share/nginx/html/config.js << 'EOF'
|
||||||
|
window.__RUNTIME_CONFIG__ = {
|
||||||
|
API_URL: '/api'
|
||||||
|
};
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Set permissions
|
||||||
|
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||||
|
chown -R nginx:nginx /var/cache/nginx && \
|
||||||
|
chown -R nginx:nginx /var/log/nginx && \
|
||||||
|
touch /var/run/nginx.pid && \
|
||||||
|
chown -R nginx:nginx /var/run/nginx.pid
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost/health || exit 1
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER nginx
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.7",
|
"version": "1.0.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.7",
|
"version": "1.0.12",
|
||||||
"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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.7",
|
"version": "1.0.12",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export const GlobalThemeProvider: React.FC<GlobalThemeProviderProps> = ({ childr
|
|||||||
const { data: settingsData } = useQuery({
|
const { data: settingsData } = useQuery({
|
||||||
queryKey: ['global-theme-settings'],
|
queryKey: ['global-theme-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/api/public/settings');
|
const response = await api.get('/public/settings');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const MaintenanceMode: React.FC = () => {
|
|||||||
queryKey: ['public-settings-maintenance'],
|
queryKey: ['public-settings-maintenance'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/api/public/settings');
|
const response = await api.get('/public/settings');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Return empty object if settings can't be fetched
|
// Return empty object if settings can't be fetched
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
// Make a lightweight request to check maintenance status
|
// Make a lightweight request to check maintenance status
|
||||||
await api.get('/api/public/settings');
|
await api.get('/public/settings');
|
||||||
// If successful, maintenance mode is off
|
// If successful, maintenance mode is off
|
||||||
setMaintenanceMode(false);
|
setMaintenanceMode(false);
|
||||||
return { maintenance: false };
|
return { maintenance: false };
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post(`/api/admin/events/${eventId}/upload`, formData, {
|
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
|
||||||
// Don't set Content-Type header - axios will set it with the boundary
|
// Don't set Content-Type header - axios will set it with the boundary
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
if (progressEvent.total) {
|
if (progressEvent.total) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface SystemVersion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSystemVersion(): Promise<SystemVersion> {
|
async function fetchSystemVersion(): Promise<SystemVersion> {
|
||||||
const response = await api.get<SystemVersion>('/api/admin/system/version');
|
const response = await api.get<SystemVersion>('/admin/system/version');
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
const { data: settingsData } = useQuery({
|
const { data: settingsData } = useQuery({
|
||||||
queryKey: ['gallery-settings'],
|
queryKey: ['gallery-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/api/public/settings');
|
const response = await api.get('/public/settings');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
@@ -195,8 +195,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
if (watermarkEnabled) {
|
if (watermarkEnabled) {
|
||||||
photos = photos.map(photo => ({
|
photos = photos.map(photo => ({
|
||||||
...photo,
|
...photo,
|
||||||
url: `/api/gallery/${slug}/photo/${photo.id}`,
|
url: `/gallery/${slug}/photo/${photo.id}`,
|
||||||
thumbnail_url: `/api/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
|
thumbnail_url: `/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/gallery/${eventId}/upload`, formData, {
|
await api.post(`/gallery/${eventId}/upload`, formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const GalleryPage: React.FC = () => {
|
|||||||
const { data: settingsData } = useQuery({
|
const { data: settingsData } = useQuery({
|
||||||
queryKey: ['gallery-settings'],
|
queryKey: ['gallery-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/api/public/settings');
|
const response = await api.get('/public/settings');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
const { data: settingsData } = useQuery({
|
const { data: settingsData } = useQuery({
|
||||||
queryKey: ['admin-login-settings'],
|
queryKey: ['admin-login-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/api/public/settings');
|
const response = await api.get('/public/settings');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const LegalPage: React.FC = () => {
|
|||||||
const { data: settingsData } = useQuery({
|
const { data: settingsData } = useQuery({
|
||||||
queryKey: ['public-settings'],
|
queryKey: ['public-settings'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await api.get('/api/public/settings');
|
const response = await api.get('/public/settings');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||||
|
|||||||
@@ -66,13 +66,13 @@ export interface AnalyticsData {
|
|||||||
export const adminService = {
|
export const adminService = {
|
||||||
// Dashboard statistics
|
// Dashboard statistics
|
||||||
async getDashboardStats(): Promise<DashboardStats> {
|
async getDashboardStats(): Promise<DashboardStats> {
|
||||||
const response = await api.get<DashboardStats>('/api/admin/dashboard/stats');
|
const response = await api.get<DashboardStats>('/admin/dashboard/stats');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Recent activity
|
// Recent activity
|
||||||
async getRecentActivity(limit: number = 10): Promise<Activity[]> {
|
async getRecentActivity(limit: number = 10): Promise<Activity[]> {
|
||||||
const response = await api.get<Activity[]>('/api/admin/dashboard/activity', {
|
const response = await api.get<Activity[]>('/admin/dashboard/activity', {
|
||||||
params: { limit }
|
params: { limit }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -80,7 +80,7 @@ export const adminService = {
|
|||||||
|
|
||||||
// Analytics data
|
// Analytics data
|
||||||
async getAnalytics(days: number = 7): Promise<AnalyticsData> {
|
async getAnalytics(days: number = 7): Promise<AnalyticsData> {
|
||||||
const response = await api.get<AnalyticsData>('/api/admin/dashboard/analytics', {
|
const response = await api.get<AnalyticsData>('/admin/dashboard/analytics', {
|
||||||
params: { days }
|
params: { days }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -88,7 +88,7 @@ export const adminService = {
|
|||||||
|
|
||||||
// System health check
|
// System health check
|
||||||
async getSystemHealth(): Promise<SystemHealth> {
|
async getSystemHealth(): Promise<SystemHealth> {
|
||||||
const response = await api.get<SystemHealth>('/api/admin/dashboard/health');
|
const response = await api.get<SystemHealth>('/admin/dashboard/health');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -124,6 +124,6 @@ export const adminService = {
|
|||||||
|
|
||||||
// Change password
|
// Change password
|
||||||
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
|
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
|
||||||
await api.post('/api/admin/auth/change-password', data);
|
await api.post('/admin/auth/change-password', data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -46,7 +46,7 @@ export interface ArchivesResponse {
|
|||||||
export const archiveService = {
|
export const archiveService = {
|
||||||
// Get all archives with pagination
|
// Get all archives with pagination
|
||||||
async getArchives(page: number = 1, limit: number = 20): Promise<ArchivesResponse> {
|
async getArchives(page: number = 1, limit: number = 20): Promise<ArchivesResponse> {
|
||||||
const response = await api.get<ArchivesResponse>('/api/admin/archives', {
|
const response = await api.get<ArchivesResponse>('/admin/archives', {
|
||||||
params: { page, limit }
|
params: { page, limit }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -54,18 +54,18 @@ export const archiveService = {
|
|||||||
|
|
||||||
// Get single archive details
|
// Get single archive details
|
||||||
async getArchiveDetails(id: number): Promise<ArchiveDetails> {
|
async getArchiveDetails(id: number): Promise<ArchiveDetails> {
|
||||||
const response = await api.get<ArchiveDetails>(`/api/admin/archives/${id}`);
|
const response = await api.get<ArchiveDetails>(`/admin/archives/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Restore archive
|
// Restore archive
|
||||||
async restoreArchive(id: number): Promise<void> {
|
async restoreArchive(id: number): Promise<void> {
|
||||||
await api.post(`/api/admin/archives/${id}/restore`);
|
await api.post(`/admin/archives/${id}/restore`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Download archive
|
// Download archive
|
||||||
async downloadArchive(id: number, filename: string): Promise<void> {
|
async downloadArchive(id: number, filename: string): Promise<void> {
|
||||||
const response = await api.get(`/api/admin/archives/${id}/download`, {
|
const response = await api.get(`/admin/archives/${id}/download`, {
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export const archiveService = {
|
|||||||
|
|
||||||
// Delete archive permanently
|
// Delete archive permanently
|
||||||
async deleteArchive(id: number): Promise<void> {
|
async deleteArchive(id: number): Promise<void> {
|
||||||
await api.delete(`/api/admin/archives/${id}`);
|
await api.delete(`/admin/archives/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Format bytes to human readable
|
// Format bytes to human readable
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const authService = {
|
|||||||
// Admin authentication
|
// Admin authentication
|
||||||
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<LoginResponse> {
|
async adminLogin(credentials: { email: string; password: string; recaptchaToken?: string | null }): Promise<LoginResponse> {
|
||||||
// Backend expects 'username' field, but we accept email
|
// Backend expects 'username' field, but we accept email
|
||||||
const response = await api.post<LoginResponse>('/api/auth/admin/login', {
|
const response = await api.post<LoginResponse>('/auth/admin/login', {
|
||||||
username: credentials.email,
|
username: credentials.email,
|
||||||
password: credentials.password,
|
password: credentials.password,
|
||||||
recaptchaToken: credentials.recaptchaToken
|
recaptchaToken: credentials.recaptchaToken
|
||||||
@@ -22,7 +22,7 @@ export const authService = {
|
|||||||
|
|
||||||
// Gallery authentication
|
// Gallery authentication
|
||||||
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
||||||
const response = await api.post<GalleryAuthResponse>('/api/auth/gallery/verify', {
|
const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
|
||||||
slug,
|
slug,
|
||||||
password,
|
password,
|
||||||
recaptchaToken
|
recaptchaToken
|
||||||
|
|||||||
@@ -19,30 +19,30 @@ export interface CreateCategoryData {
|
|||||||
export const categoriesService = {
|
export const categoriesService = {
|
||||||
// Get all global categories
|
// Get all global categories
|
||||||
async getGlobalCategories(): Promise<PhotoCategory[]> {
|
async getGlobalCategories(): Promise<PhotoCategory[]> {
|
||||||
const response = await api.get<PhotoCategory[]>('/api/admin/categories/global');
|
const response = await api.get<PhotoCategory[]>('/admin/categories/global');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get categories for a specific event (global + event-specific)
|
// Get categories for a specific event (global + event-specific)
|
||||||
async getEventCategories(eventId: number): Promise<PhotoCategory[]> {
|
async getEventCategories(eventId: number): Promise<PhotoCategory[]> {
|
||||||
const response = await api.get<PhotoCategory[]>(`/api/admin/categories/event/${eventId}`);
|
const response = await api.get<PhotoCategory[]>(`/admin/categories/event/${eventId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create a new category
|
// Create a new category
|
||||||
async createCategory(data: CreateCategoryData): Promise<PhotoCategory> {
|
async createCategory(data: CreateCategoryData): Promise<PhotoCategory> {
|
||||||
const response = await api.post<PhotoCategory>('/api/admin/categories', data);
|
const response = await api.post<PhotoCategory>('/admin/categories', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update a category
|
// Update a category
|
||||||
async updateCategory(id: number, name: string): Promise<PhotoCategory> {
|
async updateCategory(id: number, name: string): Promise<PhotoCategory> {
|
||||||
const response = await api.put<PhotoCategory>(`/api/admin/categories/${id}`, { name });
|
const response = await api.put<PhotoCategory>(`/admin/categories/${id}`, { name });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete a category
|
// Delete a category
|
||||||
async deleteCategory(id: number): Promise<void> {
|
async deleteCategory(id: number): Promise<void> {
|
||||||
await api.delete(`/api/admin/categories/${id}`);
|
await api.delete(`/admin/categories/${id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -13,25 +13,25 @@ export interface CMSPage {
|
|||||||
export const cmsService = {
|
export const cmsService = {
|
||||||
// Get all CMS pages
|
// Get all CMS pages
|
||||||
async getPages(): Promise<CMSPage[]> {
|
async getPages(): Promise<CMSPage[]> {
|
||||||
const response = await api.get<CMSPage[]>('/api/admin/cms/pages');
|
const response = await api.get<CMSPage[]>('/admin/cms/pages');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get a single CMS page
|
// Get a single CMS page
|
||||||
async getPage(slug: string): Promise<CMSPage> {
|
async getPage(slug: string): Promise<CMSPage> {
|
||||||
const response = await api.get<CMSPage>(`/api/admin/cms/pages/${slug}`);
|
const response = await api.get<CMSPage>(`/admin/cms/pages/${slug}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update a CMS page
|
// Update a CMS page
|
||||||
async updatePage(slug: string, data: Partial<CMSPage>): Promise<CMSPage> {
|
async updatePage(slug: string, data: Partial<CMSPage>): Promise<CMSPage> {
|
||||||
const response = await api.put<CMSPage>(`/api/admin/cms/pages/${slug}`, data);
|
const response = await api.put<CMSPage>(`/admin/cms/pages/${slug}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get public CMS page (no auth required)
|
// Get public CMS page (no auth required)
|
||||||
async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> {
|
async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> {
|
||||||
const response = await api.get<{ title: string; content: string }>(`/api/public/pages/${slug}`, {
|
const response = await api.get<{ title: string; content: string }>(`/public/pages/${slug}`, {
|
||||||
params: { lang }
|
params: { lang }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -35,41 +35,41 @@ export interface EmailPreview {
|
|||||||
export const emailService = {
|
export const emailService = {
|
||||||
// Get email configuration
|
// Get email configuration
|
||||||
async getConfig(): Promise<EmailConfig> {
|
async getConfig(): Promise<EmailConfig> {
|
||||||
const response = await api.get<EmailConfig>('/api/admin/email/config');
|
const response = await api.get<EmailConfig>('/admin/email/config');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update email configuration
|
// Update email configuration
|
||||||
async updateConfig(config: EmailConfig): Promise<void> {
|
async updateConfig(config: EmailConfig): Promise<void> {
|
||||||
await api.post('/api/admin/email/config', config);
|
await api.post('/admin/email/config', config);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Test email configuration
|
// Test email configuration
|
||||||
async testEmail(testEmail: string): Promise<void> {
|
async testEmail(testEmail: string): Promise<void> {
|
||||||
await api.post('/api/admin/email/test', { test_email: testEmail });
|
await api.post('/admin/email/test', { test_email: testEmail });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get all email templates
|
// Get all email templates
|
||||||
async getTemplates(): Promise<EmailTemplate[]> {
|
async getTemplates(): Promise<EmailTemplate[]> {
|
||||||
const response = await api.get<EmailTemplate[]>('/api/admin/email/templates');
|
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get single template
|
// Get single template
|
||||||
async getTemplate(key: string): Promise<EmailTemplate> {
|
async getTemplate(key: string): Promise<EmailTemplate> {
|
||||||
const response = await api.get<EmailTemplate>(`/api/admin/email/templates/${key}`);
|
const response = await api.get<EmailTemplate>(`/admin/email/templates/${key}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update email template
|
// Update email template
|
||||||
async updateTemplate(key: string, template: Partial<EmailTemplate>): Promise<void> {
|
async updateTemplate(key: string, template: Partial<EmailTemplate>): Promise<void> {
|
||||||
await api.put(`/api/admin/email/templates/${key}`, template);
|
await api.put(`/admin/email/templates/${key}`, template);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Preview email template
|
// Preview email template
|
||||||
async previewTemplate(key: string, previewData: Record<string, string>, language: 'en' | 'de' = 'en'): Promise<EmailPreview> {
|
async previewTemplate(key: string, previewData: Record<string, string>, language: 'en' | 'de' = 'en'): Promise<EmailPreview> {
|
||||||
const response = await api.post<EmailPreview>(
|
const response = await api.post<EmailPreview>(
|
||||||
`/api/admin/email/templates/${key}/preview`,
|
`/admin/email/templates/${key}/preview`,
|
||||||
{ preview_data: previewData, language }
|
{ preview_data: previewData, language }
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -53,36 +53,36 @@ export const eventsService = {
|
|||||||
params.append('status', status);
|
params.append('status', status);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get<EventsListResponse>(`/api/admin/events?${params}`);
|
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get single event details (admin)
|
// Get single event details (admin)
|
||||||
async getEvent(id: number): Promise<Event> {
|
async getEvent(id: number): Promise<Event> {
|
||||||
const response = await api.get<Event>(`/api/admin/events/${id}`);
|
const response = await api.get<Event>(`/admin/events/${id}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create new event (admin)
|
// Create new event (admin)
|
||||||
async createEvent(data: CreateEventData): Promise<Event> {
|
async createEvent(data: CreateEventData): Promise<Event> {
|
||||||
const response = await api.post<Event>('/api/admin/events', data);
|
const response = await api.post<Event>('/admin/events', data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update event (admin)
|
// Update event (admin)
|
||||||
async updateEvent(id: number, data: UpdateEventData): Promise<Event> {
|
async updateEvent(id: number, data: UpdateEventData): Promise<Event> {
|
||||||
const response = await api.put<Event>(`/api/admin/events/${id}`, data);
|
const response = await api.put<Event>(`/admin/events/${id}`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Delete/deactivate event (admin)
|
// Delete/deactivate event (admin)
|
||||||
async deleteEvent(id: number): Promise<void> {
|
async deleteEvent(id: number): Promise<void> {
|
||||||
await api.delete(`/api/admin/events/${id}`);
|
await api.delete(`/admin/events/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Force archive event (admin)
|
// Force archive event (admin)
|
||||||
async archiveEvent(id: number): Promise<void> {
|
async archiveEvent(id: number): Promise<void> {
|
||||||
await api.post(`/api/admin/events/${id}/archive`);
|
await api.post(`/admin/events/${id}/archive`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Bulk archive events (admin)
|
// Bulk archive events (admin)
|
||||||
@@ -93,7 +93,7 @@ export const eventsService = {
|
|||||||
failed: Array<{ id: number; name: string; error: string }>;
|
failed: Array<{ id: number; name: string; error: string }>;
|
||||||
};
|
};
|
||||||
}> {
|
}> {
|
||||||
const response = await api.post('/api/admin/events/bulk-archive', {
|
const response = await api.post('/admin/events/bulk-archive', {
|
||||||
eventIds,
|
eventIds,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -101,7 +101,7 @@ export const eventsService = {
|
|||||||
|
|
||||||
// Extend event expiration (admin)
|
// Extend event expiration (admin)
|
||||||
async extendExpiration(id: number, days: number): Promise<Event> {
|
async extendExpiration(id: number, days: number): Promise<Event> {
|
||||||
const response = await api.post<Event>(`/api/events/${id}/extend`, {
|
const response = await api.post<Event>(`/events/${id}/extend`, {
|
||||||
days,
|
days,
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -109,13 +109,13 @@ export const eventsService = {
|
|||||||
|
|
||||||
// Get event categories
|
// Get event categories
|
||||||
async getEventCategories(eventId: number): Promise<Array<{ id: number; name: string; slug: string }>> {
|
async getEventCategories(eventId: number): Promise<Array<{ id: number; name: string; slug: string }>> {
|
||||||
const response = await api.get(`/api/admin/categories/event/${eventId}`);
|
const response = await api.get(`/admin/categories/event/${eventId}`);
|
||||||
return response.data || [];
|
return response.data || [];
|
||||||
},
|
},
|
||||||
|
|
||||||
// Reset event password
|
// Reset event password
|
||||||
async resetPassword(eventId: number, sendEmail: boolean = true): Promise<{ message: string; newPassword: string; emailSent: boolean }> {
|
async resetPassword(eventId: number, sendEmail: boolean = true): Promise<{ message: string; newPassword: string; emailSent: boolean }> {
|
||||||
const response = await api.post(`/api/admin/events/${eventId}/reset-password`, { sendEmail });
|
const response = await api.post(`/admin/events/${eventId}/reset-password`, { sendEmail });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -4,26 +4,26 @@ import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
|||||||
export const galleryService = {
|
export const galleryService = {
|
||||||
// Verify share token
|
// Verify share token
|
||||||
async verifyToken(slug: string, token: string): Promise<{ valid: boolean }> {
|
async verifyToken(slug: string, token: string): Promise<{ valid: boolean }> {
|
||||||
const response = await api.get<{ valid: boolean }>(`/api/gallery/${slug}/verify-token/${token}`);
|
const response = await api.get<{ valid: boolean }>(`/gallery/${slug}/verify-token/${token}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get basic gallery info (no auth required)
|
// Get basic gallery info (no auth required)
|
||||||
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
|
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
|
||||||
const params = token ? { token } : {};
|
const params = token ? { token } : {};
|
||||||
const response = await api.get<GalleryInfo>(`/api/gallery/${slug}/info`, { params });
|
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get gallery photos (requires auth)
|
// Get gallery photos (requires auth)
|
||||||
async getGalleryPhotos(slug: string): Promise<GalleryData> {
|
async getGalleryPhotos(slug: string): Promise<GalleryData> {
|
||||||
const response = await api.get<GalleryData>(`/api/gallery/${slug}/photos`);
|
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Download single photo
|
// Download single photo
|
||||||
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
|
||||||
const response = await api.get(`/api/gallery/${slug}/download/${photoId}`, {
|
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ export const galleryService = {
|
|||||||
|
|
||||||
// Download all photos as ZIP
|
// Download all photos as ZIP
|
||||||
async downloadAllPhotos(slug: string): Promise<void> {
|
async downloadAllPhotos(slug: string): Promise<void> {
|
||||||
const response = await api.get(`/api/gallery/${slug}/download-all`, {
|
const response = await api.get(`/gallery/${slug}/download-all`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ export const galleryService = {
|
|||||||
|
|
||||||
// Get gallery statistics
|
// Get gallery statistics
|
||||||
async getGalleryStats(slug: string): Promise<GalleryStats> {
|
async getGalleryStats(slug: string): Promise<GalleryStats> {
|
||||||
const response = await api.get<GalleryStats>(`/api/gallery/${slug}/stats`);
|
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -22,7 +22,7 @@ export interface NotificationsResponse {
|
|||||||
export const notificationsService = {
|
export const notificationsService = {
|
||||||
// Get notifications
|
// Get notifications
|
||||||
async getNotifications(includeRead: boolean = false, limit: number = 20): Promise<NotificationsResponse> {
|
async getNotifications(includeRead: boolean = false, limit: number = 20): Promise<NotificationsResponse> {
|
||||||
const response = await api.get('/api/admin/notifications', {
|
const response = await api.get('/admin/notifications', {
|
||||||
params: { includeRead, limit }
|
params: { includeRead, limit }
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -30,17 +30,17 @@ export const notificationsService = {
|
|||||||
|
|
||||||
// Mark single notification as read
|
// Mark single notification as read
|
||||||
async markAsRead(notificationId: number): Promise<void> {
|
async markAsRead(notificationId: number): Promise<void> {
|
||||||
await api.put(`/api/admin/notifications/${notificationId}/read`);
|
await api.put(`/admin/notifications/${notificationId}/read`);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Mark all notifications as read
|
// Mark all notifications as read
|
||||||
async markAllAsRead(): Promise<void> {
|
async markAllAsRead(): Promise<void> {
|
||||||
await api.put('/api/admin/notifications/read-all');
|
await api.put('/admin/notifications/read-all');
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clear old notifications
|
// Clear old notifications
|
||||||
async clearOldNotifications(): Promise<{ deletedCount: number }> {
|
async clearOldNotifications(): Promise<{ deletedCount: number }> {
|
||||||
const response = await api.delete('/api/admin/notifications/clear-old');
|
const response = await api.delete('/admin/notifications/clear-old');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class PhotosService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queryString = params.toString();
|
const queryString = params.toString();
|
||||||
const url = `/api/admin/events/${eventId}/photos${queryString ? `?${queryString}` : ''}`;
|
const url = `/admin/events/${eventId}/photos${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
const response = await api.get(url);
|
const response = await api.get(url);
|
||||||
|
|
||||||
@@ -48,26 +48,26 @@ class PhotosService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deletePhoto(eventId: number, photoId: number): Promise<void> {
|
async deletePhoto(eventId: number, photoId: number): Promise<void> {
|
||||||
await api.delete(`/api/admin/events/${eventId}/photos/${photoId}`);
|
await api.delete(`/admin/events/${eventId}/photos/${photoId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deletePhotos(eventId: number, photoIds: number[]): Promise<void> {
|
async deletePhotos(eventId: number, photoIds: number[]): Promise<void> {
|
||||||
await api.post(`/api/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
|
await api.post(`/admin/events/${eventId}/photos/bulk-delete`, { photoIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise<void> {
|
async updatePhotoCategory(eventId: number, photoId: number, categoryId: number | null): Promise<void> {
|
||||||
await api.patch(`/api/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
|
await api.patch(`/admin/events/${eventId}/photos/${photoId}`, { category_id: categoryId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
|
async updatePhotosCategory(eventId: number, photoIds: number[], categoryId: number | null): Promise<void> {
|
||||||
await api.post(`/api/admin/events/${eventId}/photos/bulk-update`, {
|
await api.post(`/admin/events/${eventId}/photos/bulk-update`, {
|
||||||
photoIds,
|
photoIds,
|
||||||
updates: { category_id: categoryId }
|
updates: { category_id: categoryId }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadPhoto(eventId: number, photoId: number, filename: string): Promise<void> {
|
async downloadPhoto(eventId: number, photoId: number, filename: string): Promise<void> {
|
||||||
const response = await api.get(`/api/admin/events/${eventId}/photos/${photoId}/download`, {
|
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
|
||||||
responseType: 'blob'
|
responseType: 'blob'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -79,19 +79,19 @@ export interface SystemStatus {
|
|||||||
export const settingsService = {
|
export const settingsService = {
|
||||||
// Get all settings
|
// Get all settings
|
||||||
async getAllSettings(): Promise<Record<string, any>> {
|
async getAllSettings(): Promise<Record<string, any>> {
|
||||||
const response = await api.get<Record<string, any>>('/api/admin/settings');
|
const response = await api.get<Record<string, any>>('/admin/settings');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get settings by type
|
// Get settings by type
|
||||||
async getSettingsByType(type: 'branding' | 'theme' | 'general'): Promise<Record<string, any>> {
|
async getSettingsByType(type: 'branding' | 'theme' | 'general'): Promise<Record<string, any>> {
|
||||||
const response = await api.get<Record<string, any>>(`/api/admin/settings/${type}`);
|
const response = await api.get<Record<string, any>>(`/admin/settings/${type}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update branding settings
|
// Update branding settings
|
||||||
async updateBranding(settings: BrandingSettings): Promise<void> {
|
async updateBranding(settings: BrandingSettings): Promise<void> {
|
||||||
await api.put('/api/admin/settings/branding', settings);
|
await api.put('/admin/settings/branding', settings);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Upload logo
|
// Upload logo
|
||||||
@@ -150,23 +150,23 @@ export const settingsService = {
|
|||||||
|
|
||||||
// Update theme settings
|
// Update theme settings
|
||||||
async updateTheme(settings: ThemeSettings): Promise<void> {
|
async updateTheme(settings: ThemeSettings): Promise<void> {
|
||||||
await api.put('/api/admin/settings/theme', settings);
|
await api.put('/admin/settings/theme', settings);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update multiple settings at once
|
// Update multiple settings at once
|
||||||
async updateSettings(settings: Record<string, any>): Promise<void> {
|
async updateSettings(settings: Record<string, any>): Promise<void> {
|
||||||
await api.put('/api/admin/settings/general', settings);
|
await api.put('/admin/settings/general', settings);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get storage information
|
// Get storage information
|
||||||
async getStorageInfo(): Promise<StorageInfo> {
|
async getStorageInfo(): Promise<StorageInfo> {
|
||||||
const response = await api.get<StorageInfo>('/api/admin/settings/storage/info');
|
const response = await api.get<StorageInfo>('/admin/settings/storage/info');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get system status
|
// Get system status
|
||||||
async getSystemStatus(): Promise<SystemStatus> {
|
async getSystemStatus(): Promise<SystemStatus> {
|
||||||
const response = await api.get<SystemStatus>('/api/admin/system/status');
|
const response = await api.get<SystemStatus>('/admin/system/status');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface PhotoUrlOptions {
|
|||||||
export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string {
|
export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string {
|
||||||
if (watermarkEnabled && token) {
|
if (watermarkEnabled && token) {
|
||||||
// Use the watermarked photo endpoint
|
// Use the watermarked photo endpoint
|
||||||
return `/api/gallery/${slug}/photo/${photo.id}`;
|
return `/gallery/${slug}/photo/${photo.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the static photo URL
|
// Use the static photo URL
|
||||||
@@ -24,5 +24,5 @@ export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: Ph
|
|||||||
* Get the download URL for a photo
|
* Get the download URL for a photo
|
||||||
*/
|
*/
|
||||||
export function getPhotoDownloadUrl(slug: string, photoId: number): string {
|
export function getPhotoDownloadUrl(slug: string, photoId: number): string {
|
||||||
return `/api/gallery/${slug}/download/${photoId}`;
|
return `/gallery/${slug}/download/${photoId}`;
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user