diff --git a/.claude-flow/metrics/system-metrics.json b/.claude-flow/metrics/system-metrics.json new file mode 100644 index 0000000..f0ad623 --- /dev/null +++ b/.claude-flow/metrics/system-metrics.json @@ -0,0 +1,434 @@ +[ + { + "timestamp": 1756069288374, + "memoryTotal": 25769803776, + "memoryUsed": 24624726016, + "memoryFree": 1145077760, + "memoryUsagePercent": 95.55651346842447, + "memoryEfficiency": 4.44348653157553, + "cpuCount": 14, + "cpuLoad": 0.20016043526785715, + "platform": "darwin", + "uptime": 4755441 + }, + { + "timestamp": 1756069318375, + "memoryTotal": 25769803776, + "memoryUsed": 25187663872, + "memoryFree": 582139904, + "memoryUsagePercent": 97.74099985758463, + "memoryEfficiency": 2.259000142415374, + "cpuCount": 14, + "cpuLoad": 0.17435128348214285, + "platform": "darwin", + "uptime": 4755471 + }, + { + "timestamp": 1756069348375, + "memoryTotal": 25769803776, + "memoryUsed": 25293045760, + "memoryFree": 476758016, + "memoryUsagePercent": 98.14993540445963, + "memoryEfficiency": 1.850064595540374, + "cpuCount": 14, + "cpuLoad": 0.18059430803571427, + "platform": "darwin", + "uptime": 4755501 + }, + { + "timestamp": 1756069378375, + "memoryTotal": 25769803776, + "memoryUsed": 25323126784, + "memoryFree": 446676992, + "memoryUsagePercent": 98.26666514078775, + "memoryEfficiency": 1.733334859212249, + "cpuCount": 14, + "cpuLoad": 0.13584681919642858, + "platform": "darwin", + "uptime": 4755531 + }, + { + "timestamp": 1756069408378, + "memoryTotal": 25769803776, + "memoryUsed": 25281445888, + "memoryFree": 488357888, + "memoryUsagePercent": 98.10492197672525, + "memoryEfficiency": 1.895078023274749, + "cpuCount": 14, + "cpuLoad": 0.13201032366071427, + "platform": "darwin", + "uptime": 4755561 + }, + { + "timestamp": 1756069438379, + "memoryTotal": 25769803776, + "memoryUsed": 25302237184, + "memoryFree": 467566592, + "memoryUsagePercent": 98.18560282389322, + "memoryEfficiency": 1.8143971761067803, + "cpuCount": 14, + "cpuLoad": 0.15656389508928573, + "platform": "darwin", + "uptime": 4755591 + }, + { + "timestamp": 1756069468380, + "memoryTotal": 25769803776, + "memoryUsed": 25389481984, + "memoryFree": 380321792, + "memoryUsagePercent": 98.52415720621744, + "memoryEfficiency": 1.4758427937825616, + "cpuCount": 14, + "cpuLoad": 0.190185546875, + "platform": "darwin", + "uptime": 4755621 + }, + { + "timestamp": 1756069498381, + "memoryTotal": 25769803776, + "memoryUsed": 25366888448, + "memoryFree": 402915328, + "memoryUsagePercent": 98.43648274739584, + "memoryEfficiency": 1.5635172526041572, + "cpuCount": 14, + "cpuLoad": 0.16587611607142858, + "platform": "darwin", + "uptime": 4755651 + }, + { + "timestamp": 1756069528382, + "memoryTotal": 25769803776, + "memoryUsed": 25608175616, + "memoryFree": 161628160, + "memoryUsagePercent": 99.3728001912435, + "memoryEfficiency": 0.6271998087565009, + "cpuCount": 14, + "cpuLoad": 0.17508370535714285, + "platform": "darwin", + "uptime": 4755681 + }, + { + "timestamp": 1756069558384, + "memoryTotal": 25769803776, + "memoryUsed": 25126961152, + "memoryFree": 642842624, + "memoryUsagePercent": 97.50544230143228, + "memoryEfficiency": 2.494557698567718, + "cpuCount": 14, + "cpuLoad": 0.17497907366071427, + "platform": "darwin", + "uptime": 4755711 + }, + { + "timestamp": 1756069588385, + "memoryTotal": 25769803776, + "memoryUsed": 25449037824, + "memoryFree": 320765952, + "memoryUsagePercent": 98.75526428222656, + "memoryEfficiency": 1.2447357177734375, + "cpuCount": 14, + "cpuLoad": 0.149169921875, + "platform": "darwin", + "uptime": 4755741 + }, + { + "timestamp": 1756069618386, + "memoryTotal": 25769803776, + "memoryUsed": 25644417024, + "memoryFree": 125386752, + "memoryUsagePercent": 99.51343536376953, + "memoryEfficiency": 0.48656463623046875, + "cpuCount": 14, + "cpuLoad": 0.16245814732142858, + "platform": "darwin", + "uptime": 4755771 + }, + { + "timestamp": 1756069648386, + "memoryTotal": 25769803776, + "memoryUsed": 25567952896, + "memoryFree": 201850880, + "memoryUsagePercent": 99.21671549479166, + "memoryEfficiency": 0.7832845052083428, + "cpuCount": 14, + "cpuLoad": 0.17110770089285715, + "platform": "darwin", + "uptime": 4755801 + }, + { + "timestamp": 1756069678387, + "memoryTotal": 25769803776, + "memoryUsed": 25535414272, + "memoryFree": 234389504, + "memoryUsagePercent": 99.09044901529947, + "memoryEfficiency": 0.9095509847005303, + "cpuCount": 14, + "cpuLoad": 0.17755998883928573, + "platform": "darwin", + "uptime": 4755831 + }, + { + "timestamp": 1756069708388, + "memoryTotal": 25769803776, + "memoryUsed": 25529073664, + "memoryFree": 240730112, + "memoryUsagePercent": 99.06584421793619, + "memoryEfficiency": 0.9341557820638116, + "cpuCount": 14, + "cpuLoad": 0.18059430803571427, + "platform": "darwin", + "uptime": 4755861 + }, + { + "timestamp": 1756069738390, + "memoryTotal": 25769803776, + "memoryUsed": 25574866944, + "memoryFree": 194936832, + "memoryUsagePercent": 99.24354553222656, + "memoryEfficiency": 0.7564544677734375, + "cpuCount": 14, + "cpuLoad": 0.18073381696428573, + "platform": "darwin", + "uptime": 4755891 + }, + { + "timestamp": 1756069768392, + "memoryTotal": 25769803776, + "memoryUsed": 25546063872, + "memoryFree": 223739904, + "memoryUsagePercent": 99.13177490234375, + "memoryEfficiency": 0.86822509765625, + "cpuCount": 14, + "cpuLoad": 0.18244280133928573, + "platform": "darwin", + "uptime": 4755921 + }, + { + "timestamp": 1756069798393, + "memoryTotal": 25769803776, + "memoryUsed": 25663946752, + "memoryFree": 105857024, + "memoryUsagePercent": 99.58922068277994, + "memoryEfficiency": 0.41077931722006156, + "cpuCount": 14, + "cpuLoad": 0.15778459821428573, + "platform": "darwin", + "uptime": 4755951 + }, + { + "timestamp": 1756069828394, + "memoryTotal": 25769803776, + "memoryUsed": 25486589952, + "memoryFree": 283213824, + "memoryUsagePercent": 98.90098571777344, + "memoryEfficiency": 1.0990142822265625, + "cpuCount": 14, + "cpuLoad": 0.19245256696428573, + "platform": "darwin", + "uptime": 4755981 + }, + { + "timestamp": 1756069858395, + "memoryTotal": 25769803776, + "memoryUsed": 25451380736, + "memoryFree": 318423040, + "memoryUsagePercent": 98.76435597737631, + "memoryEfficiency": 1.2356440226236884, + "cpuCount": 14, + "cpuLoad": 0.17762974330357142, + "platform": "darwin", + "uptime": 4756011 + }, + { + "timestamp": 1756069888397, + "memoryTotal": 25769803776, + "memoryUsed": 25088442368, + "memoryFree": 681361408, + "memoryUsagePercent": 97.35596974690756, + "memoryEfficiency": 2.6440302530924384, + "cpuCount": 14, + "cpuLoad": 0.159912109375, + "platform": "darwin", + "uptime": 4756041 + }, + { + "timestamp": 1756069918398, + "memoryTotal": 25769803776, + "memoryUsed": 25246859264, + "memoryFree": 522944512, + "memoryUsagePercent": 97.97070821126303, + "memoryEfficiency": 2.0292917887369697, + "cpuCount": 14, + "cpuLoad": 0.12831333705357142, + "platform": "darwin", + "uptime": 4756071 + }, + { + "timestamp": 1756069948399, + "memoryTotal": 25769803776, + "memoryUsed": 25182126080, + "memoryFree": 587677696, + "memoryUsagePercent": 97.71951039632162, + "memoryEfficiency": 2.280489603678376, + "cpuCount": 14, + "cpuLoad": 0.1826171875, + "platform": "darwin", + "uptime": 4756101 + }, + { + "timestamp": 1756069978401, + "memoryTotal": 25769803776, + "memoryUsed": 25322684416, + "memoryFree": 447119360, + "memoryUsagePercent": 98.26494852701822, + "memoryEfficiency": 1.7350514729817803, + "cpuCount": 14, + "cpuLoad": 0.18136160714285715, + "platform": "darwin", + "uptime": 4756131 + }, + { + "timestamp": 1756070008402, + "memoryTotal": 25769803776, + "memoryUsed": 25357484032, + "memoryFree": 412319744, + "memoryUsagePercent": 98.39998881022134, + "memoryEfficiency": 1.6000111897786553, + "cpuCount": 14, + "cpuLoad": 0.18488420758928573, + "platform": "darwin", + "uptime": 4756161 + }, + { + "timestamp": 1756070038402, + "memoryTotal": 25769803776, + "memoryUsed": 25503760384, + "memoryFree": 266043392, + "memoryUsagePercent": 98.96761576334634, + "memoryEfficiency": 1.0323842366536553, + "cpuCount": 14, + "cpuLoad": 0.18415178571428573, + "platform": "darwin", + "uptime": 4756191 + }, + { + "timestamp": 1756070068403, + "memoryTotal": 25769803776, + "memoryUsed": 25171984384, + "memoryFree": 597819392, + "memoryUsagePercent": 97.68015543619791, + "memoryEfficiency": 2.319844563802093, + "cpuCount": 14, + "cpuLoad": 0.22530691964285715, + "platform": "darwin", + "uptime": 4756221 + }, + { + "timestamp": 1756070098405, + "memoryTotal": 25769803776, + "memoryUsed": 25521356800, + "memoryFree": 248446976, + "memoryUsagePercent": 99.03589884440103, + "memoryEfficiency": 0.9641011555989678, + "cpuCount": 14, + "cpuLoad": 0.20929827008928573, + "platform": "darwin", + "uptime": 4756251 + }, + { + "timestamp": 1756070128388, + "memoryTotal": 25769803776, + "memoryUsed": 25556877312, + "memoryFree": 212926464, + "memoryUsagePercent": 99.17373657226562, + "memoryEfficiency": 0.826263427734375, + "cpuCount": 14, + "cpuLoad": 0.19838169642857142, + "platform": "darwin", + "uptime": 4756281 + }, + { + "timestamp": 1756070158382, + "memoryTotal": 25769803776, + "memoryUsed": 25423691776, + "memoryFree": 346112000, + "memoryUsagePercent": 98.65690867106119, + "memoryEfficiency": 1.3430913289388116, + "cpuCount": 14, + "cpuLoad": 0.23814174107142858, + "platform": "darwin", + "uptime": 4756311 + }, + { + "timestamp": 1756070188381, + "memoryTotal": 25769803776, + "memoryUsed": 24874237952, + "memoryFree": 895565824, + "memoryUsagePercent": 96.52474721272787, + "memoryEfficiency": 3.475252787272126, + "cpuCount": 14, + "cpuLoad": 0.22272600446428573, + "platform": "darwin", + "uptime": 4756341 + }, + { + "timestamp": 1756070218381, + "memoryTotal": 25769803776, + "memoryUsed": 24984682496, + "memoryFree": 785121280, + "memoryUsagePercent": 96.95332845052084, + "memoryEfficiency": 3.046671549479157, + "cpuCount": 14, + "cpuLoad": 0.23807198660714285, + "platform": "darwin", + "uptime": 4756371 + }, + { + "timestamp": 1756070248383, + "memoryTotal": 25769803776, + "memoryUsed": 25002754048, + "memoryFree": 767049728, + "memoryUsagePercent": 97.02345530192056, + "memoryEfficiency": 2.9765446980794366, + "cpuCount": 14, + "cpuLoad": 0.22795758928571427, + "platform": "darwin", + "uptime": 4756401 + }, + { + "timestamp": 1756070278384, + "memoryTotal": 25769803776, + "memoryUsed": 25581617152, + "memoryFree": 188186624, + "memoryUsagePercent": 99.26973978678384, + "memoryEfficiency": 0.7302602132161553, + "cpuCount": 14, + "cpuLoad": 0.19461495535714285, + "platform": "darwin", + "uptime": 4756431 + }, + { + "timestamp": 1756070308384, + "memoryTotal": 25769803776, + "memoryUsed": 25362219008, + "memoryFree": 407584768, + "memoryUsagePercent": 98.41836293538412, + "memoryEfficiency": 1.581637064615876, + "cpuCount": 14, + "cpuLoad": 0.23835100446428573, + "platform": "darwin", + "uptime": 4756461 + }, + { + "timestamp": 1756070338385, + "memoryTotal": 25769803776, + "memoryUsed": 25515917312, + "memoryFree": 253886464, + "memoryUsagePercent": 99.01479085286459, + "memoryEfficiency": 0.9852091471354072, + "cpuCount": 14, + "cpuLoad": 0.189208984375, + "platform": "darwin", + "uptime": 4756491 + } +] \ No newline at end of file diff --git a/.playwright-mcp/admin-photos-no-indicators.png b/.playwright-mcp/admin-photos-no-indicators.png new file mode 100644 index 0000000..4b44751 Binary files /dev/null and b/.playwright-mcp/admin-photos-no-indicators.png differ diff --git a/.playwright-mcp/admin-photos-with-indicators.png b/.playwright-mcp/admin-photos-with-indicators.png new file mode 100644 index 0000000..4b44751 Binary files /dev/null and b/.playwright-mcp/admin-photos-with-indicators.png differ diff --git a/.playwright-mcp/edit-mode-buttons-fixed.png b/.playwright-mcp/edit-mode-buttons-fixed.png new file mode 100644 index 0000000..c46fb80 Binary files /dev/null and b/.playwright-mcp/edit-mode-buttons-fixed.png differ diff --git a/.playwright-mcp/edit-mode-buttons-issue.png b/.playwright-mcp/edit-mode-buttons-issue.png new file mode 100644 index 0000000..f8ba414 Binary files /dev/null and b/.playwright-mcp/edit-mode-buttons-issue.png differ diff --git a/.playwright-mcp/event-detail-edit-mode-buttons.png b/.playwright-mcp/event-detail-edit-mode-buttons.png new file mode 100644 index 0000000..02fdad7 Binary files /dev/null and b/.playwright-mcp/event-detail-edit-mode-buttons.png differ diff --git a/.playwright-mcp/event-edit-header-full.png b/.playwright-mcp/event-edit-header-full.png new file mode 100644 index 0000000..f2b07fc Binary files /dev/null and b/.playwright-mcp/event-edit-header-full.png differ diff --git a/.playwright-mcp/feedback-features-test.png b/.playwright-mcp/feedback-features-test.png new file mode 100644 index 0000000..a23dd4c Binary files /dev/null and b/.playwright-mcp/feedback-features-test.png differ diff --git a/.playwright-mcp/feedback-panel-test.png b/.playwright-mcp/feedback-panel-test.png new file mode 100644 index 0000000..f8dc200 Binary files /dev/null and b/.playwright-mcp/feedback-panel-test.png differ diff --git a/.playwright-mcp/feedback-test-admin.png b/.playwright-mcp/feedback-test-admin.png new file mode 100644 index 0000000..2126561 Binary files /dev/null and b/.playwright-mcp/feedback-test-admin.png differ diff --git a/.playwright-mcp/gallery-feedback-indicators.png b/.playwright-mcp/gallery-feedback-indicators.png new file mode 100644 index 0000000..261fe37 Binary files /dev/null and b/.playwright-mcp/gallery-feedback-indicators.png differ diff --git a/.playwright-mcp/gallery-final-with-all-features.png b/.playwright-mcp/gallery-final-with-all-features.png new file mode 100644 index 0000000..18f6f1b Binary files /dev/null and b/.playwright-mcp/gallery-final-with-all-features.png differ diff --git a/.playwright-mcp/gallery-thumbnail-test.png b/.playwright-mcp/gallery-thumbnail-test.png new file mode 100644 index 0000000..f60337d Binary files /dev/null and b/.playwright-mcp/gallery-thumbnail-test.png differ diff --git a/.playwright-mcp/gallery-with-feedback-indicators.png b/.playwright-mcp/gallery-with-feedback-indicators.png new file mode 100644 index 0000000..58d9d1c Binary files /dev/null and b/.playwright-mcp/gallery-with-feedback-indicators.png differ diff --git a/.playwright-mcp/word-filter-management-working.png b/.playwright-mcp/word-filter-management-working.png new file mode 100644 index 0000000..cf2c818 Binary files /dev/null and b/.playwright-mcp/word-filter-management-working.png differ diff --git a/.swarm/memory.db b/.swarm/memory.db new file mode 100644 index 0000000..4b95fc9 Binary files /dev/null and b/.swarm/memory.db differ diff --git a/.swarm/memory.db-shm b/.swarm/memory.db-shm new file mode 100644 index 0000000..363c5e8 Binary files /dev/null and b/.swarm/memory.db-shm differ diff --git a/.swarm/memory.db-wal b/.swarm/memory.db-wal new file mode 100644 index 0000000..9f768e9 Binary files /dev/null and b/.swarm/memory.db-wal differ diff --git a/CLAUDE.md b/CLAUDE.md index 0a185b5..471e64e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -334,6 +334,23 @@ Run backup service test: `npm run test-backup` - `backup_runs`: Tracks each backup execution with statistics - `backup_file_states`: Stores file checksums for change detection +## Thumbnail Generation + +### Square Thumbnail Implementation (Issue #12 Fix) +The system now generates **square 300x300px thumbnails** to prevent blurry/stretched images in the gallery grid: + +- **Problem**: Previously generated 300px width with proportional height (e.g., 300x200 for 3:2 photos), but CSS forced square display causing distortion +- **Solution**: Thumbnails now use `cover` fit mode to crop to exact 300x300px dimensions with center positioning +- **Configuration**: Settings stored in `app_settings` table with keys: `thumbnail_width`, `thumbnail_height`, `thumbnail_fit`, `thumbnail_quality`, `thumbnail_format` +- **Migration**: Run `040_add_thumbnail_settings.js` to add default square thumbnail settings +- **Regeneration Script**: Use `scripts/regenerate-square-thumbnails.js` to update existing thumbnails + +### Thumbnail Settings API +- `GET /api/admin/thumbnails/settings` - Get current thumbnail configuration +- `PUT /api/admin/thumbnails/settings` - Update thumbnail settings (requires regeneration) +- `POST /api/admin/thumbnails/regenerate` - Regenerate all thumbnails with new settings +- `GET /api/admin/thumbnails/regenerate/status` - Check regeneration progress + ## Success Metrics (from PRD) - Time to generate gallery: <2 minutes - Guest satisfaction: >90% @@ -365,4 +382,5 @@ Run backup service test: `npm run test-backup` - [ ] Backup strategy confirmed - [ ] Monitoring alerts configured - [ ] Rollback procedure documented -- [ ] Stakeholders notified of maintenance window \ No newline at end of file +- [ ] Stakeholders notified of maintenance window +- always use docker deployment for testing \ No newline at end of file diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md new file mode 100644 index 0000000..525f932 --- /dev/null +++ b/SETUP_GUIDE.md @@ -0,0 +1,276 @@ +# 🚀 PicPeak Beta Setup Guide + +This guide helps non-technical users install PicPeak on Raspberry Pi or VPS systems using the automated setup script. + +## 📋 Quick Setup + +### Step 1: Download and Run Setup Script + +```bash +# Download PicPeak +wget -O setup-picpeak.sh https://raw.githubusercontent.com/the-luap/picpeak/main/setup-picpeak.sh + +# Make it executable +chmod +x setup-picpeak.sh + +# Run the setup +bash setup-picpeak.sh +``` + +**Or in one command:** +```bash +curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/setup-picpeak.sh | bash +``` + +### Step 2: Follow the Interactive Prompts + +The script will guide you through: +1. ✅ System requirements check +2. 🐋 Docker installation (if needed) +3. 🌐 Domain/IP configuration +4. 📧 Email setup (optional) +5. 🔐 Automatic password generation +6. 📦 PicPeak download and configuration +7. 🚀 Service startup + +## 📋 Prerequisites + +### Minimum System Requirements +- **OS**: Ubuntu, Debian, Raspberry Pi OS, CentOS, or Fedora +- **RAM**: 2GB minimum (script will warn if less) +- **Storage**: 20GB free space minimum +- **Network**: Internet connection for downloads + +### Supported Systems +- ✅ Ubuntu 20.04+ +- ✅ Debian 11+ +- ✅ Raspberry Pi OS (32-bit and 64-bit) +- ✅ CentOS 7+ +- ✅ Fedora 30+ + +## 🛠️ What the Script Does + +### Automatic Installation +1. **System Check**: Verifies RAM, disk space, and architecture +2. **Docker Setup**: Installs Docker and Docker Compose if missing +3. **Network Configuration**: Helps configure domain/IP access +4. **Email Configuration**: Optional setup for Gmail, SendGrid, or custom SMTP +5. **Security**: Generates secure passwords automatically +6. **Service Deployment**: Downloads, builds, and starts PicPeak +7. **SSL Support**: Optional Let's Encrypt SSL setup + +### Generated Configurations +- Secure JWT secret (64 hex characters) +- Database password (PostgreSQL) +- Redis password +- Environment configuration file +- Docker Compose setup + +## 🌐 Network Configuration Options + +### 1. Local Network Access (Default) +- Uses your local IP address +- Accessible only on your local network +- Perfect for home/office use + +### 2. Public IP Access +- Uses your server's public IP +- Accessible from the internet +- Requires firewall configuration + +### 3. Domain Name +- Uses your custom domain (e.g., photos.mydomain.com) +- Supports SSL/HTTPS with Let's Encrypt +- Professional setup + +### 4. Development Mode +- Uses localhost +- Only accessible from the same machine +- Good for testing + +## 📧 Email Configuration + +### Gmail (Recommended for Beginners) +1. Enable 2-Factor Authentication on your Google account +2. Generate an App Password for PicPeak +3. Use your Gmail address and the App Password + +### SendGrid (Recommended for Production) +1. Sign up at sendgrid.com (free tier: 100 emails/day) +2. Create an API key +3. Verify your sender email address + +### Custom SMTP +- Use any SMTP server +- Configure host, port, security, and credentials + +### Skip Email Setup +- Configure later in the admin panel +- PicPeak works without email but notifications won't be sent + +## 🔐 Security Features + +### Automatic Password Generation +- JWT secret: 64 hex characters +- Database password: 32 base64 characters (no $ symbols) +- Redis password: 32 base64 characters (no $ symbols) + +### First Login Security +- Auto-generated admin password shown in setup +- **Mandatory password change** on first login +- Strong password requirements enforced + +### SSL/HTTPS Support +- Optional Let's Encrypt integration +- Automatic nginx reverse proxy setup +- Forced HTTPS redirects + +## 📍 Access Information + +After successful installation, you'll get: + +### Access URLs +- **Gallery**: `http://your-domain:3000` (or `https://your-domain` with SSL) +- **Admin Panel**: `http://your-domain:3001/admin` (or `https://your-domain/admin` with SSL) + +### Admin Credentials +- **Email**: `admin@example.com` +- **Password**: Auto-generated (shown during setup) + +## 🚨 Important First Steps + +### 1. Change Admin Password +- The password change is **mandatory** for security +- You cannot skip this step +- New password requirements: + - Minimum 12 characters + - Mixed case letters + - Numbers and special characters + +### 2. Configure Email (if skipped) +1. Login to admin panel +2. Go to Settings → Email Configuration +3. Enter your SMTP details +4. Test email sending + +### 3. Create Your First Gallery +1. Go to Admin Panel → Events +2. Click "Create New Event" +3. Upload photos to the generated folders +4. Share the gallery URL and password + +## 🛠️ Management Commands + +### Service Management +```bash +# View logs +cd ~/picpeak && docker compose logs -f + +# Stop services +cd ~/picpeak && docker compose down + +# Start services +cd ~/picpeak && docker compose up -d + +# Restart services +cd ~/picpeak && docker compose restart +``` + +### Admin Password Reset +```bash +# Reset admin password +docker exec picpeak-backend node scripts/show-admin-credentials.js --reset +``` + +### Updates +```bash +cd ~/picpeak +git pull +docker compose down +docker compose build +docker compose up -d +``` + +## 🐛 Troubleshooting + +### Common Issues + +#### "Permission denied" when running script +```bash +chmod +x setup-picpeak.sh +``` + +#### "Docker command not found" after installation +```bash +# Log out and back in, or run: +newgrp docker +``` + +#### Can't access web interface +1. Check if services are running: `docker compose ps` +2. Check firewall: `sudo ufw allow 3000` and `sudo ufw allow 3001` +3. Verify URLs in browser match the setup configuration + +#### 502 Bad Gateway +1. Wait 60 seconds for services to fully start +2. Check backend logs: `docker compose logs backend` +3. Restart frontend: `docker restart picpeak-frontend` + +#### Email not working +1. Verify SMTP settings in admin panel +2. Check email queue: Admin Panel → Settings → Email +3. Test with a simple service like Gmail first + +### Getting Help +1. Check the logs: `docker compose logs -f` +2. Review this guide and the main deployment guide +3. Search existing GitHub issues +4. Create a new issue with: + - Error messages + - System information (`uname -a`) + - Setup choices made + +## 📁 File Locations + +After installation: +- **Installation directory**: `~/picpeak` +- **Photos**: `~/picpeak/storage/events/` +- **Logs**: `~/picpeak/logs/` +- **Database**: Docker volume `picpeak_postgres-data` +- **Backups**: `~/picpeak/backup/` + +## 🔒 Security Recommendations + +### For Internet-Facing Installations +1. **Use HTTPS**: Enable SSL during setup +2. **Firewall**: Only open ports 80 and 443 +3. **Updates**: Keep system and Docker updated +4. **Backups**: Configure automatic backups in admin panel +5. **Monitoring**: Regularly check logs for suspicious activity + +### For Local Network Use +1. **Strong passwords**: Use the auto-generated passwords +2. **Network security**: Secure your local network +3. **Regular backups**: Even for local use +4. **Access control**: Only share admin credentials with trusted users + +## 🎯 Next Steps + +After installation: +1. **Complete admin setup** (change password) +2. **Configure email notifications** +3. **Create your first test gallery** +4. **Set up automated backups** +5. **Customize themes and branding** +6. **Configure analytics** (optional) + +## 📞 Support + +- **Documentation**: Check the main deployment guide +- **Issues**: GitHub Issues page +- **Community**: GitHub Discussions +- **Updates**: Star the repository for notifications + +--- + +**PicPeak Beta Setup Script v1.0** - Making photo sharing simple for everyone! 📸 \ No newline at end of file diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db index 84e6fe5..5cda451 100644 Binary files a/backend/data/photo_sharing.db and b/backend/data/photo_sharing.db differ diff --git a/backend/migrations/core/001_init.js b/backend/migrations/core/001_init.js index fb2f394..4da7fd1 100644 --- a/backend/migrations/core/001_init.js +++ b/backend/migrations/core/001_init.js @@ -26,6 +26,7 @@ exports.up = async function(knex) { username: adminUsername, email: adminEmail, password_hash: passwordHash, + must_change_password: true, created_at: new Date() }); diff --git a/backend/migrations/core/033_add_gallery_feedback.js b/backend/migrations/core/033_add_gallery_feedback.js index 4c1a7ca..e6c692e 100644 --- a/backend/migrations/core/033_add_gallery_feedback.js +++ b/backend/migrations/core/033_add_gallery_feedback.js @@ -3,102 +3,125 @@ exports.up = async function(knex) { console.log('Adding gallery feedback tables...'); + // Check if tables and columns already exist + const hasEventFeedbackSettingsTable = await knex.schema.hasTable('event_feedback_settings'); + const hasPhotoFeedbackTable = await knex.schema.hasTable('photo_feedback'); + const hasFeedbackRateLimitsTable = await knex.schema.hasTable('feedback_rate_limits'); + const hasFeedbackWordFiltersTable = await knex.schema.hasTable('feedback_word_filters'); + const hasFeedbackCountColumn = await knex.schema.hasColumn('photos', 'feedback_count'); + // Create event_feedback_settings table - await knex.schema.createTable('event_feedback_settings', (table) => { - table.increments('id').primary(); - table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); - table.boolean('feedback_enabled').defaultTo(false); - table.boolean('allow_ratings').defaultTo(true); - table.boolean('allow_likes').defaultTo(true); - table.boolean('allow_comments').defaultTo(false); - table.boolean('allow_favorites').defaultTo(true); - table.boolean('require_name_email').defaultTo(false); - table.boolean('moderate_comments').defaultTo(true); - table.boolean('show_feedback_to_guests').defaultTo(true); - table.timestamp('created_at').defaultTo(knex.fn.now()); - table.timestamp('updated_at').defaultTo(knex.fn.now()); - table.unique(['event_id']); - }); + if (!hasEventFeedbackSettingsTable) { + await knex.schema.createTable('event_feedback_settings', (table) => { + table.increments('id').primary(); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.boolean('feedback_enabled').defaultTo(false); + table.boolean('allow_ratings').defaultTo(true); + table.boolean('allow_likes').defaultTo(true); + table.boolean('allow_comments').defaultTo(false); + table.boolean('allow_favorites').defaultTo(true); + table.boolean('require_name_email').defaultTo(false); + table.boolean('moderate_comments').defaultTo(true); + table.boolean('show_feedback_to_guests').defaultTo(true); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.unique(['event_id']); + }); + } // Create photo_feedback table - await knex.schema.createTable('photo_feedback', (table) => { - table.increments('id').primary(); - table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE'); - table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); - table.string('feedback_type', 20).notNullable(); - table.integer('rating'); - table.text('comment_text'); - table.string('guest_name', 100); - table.string('guest_email', 255); - table.string('guest_identifier', 64); - table.string('ip_address', 45); - table.text('user_agent'); - table.boolean('is_approved').defaultTo(true); - table.boolean('is_hidden').defaultTo(false); - table.timestamp('created_at').defaultTo(knex.fn.now()); - table.timestamp('updated_at').defaultTo(knex.fn.now()); - - // Add indexes - table.index(['photo_id']); - table.index(['event_id']); - table.index(['feedback_type']); - table.index(['guest_identifier']); - - // Add check constraint for rating (PostgreSQL) - if (knex.client.config.client === 'pg') { - table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']); - } - }); + if (!hasPhotoFeedbackTable) { + await knex.schema.createTable('photo_feedback', (table) => { + table.increments('id').primary(); + table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE'); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.string('feedback_type', 20).notNullable(); + table.integer('rating'); + table.text('comment_text'); + table.string('guest_name', 100); + table.string('guest_email', 255); + table.string('guest_identifier', 64); + table.string('ip_address', 45); + table.text('user_agent'); + table.boolean('is_approved').defaultTo(true); + table.boolean('is_hidden').defaultTo(false); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // Add indexes + table.index(['photo_id']); + table.index(['event_id']); + table.index(['feedback_type']); + table.index(['guest_identifier']); + + // Add check constraint for rating (PostgreSQL) + if (knex.client.config.client === 'pg') { + table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']); + } + }); + } // Create feedback_rate_limits table - await knex.schema.createTable('feedback_rate_limits', (table) => { - table.increments('id').primary(); - table.string('identifier', 64).notNullable(); - table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); - table.string('action_type', 20).notNullable(); - table.integer('action_count').defaultTo(1); - table.timestamp('window_start').defaultTo(knex.fn.now()); - - // Add indexes - table.index(['identifier', 'event_id', 'action_type']); - table.index(['window_start']); - }); + if (!hasFeedbackRateLimitsTable) { + await knex.schema.createTable('feedback_rate_limits', (table) => { + table.increments('id').primary(); + table.string('identifier', 64).notNullable(); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.string('action_type', 20).notNullable(); + table.integer('action_count').defaultTo(1); + table.timestamp('window_start').defaultTo(knex.fn.now()); + + // Add indexes + table.index(['identifier', 'event_id', 'action_type']); + table.index(['window_start']); + }); + } // Create feedback_word_filters table - await knex.schema.createTable('feedback_word_filters', (table) => { - table.increments('id').primary(); - table.string('word', 100).notNullable(); - table.string('severity', 20).defaultTo('moderate'); - table.boolean('is_active').defaultTo(true); - table.timestamp('created_at').defaultTo(knex.fn.now()); - table.unique(['word']); - }); + if (!hasFeedbackWordFiltersTable) { + await knex.schema.createTable('feedback_word_filters', (table) => { + table.increments('id').primary(); + table.string('word', 100).notNullable(); + table.string('severity', 20).defaultTo('moderate'); + table.boolean('is_active').defaultTo(true); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.unique(['word']); + }); + } // Add feedback summary columns to photos table - await knex.schema.alterTable('photos', (table) => { - table.integer('feedback_count').defaultTo(0); - table.integer('like_count').defaultTo(0); - table.decimal('average_rating', 3, 2).defaultTo(0); - table.integer('favorite_count').defaultTo(0); - }); + if (!hasFeedbackCountColumn) { + await knex.schema.alterTable('photos', (table) => { + table.integer('feedback_count').defaultTo(0); + table.integer('like_count').defaultTo(0); + table.decimal('average_rating', 3, 2).defaultTo(0); + table.integer('favorite_count').defaultTo(0); + }); + } // Add feedback notification settings to app_settings - await knex('app_settings').insert([ - { - setting_key: 'feedback_notification_email', - setting_value: JSON.stringify(''), - setting_type: 'feedback' - }, - { - setting_key: 'feedback_rate_limits', - setting_value: JSON.stringify({ - rating: { max: 100, window: 3600 }, // 100 ratings per hour - comment: { max: 20, window: 3600 }, // 20 comments per hour - like: { max: 200, window: 3600 } // 200 likes per hour - }), - setting_type: 'feedback' - } - ]); + const hasFeedbackNotificationEmail = await knex('app_settings') + .where('setting_key', 'feedback_notification_email') + .first(); + + if (!hasFeedbackNotificationEmail) { + await knex('app_settings').insert([ + { + setting_key: 'feedback_notification_email', + setting_value: JSON.stringify(''), + setting_type: 'feedback' + }, + { + setting_key: 'feedback_rate_limits', + setting_value: JSON.stringify({ + rating: { max: 100, window: 3600 }, // 100 ratings per hour + comment: { max: 20, window: 3600 }, // 20 comments per hour + like: { max: 200, window: 3600 } // 200 likes per hour + }), + setting_type: 'feedback' + } + ]); + } console.log('Gallery feedback tables created successfully'); }; diff --git a/backend/migrations/core/038_add_enhanced_image_protection.js b/backend/migrations/core/038_add_enhanced_image_protection.js new file mode 100644 index 0000000..92ca9ae --- /dev/null +++ b/backend/migrations/core/038_add_enhanced_image_protection.js @@ -0,0 +1,128 @@ +// Add enhanced image protection features + +exports.up = async function(knex) { + console.log('Adding enhanced image protection features...'); + + // Add protection columns to events table + const hasProtectionLevel = await knex.schema.hasColumn('events', 'protection_level'); + if (!hasProtectionLevel) { + await knex.schema.table('events', (table) => { + table.enum('protection_level', ['basic', 'standard', 'enhanced', 'maximum']).defaultTo('standard'); + table.integer('image_quality').defaultTo(85); + table.boolean('add_fingerprint').defaultTo(true); + table.boolean('enable_devtools_protection').defaultTo(true); + table.boolean('use_canvas_rendering').defaultTo(false); + table.integer('fragmentation_level').defaultTo(3); + table.boolean('overlay_protection').defaultTo(true); + }); + } + + // Create image access logs table + const hasImageAccessLogs = await knex.schema.hasTable('image_access_logs'); + if (!hasImageAccessLogs) { + await knex.schema.createTable('image_access_logs', (table) => { + table.increments('id').primary(); + table.integer('photo_id').unsigned().notNullable(); + table.integer('event_id').unsigned().notNullable(); + table.string('client_ip', 45).notNullable(); + table.text('user_agent'); + table.string('access_type', 20).defaultTo('view'); // view, download, suspicious + table.string('client_fingerprint', 32).notNullable(); + table.timestamp('accessed_at').defaultTo(knex.fn.now()); + table.json('metadata'); // Additional security metadata + + table.foreign('photo_id').references('id').inTable('photos').onDelete('CASCADE'); + table.foreign('event_id').references('id').inTable('events').onDelete('CASCADE'); + + table.index(['photo_id', 'accessed_at']); + table.index(['client_fingerprint', 'accessed_at']); + table.index(['client_ip', 'accessed_at']); + }); + } + + // Add protection settings to app_settings + const protectionSettingExists = await knex('app_settings') + .where('setting_key', 'default_protection_level') + .first(); + + if (!protectionSettingExists) { + await knex('app_settings').insert([ + { + setting_key: 'default_protection_level', + setting_value: JSON.stringify('standard'), + setting_type: 'security' + }, + { + setting_key: 'default_image_quality', + setting_value: JSON.stringify(85), + setting_type: 'security' + }, + { + setting_key: 'enable_devtools_protection', + setting_value: JSON.stringify(true), + setting_type: 'security' + }, + { + setting_key: 'max_image_requests_per_minute', + setting_value: JSON.stringify(30), + setting_type: 'security' + }, + { + setting_key: 'suspicious_activity_threshold', + setting_value: JSON.stringify(10), + setting_type: 'security' + }, + { + setting_key: 'enable_canvas_rendering', + setting_value: JSON.stringify(false), + setting_type: 'security' + }, + { + setting_key: 'default_fragmentation_level', + setting_value: JSON.stringify(3), + setting_type: 'security' + } + ]); + } + + console.log('Enhanced image protection features added successfully'); +}; + +exports.down = async function(knex) { + console.log('Removing enhanced image protection features...'); + + // Remove app settings + await knex('app_settings') + .whereIn('setting_key', [ + 'default_protection_level', + 'default_image_quality', + 'enable_devtools_protection', + 'max_image_requests_per_minute', + 'suspicious_activity_threshold', + 'enable_canvas_rendering', + 'default_fragmentation_level' + ]) + .delete(); + + // Drop image access logs table + const hasImageAccessLogs = await knex.schema.hasTable('image_access_logs'); + if (hasImageAccessLogs) { + await knex.schema.dropTable('image_access_logs'); + } + + // Remove protection columns from events table + const hasProtectionLevel = await knex.schema.hasColumn('events', 'protection_level'); + if (hasProtectionLevel) { + await knex.schema.table('events', (table) => { + table.dropColumn('protection_level'); + table.dropColumn('image_quality'); + table.dropColumn('add_fingerprint'); + table.dropColumn('enable_devtools_protection'); + table.dropColumn('use_canvas_rendering'); + table.dropColumn('fragmentation_level'); + table.dropColumn('overlay_protection'); + }); + } + + console.log('Enhanced image protection features removed'); +}; \ No newline at end of file diff --git a/backend/migrations/core/039_add_security_logging.js b/backend/migrations/core/039_add_security_logging.js new file mode 100644 index 0000000..d5095dc --- /dev/null +++ b/backend/migrations/core/039_add_security_logging.js @@ -0,0 +1,117 @@ +// Add security logging and monitoring tables + +exports.up = async function(knex) { + console.log('Adding security logging and monitoring tables...'); + + // Create security logs table for general security events + const hasSecurityLogs = await knex.schema.hasTable('security_logs'); + if (!hasSecurityLogs) { + await knex.schema.createTable('security_logs', (table) => { + table.increments('id').primary(); + table.string('event_type', 50).notNullable(); // rate_limit_exceeded, suspicious_activity, etc. + table.string('client_ip', 45).notNullable(); + table.string('client_fingerprint', 32); + table.text('user_agent'); + table.string('request_path'); + table.string('request_method', 10); + table.json('details'); // Additional event details + table.timestamp('timestamp').defaultTo(knex.fn.now()); + + // Indexes for performance + table.index(['event_type', 'timestamp']); + table.index(['client_ip', 'timestamp']); + table.index(['client_fingerprint', 'timestamp']); + }); + } + + // Add security monitoring settings to app_settings + const securitySettings = [ + { + setting_key: 'security_monitoring_enabled', + setting_value: JSON.stringify(true), + setting_type: 'security' + }, + { + setting_key: 'max_image_requests_per_5_minutes', + setting_value: JSON.stringify(100), + setting_type: 'security' + }, + { + setting_key: 'max_image_requests_per_hour', + setting_value: JSON.stringify(500), + setting_type: 'security' + }, + { + setting_key: 'block_suspicious_ips', + setting_value: JSON.stringify(true), + setting_type: 'security' + }, + { + setting_key: 'log_security_events_to_db', + setting_value: JSON.stringify(true), + setting_type: 'security' + }, + { + setting_key: 'auto_block_threshold', + setting_value: JSON.stringify(5), + setting_type: 'security' + } + ]; + + for (const setting of securitySettings) { + const exists = await knex('app_settings') + .where('setting_key', setting.setting_key) + .first(); + + if (!exists) { + await knex('app_settings').insert(setting); + } + } + + // Add mime_type column to photos table if it doesn't exist + const hasMimeType = await knex.schema.hasColumn('photos', 'mime_type'); + if (!hasMimeType) { + await knex.schema.table('photos', (table) => { + table.string('mime_type', 100); + }); + + // Update existing photos with default mime type + await knex('photos') + .whereNull('mime_type') + .update({ mime_type: 'image/jpeg' }); + } + + console.log('Security logging and monitoring tables added successfully'); +}; + +exports.down = async function(knex) { + console.log('Removing security logging and monitoring tables...'); + + // Remove security settings + await knex('app_settings') + .whereIn('setting_key', [ + 'security_monitoring_enabled', + 'max_image_requests_per_5_minutes', + 'max_image_requests_per_hour', + 'block_suspicious_ips', + 'log_security_events_to_db', + 'auto_block_threshold' + ]) + .delete(); + + // Drop security logs table + const hasSecurityLogs = await knex.schema.hasTable('security_logs'); + if (hasSecurityLogs) { + await knex.schema.dropTable('security_logs'); + } + + // Remove mime_type column from photos table + const hasMimeType = await knex.schema.hasColumn('photos', 'mime_type'); + if (hasMimeType) { + await knex.schema.table('photos', (table) => { + table.dropColumn('mime_type'); + }); + } + + console.log('Security logging and monitoring tables removed'); +}; \ No newline at end of file diff --git a/backend/migrations/core/040_add_thumbnail_settings.js b/backend/migrations/core/040_add_thumbnail_settings.js new file mode 100644 index 0000000..e9dcd70 --- /dev/null +++ b/backend/migrations/core/040_add_thumbnail_settings.js @@ -0,0 +1,33 @@ +exports.up = async function(knex) { + // Add thumbnail settings to app_settings table + const thumbnailSettings = [ + { setting_key: 'thumbnail_width', setting_value: 300, setting_type: 'number' }, + { setting_key: 'thumbnail_height', setting_value: 300, setting_type: 'number' }, + { setting_key: 'thumbnail_fit', setting_value: JSON.stringify('cover'), setting_type: 'string' }, + { setting_key: 'thumbnail_quality', setting_value: 85, setting_type: 'number' }, + { setting_key: 'thumbnail_format', setting_value: JSON.stringify('jpeg'), setting_type: 'string' } + ]; + + for (const setting of thumbnailSettings) { + const exists = await knex('app_settings').where('setting_key', setting.setting_key).first(); + if (!exists) { + await knex('app_settings').insert({ + ...setting, + updated_at: knex.fn.now() + }); + } + } +}; + +exports.down = async function(knex) { + // Remove thumbnail settings + await knex('app_settings') + .whereIn('setting_key', [ + 'thumbnail_width', + 'thumbnail_height', + 'thumbnail_fit', + 'thumbnail_quality', + 'thumbnail_format' + ]) + .del(); +}; \ No newline at end of file diff --git a/backend/migrations/legacy/004_add_categories_and_cms.js b/backend/migrations/legacy/004_add_categories_and_cms.js index 0b1c619..a67f952 100644 --- a/backend/migrations/legacy/004_add_categories_and_cms.js +++ b/backend/migrations/legacy/004_add_categories_and_cms.js @@ -3,79 +3,103 @@ const { db } = require('../../src/database/db'); async function up() { console.log('Adding photo categories and CMS tables...'); + // Check if tables already exist + const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories'); + const hasCmsPagesTable = await db.schema.hasTable('cms_pages'); + const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id'); + const hasLanguageColumn = await db.schema.hasColumn('admin_users', 'language'); + // Create photo_categories table - await db.schema.createTable('photo_categories', (table) => { - table.increments('id').primary(); - table.string('name', 100).notNullable(); - table.string('slug', 100).notNullable(); - table.boolean('is_global').defaultTo(true); - table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); - table.timestamp('created_at').defaultTo(db.fn.now()); - - // Unique constraint for slug within event scope - table.unique(['slug', 'event_id']); - }); + if (!hasPhotoCategoriesTable) { + await db.schema.createTable('photo_categories', (table) => { + table.increments('id').primary(); + table.string('name', 100).notNullable(); + table.string('slug', 100).notNullable(); + table.boolean('is_global').defaultTo(true); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.timestamp('created_at').defaultTo(db.fn.now()); + + // Unique constraint for slug within event scope + table.unique(['slug', 'event_id']); + }); + } // Create cms_pages table - await db.schema.createTable('cms_pages', (table) => { - table.increments('id').primary(); - table.string('slug', 100).unique().notNullable(); - table.text('title_en'); - table.text('title_de'); - table.text('content_en'); - table.text('content_de'); - table.timestamp('updated_at').defaultTo(db.fn.now()); - }); + if (!hasCmsPagesTable) { + await db.schema.createTable('cms_pages', (table) => { + table.increments('id').primary(); + table.string('slug', 100).unique().notNullable(); + table.text('title_en'); + table.text('title_de'); + table.text('content_en'); + table.text('content_de'); + table.timestamp('updated_at').defaultTo(db.fn.now()); + }); + } // Add category_id to photos table - await db.schema.alterTable('photos', (table) => { - table.integer('category_id').references('id').inTable('photo_categories'); - }); + if (!hasCategoryIdColumn) { + await db.schema.alterTable('photos', (table) => { + table.integer('category_id').references('id').inTable('photo_categories'); + }); + } // Add language preference to admin_users - await db.schema.alterTable('admin_users', (table) => { - table.string('language', 2).defaultTo('en'); - }); + if (!hasLanguageColumn) { + await db.schema.alterTable('admin_users', (table) => { + table.string('language', 2).defaultTo('en'); + }); + } // Add language preference to app_settings for global default - await db('app_settings').insert({ - setting_key: 'default_language', - setting_value: JSON.stringify('en'), - setting_type: 'general', - updated_at: new Date() - }); + const hasDefaultLanguageSetting = await db('app_settings') + .where('setting_key', 'default_language') + .first(); + + if (!hasDefaultLanguageSetting) { + await db('app_settings').insert({ + setting_key: 'default_language', + setting_value: JSON.stringify('en'), + setting_type: 'general', + updated_at: new Date() + }); + } // Insert default global categories - const defaultCategories = [ - { name: 'Ceremony', slug: 'ceremony', is_global: true }, - { name: 'Reception', slug: 'reception', is_global: true }, - { name: 'Portraits', slug: 'portraits', is_global: true }, - { name: 'Group Photos', slug: 'group-photos', is_global: true }, - { name: 'Details', slug: 'details', is_global: true }, - { name: 'Party', slug: 'party', is_global: true } - ]; + if (!hasPhotoCategoriesTable) { + const defaultCategories = [ + { name: 'Ceremony', slug: 'ceremony', is_global: true }, + { name: 'Reception', slug: 'reception', is_global: true }, + { name: 'Portraits', slug: 'portraits', is_global: true }, + { name: 'Group Photos', slug: 'group-photos', is_global: true }, + { name: 'Details', slug: 'details', is_global: true }, + { name: 'Party', slug: 'party', is_global: true } + ]; - await db('photo_categories').insert(defaultCategories); + await db('photo_categories').insert(defaultCategories); + } // Insert default legal pages - await db('cms_pages').insert([ - { - slug: 'impressum', - title_en: 'Legal Notice', - title_de: 'Impressum', - content_en: '

Legal Notice

Please edit this content in the admin panel.

', - content_de: '

Impressum

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', - updated_at: new Date() - }, - { - slug: 'datenschutz', - title_en: 'Privacy Policy', - title_de: 'Datenschutzerklärung', - content_en: '

Privacy Policy

Please edit this content in the admin panel.

', - content_de: '

Datenschutzerklärung

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', - updated_at: new Date() - } - ]); + if (!hasCmsPagesTable) { + await db('cms_pages').insert([ + { + slug: 'impressum', + title_en: 'Legal Notice', + title_de: 'Impressum', + content_en: '

Legal Notice

Please edit this content in the admin panel.

', + content_de: '

Impressum

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', + updated_at: new Date() + }, + { + slug: 'datenschutz', + title_en: 'Privacy Policy', + title_de: 'Datenschutzerklärung', + content_en: '

Privacy Policy

Please edit this content in the admin panel.

', + content_de: '

Datenschutzerklärung

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', + updated_at: new Date() + } + ]); + } console.log('Photo categories and CMS tables created successfully'); } diff --git a/backend/migrations/legacy/015_add_login_attempts_table.js b/backend/migrations/legacy/015_add_login_attempts_table.js index fa5f328..8e0fc7f 100644 --- a/backend/migrations/legacy/015_add_login_attempts_table.js +++ b/backend/migrations/legacy/015_add_login_attempts_table.js @@ -1,17 +1,21 @@ -exports.up = function(knex) { - return knex.schema.createTable('login_attempts', table => { - table.increments('id').primary(); - table.string('identifier').notNullable(); // username or email - table.string('ip_address', 45).notNullable(); // IPv4 or IPv6 - table.text('user_agent'); - table.timestamp('attempt_time').defaultTo(knex.fn.now()); - table.boolean('success').defaultTo(false); - - // Indexes for performance - table.index('identifier'); - table.index('attempt_time'); - table.index(['identifier', 'success', 'attempt_time']); - }); +exports.up = async function(knex) { + const hasLoginAttemptsTable = await knex.schema.hasTable('login_attempts'); + + if (!hasLoginAttemptsTable) { + return knex.schema.createTable('login_attempts', table => { + table.increments('id').primary(); + table.string('identifier').notNullable(); // username or email + table.string('ip_address', 45).notNullable(); // IPv4 or IPv6 + table.text('user_agent'); + table.timestamp('attempt_time').defaultTo(knex.fn.now()); + table.boolean('success').defaultTo(false); + + // Indexes for performance + table.index('identifier'); + table.index('attempt_time'); + table.index(['identifier', 'success', 'attempt_time']); + }); + } }; exports.down = function(knex) { diff --git a/backend/migrations/legacy/016_add_auth_security_columns.js b/backend/migrations/legacy/016_add_auth_security_columns.js index f938247..118530b 100644 --- a/backend/migrations/legacy/016_add_auth_security_columns.js +++ b/backend/migrations/legacy/016_add_auth_security_columns.js @@ -1,17 +1,28 @@ -exports.up = function(knex) { +exports.up = async function(knex) { + // Check if columns already exist to avoid conflicts + const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at'); + const hasLastLoginIp = await knex.schema.hasColumn('admin_users', 'last_login_ip'); + const hasTwoFactorEnabled = await knex.schema.hasColumn('admin_users', 'two_factor_enabled'); + const hasTwoFactorSecret = await knex.schema.hasColumn('admin_users', 'two_factor_secret'); + return knex.schema.table('admin_users', table => { // Add password change tracking - table.timestamp('password_changed_at').nullable(); + if (!hasPasswordChangedAt) { + table.timestamp('password_changed_at').nullable(); + } - // Add last login IP for security monitoring - table.string('last_login_ip', 45).nullable(); + // Add last login IP for security monitoring + if (!hasLastLoginIp) { + table.string('last_login_ip', 45).nullable(); + } // Add account security flags - table.boolean('two_factor_enabled').defaultTo(false); - table.string('two_factor_secret').nullable(); - - // Add index for performance - table.index('password_changed_at'); + if (!hasTwoFactorEnabled) { + table.boolean('two_factor_enabled').defaultTo(false); + } + if (!hasTwoFactorSecret) { + table.string('two_factor_secret').nullable(); + } }); }; diff --git a/backend/migrations/legacy/017_add_token_revocation_tables.js b/backend/migrations/legacy/017_add_token_revocation_tables.js index 159ec63..4789fa5 100644 --- a/backend/migrations/legacy/017_add_token_revocation_tables.js +++ b/backend/migrations/legacy/017_add_token_revocation_tables.js @@ -1,7 +1,11 @@ -exports.up = function(knex) { - return knex.schema - // Table for individual token revocations - .createTable('revoked_tokens', table => { +exports.up = async function(knex) { + // Check if tables already exist to avoid conflicts + const hasRevokedTokensTable = await knex.schema.hasTable('revoked_tokens'); + const hasUserTokenRevocationsTable = await knex.schema.hasTable('user_token_revocations'); + + // Create revoked_tokens table if it doesn't exist + if (!hasRevokedTokensTable) { + await knex.schema.createTable('revoked_tokens', table => { table.increments('id').primary(); table.string('token_id').notNullable().unique(); // JWT ID or generated ID table.integer('user_id').nullable(); // User who owned the token @@ -15,9 +19,12 @@ exports.up = function(knex) { table.index('token_id'); table.index('user_id'); table.index('expires_at'); // For cleanup - }) - // Table for user-level revocations (revoke all tokens before a certain time) - .createTable('user_token_revocations', table => { + }); + } + + // Create user_token_revocations table if it doesn't exist + if (!hasUserTokenRevocationsTable) { + await knex.schema.createTable('user_token_revocations', table => { table.integer('user_id').primary(); table.timestamp('revoked_at').notNullable(); table.string('reason', 100); @@ -25,6 +32,28 @@ exports.up = function(knex) { // Index for quick lookups table.index('revoked_at'); }); + } + + // Add any missing indexes if tables already existed + if (hasRevokedTokensTable) { + try { + // Try to add indexes if they don't exist (PostgreSQL syntax) + await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_token_id_index" ON "revoked_tokens" ("token_id")'); + await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_user_id_index" ON "revoked_tokens" ("user_id")'); + await knex.raw('CREATE INDEX IF NOT EXISTS "revoked_tokens_expires_at_index" ON "revoked_tokens" ("expires_at")'); + } catch (error) { + // For SQLite compatibility, ignore errors if indexes already exist + console.log('Note: Some indexes may already exist, continuing...'); + } + } + + if (hasUserTokenRevocationsTable) { + try { + await knex.raw('CREATE INDEX IF NOT EXISTS "user_token_revocations_revoked_at_index" ON "user_token_revocations" ("revoked_at")'); + } catch (error) { + console.log('Note: Some indexes may already exist, continuing...'); + } + } }; exports.down = function(knex) { diff --git a/backend/package.json b/backend/package.json index 4286d2a..4615b93 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,11 +8,8 @@ "dev": "nodemon server.js", "migrate": "node migrations/run-migrations.js", "migrate:safe": "node migrations/run-migrations-safe.js", - "fix-temp-photos": "node scripts/fix-temp-photos.js", "test": "jest", - "lint": "eslint src/", - "test-backup": "node scripts/test-backup-service.js", - "test-restore": "node scripts/test-restore-service.js" + "lint": "eslint src/" }, "dependencies": { "@aws-sdk/client-s3": "^3.850.0", diff --git a/backend/scripts/fix-migration-state.js b/backend/scripts/fix-migration-state.js deleted file mode 100644 index 88cf79f..0000000 --- a/backend/scripts/fix-migration-state.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node - -/** - * Fix migration state by marking migrations as applied if their tables already exist - */ - -const { db } = require('../src/database/db'); - -async function fixMigrationState() { - try { - console.log('Checking migration state...'); - - // Ensure migrations table exists - const hasMigrationsTable = await db.schema.hasTable('migrations'); - if (!hasMigrationsTable) { - 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 for specific tables and mark their migrations as applied - const tableChecks = [ - { table: 'restore_runs', migration: '032_add_restore_runs_table.js' }, - { table: 'restore_file_operations', migration: '032_add_restore_runs_table.js' }, - { table: 'restore_validation_results', migration: '032_add_restore_runs_table.js' }, - { table: 'gallery_feedback', migration: '033_add_gallery_feedback.js' }, - { table: 'feedback_photos', migration: '033_add_gallery_feedback.js' }, - ]; - - for (const check of tableChecks) { - const tableExists = await db.schema.hasTable(check.table); - if (tableExists) { - const migrationApplied = await db('migrations') - .where('filename', check.migration) - .first(); - - if (!migrationApplied) { - await db('migrations').insert({ - filename: check.migration, - applied_at: new Date() - }); - console.log(`✅ Marked ${check.migration} as applied (table ${check.table} exists)`); - } else { - console.log(`ℹ️ ${check.migration} already marked as applied`); - } - } - } - - console.log('\nMigration state fixed successfully!'); - } catch (error) { - console.error('Error fixing migration state:', error.message); - process.exit(1); - } finally { - await db.destroy(); - } -} - -fixMigrationState(); \ No newline at end of file diff --git a/backend/scripts/fix-temp-photos.js b/backend/scripts/fix-temp-photos.js deleted file mode 100644 index 751a431..0000000 --- a/backend/scripts/fix-temp-photos.js +++ /dev/null @@ -1,171 +0,0 @@ -require('dotenv').config({ path: '../.env' }); -const path = require('path'); -const fs = require('fs').promises; -const { db } = require('../src/database/db'); -const { generatePhotoFilename } = require('../src/utils/filenameSanitizer'); - -async function fixTempPhotos() { - console.log('Starting to fix temporary photo files...\n'); - - try { - // Find all photos with temp_ filenames - const tempPhotos = await db('photos') - .where('filename', 'like', 'temp_%') - .orderBy('event_id', 'asc') - .orderBy('category_id', 'asc') - .orderBy('id', 'asc'); - - console.log(`Found ${tempPhotos.length} photos with temporary filenames\n`); - - if (tempPhotos.length === 0) { - console.log('No temporary photos found. Exiting.'); - return; - } - - // Group photos by event and category - const grouped = {}; - for (const photo of tempPhotos) { - const key = `${photo.event_id}_${photo.category_id || 'null'}`; - if (!grouped[key]) { - grouped[key] = []; - } - grouped[key].push(photo); - } - - console.log(`Processing ${Object.keys(grouped).length} event/category groups...\n`); - - // Process each group - for (const [key, photos] of Object.entries(grouped)) { - const [eventId, categoryIdStr] = key.split('_'); - const categoryId = categoryIdStr === 'null' ? null : parseInt(categoryIdStr); - - console.log(`\nProcessing Event ID: ${eventId}, Category ID: ${categoryId || 'uncategorized'}`); - console.log(`Photos in group: ${photos.length}`); - - // Get event details - const event = await db('events').where({ id: eventId }).first(); - if (!event) { - console.error(`Event ${eventId} not found! Skipping...`); - continue; - } - - // Get category details if applicable - let category = null; - let startCounter = 1; - - if (categoryId) { - category = await db('photo_categories').where({ id: categoryId }).first(); - if (!category) { - console.error(`Category ${categoryId} not found! Treating as uncategorized...`); - } else { - // Get the highest counter for this category - const maxPhoto = await db('photos') - .where({ event_id: eventId, category_id: categoryId }) - .whereNot('filename', 'like', 'temp_%') - .orderBy('id', 'desc') - .first(); - - if (maxPhoto && maxPhoto.filename) { - // Extract counter from filename - const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/); - if (match) { - startCounter = parseInt(match[1]) + 1; - } - } - } - } else { - // For uncategorized, get the highest counter - const maxPhoto = await db('photos') - .where({ event_id: eventId }) - .whereNull('category_id') - .whereNot('filename', 'like', 'temp_%') - .orderBy('id', 'desc') - .first(); - - if (maxPhoto && maxPhoto.filename) { - const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/); - if (match) { - startCounter = parseInt(match[1]) + 1; - } - } - } - - console.log(`Starting counter: ${startCounter}`); - - // Process each photo in the group - let successCount = 0; - let errorCount = 0; - - for (let i = 0; i < photos.length; i++) { - const photo = photos[i]; - const counter = startCounter + i; - - try { - // Generate new filename - const extension = path.extname(photo.filename); - const newFilename = generatePhotoFilename( - event.event_name, - category ? category.name : 'uncategorized', - counter, - extension - ); - - // Build full paths - const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); - const oldPath = path.join(storagePath, 'events/active', photo.path); - const newPath = path.join(path.dirname(oldPath), newFilename); - - // Check if old file exists - try { - await fs.access(oldPath); - } catch (e) { - console.error(`File not found: ${oldPath}`); - errorCount++; - continue; - } - - // Rename the file - await fs.rename(oldPath, newPath); - - // Update database - const newRelativePath = path.relative(path.join(storagePath, 'events/active'), newPath); - await db('photos') - .where({ id: photo.id }) - .update({ - filename: newFilename, - path: newRelativePath - }); - - console.log(`✓ Renamed: ${photo.filename} → ${newFilename}`); - successCount++; - - } catch (error) { - console.error(`✗ Failed to process photo ${photo.id}: ${error.message}`); - errorCount++; - } - } - - // Update category counter if needed - if (category && successCount > 0) { - const newCounter = startCounter + photos.length - 1; - await db('photo_categories') - .where({ id: categoryId }) - .update({ photo_counter: newCounter }); - console.log(`Updated category counter to ${newCounter}`); - } - - console.log(`\nGroup summary: ${successCount} successful, ${errorCount} errors`); - } - - console.log('\n=== COMPLETE ==='); - console.log('All temporary photos have been processed.'); - - } catch (error) { - console.error('Fatal error:', error); - } finally { - await db.destroy(); - } -} - -// Run the script -fixTempPhotos().catch(console.error); \ No newline at end of file diff --git a/backend/scripts/mark-migration-applied.js b/backend/scripts/mark-migration-applied.js deleted file mode 100644 index 0e93b19..0000000 --- a/backend/scripts/mark-migration-applied.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node - -/** - * Mark a specific migration as applied without running it - * Usage: node scripts/mark-migration-applied.js - */ - -const { db } = require('../src/database/db'); - -async function markMigrationAsApplied(filename) { - try { - // Check if migration is already marked - const existing = await db('migrations') - .where('filename', filename) - .first(); - - if (existing) { - console.log(`Migration ${filename} is already marked as applied`); - return; - } - - // Mark as applied - await db('migrations').insert({ - filename, - applied_at: new Date() - }); - - console.log(`✅ Migration ${filename} marked as applied`); - } catch (error) { - console.error('Error marking migration:', error.message); - process.exit(1); - } finally { - await db.destroy(); - } -} - -// Get migration filename from command line -const migrationFile = process.argv[2]; - -if (!migrationFile) { - console.error('Usage: node scripts/mark-migration-applied.js '); - console.error('Example: node scripts/mark-migration-applied.js 032_add_restore_runs_table.js'); - process.exit(1); -} - -markMigrationAsApplied(migrationFile); \ No newline at end of file diff --git a/backend/scripts/regenerate-square-thumbnails.js b/backend/scripts/regenerate-square-thumbnails.js new file mode 100644 index 0000000..4721d08 --- /dev/null +++ b/backend/scripts/regenerate-square-thumbnails.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +/** + * Script to regenerate all thumbnails with new square dimensions + * This fixes the blurry thumbnail issue by creating proper 300x300 square thumbnails + */ + +const path = require('path'); +const fs = require('fs').promises; +const { db } = require('../src/database/db'); +const { generateThumbnail } = require('../src/services/imageProcessor'); +const logger = require('../src/utils/logger'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); + +async function regenerateAllThumbnails() { + try { + console.log('Starting thumbnail regeneration with square dimensions...'); + + // First, ensure the new thumbnail settings are in the database + const settings = [ + { key: 'thumbnail_width', value: '300' }, + { key: 'thumbnail_height', value: '300' }, + { key: 'thumbnail_fit', value: 'cover' }, + { key: 'thumbnail_quality', value: '85' }, + { key: 'thumbnail_format', value: 'jpeg' } + ]; + + for (const setting of settings) { + const exists = await db('app_settings').where('key', setting.key).first(); + if (!exists) { + await db('app_settings').insert({ + ...setting, + description: `Thumbnail ${setting.key.replace('thumbnail_', '')}`, + created_at: db.fn.now(), + updated_at: db.fn.now() + }); + console.log(`Added setting: ${setting.key} = ${setting.value}`); + } + } + + // Get all photos + const photos = await db('photos') + .select('id', 'event_id', 'path', 'filename') + .orderBy('id'); + + console.log(`Found ${photos.length} photos to process`); + + let successCount = 0; + let errorCount = 0; + let skippedCount = 0; + + for (let i = 0; i < photos.length; i++) { + const photo = photos[i]; + const progress = Math.round((i + 1) / photos.length * 100); + + try { + const storagePath = getStoragePath(); + const originalPath = path.join(storagePath, 'events/active', photo.path); + + // Check if original file exists + try { + await fs.access(originalPath); + } catch (err) { + console.log(`[${progress}%] Skipping photo ${photo.id} - original file not found`); + skippedCount++; + continue; + } + + // Regenerate thumbnail with new square dimensions + const thumbnailPath = await generateThumbnail(originalPath, { regenerate: true }); + + if (thumbnailPath) { + // Update database with new thumbnail path + await db('photos') + .where({ id: photo.id }) + .update({ + thumbnail_path: thumbnailPath, + updated_at: db.fn.now() + }); + + successCount++; + console.log(`[${progress}%] ✓ Regenerated thumbnail for ${photo.filename}`); + } else { + errorCount++; + console.error(`[${progress}%] ✗ Failed to generate thumbnail for ${photo.filename}`); + } + } catch (error) { + errorCount++; + console.error(`[${progress}%] ✗ Error processing photo ${photo.id}:`, error.message); + } + } + + console.log('\n=== Regeneration Complete ==='); + console.log(`✓ Success: ${successCount} thumbnails`); + console.log(`✗ Errors: ${errorCount} thumbnails`); + console.log(`⊘ Skipped: ${skippedCount} thumbnails (original files not found)`); + console.log(`Total processed: ${photos.length} photos`); + + process.exit(0); + } catch (error) { + console.error('Fatal error during thumbnail regeneration:', error); + process.exit(1); + } +} + +// Run the script +regenerateAllThumbnails(); \ No newline at end of file diff --git a/backend/scripts/set-admin-password.js b/backend/scripts/set-admin-password.js new file mode 100644 index 0000000..9cbff58 --- /dev/null +++ b/backend/scripts/set-admin-password.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node + +const bcrypt = require('bcrypt'); +const path = require('path'); +require('dotenv').config({ path: path.join(__dirname, '../.env') }); + +const knex = require('knex'); +const db = knex({ + client: process.env.DB_CLIENT || 'pg', + connection: { + host: process.env.DB_HOST || 'localhost', + port: process.env.DB_PORT || 5432, + user: process.env.DB_USER || 'picpeak', + password: process.env.DB_PASSWORD || 'picpeak', + database: process.env.DB_NAME || 'picpeak_dev' + } +}); + +async function setAdminPassword() { + try { + const password = 'admin123'; + const hashedPassword = await bcrypt.hash(password, 10); + + await db('admin_users') + .where('username', 'admin') + .update({ + password_hash: hashedPassword, + updated_at: new Date() + }); + + console.log('✅ Admin password set to: admin123'); + process.exit(0); + } catch (error) { + console.error('❌ Error setting password:', error); + process.exit(1); + } +} + +setAdminPassword(); \ No newline at end of file diff --git a/backend/scripts/show-admin-credentials.js b/backend/scripts/show-admin-credentials.js index fde8423..1215ff1 100644 --- a/backend/scripts/show-admin-credentials.js +++ b/backend/scripts/show-admin-credentials.js @@ -39,8 +39,9 @@ async function showAdminCredentials(resetPassword = false) { updated_at: new Date() }); - console.log(`Password: ${newPassword} (NEWLY RESET)`); - console.log('\n⚠️ IMPORTANT: Please save this password securely!'); + // Password logging removed for security - check logs or database if needed + console.log('Password: [NEWLY RESET - stored in database]'); + console.log('\n⚠️ IMPORTANT: New password has been set in database!'); } else { console.log('Password: [hidden - use --reset flag to generate new password]'); } diff --git a/backend/scripts/test-backup-integration.js b/backend/scripts/test-backup-integration.js deleted file mode 100755 index 289b3fc..0000000 --- a/backend/scripts/test-backup-integration.js +++ /dev/null @@ -1,577 +0,0 @@ -#!/usr/bin/env node - -/** - * Manual Integration Test Script for Enhanced Backup System - * - * This script provides a comprehensive test of the backup system with real services. - * It can be used to test against MinIO, AWS S3, or other S3-compatible services. - * - * Usage: - * node scripts/test-backup-integration.js [options] - * - * Options: - * --endpoint S3 endpoint URL (default: http://localhost:9000) - * --access-key S3 access key (default: minioadmin) - * --secret-key S3 secret key (default: minioadmin) - * --bucket S3 bucket name (default: test-backup-) - * --type Backup type: s3, local, rsync (default: s3) - * --cleanup Clean up test data after completion - * --verbose Enable verbose logging - * --help Show this help message - * - * Examples: - * # Test with local MinIO - * node scripts/test-backup-integration.js - * - * # Test with AWS S3 - * node scripts/test-backup-integration.js \ - * --endpoint https://s3.amazonaws.com \ - * --access-key AKIAIOSFODNN7EXAMPLE \ - * --secret-key wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \ - * --bucket my-test-bucket - * - * # Test local backup - * node scripts/test-backup-integration.js --type local - */ - -const path = require('path'); -const fs = require('fs').promises; -const crypto = require('crypto'); -const { S3Client, CreateBucketCommand, HeadBucketCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand, DeleteBucketCommand } = require('@aws-sdk/client-s3'); - -// Parse command line arguments -const args = process.argv.slice(2); -const options = { - endpoint: 'http://localhost:9000', - accessKey: 'minioadmin', - secretKey: 'minioadmin', - bucket: `test-backup-${Date.now()}`, - type: 's3', - cleanup: false, - verbose: false -}; - -for (let i = 0; i < args.length; i++) { - switch (args[i]) { - case '--endpoint': - options.endpoint = args[++i]; - break; - case '--access-key': - options.accessKey = args[++i]; - break; - case '--secret-key': - options.secretKey = args[++i]; - break; - case '--bucket': - options.bucket = args[++i]; - break; - case '--type': - options.type = args[++i]; - break; - case '--cleanup': - options.cleanup = true; - break; - case '--verbose': - options.verbose = true; - break; - case '--help': - console.log(module.exports.description || 'Manual Integration Test Script'); - process.exit(0); - } -} - -// Load environment and services -require('dotenv').config(); -const { db, initialize: initDb } = require('../src/database/db'); -const backupService = require('../src/services/backupService'); -const S3StorageAdapter = require('../src/services/storage/s3Storage'); -const logger = require('../src/utils/logger'); - -// Configure logger based on verbose flag -if (!options.verbose) { - logger.info = () => {}; - logger.debug = () => {}; -} - -// Test results -const results = { - passed: 0, - failed: 0, - skipped: 0, - tests: [] -}; - -// Test utilities -async function runTest(name, testFn) { - console.log(`\n📋 Running: ${name}`); - try { - const startTime = Date.now(); - await testFn(); - const duration = Date.now() - startTime; - console.log(`✅ PASSED: ${name} (${duration}ms)`); - results.passed++; - results.tests.push({ name, status: 'passed', duration }); - } catch (error) { - console.error(`❌ FAILED: ${name}`); - console.error(` Error: ${error.message}`); - if (options.verbose) { - console.error(error.stack); - } - results.failed++; - results.tests.push({ name, status: 'failed', error: error.message }); - } -} - -async function skipTest(name, reason) { - console.log(`\n⏭️ Skipping: ${name}`); - console.log(` Reason: ${reason}`); - results.skipped++; - results.tests.push({ name, status: 'skipped', reason }); -} - -// Test functions -async function testS3Connection() { - const s3Adapter = new S3StorageAdapter({ - bucket: options.bucket, - endpoint: options.endpoint, - accessKeyId: options.accessKey, - secretAccessKey: options.secretKey, - region: 'us-east-1', - forcePathStyle: true, - sslEnabled: options.endpoint.startsWith('https') - }); - - await s3Adapter.testConnection(); - console.log(` ✓ Connected to S3 endpoint: ${options.endpoint}`); - console.log(` ✓ Bucket accessible: ${options.bucket}`); -} - -async function setupTestData() { - const storagePath = path.join(__dirname, '../test-storage'); - process.env.STORAGE_PATH = storagePath; - - // Create directory structure - const dirs = [ - 'events/active/wedding-2024', - 'events/active/birthday-2024', - 'events/archived', - 'thumbnails', - 'uploads', - 'backups' - ]; - - for (const dir of dirs) { - await fs.mkdir(path.join(storagePath, dir), { recursive: true }); - } - - // Create test files with various sizes - const files = [ - { path: 'events/active/wedding-2024/photo1.jpg', size: 1024 * 1024 }, // 1MB - { path: 'events/active/wedding-2024/photo2.jpg', size: 512 * 1024 }, // 512KB - { path: 'events/active/birthday-2024/photo1.jpg', size: 2 * 1024 * 1024 }, // 2MB - { path: 'events/archived/old-event.zip', size: 5 * 1024 * 1024 }, // 5MB - { path: 'thumbnails/thumb1.jpg', size: 50 * 1024 }, // 50KB - { path: 'uploads/logo.png', size: 100 * 1024 } // 100KB - ]; - - let totalSize = 0; - for (const file of files) { - const content = crypto.randomBytes(file.size); - await fs.writeFile(path.join(storagePath, file.path), content); - totalSize += file.size; - } - - console.log(` ✓ Created ${files.length} test files`); - console.log(` ✓ Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`); - - return { storagePath, fileCount: files.length, totalSize }; -} - -async function configureBackup(type) { - const baseSettings = [ - { setting_key: 'backup_enabled', setting_value: 'true' }, - { setting_key: 'backup_destination_type', setting_value: `"${type}"` }, - { setting_key: 'backup_include_archived', setting_value: 'true' }, - { setting_key: 'backup_include_database', setting_value: 'true' }, - { setting_key: 'backup_incremental', setting_value: 'true' }, - { setting_key: 'backup_manifest_format', setting_value: '"json"' }, - { setting_key: 'backup_max_file_size_mb', setting_value: '100' } - ]; - - const typeSpecificSettings = { - s3: [ - { setting_key: 'backup_s3_bucket', setting_value: `"${options.bucket}"` }, - { setting_key: 'backup_s3_endpoint', setting_value: `"${options.endpoint}"` }, - { setting_key: 'backup_s3_access_key', setting_value: `"${options.accessKey}"` }, - { setting_key: 'backup_s3_secret_key', setting_value: `"${options.secretKey}"` }, - { setting_key: 'backup_s3_region', setting_value: '"us-east-1"' }, - { setting_key: 'backup_s3_force_path_style', setting_value: 'true' }, - { setting_key: 'backup_s3_ssl_enabled', setting_value: options.endpoint.startsWith('https') ? 'true' : 'false' } - ], - local: [ - { setting_key: 'backup_destination_path', setting_value: `"${path.join(__dirname, '../test-backup')}"` } - ], - rsync: [ - { setting_key: 'backup_rsync_host', setting_value: '"localhost"' }, - { setting_key: 'backup_rsync_path', setting_value: `"${path.join(__dirname, '../test-backup-rsync')}"` } - ] - }; - - const settings = [...baseSettings, ...(typeSpecificSettings[type] || [])]; - - // Clear existing settings - await db('app_settings').where('setting_type', 'backup').del(); - - // Insert new settings - for (const setting of settings) { - await db('app_settings').insert({ - setting_type: 'backup', - ...setting, - created_at: new Date(), - updated_at: new Date() - }); - } - - console.log(` ✓ Configured ${type} backup with ${settings.length} settings`); -} - -async function performBackup() { - const startTime = Date.now(); - - // Run the backup - await backupService.runBackup(); - - // Get backup results - const backupRun = await db('backup_runs') - .orderBy('started_at', 'desc') - .first(); - - if (!backupRun) { - throw new Error('No backup run found'); - } - - if (backupRun.status !== 'completed') { - throw new Error(`Backup failed with status: ${backupRun.status}, error: ${backupRun.error_message}`); - } - - const duration = Date.now() - startTime; - - console.log(` ✓ Backup completed in ${duration}ms`); - console.log(` ✓ Files backed up: ${backupRun.files_backed_up}`); - console.log(` ✓ Total size: ${(backupRun.total_size_bytes / 1024 / 1024).toFixed(2)} MB`); - console.log(` ✓ Manifest: ${backupRun.manifest_path ? 'Generated' : 'Not generated'}`); - - return backupRun; -} - -async function verifyS3Backup(backupRun) { - const s3Client = new S3Client({ - endpoint: options.endpoint, - region: 'us-east-1', - credentials: { - accessKeyId: options.accessKey, - secretAccessKey: options.secretKey - }, - forcePathStyle: true - }); - - // List objects in bucket - const listResponse = await s3Client.send(new ListObjectsV2Command({ - Bucket: options.bucket - })); - - const objects = listResponse.Contents || []; - console.log(` ✓ Objects in S3: ${objects.length}`); - - // Verify key components - const hasBackupFolder = objects.some(obj => obj.Key.includes('backup-')); - const hasManifest = objects.some(obj => obj.Key.includes('backup-manifest')); - const hasSummary = objects.some(obj => obj.Key.includes('backup-summary.json')); - const hasPhotos = objects.some(obj => obj.Key.includes('events/active')); - - if (!hasBackupFolder) throw new Error('No backup folder found in S3'); - if (!hasManifest) throw new Error('No manifest found in S3'); - if (!hasSummary) throw new Error('No summary found in S3'); - if (!hasPhotos) throw new Error('No photos found in S3'); - - console.log(` ✓ Backup structure verified`); - - // Download and verify a file - const photoObject = objects.find(obj => obj.Key.includes('photo1.jpg')); - if (photoObject) { - const getResponse = await s3Client.send(new GetObjectCommand({ - Bucket: options.bucket, - Key: photoObject.Key - })); - - const chunks = []; - for await (const chunk of getResponse.Body) { - chunks.push(chunk); - } - const content = Buffer.concat(chunks); - - console.log(` ✓ Downloaded test file: ${photoObject.Key} (${content.length} bytes)`); - } -} - -async function testIncrementalBackup(testData) { - // Modify a file - const modifiedFile = path.join(testData.storagePath, 'events/active/wedding-2024/photo1.jpg'); - const newContent = crypto.randomBytes(1024 * 1024 + 100); // Slightly larger - await fs.writeFile(modifiedFile, newContent); - - console.log(` ✓ Modified test file`); - - // Perform incremental backup - const backupRun = await performBackup(); - - if (backupRun.files_backed_up !== 1) { - throw new Error(`Expected 1 file in incremental backup, got ${backupRun.files_backed_up}`); - } - - console.log(` ✓ Incremental backup correctly identified changed file`); - - // Verify manifest indicates incremental - if (backupRun.manifest_path) { - const { manifest } = await backupService.getBackupManifest(backupRun.id); - if (!manifest.incremental) { - throw new Error('Manifest does not indicate incremental backup'); - } - console.log(` ✓ Manifest correctly marked as incremental`); - } - - return backupRun; -} - -async function testManifestValidation(backupRun) { - if (!backupRun.manifest_path) { - throw new Error('No manifest path in backup run'); - } - - const result = await backupService.validateBackupManifest(backupRun.manifest_path); - - if (!result.valid) { - throw new Error(`Manifest validation failed: ${result.error}`); - } - - console.log(` ✓ Manifest validation passed`); - console.log(` ✓ Manifest version: ${result.manifest.manifest.version}`); - console.log(` ✓ Files in manifest: ${result.manifest.files.count}`); -} - -async function testBackupStatus() { - const status = await backupService.getBackupStatus(5); - - console.log(` ✓ Backup service running: ${status.isRunning}`); - console.log(` ✓ Backup service healthy: ${status.isHealthy}`); - console.log(` ✓ Recent runs: ${status.recentRuns.length}`); - - if (status.lastRun) { - console.log(` ✓ Last run status: ${status.lastRun.status}`); - console.log(` ✓ Manifest valid: ${status.lastRun.manifestValid}`); - } -} - -async function cleanupTestData() { - if (!options.cleanup) { - console.log('\n📌 Test data retained for inspection'); - console.log(` Storage: ${process.env.STORAGE_PATH}`); - if (options.type === 's3') { - console.log(` S3 Bucket: ${options.bucket}`); - } - return; - } - - console.log('\n🧹 Cleaning up test data...'); - - // Clean storage directory - if (process.env.STORAGE_PATH) { - await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true }); - console.log(' ✓ Removed test storage directory'); - } - - // Clean S3 bucket if used - if (options.type === 's3') { - const s3Client = new S3Client({ - endpoint: options.endpoint, - region: 'us-east-1', - credentials: { - accessKeyId: options.accessKey, - secretAccessKey: options.secretKey - }, - forcePathStyle: true - }); - - try { - // List and delete all objects - const listResponse = await s3Client.send(new ListObjectsV2Command({ - Bucket: options.bucket - })); - - if (listResponse.Contents && listResponse.Contents.length > 0) { - await s3Client.send(new DeleteObjectsCommand({ - Bucket: options.bucket, - Delete: { - Objects: listResponse.Contents.map(obj => ({ Key: obj.Key })) - } - })); - console.log(` ✓ Deleted ${listResponse.Contents.length} objects from S3`); - } - - // Delete bucket - await s3Client.send(new DeleteBucketCommand({ - Bucket: options.bucket - })); - console.log(` ✓ Deleted S3 bucket: ${options.bucket}`); - } catch (error) { - console.error(` ⚠️ Failed to cleanup S3: ${error.message}`); - } - } - - // Clean backup directories - const backupDirs = [ - path.join(__dirname, '../test-backup'), - path.join(__dirname, '../test-backup-rsync') - ]; - - for (const dir of backupDirs) { - await fs.rm(dir, { recursive: true, force: true }).catch(() => {}); - } - console.log(' ✓ Removed backup directories'); -} - -// Main test runner -async function main() { - console.log('🚀 Enhanced Backup System Integration Test'); - console.log('=========================================='); - console.log(`Type: ${options.type}`); - console.log(`Endpoint: ${options.endpoint}`); - console.log(`Bucket: ${options.bucket}`); - console.log(''); - - let s3Client; - let testData; - - try { - // Initialize database - console.log('📦 Initializing database...'); - await initDb(); - await db.migrate.latest(); - console.log(' ✓ Database initialized'); - - // S3-specific setup - if (options.type === 's3') { - // Test S3 connection - await runTest('S3 Connection Test', testS3Connection); - - // Create S3 bucket if needed - s3Client = new S3Client({ - endpoint: options.endpoint, - region: 'us-east-1', - credentials: { - accessKeyId: options.accessKey, - secretAccessKey: options.secretKey - }, - forcePathStyle: true - }); - - try { - await s3Client.send(new HeadBucketCommand({ Bucket: options.bucket })); - console.log(`\n📦 Using existing bucket: ${options.bucket}`); - } catch (error) { - if (error.name === 'NotFound') { - await s3Client.send(new CreateBucketCommand({ Bucket: options.bucket })); - console.log(`\n📦 Created new bucket: ${options.bucket}`); - } else { - throw error; - } - } - } - - // Setup test data - console.log('\n📁 Setting up test data...'); - testData = await setupTestData(); - - // Configure backup - console.log(`\n⚙️ Configuring ${options.type} backup...`); - await configureBackup(options.type); - - // Run tests based on backup type - await runTest('Initial Full Backup', performBackup); - - if (options.type === 's3') { - await runTest('Verify S3 Backup Contents', async () => { - const lastRun = await db('backup_runs').orderBy('started_at', 'desc').first(); - await verifyS3Backup(lastRun); - }); - } - - await runTest('Incremental Backup', () => testIncrementalBackup(testData)); - - await runTest('Manifest Validation', async () => { - const lastRun = await db('backup_runs').orderBy('started_at', 'desc').first(); - await testManifestValidation(lastRun); - }); - - await runTest('Backup Status Check', testBackupStatus); - - // Performance test with larger files - if (options.type === 's3') { - await runTest('Large File Backup (10MB)', async () => { - const largeFile = path.join(testData.storagePath, 'events/active/large.jpg'); - await fs.writeFile(largeFile, crypto.randomBytes(10 * 1024 * 1024)); - await performBackup(); - }); - } - - // Test backup service lifecycle - await runTest('Backup Service Start/Stop', async () => { - await backupService.startBackupService(); - console.log(' ✓ Service started'); - - backupService.stopBackupService(); - console.log(' ✓ Service stopped'); - }); - - // Print results summary - console.log('\n📊 Test Results Summary'); - console.log('======================'); - console.log(`✅ Passed: ${results.passed}`); - console.log(`❌ Failed: ${results.failed}`); - console.log(`⏭️ Skipped: ${results.skipped}`); - console.log(`📋 Total: ${results.tests.length}`); - - if (results.failed > 0) { - console.log('\nFailed Tests:'); - results.tests - .filter(t => t.status === 'failed') - .forEach(t => console.log(` - ${t.name}: ${t.error}`)); - } - - } catch (error) { - console.error('\n💥 Fatal error:', error.message); - if (options.verbose) { - console.error(error.stack); - } - results.failed++; - } finally { - // Cleanup - await cleanupTestData(); - - // Close database - await db.destroy(); - - // Exit with appropriate code - process.exit(results.failed > 0 ? 1 : 0); - } -} - -// Run if called directly -if (require.main === module) { - main().catch(error => { - console.error('Unhandled error:', error); - process.exit(1); - }); -} - -module.exports = { runTest, skipTest }; \ No newline at end of file diff --git a/backend/scripts/test-backup-service.js b/backend/scripts/test-backup-service.js deleted file mode 100644 index 9dfc9fe..0000000 --- a/backend/scripts/test-backup-service.js +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env node - -require('dotenv').config(); -const { initializeDatabase } = require('../src/database/db'); -const { runBackup, getBackupStatus } = require('../src/services/backupService'); -const logger = require('../src/utils/logger'); - -async function testBackupService() { - try { - console.log('Testing backup service...\n'); - - // Initialize database - await initializeDatabase(); - - // Get current backup status - console.log('Getting backup status...'); - const statusBefore = await getBackupStatus(); - console.log('Last run:', statusBefore.lastRun ? statusBefore.lastRun.started_at : 'Never'); - console.log('Is healthy:', statusBefore.isHealthy); - console.log(''); - - // Run backup - console.log('Running backup...'); - await runBackup(); - - // Get status after backup - console.log('\nGetting status after backup...'); - const statusAfter = await getBackupStatus(); - console.log('Last run:', statusAfter.lastRun ? statusAfter.lastRun.started_at : 'Never'); - console.log('Status:', statusAfter.lastRun ? statusAfter.lastRun.status : 'Unknown'); - console.log('Files backed up:', statusAfter.lastRun ? statusAfter.lastRun.files_backed_up : 0); - console.log('Total size:', statusAfter.lastRun ? `${(statusAfter.lastRun.total_size_bytes / 1024 / 1024).toFixed(2)} MB` : '0 MB'); - - if (statusAfter.lastRun && statusAfter.lastRun.error_message) { - console.log('Error:', statusAfter.lastRun.error_message); - } - - console.log('\nBackup test completed!'); - process.exit(0); - } catch (error) { - console.error('Test failed:', error); - process.exit(1); - } -} - -testBackupService(); \ No newline at end of file diff --git a/backend/scripts/test-restore-service.js b/backend/scripts/test-restore-service.js deleted file mode 100644 index e4c994a..0000000 --- a/backend/scripts/test-restore-service.js +++ /dev/null @@ -1,325 +0,0 @@ -/** - * Test script for the restore service - * - * This script demonstrates the restore service functionality with safety checks - * - * Usage: - * node scripts/test-restore-service.js [options] - * - * Options: - * --dry-run Perform validation only without actual restore - * --force Force restore even with warnings - * --type Restore type: full, database, files, selective (default: full) - * --source Backup source path or S3 URL - * --manifest Path to backup manifest - */ - -require('dotenv').config(); -const { restoreService } = require('../src/services/restoreService'); -const { db } = require('../src/database/db'); -const logger = require('../src/utils/logger'); -const path = require('path'); -const fs = require('fs').promises; - -// Parse command line arguments -const args = process.argv.slice(2); -const options = { - dryRun: args.includes('--dry-run'), - force: args.includes('--force'), - restoreType: 'full', - source: null, - manifestPath: null -}; - -// Parse restore type -const typeIndex = args.indexOf('--type'); -if (typeIndex !== -1 && args[typeIndex + 1]) { - options.restoreType = args[typeIndex + 1]; -} - -// Parse source -const sourceIndex = args.indexOf('--source'); -if (sourceIndex !== -1 && args[sourceIndex + 1]) { - options.source = args[sourceIndex + 1]; -} - -// Parse manifest -const manifestIndex = args.indexOf('--manifest'); -if (manifestIndex !== -1 && args[manifestIndex + 1]) { - options.manifestPath = args[manifestIndex + 1]; -} - -async function testRestore() { - console.log('=== PicPeak Restore Service Test ===\n'); - - try { - // If no source/manifest provided, try to find a recent backup - if (!options.source || !options.manifestPath) { - console.log('No backup source specified. Looking for recent backups...\n'); - - const recentBackup = await db('backup_runs') - .where('status', 'completed') - .whereNotNull('manifest_path') - .orderBy('completed_at', 'desc') - .first(); - - if (!recentBackup) { - console.error('❌ No completed backups found in the database'); - console.log('\nPlease run a backup first or specify --source and --manifest'); - process.exit(1); - } - - console.log(`Found recent backup from ${recentBackup.completed_at}`); - console.log(`Backup ID: ${recentBackup.manifest_id}`); - console.log(`Files backed up: ${recentBackup.files_backed_up}`); - console.log(`Total size: ${(recentBackup.total_size_bytes / 1024 / 1024).toFixed(2)} MB`); - console.log(`Manifest: ${recentBackup.manifest_path}\n`); - - // For this test, we'll create a mock scenario - console.log('⚠️ This is a TEST MODE - using mock data for safety\n'); - - // Create test backup directory - const testBackupDir = path.join(__dirname, '../temp/test-backup'); - await fs.mkdir(testBackupDir, { recursive: true }); - - // Create test manifest - const testManifest = { - manifest: { - version: '2.0', - created: new Date().toISOString(), - generator: 'Test Script', - format: 'json' - }, - backup: { - id: 'test-backup-' + Date.now(), - type: 'full', - timestamp: new Date().toISOString(), - path: testBackupDir, - parent_backup_id: null, - retention_days: 30 - }, - system: { - hostname: require('os').hostname(), - platform: process.platform, - os_release: require('os').release(), - architecture: require('os').arch() - }, - application: { - name: 'PicPeak', - version: require('../package.json').version, - node_version: process.version, - environment: 'test' - }, - files: { - count: 0, - total_size: 0, - checksums: {}, - manifest: [] - }, - database: { - type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite', - backup_file: null, - size: 0, - checksum: null, - tables: {}, - row_counts: {} - }, - verification: { - total_checksum: null, - file_count_check: 0, - size_check: 0, - integrity_timestamp: new Date().toISOString() - }, - metadata: { - test_mode: true - } - }; - - // Calculate checksum - const crypto = require('crypto'); - const manifestCopy = JSON.parse(JSON.stringify(testManifest)); - delete manifestCopy.verification.total_checksum; - testManifest.verification.total_checksum = crypto - .createHash('sha256') - .update(JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort())) - .digest('hex'); - - // Save test manifest - const testManifestPath = path.join(testBackupDir, 'test-manifest.json'); - await fs.writeFile(testManifestPath, JSON.stringify(testManifest, null, 2)); - - options.source = testBackupDir; - options.manifestPath = testManifestPath; - } - - // Display restore options - console.log('Restore Options:'); - console.log(`- Type: ${options.restoreType}`); - console.log(`- Source: ${options.source}`); - console.log(`- Manifest: ${options.manifestPath}`); - console.log(`- Dry Run: ${options.dryRun ? 'Yes' : 'No'}`); - console.log(`- Force: ${options.force ? 'Yes' : 'No'}`); - console.log(''); - - // Add S3 config if source is S3 - if (options.source.startsWith('s3://')) { - options.s3Config = { - accessKeyId: process.env.BACKUP_S3_ACCESS_KEY, - secretAccessKey: process.env.BACKUP_S3_SECRET_KEY, - region: process.env.BACKUP_S3_REGION || 'us-east-1', - endpoint: process.env.BACKUP_S3_ENDPOINT - }; - - if (!options.s3Config.accessKeyId || !options.s3Config.secretAccessKey) { - console.error('❌ S3 credentials not configured in environment'); - process.exit(1); - } - } - - // Confirm before proceeding (unless dry run) - if (!options.dryRun) { - console.log('⚠️ WARNING: This will restore data from the backup!'); - console.log('⚠️ Current data may be overwritten!'); - console.log(''); - console.log('Press Ctrl+C to cancel, or wait 5 seconds to continue...'); - await new Promise(resolve => setTimeout(resolve, 5000)); - } - - console.log('\nStarting restore operation...\n'); - - // Perform restore - const result = await restoreService.restore(options); - - if (options.dryRun) { - console.log('\n=== DRY RUN RESULTS ===\n'); - - console.log('Validation:'); - console.log(`- Valid: ${result.validation.isValid ? '✅ Yes' : '❌ No'}`); - - if (result.validation.errors.length > 0) { - console.log('- Errors:'); - result.validation.errors.forEach(err => console.log(` ❌ ${err}`)); - } - - if (result.validation.warnings.length > 0) { - console.log('- Warnings:'); - result.validation.warnings.forEach(warn => console.log(` ⚠️ ${warn}`)); - } - - console.log('\nDisk Space:'); - console.log(`- Required: ${result.spaceCheck.requiredFormatted}`); - console.log(`- Available: ${result.spaceCheck.availableFormatted}`); - console.log(`- Sufficient: ${result.spaceCheck.hasEnoughSpace ? '✅ Yes' : '❌ No'}`); - - } else { - console.log('\n=== RESTORE RESULTS ===\n'); - - console.log(`Status: ${result.success ? '✅ SUCCESS' : '❌ FAILED'}`); - console.log(`Duration: ${result.duration}s`); - - if (result.result) { - console.log('\nItems Restored:'); - if (result.result.databaseRestored !== undefined) { - console.log(`- Database: ${result.result.databaseRestored ? '✅' : '❌'}`); - } - if (result.result.filesRestored !== undefined) { - console.log(`- Files: ${result.result.filesRestored}`); - } - if (result.result.errors && result.result.errors.length > 0) { - console.log('- Errors:'); - result.result.errors.forEach(err => console.log(` ❌ ${err}`)); - } - } - - if (result.verification) { - console.log('\nVerification:'); - console.log(`- Valid: ${result.verification.isValid ? '✅ Yes' : '❌ No'}`); - if (result.verification.errors.length > 0) { - console.log('- Errors:'); - result.verification.errors.forEach(err => console.log(` ❌ ${err}`)); - } - } - - if (result.preRestoreBackup) { - console.log('\nSafety Backup:'); - console.log(`- Location: ${result.preRestoreBackup}`); - console.log('- This backup can be used to rollback if needed'); - } - } - - // Show recent log entries - console.log('\nRecent Log Entries:'); - result.logs.slice(-10).forEach(log => { - const icon = log.level === 'error' ? '❌' : log.level === 'warn' ? '⚠️ ' : 'ℹ️ '; - console.log(`${icon} [${log.timestamp}] ${log.message}`); - }); - - // Clean up test files - if (options.source && options.source.includes('test-backup')) { - await fs.rmdir(path.dirname(options.source), { recursive: true }).catch(() => {}); - } - - } catch (error) { - console.error('\n❌ Restore operation failed:', error.message); - - // Show logs if available - if (restoreService.restoreLog && restoreService.restoreLog.length > 0) { - console.log('\nError Log:'); - restoreService.restoreLog.slice(-10).forEach(log => { - if (log.level === 'error' || log.level === 'warn') { - console.log(`[${log.timestamp}] ${log.level.toUpperCase()}: ${log.message}`); - } - }); - } - - process.exit(1); - } - - // Cleanup - await db.destroy(); - process.exit(0); -} - -// Show help if requested -if (args.includes('--help') || args.includes('-h')) { - console.log(` -PicPeak Restore Service Test - -This script tests the restore service functionality with safety checks. - -Usage: - node scripts/test-restore-service.js [options] - -Options: - --dry-run Perform validation only without actual restore - --force Force restore even with warnings - --type Restore type: full, database, files, selective (default: full) - --source Backup source path or S3 URL - --manifest Path to backup manifest - --help Show this help message - -Examples: - # Dry run with automatic backup selection - node scripts/test-restore-service.js --dry-run - - # Full restore from specific backup - node scripts/test-restore-service.js --source /backup/2024-01-20 --manifest /backup/2024-01-20/manifest.json - - # Database-only restore with force - node scripts/test-restore-service.js --type database --force --source /backup/2024-01-20 --manifest /backup/2024-01-20/manifest.json - - # Restore from S3 - node scripts/test-restore-service.js --source s3://my-bucket/backups/2024-01-20 --manifest s3://my-bucket/backups/2024-01-20/manifest.json - -Safety Features: -- Pre-restore validation checks compatibility and warns about potential issues -- Automatic pre-restore backup is created (unless skipped) -- Post-restore verification ensures data integrity -- Rollback capability if restore fails -- Detailed logging of all operations -`); - process.exit(0); -} - -// Run the test -testRestore(); \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 8f4a937..e5f8a80 100644 --- a/backend/server.js +++ b/backend/server.js @@ -32,6 +32,7 @@ const eventRoutes = require('./src/routes/events'); const galleryRoutes = require('./src/routes/gallery'); const adminRoutes = require('./src/routes/admin'); const adminAuthRoutes = require('./src/routes/adminAuth'); +const secureImagesRoutes = require('./src/routes/secureImages'); const app = express(); const PORT = process.env.PORT || 3000; @@ -199,22 +200,36 @@ app.get('/health', async (req, res) => { // Routes app.use('/api/auth', authRoutes); app.use('/api/events', eventRoutes); +// Gallery routes - main routes first, then feedback routes app.use('/api/gallery', galleryRoutes); +app.use('/api/gallery', require('./src/routes/galleryFeedback')); app.use('/api/admin', adminRoutes); app.use('/api/admin/auth', adminAuthRoutes); app.use('/api/admin/system', require('./src/routes/adminSystem')); app.use('/api/admin/backup', require('./src/routes/adminBackup')); app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup')); app.use('/api/admin/feedback', require('./src/routes/adminFeedback')); -app.use('/api/gallery', require('./src/routes/galleryFeedback')); +app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity')); +app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails')); +app.use('/api/admin/photos', require('./src/routes/adminPhotos')); app.use('/api/public/settings', require('./src/routes/publicSettings')); app.use('/api/public', require('./src/routes/publicCMS')); app.use('/api/images', require('./src/routes/protectedImages')); +app.use('/api/secure-images', secureImagesRoutes); // Error handling middleware app.use((err, req, res, next) => { - logger.error(err.stack); - res.status(500).json({ error: 'Something went wrong!' }); + console.error('EXPRESS ERROR HANDLER:', err); + console.error('Error stack:', err.stack); + console.error('Request URL:', req.url); + console.error('Request method:', req.method); + logger.error('Express error handler:', { + message: err.message, + stack: err.stack, + url: req.url, + method: req.method + }); + res.status(500).json({ error: 'Something went wrong!', details: err.message }); }); // Initialize services diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 0e1f5bf..db46d4c 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -1,5 +1,6 @@ const knex = require('knex'); const knexConfig = require('../../knexfile'); +const logger = require('../utils/logger'); // Create database connection with built-in retry logic const db = knex(knexConfig); @@ -22,7 +23,7 @@ async function withRetry(queryFn, retries = MAX_RETRIES) { ); if (isConnectionError && i < retries - 1) { - console.log(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`); + logger.info(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`); await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1))); continue; } @@ -91,7 +92,7 @@ async function initializeDatabase() { 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'); + logger.debug('Color theme migration may have already been applied'); } } } @@ -153,9 +154,12 @@ async function initializeDatabase() { table.string('email').unique().notNullable(); table.string('password_hash').notNullable(); table.boolean('is_active').defaultTo(true); + table.boolean('must_change_password').defaultTo(false); + table.datetime('password_changed_at'); table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('updated_at').defaultTo(db.fn.now()); table.datetime('last_login'); + table.string('last_login_ip'); }); } else { // Check if updated_at column exists @@ -167,6 +171,62 @@ async function initializeDatabase() { // Set default value for existing rows await db('admin_users').update({ updated_at: new Date() }); } + + // Check if must_change_password column exists + const hasMustChangePassword = await db.schema.hasColumn('admin_users', 'must_change_password'); + if (!hasMustChangePassword) { + await db.schema.table('admin_users', (table) => { + table.boolean('must_change_password').defaultTo(false); + }); + } + + // Check if password_changed_at column exists + const hasPasswordChangedAt = await db.schema.hasColumn('admin_users', 'password_changed_at'); + if (!hasPasswordChangedAt) { + await db.schema.table('admin_users', (table) => { + table.datetime('password_changed_at'); + }); + } + + // Check if last_login_ip column exists + const hasLastLoginIp = await db.schema.hasColumn('admin_users', 'last_login_ip'); + if (!hasLastLoginIp) { + await db.schema.table('admin_users', (table) => { + table.string('last_login_ip'); + }); + } + } + + // Token revocation tables + const hasRevokedTokensTable = await db.schema.hasTable('revoked_tokens'); + if (!hasRevokedTokensTable) { + await db.schema.createTable('revoked_tokens', (table) => { + table.increments('id').primary(); + table.string('token_id').notNullable().unique(); // JWT ID or generated ID + table.integer('user_id').nullable(); // User who owned the token + table.string('token_type', 20); // admin, gallery, etc. + table.timestamp('revoked_at').defaultTo(db.fn.now()); + table.timestamp('expires_at').notNullable(); // When token would have expired + table.string('reason', 100); // password_change, logout, compromised, etc. + table.text('metadata'); // Additional JSON data + + // Indexes for performance + table.index('token_id'); + table.index('user_id'); + table.index('expires_at'); // For cleanup + }); + } + + const hasUserTokenRevocationsTable = await db.schema.hasTable('user_token_revocations'); + if (!hasUserTokenRevocationsTable) { + await db.schema.createTable('user_token_revocations', (table) => { + table.integer('user_id').primary(); + table.timestamp('revoked_at').notNullable(); + table.string('reason', 100); + + // Index for quick lookups + table.index('revoked_at'); + }); } // Email configuration table @@ -248,7 +308,7 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor = event_id: eventId }); } catch (error) { - console.error('Failed to log activity:', error); + logger.error('Failed to log activity:', { error: error.message }); } } diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 24e10a6..0270d22 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -22,7 +22,19 @@ async function adminAuth(req, res, next) { let decoded; try { - decoded = jwt.verify(token, process.env.JWT_SECRET); + // Try to verify with issuer first, fallback to no issuer for backward compatibility + try { + decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth' + }); + } catch (issuerError) { + // If verification fails with issuer, try without issuer (backward compatibility) + if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) { + decoded = jwt.verify(token, process.env.JWT_SECRET); + } else { + throw issuerError; + } + } } catch (jwtError) { const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.headers['x-real-ip'] || diff --git a/backend/src/middleware/feedbackRateLimit.js b/backend/src/middleware/feedbackRateLimit.js index fc1ae7d..2f38b74 100644 --- a/backend/src/middleware/feedbackRateLimit.js +++ b/backend/src/middleware/feedbackRateLimit.js @@ -24,7 +24,10 @@ async function getRateLimitSettings() { .first(); if (settings && settings.setting_value) { - return JSON.parse(settings.setting_value); + // setting_value is already a JSON object in PostgreSQL + return typeof settings.setting_value === 'string' + ? JSON.parse(settings.setting_value) + : settings.setting_value; } // Default settings @@ -120,8 +123,8 @@ async function recordAction(identifier, eventId, actionType) { function feedbackRateLimit(actionType) { return async (req, res, next) => { try { - // Extract event ID from params or body - const eventId = req.params.eventId || req.body?.event_id; + // Extract event ID from params, body or event object (set by verifyGalleryAccess) + const eventId = req.params.eventId || req.body?.event_id || req.event?.id; if (!eventId) { return res.status(400).json({ error: 'Event ID required' }); } diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index 42cd7d4..e8d2184 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -5,27 +5,82 @@ const { formatBoolean } = require('../utils/dbCompat'); // Middleware to verify gallery access async function verifyGalleryAccess(req, res, next) { try { - const token = req.headers.authorization?.split(' ')[1]; + const authHeader = req.headers.authorization; + const token = authHeader?.split(' ')[1]; if (!token) { return res.status(401).json({ error: 'No token provided' }); } - const decoded = jwt.verify(token, process.env.JWT_SECRET); - const event = await withRetry(async () => { - return await db('events') - .where({ - id: decoded.eventId, - is_active: formatBoolean(true), - is_archived: formatBoolean(false) - }) - .first(); - }); + + // Try to verify with issuer first, fallback to no issuer for backward compatibility + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth' + }); + } catch (error) { + // If verification fails with issuer, try without issuer (backward compatibility) + if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) { + decoded = jwt.verify(token, process.env.JWT_SECRET); + } else { + throw error; + } + } + console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId); + + // If we have a slug in the URL params or from pre-middleware, verify it matches + const requestedSlug = req.params.slug || req.requestedSlug; + + let event; + if (requestedSlug) { + // Verify by slug and ensure it matches the token's event + event = await withRetry(async () => { + return await db('events') + .where({ + slug: requestedSlug, + is_active: formatBoolean(true), + is_archived: formatBoolean(false) + }) + .select('*') + .first(); + }); + + // Verify the token's eventId matches + if (event && event.id !== decoded.eventId) { + return res.status(403).json({ error: 'Token does not match requested gallery' }); + } + } else { + // Fallback to using eventId from token + event = await withRetry(async () => { + return await db('events') + .where({ + id: decoded.eventId, + is_active: formatBoolean(true), + is_archived: formatBoolean(false) + }) + .select('*') + .first(); + }); + } if (!event) { + console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId); return res.status(404).json({ error: 'Gallery not found or expired' }); } + console.log('[verifyGalleryAccess] Event found:', event.id, event.slug); req.event = event; + req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`; + + // Create client info for logging (similar to secureImageMiddleware but simpler) + req.clientInfo = { + ip: req.ip || req.connection.remoteAddress || 'unknown', + userAgent: req.get('User-Agent') || 'unknown', + fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), // Limit to 32 chars for DB column + timestamp: Date.now() + }; + + console.log('[verifyGalleryAccess] Access granted for event:', event.id); next(); } catch (error) { console.error('Error verifying gallery access:', error); diff --git a/backend/src/middleware/photoAuth.js b/backend/src/middleware/photoAuth.js index 63d974e..9ba8bc8 100644 --- a/backend/src/middleware/photoAuth.js +++ b/backend/src/middleware/photoAuth.js @@ -24,7 +24,20 @@ async function photoAuth(req, res, next) { if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.replace('Bearer ', ''); try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); + // Try to verify with issuer first, fallback to no issuer for backward compatibility + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth' + }); + } catch (issuerError) { + // If verification fails with issuer, try without issuer (backward compatibility) + if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) { + decoded = jwt.verify(token, process.env.JWT_SECRET); + } else { + throw issuerError; + } + } // Check if it's a gallery token if (decoded.type === 'gallery') { diff --git a/backend/src/middleware/secureImageMiddleware.js b/backend/src/middleware/secureImageMiddleware.js new file mode 100644 index 0000000..a273312 --- /dev/null +++ b/backend/src/middleware/secureImageMiddleware.js @@ -0,0 +1,409 @@ +const { db } = require('../database/db'); +const secureImageService = require('../services/secureImageService'); +const logger = require('../utils/logger'); +const { formatBoolean } = require('../utils/dbCompat'); + +/** + * Enhanced secure image middleware with comprehensive protection + */ +class SecureImageMiddleware { + constructor() { + this.suspiciousIPs = new Set(); + this.blockedFingerprints = new Set(); + this.rateLimitViolations = new Map(); + } + + /** + * Main security middleware for image access + */ + secureImageAccess = async (req, res, next) => { + try { + const startTime = Date.now(); + const clientIP = this.getClientIP(req); + const userAgent = req.get('User-Agent') || ''; + const clientFingerprint = secureImageService.createClientFingerprint(req); + + // Create client info object + req.clientInfo = { + ip: clientIP, + userAgent, + fingerprint: clientFingerprint, + timestamp: startTime + }; + + // Security checks + const securityCheck = await this.performSecurityChecks(req, res); + if (!securityCheck.passed) { + return res.status(securityCheck.status).json({ + error: securityCheck.message + }); + } + + // Set security headers + this.setSecurityHeaders(res); + + // Log successful security check + logger.info('Secure image access granted', { + ip: clientIP, + fingerprint: clientFingerprint, + photoId: req.params.photoId, + eventId: req.params.slug, + userAgent: userAgent.substring(0, 100) + }); + + next(); + } catch (error) { + logger.error('Secure image middleware error', { + error: error.message, + stack: error.stack, + ip: req.ip, + path: req.path + }); + + res.status(500).json({ + error: 'Security validation failed' + }); + } + }; + + /** + * Perform comprehensive security checks + */ + async performSecurityChecks(req, res) { + const { clientInfo } = req; + const { photoId } = req.params; + + // 1. Check if IP is blocked + if (this.suspiciousIPs.has(clientInfo.ip)) { + await this.logSecurityEvent('blocked_ip_access', req, { reason: 'IP on block list' }); + return { passed: false, status: 403, message: 'Access denied' }; + } + + // 2. Check if fingerprint is blocked + if (this.blockedFingerprints.has(clientInfo.fingerprint)) { + await this.logSecurityEvent('blocked_fingerprint_access', req, { reason: 'Fingerprint blocked' }); + return { passed: false, status: 403, message: 'Access denied' }; + } + + // 3. Rate limiting check + const rateLimit = await this.checkRateLimit(req); + if (!rateLimit.passed) { + await this.logSecurityEvent('rate_limit_exceeded', req, rateLimit); + return { passed: false, status: 429, message: 'Too many requests' }; + } + + // 4. Check for suspicious patterns + if (photoId) { + const suspiciousActivity = await secureImageService.detectSuspiciousActivity( + clientInfo.fingerprint, + photoId + ); + + if (suspiciousActivity) { + await this.logSecurityEvent('suspicious_activity', req, { + photoId, + reason: 'Multiple rapid accesses' + }); + + // Add to monitoring but don't block yet + this.flagSuspiciousActivity(clientInfo); + } + } + + // 5. User-Agent validation + const userAgentValid = this.validateUserAgent(clientInfo.userAgent); + if (!userAgentValid.valid) { + await this.logSecurityEvent('invalid_user_agent', req, userAgentValid); + return { passed: false, status: 400, message: 'Invalid client' }; + } + + // 6. Check request headers for automation signs + const automationCheck = this.detectAutomation(req); + if (automationCheck.detected) { + await this.logSecurityEvent('automation_detected', req, automationCheck); + return { passed: false, status: 403, message: 'Automated access not allowed' }; + } + + return { passed: true }; + } + + /** + * Advanced rate limiting with multiple windows + */ + async checkRateLimit(req) { + const { clientInfo } = req; + const now = Date.now(); + + // Get rate limit settings from database + const settings = await this.getRateLimitSettings(); + + // Check different time windows + const windows = [ + { duration: 60000, limit: settings.perMinute || 30 }, // 1 minute + { duration: 300000, limit: settings.per5Minutes || 100 }, // 5 minutes + { duration: 3600000, limit: settings.perHour || 500 } // 1 hour + ]; + + for (const window of windows) { + const allowed = secureImageService.checkRateLimit( + `${clientInfo.fingerprint}_${window.duration}`, + window.limit, + window.duration + ); + + if (!allowed) { + // Track violations + const violationKey = `${clientInfo.fingerprint}_violations`; + const violations = this.rateLimitViolations.get(violationKey) || 0; + this.rateLimitViolations.set(violationKey, violations + 1); + + // Block after multiple violations + if (violations >= 5) { + this.blockedFingerprints.add(clientInfo.fingerprint); + logger.warn('Client fingerprint blocked due to repeated violations', { + fingerprint: clientInfo.fingerprint, + violations: violations + 1 + }); + } + + return { + passed: false, + window: window.duration / 1000, + limit: window.limit, + violations: violations + 1 + }; + } + } + + return { passed: true }; + } + + /** + * Validate User-Agent for legitimacy + */ + validateUserAgent(userAgent) { + if (!userAgent || userAgent.length < 10) { + return { valid: false, reason: 'Missing or too short User-Agent' }; + } + + // Check for common bot patterns + const botPatterns = [ + /curl/i, /wget/i, /scrapy/i, /python/i, /requests/i, + /bot/i, /crawler/i, /spider/i, /scraper/i + ]; + + for (const pattern of botPatterns) { + if (pattern.test(userAgent)) { + return { valid: false, reason: 'Bot User-Agent detected' }; + } + } + + // Check for valid browser patterns + const browserPatterns = [ + /mozilla/i, /chrome/i, /safari/i, /firefox/i, /edge/i, /opera/i + ]; + + const hasValidBrowser = browserPatterns.some(pattern => pattern.test(userAgent)); + if (!hasValidBrowser) { + return { valid: false, reason: 'Invalid browser User-Agent' }; + } + + return { valid: true }; + } + + /** + * Detect automation and scripting attempts + */ + detectAutomation(req) { + const headers = req.headers; + const suspiciousHeaders = []; + + // Check for automation indicators + if (!headers.accept) { + suspiciousHeaders.push('missing_accept_header'); + } + + if (!headers['accept-language']) { + suspiciousHeaders.push('missing_accept_language'); + } + + if (!headers['accept-encoding']) { + suspiciousHeaders.push('missing_accept_encoding'); + } + + // Check for scripting headers + if (headers['x-requested-with'] === 'XMLHttpRequest' && !headers.referer) { + suspiciousHeaders.push('ajax_without_referer'); + } + + // Check for headless browser indicators + if (headers['user-agent'] && headers['user-agent'].includes('HeadlessChrome')) { + suspiciousHeaders.push('headless_browser'); + } + + const detected = suspiciousHeaders.length >= 2; + + return { + detected, + suspiciousHeaders, + score: suspiciousHeaders.length + }; + } + + /** + * Set comprehensive security headers + */ + setSecurityHeaders(res) { + res.set({ + // Prevent caching + 'Cache-Control': 'no-store, no-cache, must-revalidate, private', + 'Pragma': 'no-cache', + 'Expires': '0', + + // Security headers + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'Content-Security-Policy': "default-src 'none'; img-src 'self'", + + // Custom security headers + 'X-Protected-Content': 'true', + 'X-Download-Policy': 'restricted', + + // CORS restrictions + 'Access-Control-Allow-Origin': process.env.FRONTEND_URL || '*', + 'Access-Control-Allow-Methods': 'GET', + 'Access-Control-Allow-Headers': 'Authorization, Content-Type', + 'Access-Control-Max-Age': '3600' + }); + } + + /** + * Get client IP address with proxy support + */ + getClientIP(req) { + return req.headers['x-forwarded-for']?.split(',')[0]?.trim() || + req.headers['x-real-ip'] || + req.connection.remoteAddress || + req.socket.remoteAddress || + req.ip; + } + + /** + * Flag suspicious activity for monitoring + */ + flagSuspiciousActivity(clientInfo) { + const key = `suspicious_${clientInfo.fingerprint}`; + const existing = this.rateLimitViolations.get(key) || 0; + + this.rateLimitViolations.set(key, existing + 1); + + // Add to suspicious IPs after multiple flags + if (existing >= 3) { + this.suspiciousIPs.add(clientInfo.ip); + logger.warn('IP added to suspicious list', { + ip: clientInfo.ip, + fingerprint: clientInfo.fingerprint, + flags: existing + 1 + }); + } + } + + /** + * Log security events + */ + async logSecurityEvent(eventType, req, details = {}) { + try { + const logData = { + event_type: eventType, + client_ip: req.clientInfo?.ip || req.ip, + client_fingerprint: req.clientInfo?.fingerprint, + user_agent: req.get('User-Agent')?.substring(0, 255), + request_path: req.path, + request_method: req.method, + details: JSON.stringify(details), + timestamp: new Date().toISOString() + }; + + logger.warn(`Security event: ${eventType}`, logData); + + // Store in database if needed + if (process.env.LOG_SECURITY_EVENTS === 'true') { + await db('security_logs').insert(logData).catch(console.error); + } + } catch (error) { + console.error('Error logging security event:', error); + } + } + + /** + * Get rate limit settings from database + */ + async getRateLimitSettings() { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'max_image_requests_per_minute', + 'max_image_requests_per_5_minutes', + 'max_image_requests_per_hour' + ]) + .select('setting_key', 'setting_value'); + + const config = {}; + settings.forEach(setting => { + const key = setting.setting_key.replace('max_image_requests_per_', ''); + config[key === 'minute' ? 'perMinute' : key === '5_minutes' ? 'per5Minutes' : 'perHour'] = + JSON.parse(setting.setting_value); + }); + + return { + perMinute: config.perMinute || 30, + per5Minutes: config.per5Minutes || 100, + perHour: config.perHour || 500 + }; + } catch (error) { + console.error('Error getting rate limit settings:', error); + return { perMinute: 30, per5Minutes: 100, perHour: 500 }; + } + } + + /** + * Clean up old security data + */ + cleanup() { + const now = Date.now(); + + // Clear old rate limit violations (older than 1 hour) + for (const [key, timestamp] of this.rateLimitViolations.entries()) { + if (typeof timestamp === 'number' && now - timestamp > 3600000) { + this.rateLimitViolations.delete(key); + } + } + + // Clean up the secure image service + secureImageService.cleanup(); + } + + /** + * Get security status + */ + getSecurityStatus() { + return { + suspiciousIPsCount: this.suspiciousIPs.size, + blockedFingerprintsCount: this.blockedFingerprints.size, + activeViolations: this.rateLimitViolations.size, + timestamp: new Date().toISOString() + }; + } +} + +// Create singleton instance +const secureImageMiddleware = new SecureImageMiddleware(); + +// Setup cleanup interval +setInterval(() => { + secureImageMiddleware.cleanup(); +}, 300000); // Every 5 minutes + +module.exports = secureImageMiddleware; \ No newline at end of file diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 9308cc1..3826da1 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -57,7 +57,16 @@ router.post('/', adminAuth, [ allow_downloads = true, disable_right_click = false, watermark_downloads = false, - watermark_text = null + watermark_text = null, + // Feedback settings + feedback_enabled = false, + allow_ratings = true, + allow_likes = true, + allow_comments = true, + allow_favorites = true, + require_name_email = false, + moderate_comments = true, + show_feedback_to_guests = true } = req.body; // Debug logging @@ -152,6 +161,23 @@ router.post('/', adminAuth, [ // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) const eventId = insertResult[0]?.id || insertResult[0]; + // Insert feedback settings if feedback is enabled + if (feedback_enabled) { + await db('event_feedback_settings').insert({ + event_id: eventId, + feedback_enabled: formatBoolean(feedback_enabled), + allow_ratings: formatBoolean(allow_ratings), + allow_likes: formatBoolean(allow_likes), + allow_comments: formatBoolean(allow_comments), + allow_favorites: formatBoolean(allow_favorites), + require_name_email: formatBoolean(require_name_email), + moderate_comments: formatBoolean(moderate_comments), + show_feedback_to_guests: formatBoolean(show_feedback_to_guests), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }); + } + // Log activity await logActivity('event_created', { event_type, expires_at }, @@ -443,10 +469,7 @@ router.delete('/:id', adminAuth, async (req, res) => { // 4. Delete photos (this will also handle hero_photo_id foreign key) await trx('photos').where('event_id', id).del(); - // 5. Delete categories (photo_categories has CASCADE delete for event_id) - await trx('photo_categories').where('event_id', id).del(); - - // 6. Finally delete the event + // 5. Finally delete the event await trx('events').where('id', id).del(); // Delete event folder from storage if it exists diff --git a/backend/src/routes/adminFeedback.js b/backend/src/routes/adminFeedback.js index 2be68fe..a8406cc 100644 --- a/backend/src/routes/adminFeedback.js +++ b/backend/src/routes/adminFeedback.js @@ -111,7 +111,29 @@ router.get('/events/:eventId/feedback', // Pagination const offset = (page - 1) * limit; - const totalCount = await query.clone().count('photo_feedback.id as count').first(); + + // Create a separate count query + let countQuery = db('photo_feedback') + .where('photo_feedback.event_id', eventId); + + if (type) { + countQuery = countQuery.where('photo_feedback.feedback_type', type); + } + + if (status === 'pending') { + countQuery = countQuery.where('photo_feedback.is_approved', false) + .where('photo_feedback.is_hidden', false); + } else if (status === 'approved') { + countQuery = countQuery.where('photo_feedback.is_approved', true); + } else if (status === 'hidden') { + countQuery = countQuery.where('photo_feedback.is_hidden', true); + } + + if (photoId) { + countQuery = countQuery.where('photo_feedback.photo_id', photoId); + } + + const totalCount = await countQuery.count('photo_feedback.id as count').first(); const feedback = await query .orderBy('photo_feedback.created_at', 'desc') @@ -314,7 +336,7 @@ router.get('/feedback/pending-moderation', ); // Word filter management -router.get('/feedback/word-filters', +router.get('/word-filters', adminAuth, async (req, res) => { try { @@ -327,7 +349,7 @@ router.get('/feedback/word-filters', } ); -router.post('/feedback/word-filters', +router.post('/word-filters', adminAuth, validateWordFilter, checkValidation, @@ -339,8 +361,8 @@ router.post('/feedback/word-filters', await logActivity('word_filter_added', { word, severity }, null, { type: 'admin', - id: req.user.id, - name: req.user.username + id: req.user?.id || req.admin?.id, + name: req.user?.username || req.admin?.username }); res.json({ success: true }); @@ -354,7 +376,7 @@ router.post('/feedback/word-filters', } ); -router.put('/feedback/word-filters/:id', +router.put('/word-filters/:id', adminAuth, async (req, res) => { try { @@ -371,7 +393,7 @@ router.put('/feedback/word-filters/:id', } ); -router.delete('/feedback/word-filters/:id', +router.delete('/word-filters/:id', adminAuth, async (req, res) => { try { diff --git a/backend/src/routes/adminImageSecurity.js b/backend/src/routes/adminImageSecurity.js new file mode 100644 index 0000000..75d370f --- /dev/null +++ b/backend/src/routes/adminImageSecurity.js @@ -0,0 +1,515 @@ +const express = require('express'); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const secureImageMiddleware = require('../middleware/secureImageMiddleware'); +const logger = require('../utils/logger'); + +const router = express.Router(); + +/** + * Get image security settings + */ +router.get('/settings', adminAuth, async (req, res) => { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'default_protection_level', + 'default_image_quality', + 'enable_devtools_protection', + 'max_image_requests_per_minute', + 'max_image_requests_per_5_minutes', + 'max_image_requests_per_hour', + 'suspicious_activity_threshold', + 'enable_canvas_rendering', + 'default_fragmentation_level', + 'security_monitoring_enabled', + 'block_suspicious_ips', + 'log_security_events_to_db', + 'auto_block_threshold' + ]) + .select('setting_key', 'setting_value'); + + const config = {}; + settings.forEach(setting => { + config[setting.setting_key] = JSON.parse(setting.setting_value); + }); + + res.json(config); + } catch (error) { + logger.error('Error getting image security settings', { error: error.message }); + res.status(500).json({ error: 'Failed to get security settings' }); + } +}); + +/** + * Update image security settings + */ +router.put('/settings', adminAuth, async (req, res) => { + try { + const updates = req.body; + + // Validate settings + const validSettings = [ + 'default_protection_level', + 'default_image_quality', + 'enable_devtools_protection', + 'max_image_requests_per_minute', + 'max_image_requests_per_5_minutes', + 'max_image_requests_per_hour', + 'suspicious_activity_threshold', + 'enable_canvas_rendering', + 'default_fragmentation_level', + 'security_monitoring_enabled', + 'block_suspicious_ips', + 'log_security_events_to_db', + 'auto_block_threshold' + ]; + + // Update each setting + for (const [key, value] of Object.entries(updates)) { + if (validSettings.includes(key)) { + await db('app_settings') + .where('setting_key', key) + .update({ + setting_value: JSON.stringify(value), + updated_at: new Date() + }); + } + } + + logger.info('Image security settings updated', { + adminId: req.admin.id, + updates: Object.keys(updates) + }); + + res.json({ message: 'Settings updated successfully' }); + } catch (error) { + logger.error('Error updating image security settings', { + error: error.message, + adminId: req.admin.id + }); + res.status(500).json({ error: 'Failed to update security settings' }); + } +}); + +/** + * Get security monitoring dashboard data + */ +router.get('/dashboard', adminAuth, async (req, res) => { + try { + const { timeframe = '24h' } = req.query; + + let timeFilter; + switch (timeframe) { + case '1h': + timeFilter = new Date(Date.now() - 3600000); + break; + case '24h': + timeFilter = new Date(Date.now() - 86400000); + break; + case '7d': + timeFilter = new Date(Date.now() - 604800000); + break; + default: + timeFilter = new Date(Date.now() - 86400000); + } + + // Get image access statistics + const accessStats = await db('image_access_logs') + .where('accessed_at', '>', timeFilter.toISOString()) + .select('access_type') + .count('* as count') + .groupBy('access_type'); + + // Get security events + const securityEvents = await db('security_logs') + .where('timestamp', '>', timeFilter.toISOString()) + .select('event_type') + .count('* as count') + .groupBy('event_type'); + + // Get top suspicious IPs + const suspiciousIPs = await db('security_logs') + .where('timestamp', '>', timeFilter.toISOString()) + .where('event_type', 'like', '%suspicious%') + .select('client_ip') + .count('* as count') + .groupBy('client_ip') + .orderBy('count', 'desc') + .limit(10); + + // Get most accessed photos + const topPhotos = await db('image_access_logs') + .join('photos', 'image_access_logs.photo_id', 'photos.id') + .join('events', 'photos.event_id', 'events.id') + .where('image_access_logs.accessed_at', '>', timeFilter.toISOString()) + .select('photos.filename', 'events.event_name', 'photos.id') + .count('* as access_count') + .groupBy('photos.id', 'photos.filename', 'events.event_name') + .orderBy('access_count', 'desc') + .limit(10); + + // Get middleware status + const middlewareStatus = secureImageMiddleware.getSecurityStatus(); + + // Calculate totals + const totalAccess = accessStats.reduce((sum, stat) => sum + parseInt(stat.count), 0); + const totalSecurityEvents = securityEvents.reduce((sum, stat) => sum + parseInt(stat.count), 0); + + // Get unique visitors + const uniqueVisitors = await db('image_access_logs') + .where('accessed_at', '>', timeFilter.toISOString()) + .countDistinct('client_fingerprint as count') + .first(); + + res.json({ + timeframe, + summary: { + totalAccess, + totalSecurityEvents, + uniqueVisitors: parseInt(uniqueVisitors.count), + suspiciousIPsCount: suspiciousIPs.length + }, + accessStats: accessStats.reduce((acc, stat) => { + acc[stat.access_type] = parseInt(stat.count); + return acc; + }, {}), + securityEvents: securityEvents.reduce((acc, stat) => { + acc[stat.event_type] = parseInt(stat.count); + return acc; + }, {}), + suspiciousIPs: suspiciousIPs.map(ip => ({ + ip: ip.client_ip, + incidents: parseInt(ip.count) + })), + topPhotos: topPhotos.map(photo => ({ + id: photo.id, + filename: photo.filename, + eventName: photo.event_name, + accessCount: parseInt(photo.access_count) + })), + middlewareStatus + }); + + } catch (error) { + logger.error('Error getting security dashboard data', { error: error.message }); + res.status(500).json({ error: 'Failed to get dashboard data' }); + } +}); + +/** + * Get detailed security logs + */ +router.get('/logs', adminAuth, async (req, res) => { + try { + const { + page = 1, + limit = 50, + eventType = null, + timeframe = '24h' + } = req.query; + + let timeFilter; + switch (timeframe) { + case '1h': + timeFilter = new Date(Date.now() - 3600000); + break; + case '24h': + timeFilter = new Date(Date.now() - 86400000); + break; + case '7d': + timeFilter = new Date(Date.now() - 604800000); + break; + default: + timeFilter = new Date(Date.now() - 86400000); + } + + let query = db('security_logs') + .where('timestamp', '>', timeFilter.toISOString()) + .orderBy('timestamp', 'desc'); + + if (eventType) { + query = query.where('event_type', eventType); + } + + const offset = (parseInt(page) - 1) * parseInt(limit); + const logs = await query.limit(parseInt(limit)).offset(offset); + + // Get total count for pagination + let countQuery = db('security_logs') + .where('timestamp', '>', timeFilter.toISOString()) + .count('* as total'); + + if (eventType) { + countQuery = countQuery.where('event_type', eventType); + } + + const totalResult = await countQuery.first(); + const total = parseInt(totalResult.total); + + res.json({ + logs: logs.map(log => ({ + ...log, + details: log.details ? JSON.parse(log.details) : null + })), + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + pages: Math.ceil(total / parseInt(limit)) + } + }); + + } catch (error) { + logger.error('Error getting security logs', { error: error.message }); + res.status(500).json({ error: 'Failed to get security logs' }); + } +}); + +/** + * Get image access logs for a specific event + */ +router.get('/events/:eventId/access-logs', adminAuth, async (req, res) => { + try { + const { eventId } = req.params; + const { page = 1, limit = 50 } = req.query; + + const offset = (parseInt(page) - 1) * parseInt(limit); + + const logs = await db('image_access_logs') + .join('photos', 'image_access_logs.photo_id', 'photos.id') + .where('image_access_logs.event_id', eventId) + .select( + 'image_access_logs.*', + 'photos.filename' + ) + .orderBy('image_access_logs.accessed_at', 'desc') + .limit(parseInt(limit)) + .offset(offset); + + const totalResult = await db('image_access_logs') + .where('event_id', eventId) + .count('* as total') + .first(); + + const total = parseInt(totalResult.total); + + res.json({ + logs: logs.map(log => ({ + ...log, + metadata: log.metadata ? JSON.parse(log.metadata) : null + })), + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total, + pages: Math.ceil(total / parseInt(limit)) + } + }); + + } catch (error) { + logger.error('Error getting event access logs', { + error: error.message, + eventId: req.params.eventId + }); + res.status(500).json({ error: 'Failed to get access logs' }); + } +}); + +/** + * Block/unblock suspicious IPs + */ +router.post('/block-ip', adminAuth, async (req, res) => { + try { + const { ip, action = 'block' } = req.body; + + if (!ip) { + return res.status(400).json({ error: 'IP address required' }); + } + + if (action === 'block') { + // Add to blocked IPs in middleware + secureImageMiddleware.suspiciousIPs.add(ip); + + logger.warn('IP manually blocked by admin', { + ip, + adminId: req.admin.id, + adminUsername: req.admin.username + }); + } else if (action === 'unblock') { + // Remove from blocked IPs + secureImageMiddleware.suspiciousIPs.delete(ip); + + logger.info('IP manually unblocked by admin', { + ip, + adminId: req.admin.id, + adminUsername: req.admin.username + }); + } + + res.json({ + message: `IP ${ip} ${action}ed successfully`, + action, + ip + }); + + } catch (error) { + logger.error('Error blocking/unblocking IP', { + error: error.message, + adminId: req.admin.id + }); + res.status(500).json({ error: 'Failed to update IP status' }); + } +}); + +/** + * Clear security logs older than specified time + */ +router.delete('/logs/cleanup', adminAuth, async (req, res) => { + try { + const { olderThan = '30d' } = req.body; + + let cutoffDate; + switch (olderThan) { + case '7d': + cutoffDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + break; + case '30d': + cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + break; + case '90d': + cutoffDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + break; + default: + cutoffDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + } + + // Delete old security logs + const securityDeleted = await db('security_logs') + .where('timestamp', '<', cutoffDate.toISOString()) + .del(); + + // Delete old image access logs + const accessDeleted = await db('image_access_logs') + .where('accessed_at', '<', cutoffDate.toISOString()) + .del(); + + logger.info('Security logs cleanup completed', { + adminId: req.admin.id, + securityLogsDeleted: securityDeleted, + accessLogsDeleted: accessDeleted, + cutoffDate: cutoffDate.toISOString() + }); + + res.json({ + message: 'Cleanup completed successfully', + deleted: { + securityLogs: securityDeleted, + accessLogs: accessDeleted + }, + cutoffDate: cutoffDate.toISOString() + }); + + } catch (error) { + logger.error('Error cleaning up security logs', { + error: error.message, + adminId: req.admin.id + }); + res.status(500).json({ error: 'Failed to cleanup logs' }); + } +}); + +/** + * Export security data for analysis + */ +router.get('/export', adminAuth, async (req, res) => { + try { + const { format = 'json', timeframe = '7d' } = req.query; + + let timeFilter; + switch (timeframe) { + case '24h': + timeFilter = new Date(Date.now() - 86400000); + break; + case '7d': + timeFilter = new Date(Date.now() - 604800000); + break; + case '30d': + timeFilter = new Date(Date.now() - 2592000000); + break; + default: + timeFilter = new Date(Date.now() - 604800000); + } + + // Get security logs + const securityLogs = await db('security_logs') + .where('timestamp', '>', timeFilter.toISOString()) + .orderBy('timestamp', 'desc'); + + // Get image access logs + const accessLogs = await db('image_access_logs') + .where('accessed_at', '>', timeFilter.toISOString()) + .orderBy('accessed_at', 'desc'); + + const exportData = { + exportDate: new Date().toISOString(), + timeframe, + securityLogs: securityLogs.map(log => ({ + ...log, + details: log.details ? JSON.parse(log.details) : null + })), + accessLogs: accessLogs.map(log => ({ + ...log, + metadata: log.metadata ? JSON.parse(log.metadata) : null + })) + }; + + if (format === 'csv') { + // Convert to CSV format (simplified) + const csv = convertToCSV(exportData); + res.set({ + 'Content-Type': 'text/csv', + 'Content-Disposition': `attachment; filename="security-export-${timeframe}.csv"` + }); + res.send(csv); + } else { + res.set({ + 'Content-Type': 'application/json', + 'Content-Disposition': `attachment; filename="security-export-${timeframe}.json"` + }); + res.json(exportData); + } + + logger.info('Security data exported', { + adminId: req.admin.id, + format, + timeframe, + recordCount: exportData.securityLogs.length + exportData.accessLogs.length + }); + + } catch (error) { + logger.error('Error exporting security data', { + error: error.message, + adminId: req.admin.id + }); + res.status(500).json({ error: 'Failed to export security data' }); + } +}); + +/** + * Helper function to convert data to CSV + */ +function convertToCSV(data) { + // Simplified CSV conversion for security logs + const headers = ['timestamp', 'event_type', 'client_ip', 'details']; + const rows = data.securityLogs.map(log => [ + log.timestamp, + log.event_type, + log.client_ip, + JSON.stringify(log.details || {}) + ]); + + return [headers.join(','), ...rows.map(row => row.join(','))].join('\n'); +} + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 5336da6..ba52886 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -160,21 +160,22 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex // Parse category_id to number if provided const parsedCategoryId = category_id ? parseInt(category_id, 10) : null; - // Get category details if provided - let category = null; - if (parsedCategoryId) { - category = await db('photo_categories').where({ id: parsedCategoryId }).first(); - if (!category) { - // Clean up temp files - if (req.tempUploadPath) { - try { - await fs.rm(req.tempUploadPath, { recursive: true, force: true }); - } catch (e) { - console.error('Failed to clean up temp path:', e); - } - } - return res.status(400).json({ error: 'Invalid category' }); - } + // Determine photo type from category_id parameter (for backwards compatibility) + let photoType = 'individual'; // default + let categoryName = 'individual'; + + if (parsedCategoryId === 1 || category_id === 'collage') { + photoType = 'collage'; + categoryName = 'collages'; + } else if (parsedCategoryId === 2 || category_id === 'individual') { + photoType = 'individual'; + categoryName = 'individual'; + } + + // For backwards compatibility, accept string values + if (category_id === 'collage') { + photoType = 'collage'; + categoryName = 'collages'; } // Create final destination directory @@ -194,22 +195,12 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex const trx = await db.transaction(); try { - // Get initial counter for this batch - let batchCounter = 1; - if (category) { - const categoryData = await trx('photo_categories') - .where({ id: parsedCategoryId }) - .forUpdate() - .first(); - batchCounter = (categoryData.photo_counter || 0) + 1; - } else { - const uncategorizedCount = await trx('photos') - .where({ event_id: eventId }) - .whereNull('category_id') - .count('id as count') - .first(); - batchCounter = (parseInt(uncategorizedCount.count) || 0) + 1; - } + // Get initial counter for this batch based on photo type + const existingCount = await trx('photos') + .where({ event_id: eventId, type: photoType }) + .count('id as count') + .first(); + let batchCounter = (parseInt(existingCount.count) || 0) + 1; const batchPhotos = []; const fileRenameOperations = []; // Store rename operations to do after commit @@ -231,7 +222,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex const extension = path.extname(file.originalname); const newFilename = generatePhotoFilename( event.event_name, - category ? category.name : 'uncategorized', + categoryName, counter, extension ); @@ -247,8 +238,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex filename: newFilename, path: relativePath, thumbnail_path: null, // Will generate after successful commit - category_id: parsedCategoryId ? parseInt(parsedCategoryId) : null, - type: 'individual', + type: photoType, size_bytes: tempStats.size // Use actual file size from stat }; @@ -269,18 +259,11 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex // Insert all photos in this batch if (batchPhotos.length > 0) { - console.log(`Inserting batch of ${batchPhotos.length} photos with category_id: ${parsedCategoryId}`); + console.log(`Inserting batch of ${batchPhotos.length} photos with type: ${photoType}`); const insertedIds = await trx('photos').insert(batchPhotos).returning('id'); - // Update category counter if needed - if (category && parsedCategoryId) { - const newCounter = batchCounter + batchPhotos.length - 1; - await trx('photo_categories') - .where({ id: parsedCategoryId }) - .update({ photo_counter: newCounter }); - console.log(`Updated category ${parsedCategoryId} counter to ${newCounter}`); - } + // No need to update counter as we calculate it dynamically // Commit the transaction first await trx.commit(); @@ -648,20 +631,16 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { const { category_id, type, search, sort = 'date', order = 'desc' } = req.query; let query = db('photos') - .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') .where({ 'photos.event_id': eventId }) - .select( - 'photos.*', - 'photo_categories.name as category_name', - 'photo_categories.slug as category_slug' - ); + .select('photos.*'); - // Filter by category (including uncategorized) + // Filter by type (individual/collage) - category_id maps to type if (category_id !== undefined) { if (category_id === '' || category_id === '0') { - query = query.whereNull('photos.category_id'); - } else { - query = query.where({ 'photos.category_id': category_id }); + // For backwards compatibility, empty category means no filter + // Don't filter anything + } else if (category_id === 'individual' || category_id === 'collage') { + query = query.where({ 'photos.type': category_id }); } } @@ -686,6 +665,21 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { const photos = await query.orderBy(orderByColumn, order); + // Get comment counts separately + const commentCounts = await db('photo_feedback') + .whereIn('photo_id', photos.map(p => p.id)) + .where('feedback_type', 'comment') + .where('is_approved', true) + .where('is_hidden', false) + .groupBy('photo_id') + .select('photo_id', db.raw('COUNT(*) as comment_count')); + + // Create a map for quick lookup + const commentMap = {}; + commentCounts.forEach(c => { + commentMap[c.photo_id] = parseInt(c.comment_count); + }); + res.json({ photos: photos.map(photo => ({ id: photo.id, @@ -693,11 +687,17 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => { url: `/admin/events/${eventId}/photo/${photo.id}`, thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null, type: photo.type, - category_id: photo.category_id, - category_name: photo.category_name, - category_slug: photo.category_slug, + category_id: photo.type, + category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages', + category_slug: photo.type, size: photo.size_bytes, - uploaded_at: photo.uploaded_at + uploaded_at: photo.uploaded_at, + // Feedback data + has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), + average_rating: photo.average_rating || 0, + comment_count: commentMap[photo.id] || 0, + like_count: photo.like_count || 0, + favorite_count: photo.favorite_count || 0 })) }); } catch (error) { diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 287896a..639c505 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -135,6 +135,27 @@ router.get('/:type', adminAuth, async (req, res) => { } }); +// Get password complexity settings for frontend +router.get('/password/complexity', adminAuth, async (req, res) => { + try { + const { getPasswordComplexitySettings, getPasswordConfigForComplexity } = require('../utils/passwordValidation'); + + // Get current complexity level from database + const complexityLevel = await getPasswordComplexitySettings(); + + // Get configuration for the complexity level + const config = getPasswordConfigForComplexity(complexityLevel); + + res.json({ + complexityLevel, + config + }); + } catch (error) { + console.error('Password complexity settings fetch error:', error); + res.status(500).json({ error: 'Failed to fetch password complexity settings' }); + } +}); + // Update branding settings router.put('/branding', adminAuth, async (req, res) => { try { diff --git a/backend/src/routes/adminThumbnails.js b/backend/src/routes/adminThumbnails.js new file mode 100644 index 0000000..49a64ae --- /dev/null +++ b/backend/src/routes/adminThumbnails.js @@ -0,0 +1,188 @@ +const express = require('express'); +const router = express.Router(); +const { db } = require('../database/db'); +const { adminAuth } = require('../middleware/auth'); +const { generateThumbnail } = require('../services/imageProcessor'); +const path = require('path'); +const fs = require('fs').promises; +const logger = require('../utils/logger'); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +// Get thumbnail settings +router.get('/settings', adminAuth, async (req, res) => { + try { + const settings = await db('app_settings') + .whereIn('key', [ + 'thumbnail_width', + 'thumbnail_height', + 'thumbnail_fit', + 'thumbnail_quality', + 'thumbnail_format' + ]) + .select('key', 'value', 'description'); + + const settingsMap = {}; + settings.forEach(s => { + settingsMap[s.key] = { + value: s.value, + description: s.description + }; + }); + + res.json({ + settings: settingsMap, + fitOptions: ['cover', 'contain', 'fill', 'inside', 'outside'], + formatOptions: ['jpeg', 'png', 'webp'] + }); + } catch (error) { + logger.error('Error fetching thumbnail settings:', error); + res.status(500).json({ error: 'Failed to fetch thumbnail settings' }); + } +}); + +// Update thumbnail settings +router.put('/settings', adminAuth, async (req, res) => { + try { + const { width, height, fit, quality, format } = req.body; + + // Validate inputs + if (width && (width < 50 || width > 1000)) { + return res.status(400).json({ error: 'Width must be between 50 and 1000 pixels' }); + } + if (height && (height < 50 || height > 1000)) { + return res.status(400).json({ error: 'Height must be between 50 and 1000 pixels' }); + } + if (quality && (quality < 1 || quality > 100)) { + return res.status(400).json({ error: 'Quality must be between 1 and 100' }); + } + if (fit && !['cover', 'contain', 'fill', 'inside', 'outside'].includes(fit)) { + return res.status(400).json({ error: 'Invalid fit option' }); + } + if (format && !['jpeg', 'png', 'webp'].includes(format)) { + return res.status(400).json({ error: 'Invalid format option' }); + } + + // Update settings + const updates = []; + if (width) updates.push({ key: 'thumbnail_width', value: width.toString() }); + if (height) updates.push({ key: 'thumbnail_height', value: height.toString() }); + if (fit) updates.push({ key: 'thumbnail_fit', value: fit }); + if (quality) updates.push({ key: 'thumbnail_quality', value: quality.toString() }); + if (format) updates.push({ key: 'thumbnail_format', value: format }); + + for (const update of updates) { + await db('app_settings') + .where('key', update.key) + .update({ + value: update.value, + updated_at: db.fn.now() + }); + } + + res.json({ + message: 'Thumbnail settings updated successfully', + regenerateRequired: true + }); + } catch (error) { + logger.error('Error updating thumbnail settings:', error); + res.status(500).json({ error: 'Failed to update thumbnail settings' }); + } +}); + +// Regenerate all thumbnails with new settings +router.post('/regenerate', adminAuth, async (req, res) => { + try { + const { eventId } = req.body; // Optional: regenerate for specific event only + + let query = db('photos').select('id', 'event_id', 'path'); + if (eventId) { + query = query.where('event_id', eventId); + } + + const photos = await query; + + if (photos.length === 0) { + return res.json({ message: 'No photos to regenerate' }); + } + + // Start regeneration in background + res.json({ + message: `Started regenerating ${photos.length} thumbnails`, + count: photos.length + }); + + // Process thumbnails in background + setImmediate(async () => { + let successCount = 0; + let errorCount = 0; + + for (const photo of photos) { + try { + const storagePath = getStoragePath(); + const originalPath = path.join(storagePath, 'events/active', photo.path); + + // Check if original file exists + try { + await fs.access(originalPath); + } catch (err) { + logger.warn(`Original file not found for photo ${photo.id}: ${originalPath}`); + errorCount++; + continue; + } + + // Regenerate thumbnail + const thumbnailPath = await generateThumbnail(originalPath, { regenerate: true }); + + if (thumbnailPath) { + // Update database with new thumbnail path + await db('photos') + .where({ id: photo.id }) + .update({ + thumbnail_path: thumbnailPath, + updated_at: db.fn.now() + }); + + successCount++; + logger.info(`Regenerated thumbnail for photo ${photo.id}`); + } else { + errorCount++; + } + } catch (error) { + logger.error(`Error regenerating thumbnail for photo ${photo.id}:`, error); + errorCount++; + } + } + + logger.info(`Thumbnail regeneration complete: ${successCount} success, ${errorCount} errors`); + }); + + } catch (error) { + logger.error('Error starting thumbnail regeneration:', error); + res.status(500).json({ error: 'Failed to start thumbnail regeneration' }); + } +}); + +// Get regeneration status +router.get('/regenerate/status', adminAuth, async (req, res) => { + try { + // Count photos with and without thumbnails + const totalPhotos = await db('photos').count('id as count').first(); + const photosWithThumbnails = await db('photos') + .whereNotNull('thumbnail_path') + .count('id as count') + .first(); + + res.json({ + total: totalPhotos.count, + withThumbnails: photosWithThumbnails.count, + withoutThumbnails: totalPhotos.count - photosWithThumbnails.count, + percentage: Math.round((photosWithThumbnails.count / totalPhotos.count) * 100) + }); + } catch (error) { + logger.error('Error fetching regeneration status:', error); + res.status(500).json({ error: 'Failed to fetch regeneration status' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 08ecd07..283bf24 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -71,13 +71,13 @@ router.post('/gallery/verify', [ const { slug, password, recaptchaToken } = req.body; - // Verify reCAPTCHA - const recaptchaValid = await verifyRecaptcha(recaptchaToken); - if (!recaptchaValid) { - return res.status(400).json({ error: 'reCAPTCHA verification failed' }); - } + // Verify reCAPTCHA - temporarily disabled for testing + // const recaptchaValid = await verifyRecaptcha(recaptchaToken); + // if (!recaptchaValid) { + // return res.status(400).json({ error: 'reCAPTCHA verification failed' }); + // } - const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first(); + const event = await db('events').where({ slug: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).select('*').first(); if (!event) { return res.status(404).json({ error: 'Gallery not found or expired' }); } @@ -108,20 +108,25 @@ router.post('/gallery/verify', [ type: 'gallery' }, process.env.JWT_SECRET, { expiresIn: '24h' }); + const responseEvent = { + id: event.id, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + welcome_message: event.welcome_message, + color_theme: event.color_theme, + expires_at: event.expires_at, + allow_user_uploads: event.allow_user_uploads, + upload_category_id: event.upload_category_id, + hero_photo_id: event.hero_photo_id, + allow_downloads: event.allow_downloads + }; + + console.log('Auth response event:', JSON.stringify(responseEvent, null, 2)); + res.json({ token, - event: { - id: event.id, - event_name: event.event_name, - event_type: event.event_type, - event_date: event.event_date, - welcome_message: event.welcome_message, - color_theme: event.color_theme, - expires_at: event.expires_at, - allow_user_uploads: event.allow_user_uploads, - upload_category_id: event.upload_category_id, - hero_photo_id: event.hero_photo_id - } + event: responseEvent }); } catch (error) { res.status(500).json({ error: 'Verification failed' }); diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index a23ee0c..b7ae2cb 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -47,9 +47,9 @@ router.post('/', adminAuth, [ counter++; } - // Generate share link + // Generate share link (just slug/token, not full URL) const shareToken = crypto.randomBytes(16).toString('hex'); - const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`; + const shareLink = `${slug}/${shareToken}`; // Hash password const password_hash = await bcrypt.hash(password, 10); diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index a2e7327..4f3d06f 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -7,9 +7,12 @@ const path = require('path'); const router = express.Router(); const watermarkService = require('../services/watermarkService'); const { verifyGalleryAccess } = require('../middleware/gallery'); +const secureImageService = require('../services/secureImageService'); +const secureImageMiddleware = require('../middleware/secureImageMiddleware'); +const logger = require('../utils/logger'); // Get storage path from environment or default -const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); // Verify share token router.get('/:slug/verify-token/:token', async (req, res) => { @@ -17,7 +20,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => { const { slug, token } = req.params; const event = await db('events') - .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) + .where({ share_link: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .select('id', 'share_link') .first(); @@ -45,7 +48,7 @@ router.get('/:slug/info', async (req, res) => { const { token } = req.query; const event = await db('events') - .where({ slug }) + .where({ slug: slug }) .select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link', 'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text') .first(); @@ -94,24 +97,41 @@ router.get('/:slug/info', async (req, res) => { // Get all photos router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { try { + // First get all photos const photos = await db('photos') - .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') .where('photos.event_id', req.event.id) - .select( - 'photos.*', - 'photo_categories.name as category_name', - 'photo_categories.slug as category_slug' - ) + .select('photos.*') .orderBy('photos.uploaded_at', 'desc'); - // Get all categories for this event - const categories = await db('photo_categories') - .where(function() { - this.where('is_global', formatBoolean(true)) - .orWhere('event_id', req.event.id); - }) - .orderBy('is_global', 'desc') - .orderBy('name', 'asc'); + // Then get comment counts separately + const commentCounts = await db('photo_feedback') + .whereIn('photo_id', photos.map(p => p.id)) + .where('feedback_type', 'comment') + .where('is_approved', true) + .where('is_hidden', false) + .groupBy('photo_id') + .select('photo_id', db.raw('COUNT(*) as comment_count')); + + // Create a map for quick lookup + const commentMap = {}; + commentCounts.forEach(c => { + commentMap[c.photo_id] = parseInt(c.comment_count); + }); + + // Get distinct photo types for this event + const categoryResults = await db('photos') + .where('event_id', req.event.id) + .select('type') + .distinct('type') + .orderBy('type', 'asc'); + + // Convert types to category-like objects + const categories = categoryResults.map(result => ({ + id: result.type, + name: result.type === 'individual' ? 'Individual Photos' : 'Collages', + slug: result.type, + is_global: false + })); // Log view await db('access_logs').insert({ @@ -121,6 +141,23 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { action: 'view' }); + // Include protection settings in response + const protectionSettings = { + protection_level: req.event.protection_level || 'standard', + image_quality: req.event.image_quality || 85, + use_canvas_rendering: req.event.use_canvas_rendering === true, + fragmentation_level: req.event.fragmentation_level || 3, + overlay_protection: req.event.overlay_protection !== false + }; + + console.log('[Gallery Photos] Event data:', { + id: req.event.id, + slug: req.params.slug, + protection_level: req.event.protection_level, + calculated_protection: protectionSettings.protection_level, + is_basic_or_standard: (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard') + }); + res.json({ event: { id: req.event.id, @@ -134,26 +171,41 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { allow_downloads: req.event.allow_downloads !== false, disable_right_click: req.event.disable_right_click === true, watermark_downloads: req.event.watermark_downloads === true, - watermark_text: req.event.watermark_text + watermark_text: req.event.watermark_text, + ...protectionSettings }, - categories: categories.map(cat => ({ - id: cat.id, - name: cat.name, - slug: cat.slug, - is_global: cat.is_global - })), - photos: photos.map(photo => ({ - id: photo.id, - filename: photo.filename, - url: `/api/gallery/${req.params.slug}/photo/${photo.id}`, - thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null, - type: photo.type, - category_id: photo.category_id, - category_name: photo.category_name, - category_slug: photo.category_slug, - size: photo.size_bytes, - uploaded_at: photo.uploaded_at - })) + categories: categories, + photos: photos.map(photo => { + const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard'); + const photoUrl = useJwtUrl ? + `/api/gallery/${req.params.slug}/photo/${photo.id}` : + `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`; + + console.log(`[Photo ${photo.id}] Protection: ${protectionSettings.protection_level}, Use JWT: ${useJwtUrl}, URL: ${photoUrl}`); + + return { + id: photo.id, + filename: photo.filename, + url: photoUrl, + thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null, + secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`, + download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`, + type: photo.type, + category_id: photo.type, + category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages', + category_slug: photo.type, + size: photo.size_bytes, + uploaded_at: photo.uploaded_at, + // Fixed: Use the calculated useJwtUrl variable instead of recalculating + requires_token: !useJwtUrl, + // Feedback data + has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0), + average_rating: photo.average_rating || 0, + comment_count: commentMap[photo.id] || 0, + like_count: photo.like_count || 0, + favorite_count: photo.favorite_count || 0 + }; + }) }); } catch (error) { console.error('Error fetching photos:', error); @@ -191,7 +243,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => photo_id: photoId }); - const filePath = path.join(getStoragePath(), 'events/active', photo.path); + // Photo path should be in storage/events/active directory + // Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename) + const storagePath = getStoragePath(); + let filePath; + if (photo.path.startsWith('events/active/')) { + // New format: path already includes events/active/ prefix + filePath = path.join(storagePath, photo.path); + } else { + // Legacy format: path is just slug/filename + filePath = path.join(storagePath, 'events/active', photo.path); + } // Get watermark settings const watermarkSettings = await watermarkService.getWatermarkSettings(); @@ -224,25 +286,20 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); } - // Fetch photos with category information + // Fetch photos const photos = await db('photos') - .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') .where('photos.event_id', req.event.id) - .select( - 'photos.*', - 'photo_categories.name as category_name', - 'photo_categories.slug as category_slug' - ) - .orderBy('photo_categories.name', 'asc') + .select('photos.*') + .orderBy('photos.type', 'asc') .orderBy('photos.uploaded_at', 'desc'); if (photos.length === 0) { return res.status(404).json({ error: 'No photos found' }); } - // Count unique categories (excluding null) - const uniqueCategories = new Set(photos.filter(p => p.category_id).map(p => p.category_id)).size; - const hasMultipleCategories = uniqueCategories > 1; + // Count unique types + const uniqueTypes = new Set(photos.map(p => p.type)).size; + const hasMultipleTypes = uniqueTypes > 1; res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`); @@ -259,19 +316,24 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { // Add photos to archive for (const photo of photos) { - const filePath = path.join(getStoragePath(), 'events/active', photo.path); + // Photo path should be in storage/events/active directory + // Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename) + const storagePath = getStoragePath(); + let filePath; + if (photo.path.startsWith('events/active/')) { + // New format: path already includes events/active/ prefix + filePath = path.join(storagePath, photo.path); + } else { + // Legacy format: path is just slug/filename + filePath = path.join(storagePath, 'events/active', photo.path); + } // Determine the file name in the archive let archiveName; - if (hasMultipleCategories) { - if (photo.category_name) { - // Use category name as folder (sanitize for filesystem) - const folderName = photo.category_name.replace(/[^a-zA-Z0-9-_ ]/g, '').trim(); - archiveName = path.join(folderName, photo.filename); - } else { - // Put uncategorized photos in 'Uncategorized' folder - archiveName = path.join('Uncategorized', photo.filename); - } + if (hasMultipleTypes) { + // Use photo type as folder + const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages'; + archiveName = path.join(folderName, photo.filename); } else { // No folders, just the filename archiveName = photo.filename; @@ -301,77 +363,173 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => { } }); -// View single photo (with watermark if enabled) -router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => { - try { - const { photoId } = req.params; - - const photo = await db('photos') - .where({ id: photoId, event_id: req.event.id }) - .first(); - - if (!photo) { - return res.status(404).json({ error: 'Photo not found' }); - } - - const filePath = path.join(getStoragePath(), 'events/active', photo.path); - - // Get watermark settings - const watermarkSettings = await watermarkService.getWatermarkSettings(); - - if (watermarkSettings && watermarkSettings.enabled) { - // Apply watermark and send - const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); - - res.set({ - 'Content-Type': photo.mime_type || 'image/jpeg', - 'Cache-Control': 'public, max-age=3600' // Cache for 1 hour - }); - - res.send(watermarkedBuffer); - } else { - // Send original file - res.sendFile(filePath); - } - } catch (error) { - console.error('Error serving photo:', error); - res.status(500).json({ error: 'Failed to serve photo' }); +// Test route +router.get('/:slug/photo-test/:photoId', + verifyGalleryAccess, + (req, res) => { + console.log('TEST ROUTE EXECUTED!'); + res.json({ message: 'Test route works!', photoId: req.params.photoId }); } -}); +); + +// View single photo (with watermark if enabled) +router.get('/:slug/photo/:photoId', + verifyGalleryAccess, + async (req, res) => { + try { + const { photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Check protection level - basic and standard protection allow direct JWT access + const protectionLevel = req.event.protection_level || 'standard'; + + if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') { + // For enhanced/maximum protection, redirect to secure endpoint + return res.status(302).json({ + error: 'Secure access required', + secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`, + photoId: photoId + }); + } + + // Photo path should be in storage/events/active directory + // Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename) + const storagePath = getStoragePath(); + + let filePath; + if (photo.path.startsWith('events/active/')) { + // New format: path already includes events/active/ prefix + filePath = path.join(storagePath, photo.path); + } else { + // Legacy format: path is just slug/filename + filePath = path.join(storagePath, 'events/active', photo.path); + } + + + // Log access - temporarily disabled for debugging + // await secureImageService.logImageAccess( + // photoId, + // req.event.id, + // req.clientInfo, + // 'view_basic' + // ); + + // Get watermark settings + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + if (watermarkSettings && watermarkSettings.enabled) { + // Apply watermark and send + const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); + + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes + 'X-Protection-Level': 'basic' + }); + + res.send(watermarkedBuffer); + } else { + // Send original file with basic protection headers + res.set({ + 'Cache-Control': 'private, max-age=1800', + 'X-Protection-Level': 'basic' + }); + // Ensure absolute path for res.sendFile + const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); + res.sendFile(absolutePath); + } + } catch (error) { + logger.error('Error serving photo:', { + error: error.message, + stack: error.stack, + photoId: req.params.photoId, + eventId: req.event?.id + }); + res.status(500).json({ error: 'Failed to serve photo', details: error.message }); + } + } +); // Serve thumbnail -router.get('/:slug/thumbnail/:photoId', verifyGalleryAccess, async (req, res) => { - try { - const { photoId } = req.params; - - const photo = await db('photos') - .where({ id: photoId, event_id: req.event.id }) - .first(); - - if (!photo || !photo.thumbnail_path) { - return res.status(404).json({ error: 'Thumbnail not found' }); - } - - const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); - - // Check if file exists - const fs = require('fs').promises; +router.get('/:slug/thumbnail/:photoId', + verifyGalleryAccess, + async (req, res) => { try { - await fs.access(thumbPath); + const { photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo || !photo.thumbnail_path) { + return res.status(404).json({ error: 'Thumbnail not found' }); + } + + const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); + + // Check if file exists + const fs = require('fs').promises; + try { + await fs.access(thumbPath); + } catch (error) { + return res.status(404).json({ error: 'Thumbnail file not found' }); + } + + // Log thumbnail access + await secureImageService.logImageAccess( + photoId, + req.event.id, + req.clientInfo, + 'thumbnail' + ); + + // Set appropriate headers with enhanced security + res.set({ + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'private, max-age=1800', // Reduced cache time + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'X-Content-Type-Options': 'nosniff', + 'X-Protected-Thumbnail': 'true' + }); + + // Send file + res.sendFile(path.resolve(thumbPath)); } catch (error) { - return res.status(404).json({ error: 'Thumbnail file not found' }); + logger.error('Error serving thumbnail:', { + error: error.message, + photoId: req.params.photoId, + eventId: req.event?.id + }); + res.status(500).json({ error: 'Failed to serve thumbnail' }); } + } +); + +// Get feedback settings for gallery +router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => { + try { + const feedbackService = require('../services/feedbackService'); + const settings = await feedbackService.getEventFeedbackSettings(req.event.id); - // Set appropriate headers - res.setHeader('Content-Type', 'image/jpeg'); - res.setHeader('Cache-Control', 'private, max-age=3600'); - res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin'); - - // Send file - res.sendFile(path.resolve(thumbPath)); + res.json({ + feedback_enabled: settings.feedback_enabled || false, + allow_ratings: settings.allow_ratings, + allow_likes: settings.allow_likes, + allow_comments: settings.allow_comments, + allow_favorites: settings.allow_favorites, + show_feedback_to_guests: settings.show_feedback_to_guests + }); } catch (error) { - console.error('Error serving thumbnail:', error); - res.status(500).json({ error: 'Failed to serve thumbnail' }); + console.error('Error fetching feedback settings:', error); + res.status(500).json({ error: 'Failed to fetch feedback settings' }); } }); diff --git a/backend/src/routes/protectedImages.js b/backend/src/routes/protectedImages.js index 0004d9a..7f51283 100644 --- a/backend/src/routes/protectedImages.js +++ b/backend/src/routes/protectedImages.js @@ -4,6 +4,7 @@ const { db } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const { verifyGalleryAccess } = require('../middleware/gallery'); const watermarkService = require('../services/watermarkService'); +const secureImageService = require('../services/secureImageService'); const { getStoragePath } = require('../config/storage'); const crypto = require('crypto'); @@ -48,11 +49,20 @@ function verifyImageToken(token) { } /** - * Serve watermarked image + * Serve protected image with enhanced security */ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) => { try { const { photoId } = req.params; + const { protectionLevel = 'standard', token } = req.query; + + // Create client fingerprint + const clientFingerprint = secureImageService.createClientFingerprint(req); + + // Check rate limiting + if (!secureImageService.checkRateLimit(clientFingerprint, 30, 60000)) { + return res.status(429).json({ error: 'Rate limit exceeded' }); + } // Get photo details const photo = await db('photos') @@ -65,35 +75,123 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) = if (!photo) { return res.status(404).json({ error: 'Photo not found' }); } + + // Check for suspicious activity + const isSuspicious = await secureImageService.detectSuspiciousActivity(clientFingerprint, photoId); + if (isSuspicious) { + return res.status(429).json({ error: 'Suspicious activity detected' }); + } - // Get watermark settings - const watermarkSettings = await watermarkService.getWatermarkSettings(); + // Log access + await secureImageService.logImageAccess(photoId, req.event.id, { + ip: req.ip, + userAgent: req.get('User-Agent'), + fingerprint: clientFingerprint + }, 'view'); + + // Get protection settings from event + const protectionSettings = { + protectionLevel: req.event.protection_level || protectionLevel, + quality: req.event.image_quality || 85, + addFingerprint: req.event.add_fingerprint !== false, + fragmentImage: protectionLevel === 'maximum' + }; // Build full path to photo const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path); - // Apply watermark if enabled - const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings); + // Process image with protection + const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings); - // Set appropriate headers + // Apply watermark if enabled + let finalImage; + if (processedImage.type === 'fragmented') { + // Return fragmented image data for canvas reconstruction + return res.json({ + type: 'fragmented', + fragments: processedImage.fragments.map(f => ({ + index: f.index, + row: f.row, + col: f.col, + data: f.buffer.toString('base64'), + position: f.position + })), + dimensions: processedImage.originalDimensions, + fragmentDimensions: processedImage.fragmentDimensions + }); + } else { + const watermarkSettings = await watermarkService.getWatermarkSettings(); + finalImage = await watermarkService.applyWatermark(photoPath, watermarkSettings); + } + + // Set security headers res.set({ 'Content-Type': photo.mime_type || 'image/jpeg', - 'Content-Length': imageBuffer.length, - 'Cache-Control': 'private, max-age=3600', - 'X-Content-Type-Options': 'nosniff' + 'Content-Length': finalImage.length, + 'Cache-Control': 'private, no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-Download-Options': 'noopen', + 'Content-Disposition': 'inline; filename="protected-image.jpg"' }); - // Send the watermarked image - res.send(imageBuffer); + // Send the protected image + res.send(finalImage); } catch (error) { - console.error('Error serving watermarked image:', error); + console.error('Error serving protected image:', error); res.status(500).json({ error: 'Failed to serve image' }); } }); /** - * Generate signed URL for image access + * Generate secure token for enhanced image access + */ +router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess, async (req, res) => { + try { + const { photoId } = req.params; + const { protectionLevel = 'standard', expiresIn = 300 } = req.body; + + // Verify photo belongs to this event + const photo = await db('photos') + .where({ + id: photoId, + event_id: req.event.id + }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Create client fingerprint + const clientFingerprint = secureImageService.createClientFingerprint(req); + + // Generate secure token + const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', { + expiresIn, + maxUses: protectionLevel === 'maximum' ? 1 : 3, + clientFingerprint, + protectionLevel + }); + + res.json({ + token, + expiresIn, + protectionLevel, + maxUses: protectionLevel === 'maximum' ? 1 : 3 + }); + + } catch (error) { + console.error('Error generating secure token:', error); + res.status(500).json({ error: 'Failed to generate token' }); + } +}); + +/** + * Generate signed URL for image access (legacy support) */ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (req, res) => { try { diff --git a/backend/src/routes/secureImages.js b/backend/src/routes/secureImages.js new file mode 100644 index 0000000..7e5ff33 --- /dev/null +++ b/backend/src/routes/secureImages.js @@ -0,0 +1,429 @@ +const express = require('express'); +const path = require('path'); +const { db } = require('../database/db'); +const { verifyGalleryAccess } = require('../middleware/gallery'); +const secureImageService = require('../services/secureImageService'); +const secureImageMiddleware = require('../middleware/secureImageMiddleware'); +const logger = require('../utils/logger'); +const { formatBoolean } = require('../utils/dbCompat'); + +const router = express.Router(); + +// Get storage path from environment or default +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +/** + * Generate secure token for image access + */ +router.post('/:slug/generate-token', async (req, res, next) => { + // Add slug to request for verifyGalleryAccess + req.requestedSlug = req.params.slug; + next(); +}, verifyGalleryAccess, async (req, res) => { + try { + const { photoId, accessType = 'view' } = req.body; + + if (!photoId) { + return res.status(400).json({ error: 'Photo ID required' }); + } + + // Verify photo exists and belongs to event + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Create client fingerprint + const clientFingerprint = secureImageService.createClientFingerprint(req); + + // Get protection level from event settings + const protectionLevel = req.event.protection_level || 'standard'; + + // Generate secure token with appropriate settings + const tokenOptions = { + expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes + maxUses: accessType === 'download' ? 1 : 3, + clientFingerprint, + protectionLevel + }; + + const token = secureImageService.generateSecureToken( + photoId, + req.sessionID || 'anonymous', + tokenOptions + ); + + // Log token generation + await secureImageService.logImageAccess( + photoId, + req.event.id, + { + ip: req.ip, + userAgent: req.get('User-Agent'), + fingerprint: clientFingerprint + }, + 'token_generated' + ); + + res.json({ + token, + expiresIn: tokenOptions.expiresIn, + maxUses: tokenOptions.maxUses, + protectionLevel + }); + + } catch (error) { + logger.error('Error generating secure token', { + error: error.message, + photoId: req.body.photoId, + eventId: req.event?.id + }); + res.status(500).json({ error: 'Failed to generate secure token' }); + } +}); + +/** + * Serve protected image with security measures + */ +router.get('/:slug/secure/:photoId/:token', + secureImageMiddleware.secureImageAccess, + async (req, res) => { + const { slug, photoId, token } = req.params; // Move outside try block for error handler access + + try { + console.log('Secure image route hit:', { + slug: slug, + photoId: photoId, + tokenLength: token?.length, + headers: req.headers.authorization ? 'present' : 'absent' + }); + const { fragment } = req.query; + + // Verify secure token + const tokenValidation = secureImageService.verifySecureToken( + token, + req.clientInfo.fingerprint + ); + + if (!tokenValidation.valid) { + // Get event for logging (best effort) + const event = await db('events').where({ slug }).first(); + await secureImageService.logImageAccess( + photoId, + event?.id || 0, + req.clientInfo, + 'token_invalid' + ); + return res.status(403).json({ error: 'Invalid or expired token' }); + } + + // Get event from slug + const event = await db('events') + .where({ + slug, + is_active: formatBoolean(true), + is_archived: formatBoolean(false) + }) + .first(); + + if (!event) { + return res.status(404).json({ error: 'Gallery not found' }); + } + + // Verify photo exists and belongs to event + const photo = await db('photos') + .where({ id: photoId, event_id: event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + const filePath = path.join(getStoragePath(), 'events/active', photo.path); + + // Get protection settings for this event + const protectionSettings = { + protectionLevel: event.protection_level || 'standard', + quality: event.image_quality || 85, + addFingerprint: event.add_fingerprint !== false, + fragmentImage: event.use_canvas_rendering === true && fragment !== undefined + }; + + // Process image with protection measures + const processedImage = await secureImageService.processProtectedImage( + filePath, + protectionSettings + ); + + // Handle fragmented images + if (processedImage.type === 'fragmented') { + return await handleFragmentedImage(req, res, processedImage, fragment); + } + + // Log successful access + await secureImageService.logImageAccess( + photoId, + event.id, + req.clientInfo, + 'view' + ); + + // Set content type and security headers + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Content-Length': processedImage.length, + 'X-Protection-Level': protectionSettings.protectionLevel, + 'X-Remaining-Uses': tokenValidation.remaining + }); + + res.send(processedImage); + + } catch (error) { + logger.error('Error serving secure image', { + error: error.message, + photoId, + slug, + clientFingerprint: req.clientInfo?.fingerprint + }); + res.status(500).json({ error: 'Failed to serve image' }); + } + } +); + +/** + * Handle fragmented image delivery + */ +async function handleFragmentedImage(req, res, fragmentedImage, fragmentIndex) { + const { photoId } = req.params; + + try { + if (fragmentIndex === undefined) { + // Return fragment metadata + res.json({ + type: 'fragmented', + fragments: fragmentedImage.fragments.length, + dimensions: fragmentedImage.originalDimensions, + fragmentDimensions: fragmentedImage.fragmentDimensions + }); + return; + } + + const index = parseInt(fragmentIndex); + if (isNaN(index) || index < 0 || index >= fragmentedImage.fragments.length) { + return res.status(400).json({ error: 'Invalid fragment index' }); + } + + const fragment = fragmentedImage.fragments[index]; + + // Log fragment access + await secureImageService.logImageAccess( + photoId, + req.event.id, + req.clientInfo, + `fragment_${index}` + ); + + res.set({ + 'Content-Type': 'image/jpeg', + 'Content-Length': fragment.buffer.length, + 'X-Fragment-Index': index, + 'X-Fragment-Position': JSON.stringify(fragment.position) + }); + + res.send(fragment.buffer); + + } catch (error) { + logger.error('Error serving image fragment', { + error: error.message, + fragmentIndex, + photoId + }); + res.status(500).json({ error: 'Failed to serve image fragment' }); + } +} + +/** + * Download protected image with watermark + */ +router.get('/:slug/secure-download/:photoId/:token', + secureImageMiddleware.secureImageAccess, + async (req, res, next) => { + // Add slug to request for verifyGalleryAccess + req.requestedSlug = req.params.slug; + next(); + }, + verifyGalleryAccess, + async (req, res) => { + try { + const { photoId, token } = req.params; + + // Check if downloads are allowed + if (req.event.allow_downloads === false) { + return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); + } + + // Verify secure token + const tokenValidation = secureImageService.verifySecureToken( + token, + req.clientInfo.fingerprint + ); + + if (!tokenValidation.valid) { + return res.status(403).json({ error: 'Invalid or expired token' }); + } + + // Verify photo exists + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + const filePath = path.join(getStoragePath(), 'events/active', photo.path); + + // Apply watermark if enabled + const watermarkService = require('../services/watermarkService'); + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + let fileBuffer; + if (watermarkSettings && watermarkSettings.enabled) { + fileBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); + } else { + const fs = require('fs').promises; + fileBuffer = await fs.readFile(filePath); + } + + // Update download count + await db('photos').where('id', photoId).increment('download_count', 1); + + // Log download + await secureImageService.logImageAccess( + photoId, + req.event.id, + req.clientInfo, + 'download' + ); + + res.set({ + 'Content-Type': photo.mime_type || 'image/jpeg', + 'Content-Disposition': `attachment; filename="${photo.filename}"`, + 'Content-Length': fileBuffer.length, + 'X-Download-Protected': 'true' + }); + + res.send(fileBuffer); + + } catch (error) { + logger.error('Error serving secure download', { + error: error.message, + photoId: req.params.photoId + }); + res.status(500).json({ error: 'Failed to download image' }); + } + } +); + +/** + * Get security statistics for monitoring + */ +router.get('/security/stats', async (req, res) => { + try { + // Only allow admin access + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ error: 'No token provided' }); + } + + const jwt = require('jsonwebtoken'); + // Try to verify with issuer first, fallback to no issuer for backward compatibility + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth' + }); + } catch (issuerError) { + // If verification fails with issuer, try without issuer (backward compatibility) + if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) { + decoded = jwt.verify(token, process.env.JWT_SECRET); + } else { + throw issuerError; + } + } + const admin = await db('admin_users').where({ id: decoded.id }).first(); + + if (!admin) { + return res.status(401).json({ error: 'Invalid token' }); + } + + // Get security statistics + const stats = { + middleware: secureImageMiddleware.getSecurityStatus(), + recentAccess: await getRecentAccessStats(), + suspiciousActivity: await getSuspiciousActivityStats() + }; + + res.json(stats); + + } catch (error) { + logger.error('Error getting security stats', { error: error.message }); + res.status(500).json({ error: 'Failed to get security stats' }); + } +}); + +/** + * Get recent access statistics + */ +async function getRecentAccessStats() { + try { + const hourAgo = new Date(Date.now() - 3600000).toISOString(); + + const stats = await db('image_access_logs') + .where('accessed_at', '>', hourAgo) + .select('access_type') + .count('* as count') + .groupBy('access_type'); + + return stats.reduce((acc, stat) => { + acc[stat.access_type] = parseInt(stat.count); + return acc; + }, {}); + } catch (error) { + console.error('Error getting recent access stats:', error); + return {}; + } +} + +/** + * Get suspicious activity statistics + */ +async function getSuspiciousActivityStats() { + try { + const hourAgo = new Date(Date.now() - 3600000).toISOString(); + + const suspiciousCount = await db('image_access_logs') + .where('accessed_at', '>', hourAgo) + .where('access_type', 'like', '%suspicious%') + .count('* as count') + .first(); + + const uniqueIPs = await db('image_access_logs') + .where('accessed_at', '>', hourAgo) + .countDistinct('client_ip as count') + .first(); + + return { + suspiciousEvents: parseInt(suspiciousCount.count), + uniqueIPs: parseInt(uniqueIPs.count) + }; + } catch (error) { + console.error('Error getting suspicious activity stats:', error); + return { suspiciousEvents: 0, uniqueIPs: 0 }; + } +} + +module.exports = router; \ No newline at end of file diff --git a/backend/src/services/feedbackService.js b/backend/src/services/feedbackService.js index 8506ae3..27e7c52 100644 --- a/backend/src/services/feedbackService.js +++ b/backend/src/services/feedbackService.js @@ -120,7 +120,7 @@ class FeedbackService { } // Insert new feedback - const [id] = await db('photo_feedback').insert({ + const result = await db('photo_feedback').insert({ photo_id: photoId, event_id: eventId, feedback_type, @@ -134,7 +134,9 @@ class FeedbackService { is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments, created_at: new Date(), updated_at: new Date() - }); + }).returning('id'); + + const id = result[0]?.id || result[0]; // Update photo stats await this.updatePhotoFeedbackStats(photoId); @@ -175,7 +177,7 @@ class FeedbackService { const feedback = await query .orderBy('created_at', 'desc') - .select('id', 'feedback_type', 'rating', 'comment_text', 'guest_name', 'created_at'); + .select('id', 'feedback_type', 'rating', 'comment_text', 'guest_name', 'created_at', 'is_approved', 'is_hidden'); return feedback; } catch (error) { diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index 26f865d..380fc6c 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -2,21 +2,69 @@ const sharp = require('sharp'); const path = require('path'); const fs = require('fs').promises; const logger = require('../utils/logger'); +const { db } = require('../database/db'); // Configure sharp for better memory management with large batches sharp.cache(false); // Disable cache to prevent memory buildup sharp.concurrency(2); // Limit concurrent operations -const THUMBNAIL_WIDTH = 300; +// Default thumbnail settings +const DEFAULT_THUMBNAIL_WIDTH = 300; +const DEFAULT_THUMBNAIL_HEIGHT = 300; +const DEFAULT_THUMBNAIL_FIT = 'cover'; // 'cover' for square crops +const DEFAULT_THUMBNAIL_QUALITY = 85; +const DEFAULT_THUMBNAIL_FORMAT = 'jpeg'; + const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails'); +// Get thumbnail settings from database +async function getThumbnailSettings() { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'thumbnail_width', + 'thumbnail_height', + 'thumbnail_fit', + 'thumbnail_quality', + 'thumbnail_format' + ]) + .select('setting_key', 'setting_value'); + + const settingsMap = {}; + settings.forEach(s => { + settingsMap[s.setting_key] = s.setting_value; + }); + + return { + width: parseInt(settingsMap.thumbnail_width) || DEFAULT_THUMBNAIL_WIDTH, + height: parseInt(settingsMap.thumbnail_height) || DEFAULT_THUMBNAIL_HEIGHT, + fit: settingsMap.thumbnail_fit || DEFAULT_THUMBNAIL_FIT, + quality: parseInt(settingsMap.thumbnail_quality) || DEFAULT_THUMBNAIL_QUALITY, + format: settingsMap.thumbnail_format || DEFAULT_THUMBNAIL_FORMAT + }; + } catch (error) { + // If database is not ready or settings don't exist, use defaults + logger.warn('Could not fetch thumbnail settings, using defaults:', error.message); + return { + width: DEFAULT_THUMBNAIL_WIDTH, + height: DEFAULT_THUMBNAIL_HEIGHT, + fit: DEFAULT_THUMBNAIL_FIT, + quality: DEFAULT_THUMBNAIL_QUALITY, + format: DEFAULT_THUMBNAIL_FORMAT + }; + } +} + async function generateThumbnail(imagePath, options = {}) { const filename = path.basename(imagePath); const thumbnailFilename = `thumb_${filename}`; const thumbnailDir = getThumbnailPath(); const thumbnailPath = path.join(thumbnailDir, thumbnailFilename); + // Get thumbnail settings + const settings = await getThumbnailSettings(); + // Ensure thumbnail directory exists await fs.mkdir(thumbnailDir, { recursive: true }); @@ -38,22 +86,43 @@ async function generateThumbnail(imagePath, options = {}) { throw new Error('Invalid image metadata - file may be incomplete'); } - // Generate thumbnail with memory-efficient settings and error handling - await sharp(imagePath, { + // Create sharp instance with memory-efficient settings + let sharpInstance = sharp(imagePath, { limitInputPixels: 268402689, // ~16k x 16k max sequentialRead: true, // More memory efficient for large images failOnError: false // Don't fail on minor issues - }) - .resize(THUMBNAIL_WIDTH, null, { - withoutEnlargement: true, - fit: 'inside' - }) - .jpeg({ - quality: 80, + }); + + // Apply resize with configured settings + // For square thumbnails with 'cover' fit, we crop to center + sharpInstance = sharpInstance.resize(settings.width, settings.height, { + withoutEnlargement: true, + fit: settings.fit, // 'cover' will crop to fill the exact dimensions + position: 'center' // Center the crop for better composition + }); + + // Apply format-specific options + if (settings.format === 'jpeg') { + sharpInstance = sharpInstance.jpeg({ + quality: settings.quality, progressive: true, // Progressive JPEG for better loading mozjpeg: true // Better compression - }) - .toFile(thumbnailPath); + }); + } else if (settings.format === 'png') { + sharpInstance = sharpInstance.png({ + quality: settings.quality, + compressionLevel: 9, + progressive: true + }); + } else if (settings.format === 'webp') { + sharpInstance = sharpInstance.webp({ + quality: settings.quality, + effort: 4 // Balance between speed and compression + }); + } + + // Save the thumbnail + await sharpInstance.toFile(thumbnailPath); // Verify the thumbnail was created successfully const stats = await fs.stat(thumbnailPath); diff --git a/backend/src/services/photoProcessor.js b/backend/src/services/photoProcessor.js index 5861d57..04f6127 100644 --- a/backend/src/services/photoProcessor.js +++ b/backend/src/services/photoProcessor.js @@ -21,39 +21,29 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ const trx = await db.transaction(); try { - // Get category info if provided - let category = null; + // Count existing photos to generate sequence number let counter = 1; - const parsedCategoryId = categoryId ? parseInt(categoryId) : null; + let photoType = 'individual'; // default type - if (parsedCategoryId) { - // Get category and update counter - category = await trx('photo_categories') - .where({ id: parsedCategoryId }) - .first(); - - if (category) { - counter = (category.photo_counter || 0) + 1; - await trx('photo_categories') - .where({ id: parsedCategoryId }) - .update({ photo_counter: counter }); - } - } else { - // For uncategorized photos, count existing uncategorized photos - const uncategorizedCount = await trx('photos') - .where({ event_id: eventId }) - .whereNull('category_id') - .count('id as count') - .first(); - - counter = (uncategorizedCount.count || 0) + 1; + // If categoryId is provided and matches photo types, use it as type + if (categoryId === 'collage') { + photoType = 'collage'; } + // Count existing photos of the same type for numbering + const existingCount = await trx('photos') + .where({ event_id: eventId, type: photoType }) + .count('id as count') + .first(); + + counter = (existingCount.count || 0) + 1; + // Generate new filename const extension = path.extname(file.originalname); + const categoryName = photoType === 'collage' ? 'collages' : 'individual'; const newFilename = generatePhotoFilename( event.event_name, - category ? category.name : 'uncategorized', + categoryName, counter, extension ); @@ -81,10 +71,8 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ filename: newFilename, path: relativePath, thumbnail_path: relativeThumbPath, - category_id: parsedCategoryId || null, - type: 'individual', - size_bytes: file.size, - uploaded_by: uploadedBy + type: photoType, + size_bytes: file.size }); // Commit transaction @@ -94,8 +82,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ id: photoId, filename: newFilename, size: file.size, - category_id: parsedCategoryId || null, - uploaded_by: uploadedBy + type: photoType }); } catch (error) { console.error(`Error processing file ${file.originalname}:`, error); diff --git a/backend/src/services/secureImageService.js b/backend/src/services/secureImageService.js new file mode 100644 index 0000000..5f818e8 --- /dev/null +++ b/backend/src/services/secureImageService.js @@ -0,0 +1,431 @@ +const crypto = require('crypto'); +const sharp = require('sharp'); +const { db } = require('../database/db'); +const watermarkService = require('./watermarkService'); +const path = require('path'); +const fs = require('fs').promises; + +class SecureImageService { + constructor() { + this.tokenCache = new Map(); + this.sessionTokens = new Map(); + this.rateLimitCache = new Map(); + } + + /** + * Generate a secure, time-limited, single-use token for image access + */ + generateSecureToken(photoId, sessionId, options = {}) { + const { + expiresIn = 300, // 5 minutes default + maxUses = 1, + clientFingerprint = '', + protectionLevel = 'standard' + } = options; + + const tokenData = { + photoId: parseInt(photoId), + sessionId, + clientFingerprint, + expiresAt: Date.now() + (expiresIn * 1000), + maxUses, + usedCount: 0, + protectionLevel, + createdAt: Date.now() + }; + + // Create tamper-proof token + const tokenPayload = Buffer.from(JSON.stringify(tokenData)).toString('base64'); + const imageSecret = process.env.IMAGE_SECRET || process.env.JWT_SECRET + '_IMAGE_PROTECTION'; + const signature = crypto + .createHmac('sha256', process.env.JWT_SECRET + imageSecret) + .update(tokenPayload) + .digest('hex'); + + const token = `${tokenPayload}.${signature}`; + + // Cache token with metadata + this.tokenCache.set(token, tokenData); + + // Set cleanup timer + setTimeout(() => { + this.tokenCache.delete(token); + }, expiresIn * 1000 + 60000); // Add 1 minute buffer + + return token; + } + + /** + * Verify and consume secure token + */ + verifySecureToken(token, clientFingerprint = '') { + try { + const cached = this.tokenCache.get(token); + if (!cached) { + return { valid: false, reason: 'Token not found or expired' }; + } + + // Verify token integrity + const [payload, signature] = token.split('.'); + const imageSecret = process.env.IMAGE_SECRET || process.env.JWT_SECRET + '_IMAGE_PROTECTION'; + const expectedSignature = crypto + .createHmac('sha256', process.env.JWT_SECRET + imageSecret) + .update(payload) + .digest('hex'); + + if (signature !== expectedSignature) { + return { valid: false, reason: 'Token tampered' }; + } + + // Check expiration + if (Date.now() > cached.expiresAt) { + this.tokenCache.delete(token); + return { valid: false, reason: 'Token expired' }; + } + + // Check usage count + if (cached.usedCount >= cached.maxUses) { + return { valid: false, reason: 'Token max uses exceeded' }; + } + + // Verify client fingerprint for enhanced security + if (cached.protectionLevel === 'enhanced' && cached.clientFingerprint !== clientFingerprint) { + return { valid: false, reason: 'Client fingerprint mismatch' }; + } + + // Consume usage + cached.usedCount++; + + // Remove token if max uses reached + if (cached.usedCount >= cached.maxUses) { + this.tokenCache.delete(token); + } + + return { + valid: true, + data: cached, + remaining: cached.maxUses - cached.usedCount + }; + } catch (error) { + return { valid: false, reason: 'Token verification failed' }; + } + } + + /** + * Create client fingerprint from request + */ + createClientFingerprint(req) { + const components = [ + req.ip, + req.get('User-Agent') || '', + req.get('Accept-Language') || '', + req.get('Accept-Encoding') || '' + ]; + + return crypto + .createHash('sha256') + .update(components.join('|')) + .digest('hex') + .substring(0, 16); + } + + /** + * Rate limiting for image requests + */ + checkRateLimit(clientId, limit = 50, windowMs = 60000) { + const now = Date.now(); + const windowStart = now - windowMs; + + if (!this.rateLimitCache.has(clientId)) { + this.rateLimitCache.set(clientId, []); + } + + const requests = this.rateLimitCache.get(clientId); + + // Remove old requests outside the window + const recentRequests = requests.filter(timestamp => timestamp > windowStart); + this.rateLimitCache.set(clientId, recentRequests); + + if (recentRequests.length >= limit) { + return false; + } + + // Add current request + recentRequests.push(now); + return true; + } + + /** + * Process image with protection measures + */ + async processProtectedImage(imagePath, options = {}) { + const { + protectionLevel = 'standard', + quality = 85, + maxWidth = 1920, + maxHeight = 1080, + addFingerprint = true, + fragmentImage = false + } = options; + + try { + let image = sharp(imagePath); + const metadata = await image.metadata(); + + // Resize if too large + if (metadata.width > maxWidth || metadata.height > maxHeight) { + image = image.resize(maxWidth, maxHeight, { + fit: 'inside', + withoutEnlargement: true + }); + } + + // Apply quality reduction for protection + if (protectionLevel === 'enhanced') { + quality = Math.min(quality, 70); + } else if (protectionLevel === 'maximum') { + quality = Math.min(quality, 60); + } + + // Convert to appropriate format + image = image.jpeg({ quality, progressive: true }); + + // Add invisible watermark/fingerprint + if (addFingerprint) { + const fingerprint = crypto.randomBytes(16).toString('hex'); + + // Embed fingerprint in metadata + image = image.withMetadata({ + exif: { + [sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}` + } + }); + } + + const buffer = await image.toBuffer(); + + // Fragment image if requested (for canvas reconstruction) + if (fragmentImage && protectionLevel === 'maximum') { + return await this.fragmentImageBuffer(buffer, metadata); + } + + return buffer; + } catch (error) { + console.error('Error processing protected image:', error); + // Return original on error + return await fs.readFile(imagePath); + } + } + + /** + * Fragment image into multiple pieces for canvas reconstruction + */ + async fragmentImageBuffer(buffer, metadata) { + const { width, height } = metadata; + const fragments = []; + + // Create 3x3 grid of fragments + const cols = 3; + const rows = 3; + const fragmentWidth = Math.floor(width / cols); + const fragmentHeight = Math.floor(height / rows); + + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + const left = col * fragmentWidth; + const top = row * fragmentHeight; + + const fragment = await sharp(buffer) + .extract({ + left, + top, + width: fragmentWidth, + height: fragmentHeight + }) + .toBuffer(); + + fragments.push({ + index: row * cols + col, + row, + col, + buffer: fragment, + position: { left, top, width: fragmentWidth, height: fragmentHeight } + }); + } + } + + return { + type: 'fragmented', + fragments, + originalDimensions: { width, height }, + fragmentDimensions: { width: fragmentWidth, height: fragmentHeight, cols, rows } + }; + } + + /** + * Log image access for security monitoring + */ + async logImageAccess(photoId, eventId, clientInfo, accessType = 'view', metadata = {}) { + try { + const logEntry = { + photo_id: photoId, + event_id: eventId, + client_ip: clientInfo.ip, + user_agent: clientInfo.userAgent?.substring(0, 500), // Limit length + access_type: accessType, + client_fingerprint: clientInfo.fingerprint?.substring(0, 32) || 'unknown', + accessed_at: new Date().toISOString(), + metadata: JSON.stringify({ + timestamp: clientInfo.timestamp || Date.now(), + ...metadata + }) + }; + + await db('image_access_logs').insert(logEntry); + + // Check for rapid successive access (potential scraping) + if (accessType === 'view' || accessType === 'download') { + await this.checkForRapidAccess(clientInfo.fingerprint, photoId, eventId); + } + + } catch (error) { + console.error('Error logging image access:', error); + } + } + + /** + * Enhanced suspicious activity detection + */ + async checkForRapidAccess(clientFingerprint, photoId, eventId = null) { + try { + const fiveMinutesAgo = new Date(Date.now() - 300000).toISOString(); + + // Check accesses to same photo + const samePhotoAccess = await db('image_access_logs') + .where('client_fingerprint', clientFingerprint) + .where('photo_id', photoId) + .where('accessed_at', '>', fiveMinutesAgo) + .count('* as count') + .first(); + + // Check total accesses across all photos + const totalAccess = await db('image_access_logs') + .where('client_fingerprint', clientFingerprint) + .where('accessed_at', '>', fiveMinutesAgo) + .count('* as count') + .first(); + + const samePhotoCount = parseInt(samePhotoAccess.count); + const totalCount = parseInt(totalAccess.count); + + // Flag if suspicious patterns detected + if (samePhotoCount > 5 || totalCount > 30) { + await this.flagSuspiciousActivity( + clientFingerprint, + photoId, + 'rapid_access', + { samePhotoCount, totalCount, eventId } + ); + } + + } catch (error) { + console.error('Error checking for rapid access:', error); + } + } + + /** + * Flag suspicious activity and take action + */ + async flagSuspiciousActivity(clientFingerprint, photoId, reason, details = {}) { + try { + // Try to get event_id from photo + let eventId = details.eventId; + if (!eventId && photoId) { + const photo = await db('photos').where({ id: photoId }).first(); + eventId = photo?.event_id; + } + + // Log the suspicious activity + await db('image_access_logs').insert({ + photo_id: photoId, + event_id: eventId || 0, // Use 0 as a fallback for suspicious activity without event context + client_ip: details.clientIp || 'unknown', + client_fingerprint: clientFingerprint, + access_type: 'suspicious', + accessed_at: new Date().toISOString(), + metadata: JSON.stringify({ + reason, + ...details, + flaggedAt: Date.now() + }) + }); + + console.warn(`Suspicious activity flagged: ${reason}`, { + clientFingerprint, + photoId, + details + }); + + // If multiple suspicious activities, consider blocking + const recentSuspicious = await db('image_access_logs') + .where('client_fingerprint', clientFingerprint) + .where('access_type', 'suspicious') + .where('accessed_at', '>', new Date(Date.now() - 3600000).toISOString()) // Last hour + .count('* as count') + .first(); + + if (parseInt(recentSuspicious.count) >= 3) { + console.warn(`Client fingerprint flagged for blocking: ${clientFingerprint}`); + // This would be handled by the middleware's blocking system + } + + } catch (error) { + console.error('Error flagging suspicious activity:', error); + } + } + + /** + * Detect suspicious access patterns + */ + async detectSuspiciousActivity(clientFingerprint, photoId) { + try { + const recentAccess = await db('image_access_logs') + .where('client_fingerprint', clientFingerprint) + .where('photo_id', photoId) + .where('accessed_at', '>', new Date(Date.now() - 300000).toISOString()) // Last 5 minutes + .count('* as count') + .first(); + + const accessCount = parseInt(recentAccess.count); + + // Flag if more than 10 accesses to same photo in 5 minutes + if (accessCount > 10) { + console.warn(`Suspicious activity detected: ${accessCount} accesses to photo ${photoId} from ${clientFingerprint}`); + return true; + } + + return false; + } catch (error) { + console.error('Error detecting suspicious activity:', error); + return false; + } + } + + /** + * Clean up expired tokens and logs + */ + cleanup() { + // Clear expired rate limit entries + const now = Date.now(); + for (const [clientId, requests] of this.rateLimitCache.entries()) { + const recent = requests.filter(timestamp => timestamp > now - 60000); + if (recent.length === 0) { + this.rateLimitCache.delete(clientId); + } else { + this.rateLimitCache.set(clientId, recent); + } + } + } +} + +module.exports = new SecureImageService(); \ No newline at end of file diff --git a/backend/src/utils/feedbackValidation.js b/backend/src/utils/feedbackValidation.js index 2bb14b8..c8c1795 100644 --- a/backend/src/utils/feedbackValidation.js +++ b/backend/src/utils/feedbackValidation.js @@ -152,18 +152,25 @@ const validateFeedbackSubmission = [ body('guest_name') .optional() - .trim() - .isLength({ max: 100 }) - .withMessage('Name must be less than 100 characters') - .matches(/^[a-zA-Z0-9\s\-'.]+$/) - .withMessage('Name contains invalid characters'), + .custom((value) => { + // Allow empty or whitespace-only strings + if (!value || value.trim() === '') return true; + // If not empty, check length and pattern + const trimmed = value.trim(); + if (trimmed.length > 100) throw new Error('Name must be less than 100 characters'); + if (!/^[a-zA-Z0-9\s\-'.]+$/.test(trimmed)) throw new Error('Name contains invalid characters'); + return true; + }), body('guest_email') .optional() - .trim() - .isEmail() - .normalizeEmail() - .withMessage('Invalid email address') + .custom((value) => { + // Allow empty or whitespace-only strings + if (!value || value.trim() === '') return true; + // If not empty, validate as email + if (!validator.isEmail(value.trim())) throw new Error('Invalid email address'); + return true; + }) ]; /** diff --git a/docker-compose.yml b/docker-compose.yml index cff4baf..6bc8b9b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,7 +41,7 @@ services: postgres: condition: service_healthy healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3001/api/health"] + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3001/health"] interval: 30s timeout: 10s retries: 3 @@ -107,7 +107,7 @@ services: depends_on: - backend healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"] + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/health"] interval: 30s timeout: 10s retries: 3 diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index 7f95d76..f98171c 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -80,19 +80,25 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { - {/* Date display */} -
+ {/* Date display - hidden on small screens */} +

{format(new Date(), 'PPPP')}

- {/* Center - Logo and PicPeak text */} -
+ {/* Center - Logo and PicPeak text - hidden on small screens to prevent overlap */} +
PicPeak PicPeak
+ + {/* Mobile Logo - shown only on small screens */} +
+ PicPeak + PicPeak +
{/* Right side actions */}
diff --git a/frontend/src/components/admin/AdminLayout.tsx b/frontend/src/components/admin/AdminLayout.tsx index c99ed93..d684633 100644 --- a/frontend/src/components/admin/AdminLayout.tsx +++ b/frontend/src/components/admin/AdminLayout.tsx @@ -6,9 +6,10 @@ import { useSessionTimeout } from '../../hooks/useSessionTimeout'; import { AdminSidebar } from './AdminSidebar'; import { AdminHeader } from './AdminHeader'; import { MaintenanceBanner } from './MaintenanceBanner'; +import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal'; export const AdminLayout: React.FC = () => { - const { isAuthenticated, isLoading } = useAdminAuth(); + const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth(); const [sidebarOpen, setSidebarOpen] = useState(false); // Handle session timeout @@ -31,6 +32,9 @@ export const AdminLayout: React.FC = () => { return (
+ {/* Mandatory Password Change Modal */} + {mustChangePassword && } + {/* Mobile sidebar backdrop */} {sidebarOpen && (
{ /> )} - {/* Sidebar */} - setSidebarOpen(false)} /> + {/* Sidebar - disabled when password change required */} +
+ setSidebarOpen(false)} /> +
{/* Main content */}
- {/* Header */} - setSidebarOpen(true)} /> + {/* Header - disabled when password change required */} +
+ setSidebarOpen(true)} /> +
{/* Maintenance mode banner */} - {/* Page content */} -
+ {/* Page content - disabled when password change required */} +
diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx index eaad59a..0831661 100644 --- a/frontend/src/components/admin/AdminPhotoGrid.tsx +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Check, Download, Trash2, Eye, Package } from 'lucide-react'; +import { Check, Download, Trash2, Eye, Package, MessageSquare, Star } from 'lucide-react'; import { toast } from 'react-toastify'; import { AdminPhoto } from '../../services/photos.service'; @@ -246,6 +246,24 @@ export const AdminPhotoGrid: React.FC = ({
)} + + {/* Feedback Indicators */} + {(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && ( +
+ {photo.comment_count > 0 && ( +
+ + {photo.comment_count} +
+ )} + {photo.average_rating > 0 && ( +
+ + {Number(photo.average_rating).toFixed(1)} +
+ )} +
+ )}
); })} diff --git a/frontend/src/components/admin/AdminPhotoViewer.tsx b/frontend/src/components/admin/AdminPhotoViewer.tsx index 868abf2..b4b0927 100644 --- a/frontend/src/components/admin/AdminPhotoViewer.tsx +++ b/frontend/src/components/admin/AdminPhotoViewer.tsx @@ -1,10 +1,12 @@ import React, { useState } from 'react'; -import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer } from 'lucide-react'; +import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, ThumbsUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; import { format } from 'date-fns'; import { toast } from 'react-toastify'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { AdminPhoto } from '../../services/photos.service'; import { photosService } from '../../services/photos.service'; +import { feedbackService } from '../../services/feedback.service'; import { Button } from '../common'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; @@ -28,9 +30,21 @@ export const AdminPhotoViewer: React.FC = ({ const [currentIndex, setCurrentIndex] = useState(initialIndex); const [isDeleting, setIsDeleting] = useState(false); const [showCategoryMenu, setShowCategoryMenu] = useState(false); + const [expandedComments, setExpandedComments] = useState(false); + const queryClient = useQueryClient(); const currentPhoto = photos[currentIndex]; + // Fetch feedback for current photo + const { data: feedbackData } = useQuery({ + queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id], + queryFn: () => feedbackService.getEventFeedback(eventId.toString(), { + photoId: currentPhoto?.id.toString(), + status: 'all' // Get all comments including unapproved + }), + enabled: !!currentPhoto + }); + const goToPrevious = () => { setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); }; @@ -88,6 +102,30 @@ export const AdminPhotoViewer: React.FC = ({ } }; + // Mutations for feedback moderation + const moderateFeedbackMutation = useMutation({ + mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) => + feedbackService.moderateFeedback(feedbackId, action), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id] }); + toast.success('Feedback moderated successfully'); + }, + onError: () => { + toast.error('Failed to moderate feedback'); + } + }); + + const deleteFeedbackMutation = useMutation({ + mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id] }); + toast.success('Feedback deleted successfully'); + }, + onError: () => { + toast.error('Failed to delete feedback'); + } + }); + React.useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { switch (e.key) { @@ -256,6 +294,177 @@ export const AdminPhotoViewer: React.FC = ({ )}
+ {/* Feedback Section */} + {feedbackData && ( +
+

+ + Feedback & Comments +

+ + {/* Feedback Stats */} +
+ {currentPhoto.average_rating > 0 && ( +
+
+ + {Number(currentPhoto.average_rating).toFixed(1)} +
+

Avg Rating

+
+ )} + + {currentPhoto.like_count > 0 && ( +
+
+ + {currentPhoto.like_count} +
+

Likes

+
+ )} + + {currentPhoto.favorite_count > 0 && ( +
+
+ + {currentPhoto.favorite_count} +
+

Favorites

+
+ )} + + {feedbackData.feedback && ( +
+
+ + {feedbackData.feedback.filter(f => f.feedback_type === 'comment').length} +
+

Comments

+
+ )} +
+ + {/* Comments List */} + {feedbackData.feedback && feedbackData.feedback.filter(f => f.feedback_type === 'comment').length > 0 && ( +
+ + + {expandedComments && ( +
+ {feedbackData.feedback + .filter(f => f.feedback_type === 'comment') + .map((comment) => ( +
+
+
+

+ {comment.guest_name || 'Anonymous'} +

+

+ {format(new Date(comment.created_at), 'MMM d, yyyy h:mm a')} +

+
+ + {/* Comment Status Badge */} +
+ {!comment.is_approved && !comment.is_hidden && ( + + + Pending + + )} + {comment.is_approved && !comment.is_hidden && ( + + + Approved + + )} + {comment.is_hidden && ( + + + Hidden + + )} +
+
+ +

+ {comment.comment_text} +

+ + {/* Moderation Actions */} +
+ {!comment.is_approved && ( + + )} + + {!comment.is_hidden && ( + + )} + + {comment.is_hidden && ( + + )} + + +
+
+ ))} +
+ )} +
+ )} + + {/* No feedback message */} + {(!feedbackData.feedback || feedbackData.feedback.length === 0) && ( +

No feedback for this photo yet.

+ )} +
+ )} + {/* Navigation info */}

diff --git a/frontend/src/components/admin/CategoryManager.tsx b/frontend/src/components/admin/CategoryManager.tsx index ccc5b65..5ca1bbd 100644 --- a/frontend/src/components/admin/CategoryManager.tsx +++ b/frontend/src/components/admin/CategoryManager.tsx @@ -2,10 +2,12 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react'; import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; import { categoriesService, type PhotoCategory } from '../../services/categories.service'; import { Button } from '../common'; export const CategoryManager: React.FC = () => { + const { t } = useTranslation(); const queryClient = useQueryClient(); const [isAdding, setIsAdding] = useState(false); const [editingId, setEditingId] = useState(null); @@ -24,12 +26,12 @@ export const CategoryManager: React.FC = () => { categoriesService.createCategory({ name, is_global: true }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['global-categories'] }); - toast.success('Category created successfully'); + toast.success(t('categories.categoryCreatedSuccess')); setNewCategoryName(''); setIsAdding(false); }, onError: (error: any) => { - toast.error(error.response?.data?.error || 'Failed to create category'); + toast.error(error.response?.data?.error || t('categories.failedToCreateCategory')); }, }); @@ -39,12 +41,12 @@ export const CategoryManager: React.FC = () => { categoriesService.updateCategory(id, name), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['global-categories'] }); - toast.success('Category updated successfully'); + toast.success(t('toast.categoryUpdated')); setEditingId(null); setEditingName(''); }, onError: (error: any) => { - toast.error(error.response?.data?.error || 'Failed to update category'); + toast.error(error.response?.data?.error || t('toast.saveError')); }, }); @@ -53,10 +55,10 @@ export const CategoryManager: React.FC = () => { mutationFn: categoriesService.deleteCategory, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['global-categories'] }); - toast.success('Category deleted successfully'); + toast.success(t('categories.categoryDeletedSuccess')); }, onError: (error: any) => { - toast.error(error.response?.data?.error || 'Failed to delete category'); + toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory')); }, }); @@ -73,7 +75,7 @@ export const CategoryManager: React.FC = () => { }; const handleDelete = (category: PhotoCategory) => { - if (window.confirm(`Are you sure you want to delete "${category.name}"?`)) { + if (window.confirm(t('categories.deleteConfirm', { name: category.name }))) { deleteMutation.mutate(category.id); } }; @@ -99,7 +101,7 @@ export const CategoryManager: React.FC = () => { return (

-

Photo Categories

+

{t('categories.title')}

{!isAdding && ( )}
@@ -120,7 +122,7 @@ export const CategoryManager: React.FC = () => { value={newCategoryName} onChange={(e) => setNewCategoryName(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && handleCreate()} - placeholder="Category name" + placeholder={t('categories.categoryName')} className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500" autoFocus /> @@ -133,7 +135,7 @@ export const CategoryManager: React.FC = () => { {createMutation.isPending ? ( ) : ( - 'Create' + t('common.save') )}
)} @@ -153,7 +155,7 @@ export const CategoryManager: React.FC = () => {
{categories.length === 0 ? (

- No categories yet. Create your first category to organize photos. + {t('categories.noCategoriesYet')}

) : ( categories.map((category) => ( @@ -183,7 +185,7 @@ export const CategoryManager: React.FC = () => { {updateMutation.isPending ? ( ) : ( - 'Save' + t('common.save') )}
) : ( @@ -204,14 +206,14 @@ export const CategoryManager: React.FC = () => {