Compare commits

...

5 Commits

Author SHA1 Message Date
Gitea Actions Bot 9c943bc69a chore: bump version to 1.0.103 (backend + frontend) 2025-08-29 20:06:11 +00:00
paul 29a8ff914c feat: consolidate setup scripts and guides into unified solution
Mirror to GitHub / mirror (push) Successful in 46s
Test and Lint / backend-test (push) Successful in 1m51s
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 1m9s
Version and Release / trigger-drone (push) Successful in 3s
- Created unified SIMPLE_SETUP.md combining Docker and native installation guides
- Created universal scripts/setup.sh supporting both Docker and native installations
- Removed redundant setup files (simple-setup.md, simple-setup.sh, scripts/simple-setup.sh)
- Added intelligent installation method selection based on system resources
- Implemented update and uninstall functionality in unified script
- Enhanced with command-line options for unattended installations
- Improved cross-platform support (Ubuntu, Debian, RHEL/CentOS, Fedora, Raspberry Pi OS)

Fixes #7

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 21:59:39 +02:00
paul a73d217273 refactor: simplify setup file names
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m41s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Has been skipped
- Rename SETUP_GUIDE.md to simple-setup.md
- Rename setup-picpeak.sh to simple-setup.sh
- Update all internal references to use new filenames
- Simplify naming convention for easier understanding

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 23:23:02 +02:00
paul 1b4b497fdf chore: clean up codebase for production readiness
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
- Remove all console.log/debug statements from production code
- Add NODE_ENV checks for development-only logging
- Remove test scripts (test-feedback, test-image-security, test-backup-*, test-restore)
- Remove one-time fix scripts (fix-temp-photos, fix-migration-state, mark-migration-applied)
- Remove sensitive files (.env.backup, ADMIN_CREDENTIALS.txt)
- Update package.json to remove references to deleted scripts
- Replace console statements with logger utility in backend
- Secure error boundaries to not expose stack traces in production

This makes the codebase production-ready with no debug output or test scripts.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 23:19:30 +02:00
paul 827eb4819b fix: update GitHub mirror action to support fine-grained personal access tokens
Mirror to GitHub / mirror (push) Failing after 39s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m14s
- Changed authentication from x-access-token to actual username (required for fine-grained tokens)
- Implemented git config url.insteadOf method for better token compatibility
- Added comprehensive token type detection and validation
- Improved error handling with detailed troubleshooting instructions
- Added clear documentation for both classic and fine-grained token setup
- Enhanced security by removing credentials from remote URLs
- Added automatic git config cleanup after push

Required permissions for fine-grained tokens:
- Repository access: the-luap/picpeak
- Contents: Read and Write
- Metadata: Read

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 11:24:49 +02:00
149 changed files with 12754 additions and 2033 deletions
+338
View File
@@ -0,0 +1,338 @@
[
{
"timestamp": 1756496746582,
"memoryTotal": 25769803776,
"memoryUsed": 25277186048,
"memoryFree": 492617728,
"memoryUsagePercent": 98.08839162190756,
"memoryEfficiency": 1.9116083780924384,
"cpuCount": 14,
"cpuLoad": 0.24197823660714285,
"platform": "darwin",
"uptime": 5182900
},
{
"timestamp": 1756496776582,
"memoryTotal": 25769803776,
"memoryUsed": 25447661568,
"memoryFree": 322142208,
"memoryUsagePercent": 98.74992370605469,
"memoryEfficiency": 1.2500762939453125,
"cpuCount": 14,
"cpuLoad": 0.1962890625,
"platform": "darwin",
"uptime": 5182930
},
{
"timestamp": 1756496806600,
"memoryTotal": 25769803776,
"memoryUsed": 25413042176,
"memoryFree": 356761600,
"memoryUsagePercent": 98.61558278401694,
"memoryEfficiency": 1.3844172159830634,
"cpuCount": 14,
"cpuLoad": 0.22129603794642858,
"platform": "darwin",
"uptime": 5182960
},
{
"timestamp": 1756496836604,
"memoryTotal": 25769803776,
"memoryUsed": 25516834816,
"memoryFree": 252968960,
"memoryUsagePercent": 99.01835123697916,
"memoryEfficiency": 0.9816487630208428,
"cpuCount": 14,
"cpuLoad": 0.21344866071428573,
"platform": "darwin",
"uptime": 5182990
},
{
"timestamp": 1756496866605,
"memoryTotal": 25769803776,
"memoryUsed": 25414123520,
"memoryFree": 355680256,
"memoryUsagePercent": 98.61977895100912,
"memoryEfficiency": 1.380221048990876,
"cpuCount": 14,
"cpuLoad": 0.21822684151785715,
"platform": "darwin",
"uptime": 5183020
},
{
"timestamp": 1756496896607,
"memoryTotal": 25769803776,
"memoryUsed": 25196363776,
"memoryFree": 573440000,
"memoryUsagePercent": 97.77475992838541,
"memoryEfficiency": 2.225240071614593,
"cpuCount": 14,
"cpuLoad": 0.20950753348214285,
"platform": "darwin",
"uptime": 5183050
},
{
"timestamp": 1756496926609,
"memoryTotal": 25769803776,
"memoryUsed": 25503711232,
"memoryFree": 266092544,
"memoryUsagePercent": 98.96742502848306,
"memoryEfficiency": 1.0325749715169366,
"cpuCount": 14,
"cpuLoad": 0.23154994419642858,
"platform": "darwin",
"uptime": 5183080
},
{
"timestamp": 1756496956610,
"memoryTotal": 25769803776,
"memoryUsed": 25550979072,
"memoryFree": 218824704,
"memoryUsagePercent": 99.15084838867188,
"memoryEfficiency": 0.849151611328125,
"cpuCount": 14,
"cpuLoad": 0.20741489955357142,
"platform": "darwin",
"uptime": 5183110
},
{
"timestamp": 1756496986609,
"memoryTotal": 25769803776,
"memoryUsed": 25552404480,
"memoryFree": 217399296,
"memoryUsagePercent": 99.15637969970703,
"memoryEfficiency": 0.8436203002929688,
"cpuCount": 14,
"cpuLoad": 0.22112165178571427,
"platform": "darwin",
"uptime": 5183140
},
{
"timestamp": 1756497016611,
"memoryTotal": 25769803776,
"memoryUsed": 25680560128,
"memoryFree": 89243648,
"memoryUsagePercent": 99.653689066569,
"memoryEfficiency": 0.34631093343099906,
"cpuCount": 14,
"cpuLoad": 0.23615373883928573,
"platform": "darwin",
"uptime": 5183170
},
{
"timestamp": 1756497046610,
"memoryTotal": 25769803776,
"memoryUsed": 25631408128,
"memoryFree": 138395648,
"memoryUsagePercent": 99.46295420328775,
"memoryEfficiency": 0.5370457967122491,
"cpuCount": 14,
"cpuLoad": 0.19859095982142858,
"platform": "darwin",
"uptime": 5183200
},
{
"timestamp": 1756497076611,
"memoryTotal": 25769803776,
"memoryUsed": 25655099392,
"memoryFree": 114704384,
"memoryUsagePercent": 99.55488840738931,
"memoryEfficiency": 0.44511159261068656,
"cpuCount": 14,
"cpuLoad": 0.23831612723214285,
"platform": "darwin",
"uptime": 5183230
},
{
"timestamp": 1756497106613,
"memoryTotal": 25769803776,
"memoryUsed": 25590169600,
"memoryFree": 179634176,
"memoryUsagePercent": 99.30292765299478,
"memoryEfficiency": 0.6970723470052178,
"cpuCount": 14,
"cpuLoad": 0.29031808035714285,
"platform": "darwin",
"uptime": 5183260
},
{
"timestamp": 1756497136613,
"memoryTotal": 25769803776,
"memoryUsed": 25682378752,
"memoryFree": 87425024,
"memoryUsagePercent": 99.66074625651041,
"memoryEfficiency": 0.3392537434895928,
"cpuCount": 14,
"cpuLoad": 0.26834542410714285,
"platform": "darwin",
"uptime": 5183290
},
{
"timestamp": 1756497166614,
"memoryTotal": 25769803776,
"memoryUsed": 25591283712,
"memoryFree": 178520064,
"memoryUsagePercent": 99.3072509765625,
"memoryEfficiency": 0.6927490234375,
"cpuCount": 14,
"cpuLoad": 0.2820521763392857,
"platform": "darwin",
"uptime": 5183320
},
{
"timestamp": 1756497196616,
"memoryTotal": 25769803776,
"memoryUsed": 25577848832,
"memoryFree": 191954944,
"memoryUsagePercent": 99.25511678059897,
"memoryEfficiency": 0.7448832194010322,
"cpuCount": 14,
"cpuLoad": 0.23618861607142858,
"platform": "darwin",
"uptime": 5183350
},
{
"timestamp": 1756497226617,
"memoryTotal": 25769803776,
"memoryUsed": 25343754240,
"memoryFree": 426049536,
"memoryUsagePercent": 98.34671020507812,
"memoryEfficiency": 1.653289794921875,
"cpuCount": 14,
"cpuLoad": 0.23228236607142858,
"platform": "darwin",
"uptime": 5183380
},
{
"timestamp": 1756497256617,
"memoryTotal": 25769803776,
"memoryUsed": 25662668800,
"memoryFree": 107134976,
"memoryUsagePercent": 99.58426157633463,
"memoryEfficiency": 0.41573842366537406,
"cpuCount": 14,
"cpuLoad": 0.19471958705357142,
"platform": "darwin",
"uptime": 5183410
},
{
"timestamp": 1756497286619,
"memoryTotal": 25769803776,
"memoryUsed": 25610125312,
"memoryFree": 159678464,
"memoryUsagePercent": 99.38036600748697,
"memoryEfficiency": 0.6196339925130303,
"cpuCount": 14,
"cpuLoad": 0.19126674107142858,
"platform": "darwin",
"uptime": 5183440
},
{
"timestamp": 1756497316620,
"memoryTotal": 25769803776,
"memoryUsed": 25256427520,
"memoryFree": 513376256,
"memoryUsagePercent": 98.0078379313151,
"memoryEfficiency": 1.9921620686849053,
"cpuCount": 14,
"cpuLoad": 0.15576171875,
"platform": "darwin",
"uptime": 5183470
},
{
"timestamp": 1756497346620,
"memoryTotal": 25769803776,
"memoryUsed": 25464700928,
"memoryFree": 305102848,
"memoryUsagePercent": 98.81604512532553,
"memoryEfficiency": 1.1839548746744697,
"cpuCount": 14,
"cpuLoad": 0.21369280133928573,
"platform": "darwin",
"uptime": 5183500
},
{
"timestamp": 1756497376620,
"memoryTotal": 25769803776,
"memoryUsed": 25632653312,
"memoryFree": 137150464,
"memoryUsagePercent": 99.46778615315756,
"memoryEfficiency": 0.5322138468424384,
"cpuCount": 14,
"cpuLoad": 0.201904296875,
"platform": "darwin",
"uptime": 5183530
},
{
"timestamp": 1756497406621,
"memoryTotal": 25769803776,
"memoryUsed": 25066160128,
"memoryFree": 703643648,
"memoryUsagePercent": 97.26950327555338,
"memoryEfficiency": 2.730496724446624,
"cpuCount": 14,
"cpuLoad": 0.192626953125,
"platform": "darwin",
"uptime": 5183560
},
{
"timestamp": 1756497436623,
"memoryTotal": 25769803776,
"memoryUsed": 25574244352,
"memoryFree": 195559424,
"memoryUsagePercent": 99.24112955729166,
"memoryEfficiency": 0.7588704427083428,
"cpuCount": 14,
"cpuLoad": 0.19761439732142858,
"platform": "darwin",
"uptime": 5183590
},
{
"timestamp": 1756497466624,
"memoryTotal": 25769803776,
"memoryUsed": 25427623936,
"memoryFree": 342179840,
"memoryUsagePercent": 98.67216746012369,
"memoryEfficiency": 1.3278325398763116,
"cpuCount": 14,
"cpuLoad": 0.20005580357142858,
"platform": "darwin",
"uptime": 5183620
},
{
"timestamp": 1756497496626,
"memoryTotal": 25769803776,
"memoryUsed": 25596116992,
"memoryFree": 173686784,
"memoryUsagePercent": 99.32600657145181,
"memoryEfficiency": 0.6739934285481866,
"cpuCount": 14,
"cpuLoad": 0.19367327008928573,
"platform": "darwin",
"uptime": 5183650
},
{
"timestamp": 1756497526626,
"memoryTotal": 25769803776,
"memoryUsed": 25207111680,
"memoryFree": 562692096,
"memoryUsagePercent": 97.81646728515625,
"memoryEfficiency": 2.18353271484375,
"cpuCount": 14,
"cpuLoad": 0.15276227678571427,
"platform": "darwin",
"uptime": 5183680
},
{
"timestamp": 1756497556628,
"memoryTotal": 25769803776,
"memoryUsed": 25510182912,
"memoryFree": 259620864,
"memoryUsagePercent": 98.99253845214844,
"memoryEfficiency": 1.0074615478515625,
"cpuCount": 14,
"cpuLoad": 0.13654436383928573,
"platform": "darwin",
"uptime": 5183710
}
]
+54 -8
View File
@@ -9,6 +9,10 @@ on:
jobs:
mirror:
runs-on: ubuntu-latest
# Note: For GitHub fine-grained tokens, ensure the token has:
# - Repository access to the-luap/picpeak
# - Repository permissions: Contents (Read and Write), Metadata (Read)
# For classic tokens: repo scope is sufficient
steps:
- name: Checkout repository
uses: actions/checkout@v3
@@ -34,7 +38,10 @@ jobs:
rm -rf photo-sharing-prd.md || true
rm -rf CLAUDE.md || true
rm -rf storage/ || true
rm -rf events/ || true
rm -rf .playwright-mcp/
rm -rf .swarm || true
rm -rf .claude-flow || true
echo "Sensitive files removal completed"
@@ -57,29 +64,68 @@ jobs:
run: |
if [ -z "$GITHUBTOKEN" ]; then
echo "ERROR: GITHUBTOKEN secret is not set!"
echo "Please add a GitHub Personal Access Token as a secret named GITHUBTOKEN"
echo ""
echo "For fine-grained tokens:"
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens"
echo " - Create token with repository access to the-luap/picpeak"
echo " - Grant permissions: Contents (Read and Write), Metadata (Read)"
echo ""
echo "For classic tokens:"
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)"
echo " - Create token with 'repo' scope"
exit 1
else
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
# Try to detect token type (fine-grained tokens are typically longer)
if [ ${#GITHUBTOKEN} -gt 80 ]; then
echo "📌 Token appears to be a fine-grained personal access token"
else
echo "📌 Token appears to be a classic personal access token"
fi
fi
- name: Push to GitHub
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
GIT_TRACE: 1 # Enable Git trace for debugging if needed
run: |
# Remove existing github remote if it exists
git remote remove github || true
# Add GitHub remote
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
# Configure Git to use the token for authentication
# This method works for both classic and fine-grained tokens
git config --global url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf "https://github.com/"
# Add GitHub remote (clean URL without credentials)
git remote add github https://github.com/the-luap/picpeak.git
# Verify remote was added
echo "GitHub remote added:"
echo "GitHub remote configuration:"
git remote -v
# Push to GitHub main branch
# Push to GitHub main branch with error handling
echo "Pushing to GitHub..."
git push github main --force
echo "✅ Push to GitHub completed!"
if git push github main --force 2>&1; then
echo "✅ Push to GitHub completed successfully!"
else
echo "❌ Push to GitHub failed!"
echo ""
echo "Common issues and solutions:"
echo "1. Token permissions: Ensure your token has 'Contents: write' permission"
echo "2. Token expiration: Check if your token has expired"
echo "3. Repository access: Verify the token has access to the-luap/picpeak repository"
echo ""
echo "For fine-grained tokens, required permissions:"
echo " - Repository access: the-luap/picpeak"
echo " - Repository permissions: Contents (Read and Write), Metadata (Read)"
echo ""
echo "For classic tokens, required scope: 'repo'"
exit 1
fi
# Clean up the git config after push
git config --global --unset url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf
- name: Workflow completed
run: |
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19 -1
View File
@@ -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
- [ ] Stakeholders notified of maintenance window
- always use docker deployment for testing
+25 -1
View File
@@ -1,6 +1,30 @@
# 🚀 PicPeak Deployment Guide
This guide covers deploying PicPeak using Docker Compose with direct port exposure. For internet-facing deployments, you'll need to add a reverse proxy (nginx, Traefik, Caddy, etc.) for SSL/HTTPS.
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
For the easiest installation without Docker or complex configurations, use our **simple setup script**:
```bash
curl -fsSL https://raw.githubusercontent.com/yourusername/wedding-photo-sharing/main/scripts/simple-setup.sh -o setup.sh && \
chmod +x setup.sh && \
sudo ./setup.sh
```
This automated script handles everything including OS detection, dependencies, database setup, and service configuration. Perfect for:
- Small to medium deployments
- Local or VPS installations
- Users who prefer avoiding Docker complexity
- Quick testing and evaluation
👉 **See [SIMPLE_SETUP_GUIDE.md](./SIMPLE_SETUP_GUIDE.md) for detailed instructions.**
---
## 🐳 Docker Compose Deployment
This section covers deploying PicPeak using Docker Compose with direct port exposure. For internet-facing deployments, you'll need to add a reverse proxy (nginx, Traefik, Caddy, etc.) for SSL/HTTPS.
## 📋 Table of Contents
+519
View File
@@ -0,0 +1,519 @@
# 🚀 PicPeak Simple Setup Guide
This guide provides easy installation instructions for PicPeak on Linux servers with both Docker and non-Docker options.
## 📋 Quick Start
### One-Line Installation
```bash
# Download and run the unified setup script
curl -fsSL https://raw.githubusercontent.com/yourusername/wedding-photo-sharing/main/scripts/setup.sh -o setup.sh && \
chmod +x setup.sh && \
sudo ./setup.sh
```
The script will automatically detect your environment and recommend the best installation method.
## 🎯 Installation Methods
### Method 1: Docker Installation (Recommended)
Best for: Most users, easy updates, isolated environment
```bash
sudo ./setup.sh --docker
```
**Pros:**
- ✅ Easier installation and updates
- ✅ Better isolation from system
- ✅ Consistent environment across platforms
- ✅ Built-in PostgreSQL and Redis
**Cons:**
- ❌ Requires more resources (~4GB RAM recommended)
- ❌ Additional Docker overhead
### Method 2: Native Installation
Best for: Resource-constrained systems, Raspberry Pi, direct control
```bash
sudo ./setup.sh --native
```
**Pros:**
- ✅ Lower resource usage (~1GB RAM minimum)
- ✅ Direct system control
- ✅ No Docker overhead
- ✅ Better for ARM devices
**Cons:**
- ❌ More complex setup
- ❌ System dependencies required
- ❌ Manual update process
## 📋 System Requirements
### Minimum Requirements
- **OS**: Ubuntu 20.04+, Debian 11+, Fedora 38+, RHEL/CentOS 8+, Raspberry Pi OS
- **RAM**:
- Docker: 2GB minimum (4GB recommended)
- Native: 1GB minimum (2GB recommended)
- **Storage**: 2GB for application + space for photos
- **Network**: Port 3001 (or 80/443 with proxy)
### Supported Platforms
- ✅ Ubuntu 20.04, 22.04, 24.04
- ✅ Debian 11, 12
- ✅ Raspberry Pi OS (32-bit and 64-bit)
- ✅ Fedora 38, 39, 40
- ✅ RHEL/CentOS/Rocky/AlmaLinux 8, 9
## 🛠️ Installation Options
### Interactive Mode (Default)
```bash
sudo ./setup.sh
```
The script will prompt you to choose:
1. Installation method (Docker or Native)
2. Admin email and password
3. Domain configuration (optional)
4. Email server settings (optional)
5. SSL/HTTPS setup (optional)
### Unattended Installation
#### Docker with full configuration:
```bash
sudo ./setup.sh --docker --unattended \
--domain photos.example.com \
--email admin@example.com \
--admin-password SecurePass123 \
--smtp-host smtp.gmail.com \
--smtp-port 587 \
--smtp-user your-email@gmail.com \
--smtp-pass your-app-password \
--enable-ssl
```
#### Native with minimal configuration:
```bash
sudo ./setup.sh --native --unattended \
--email admin@example.com \
--admin-password SecurePass123
```
### Command Line Options
| Option | Description | Example |
|--------|-------------|---------|
| `--docker` | Use Docker installation | `--docker` |
| `--native` | Use native installation | `--native` |
| `--unattended` | Run without prompts | `--unattended` |
| `--domain` | Domain for HTTPS setup | `--domain photos.example.com` |
| `--email` | Admin email address | `--email admin@example.com` |
| `--admin-password` | Set admin password | `--admin-password MySecurePass` |
| `--smtp-host` | SMTP server hostname | `--smtp-host smtp.gmail.com` |
| `--smtp-port` | SMTP server port | `--smtp-port 587` |
| `--smtp-user` | SMTP username | `--smtp-user user@gmail.com` |
| `--smtp-pass` | SMTP password | `--smtp-pass app-password` |
| `--enable-ssl` | Enable HTTPS with Let's Encrypt | `--enable-ssl` |
| `--port` | Custom port (native only) | `--port 8080` |
| `--update` | Update existing installation | `--update` |
| `--uninstall` | Remove installation | `--uninstall` |
| `--help` | Show help message | `--help` |
## 🏗️ What Gets Installed
### Docker Installation
```
~/picpeak/ # Or custom directory
├── docker-compose.yml # Service definitions
├── .env # Configuration
├── storage/
│ └── events/ # Photo storage
│ ├── active/ # Current galleries
│ └── archived/ # Expired galleries
├── logs/ # Application logs
└── backup/ # Backup directory
```
**Services:**
- PicPeak Backend (Node.js application)
- PostgreSQL Database
- Redis Cache
- Nginx Reverse Proxy (optional)
- Background Workers
### Native Installation
```
/opt/picpeak/ # Installation directory
├── backend/ # Application code
├── events/ # Photo storage
│ ├── active/ # Current galleries
│ └── archived/ # Expired galleries
├── logs/ # Application logs
└── config/ # Configuration files
```
**Services (systemd):**
- `picpeak-backend` - Main application
- `picpeak-workers` - Background workers
- `caddy` - Web server (optional)
## 🌐 Access Methods
### Direct Access (Simplest)
- **Docker**: `http://your-server:3000` (frontend), `http://your-server:3001/admin` (admin)
- **Native**: `http://your-server:3001/admin` (admin panel)
### With Domain & HTTPS
If configured during setup:
- `https://your-domain.com` - Gallery frontend
- `https://your-domain.com/admin` - Admin panel
### Behind Existing Proxy
Add to your Nginx/Apache configuration:
```nginx
location / {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100M;
}
```
## 📁 Managing Galleries
### Creating a Gallery
#### Method 1: Via Admin Panel (Recommended)
1. Login to admin panel
2. Click "Create New Event"
3. Configure settings and upload photos
#### Method 2: File System
```bash
# Docker installation
mkdir -p ~/picpeak/storage/events/active/wedding-smith-2024
cp /path/to/photos/* ~/picpeak/storage/events/active/wedding-smith-2024/
# Native installation
sudo mkdir -p /opt/picpeak/events/active/wedding-smith-2024
sudo cp /path/to/photos/* /opt/picpeak/events/active/wedding-smith-2024/
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/wedding-smith-2024
```
### Gallery Structure
```
wedding-smith-2024/
├── collages/ # Group photos
├── individual/ # Individual photos
└── thumbnails/ # Auto-generated thumbnails
```
## 🔧 Service Management
### Docker Installation
```bash
cd ~/picpeak
# Check status
docker compose ps
# View logs
docker compose logs -f
# Stop services
docker compose down
# Start services
docker compose up -d
# Restart services
docker compose restart
# Update PicPeak
docker compose pull
docker compose up -d
```
### Native Installation
```bash
# Check status
sudo systemctl status picpeak-backend
sudo systemctl status picpeak-workers
# View logs
sudo journalctl -u picpeak-backend -f
sudo journalctl -u picpeak-workers -f
# Start services
sudo systemctl start picpeak-backend picpeak-workers
# Stop services
sudo systemctl stop picpeak-backend picpeak-workers
# Restart services
sudo systemctl restart picpeak-backend picpeak-workers
# Update PicPeak
sudo ./setup.sh --update
```
## ⚙️ Configuration
### Docker Configuration
Edit `~/picpeak/.env`:
```bash
nano ~/picpeak/.env
docker compose restart
```
### Native Configuration
Edit `/opt/picpeak/backend/.env`:
```bash
sudo nano /opt/picpeak/backend/.env
sudo systemctl restart picpeak-backend
```
### Key Settings
| Setting | Description | Default |
|---------|-------------|---------|
| `JWT_SECRET` | Token signing secret | Auto-generated |
| `ADMIN_EMAIL` | Admin email | admin@example.com |
| `ADMIN_PASSWORD` | Admin password | Auto-generated |
| `PHOTOS_DIR` | Photo storage path | Varies by method |
| `SMTP_ENABLED` | Email notifications | false |
| `DEFAULT_EXPIRY_DAYS` | Gallery expiration | 30 |
## 📧 Email Configuration
### Gmail Setup
1. Enable 2-Factor Authentication
2. Generate App Password
3. Configure:
```env
SMTP_ENABLED=true
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
```
### SendGrid Setup
1. Sign up at sendgrid.com (100 emails/day free)
2. Create API key
3. Configure:
```env
SMTP_ENABLED=true
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
SMTP_FROM=verified-sender@yourdomain.com
```
## 🔄 Maintenance
### Backups
#### Docker:
```bash
# Backup script included
cd ~/picpeak
./backup.sh
# Manual backup
docker exec picpeak-postgres pg_dump -U picpeak picpeak > backup.sql
tar -czf photos-backup.tar.gz storage/events/
```
#### Native:
```bash
# Database backup
sudo cp /opt/picpeak/backend/database.sqlite /backup/database-$(date +%Y%m%d).sqlite
# Photos backup
sudo tar -czf /backup/photos-$(date +%Y%m%d).tar.gz /opt/picpeak/events/
```
### Updates
```bash
# Docker
cd ~/picpeak
docker compose pull
docker compose up -d
# Native
sudo ./setup.sh --update
```
### Uninstall
```bash
# Will prompt for confirmation and data removal options
sudo ./setup.sh --uninstall
```
## 🐛 Troubleshooting
### Common Issues
#### Service Won't Start
```bash
# Docker
docker compose logs backend
docker compose down && docker compose up -d
# Native
sudo journalctl -u picpeak-backend -n 50
sudo systemctl restart picpeak-backend
```
#### Can't Access Admin Panel
1. Check firewall:
```bash
# Ubuntu/Debian
sudo ufw allow 3001
# RHEL/CentOS
sudo firewall-cmd --add-port=3001/tcp --permanent
sudo firewall-cmd --reload
```
2. Verify service:
```bash
# Docker
curl http://localhost:3001/api/health
# Native
sudo systemctl is-active picpeak-backend
```
#### Photos Not Showing
```bash
# Check permissions (Native)
sudo chown -R picpeak:picpeak /opt/picpeak/events/
sudo chmod -R 755 /opt/picpeak/events/
# Check permissions (Docker)
ls -la ~/picpeak/storage/events/
```
#### Reset Admin Password
```bash
# Docker
docker exec picpeak-backend node scripts/reset-admin-password.js
# Native
cd /opt/picpeak/backend
sudo -u picpeak node scripts/reset-admin-password.js
```
### Getting Help
1. **Check logs:**
- Docker: `docker compose logs -f`
- Native: `sudo journalctl -u picpeak-backend -f`
- Installation: `/tmp/picpeak-setup-*.log`
2. **Documentation:**
- [Full Documentation](https://github.com/yourusername/wedding-photo-sharing)
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
3. **Support:**
- [GitHub Issues](https://github.com/yourusername/wedding-photo-sharing/issues)
- Include: Error messages, system info (`uname -a`), installation method
## 🔒 Security Best Practices
### Essential Security
1. **Change default admin password immediately**
2. **Use HTTPS for production** (Let's Encrypt included)
3. **Configure firewall** (only open necessary ports)
4. **Regular updates** (system and PicPeak)
5. **Automated backups** (configure in admin panel)
### Advanced Security
- Use VPN for admin panel access
- Configure fail2ban for brute force protection
- Enable audit logging
- Regular security scans
- Implement IP whitelisting
## 📊 Performance Optimization
### Docker Optimization
```yaml
# Adjust in docker-compose.yml
services:
backend:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
```
### Native Optimization
```bash
# Increase Node.js memory
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/backend/.env
sudo systemctl restart picpeak-backend
```
## 🎯 Quick Setup Examples
### Home/Office Network
```bash
# Simple local setup without domain
sudo ./setup.sh --native --email admin@local.com
```
### Public Website with HTTPS
```bash
# Full production setup
sudo ./setup.sh --docker \
--domain photos.company.com \
--email admin@company.com \
--enable-ssl
```
### Raspberry Pi Setup
```bash
# Optimized for ARM devices
sudo ./setup.sh --native \
--port 8080 \
--email pi@local.com
```
## ✅ Post-Installation Checklist
- [ ] Admin password changed
- [ ] Email configuration tested
- [ ] First test gallery created
- [ ] Backup schedule configured
- [ ] Firewall rules applied
- [ ] SSL certificate working (if applicable)
- [ ] Monitoring setup
- [ ] Documentation bookmarked
---
**PicPeak Setup v1.0** | [Documentation](https://github.com/yourusername/wedding-photo-sharing) | [Support](https://github.com/yourusername/wedding-photo-sharing/issues)
Binary file not shown.
+1
View File
@@ -26,6 +26,7 @@ exports.up = async function(knex) {
username: adminUsername,
email: adminEmail,
password_hash: passwordHash,
must_change_password: true,
created_at: new Date()
});
@@ -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');
};
@@ -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');
};
@@ -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');
};
@@ -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();
};
@@ -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: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date()
},
{
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date()
}
]);
if (!hasCmsPagesTable) {
await db('cms_pages').insert([
{
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date()
},
{
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date()
}
]);
}
console.log('Photo categories and CMS tables created successfully');
}
@@ -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) {
@@ -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();
}
});
};
@@ -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) {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.102",
"version": "1.0.103",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.102",
"version": "1.0.103",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
+2 -5
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.102",
"version": "1.0.103",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -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",
-61
View File
@@ -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();
-171
View File
@@ -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);
-46
View File
@@ -1,46 +0,0 @@
#!/usr/bin/env node
/**
* Mark a specific migration as applied without running it
* Usage: node scripts/mark-migration-applied.js <migration-filename>
*/
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 <migration-filename>');
console.error('Example: node scripts/mark-migration-applied.js 032_add_restore_runs_table.js');
process.exit(1);
}
markMigrationAsApplied(migrationFile);
@@ -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();
+39
View File
@@ -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();
+3 -2
View File
@@ -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]');
}
-577
View File
@@ -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 <url> S3 endpoint URL (default: http://localhost:9000)
* --access-key <key> S3 access key (default: minioadmin)
* --secret-key <key> S3 secret key (default: minioadmin)
* --bucket <name> S3 bucket name (default: test-backup-<timestamp>)
* --type <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 };
-46
View File
@@ -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();
-325
View File
@@ -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();
+18 -3
View File
@@ -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
+63 -3
View File
@@ -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 });
}
}
+13 -1
View File
@@ -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'] ||
+6 -3
View File
@@ -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' });
}
+66 -11
View File
@@ -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);
+14 -1
View File
@@ -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') {
@@ -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;
+28 -5
View File
@@ -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
+29 -7
View File
@@ -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 {
+515
View File
@@ -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;
+57 -57
View File
@@ -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) {
+21
View File
@@ -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 {
+188
View File
@@ -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;
+23 -18
View File
@@ -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' });
+2 -2
View File
@@ -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);
+279 -121
View File
@@ -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' });
}
});
+111 -13
View File
@@ -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 {
+429
View File
@@ -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;
+5 -3
View File
@@ -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) {
+81 -12
View File
@@ -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);
+18 -31
View File
@@ -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);
+431
View File
@@ -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();
+16 -9
View File
@@ -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;
})
];
/**
+2 -2
View File
@@ -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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.102",
"version": "1.0.103",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.102",
"version": "1.0.103",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.102",
"version": "1.0.103",
"type": "module",
"scripts": {
"dev": "vite",
+10 -4
View File
@@ -80,19 +80,25 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<Menu className="w-6 h-6" />
</button>
{/* Date display */}
<div className="hidden lg:block">
{/* Date display - hidden on small screens */}
<div className="hidden xl:block">
<p className="text-base text-neutral-700">
{format(new Date(), 'PPPP')}
</p>
</div>
</div>
{/* Center - Logo and PicPeak text */}
<div className="absolute left-1/2 transform -translate-x-1/2 flex items-center gap-3">
{/* Center - Logo and PicPeak text - hidden on small screens to prevent overlap */}
<div className="hidden lg:flex absolute left-1/2 transform -translate-x-1/2 items-center gap-3">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-10 w-auto object-contain" />
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
</div>
{/* Mobile Logo - shown only on small screens */}
<div className="flex lg:hidden items-center gap-2 mx-auto">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-8 w-auto object-contain" />
<span className="text-xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
</div>
{/* Right side actions */}
<div className="flex items-center gap-3">
+15 -7
View File
@@ -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 (
<div className="h-screen bg-neutral-50 flex overflow-hidden">
{/* Mandatory Password Change Modal */}
{mustChangePassword && <MandatoryPasswordChangeModal />}
{/* Mobile sidebar backdrop */}
{sidebarOpen && (
<div
@@ -39,19 +43,23 @@ export const AdminLayout: React.FC = () => {
/>
)}
{/* Sidebar */}
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Sidebar - disabled when password change required */}
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
</div>
{/* Main content */}
<div className="flex-1 flex flex-col min-w-0 h-screen">
{/* Header */}
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
{/* Header - disabled when password change required */}
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
</div>
{/* Maintenance mode banner */}
<MaintenanceBanner />
{/* Page content */}
<main id="main-content" className="flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto">
{/* Page content - disabled when password change required */}
<main id="main-content" className={`flex-1 px-4 sm:px-6 lg:px-8 py-8 overflow-y-auto ${mustChangePassword ? 'opacity-50 pointer-events-none' : ''}`}>
<Outlet />
</main>
</div>
@@ -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<AdminPhotoGridProps> = ({
</span>
</div>
)}
{/* Feedback Indicators */}
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
<div className="absolute top-2 left-2 flex gap-1 z-10" style={{ left: isSelectionMode ? '40px' : '8px' }}>
{photo.comment_count > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
</div>
)}
{photo.average_rating > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
</div>
)}
</div>
)}
</div>
);
})}
@@ -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<AdminPhotoViewerProps> = ({
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<AdminPhotoViewerProps> = ({
}
};
// 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<AdminPhotoViewerProps> = ({
)}
</div>
{/* Feedback Section */}
{feedbackData && (
<div className="mt-6 pt-6 border-t border-neutral-700">
<h4 className="text-white font-medium mb-4 flex items-center gap-2">
<MessageSquare className="w-4 h-4" />
Feedback & Comments
</h4>
{/* Feedback Stats */}
<div className="grid grid-cols-2 gap-3 mb-4">
{currentPhoto.average_rating > 0 && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-yellow-400 mb-1">
<Star className="w-4 h-4" fill="currentColor" />
<span className="text-white font-medium">{Number(currentPhoto.average_rating).toFixed(1)}</span>
</div>
<p className="text-xs text-neutral-400">Avg Rating</p>
</div>
)}
{currentPhoto.like_count > 0 && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-red-400 mb-1">
<Heart className="w-4 h-4" fill="currentColor" />
<span className="text-white font-medium">{currentPhoto.like_count}</span>
</div>
<p className="text-xs text-neutral-400">Likes</p>
</div>
)}
{currentPhoto.favorite_count > 0 && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-blue-400 mb-1">
<Star className="w-4 h-4" />
<span className="text-white font-medium">{currentPhoto.favorite_count}</span>
</div>
<p className="text-xs text-neutral-400">Favorites</p>
</div>
)}
{feedbackData.feedback && (
<div className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-center gap-1 text-green-400 mb-1">
<MessageSquare className="w-4 h-4" />
<span className="text-white font-medium">{feedbackData.feedback.filter(f => f.feedback_type === 'comment').length}</span>
</div>
<p className="text-xs text-neutral-400">Comments</p>
</div>
)}
</div>
{/* Comments List */}
{feedbackData.feedback && feedbackData.feedback.filter(f => f.feedback_type === 'comment').length > 0 && (
<div className="space-y-2">
<button
onClick={() => setExpandedComments(!expandedComments)}
className="text-xs text-primary-400 hover:text-primary-300 mb-2"
>
{expandedComments ? 'Hide' : 'Show'} Comments ({feedbackData.feedback.filter(f => f.feedback_type === 'comment').length})
</button>
{expandedComments && (
<div className="space-y-3 max-h-64 overflow-y-auto">
{feedbackData.feedback
.filter(f => f.feedback_type === 'comment')
.map((comment) => (
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<p className="text-sm font-medium text-white">
{comment.guest_name || 'Anonymous'}
</p>
<p className="text-xs text-neutral-400">
{format(new Date(comment.created_at), 'MMM d, yyyy h:mm a')}
</p>
</div>
{/* Comment Status Badge */}
<div className="flex items-center gap-1">
{!comment.is_approved && !comment.is_hidden && (
<span className="text-xs bg-yellow-500/20 text-yellow-400 px-2 py-1 rounded flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Pending
</span>
)}
{comment.is_approved && !comment.is_hidden && (
<span className="text-xs bg-green-500/20 text-green-400 px-2 py-1 rounded flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Approved
</span>
)}
{comment.is_hidden && (
<span className="text-xs bg-red-500/20 text-red-400 px-2 py-1 rounded flex items-center gap-1">
<XCircle className="w-3 h-3" />
Hidden
</span>
)}
</div>
</div>
<p className="text-sm text-neutral-300 mb-3">
{comment.comment_text}
</p>
{/* Moderation Actions */}
<div className="flex gap-2">
{!comment.is_approved && (
<button
onClick={() => moderateFeedbackMutation.mutate({
feedbackId: comment.id.toString(),
action: 'approve'
})}
disabled={moderateFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded"
>
Approve
</button>
)}
{!comment.is_hidden && (
<button
onClick={() => moderateFeedbackMutation.mutate({
feedbackId: comment.id.toString(),
action: 'hide'
})}
disabled={moderateFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-yellow-600 hover:bg-yellow-700 text-white rounded"
>
Hide
</button>
)}
{comment.is_hidden && (
<button
onClick={() => moderateFeedbackMutation.mutate({
feedbackId: comment.id.toString(),
action: 'approve'
})}
disabled={moderateFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-green-600 hover:bg-green-700 text-white rounded"
>
Unhide
</button>
)}
<button
onClick={() => {
if (confirm('Are you sure you want to delete this comment?')) {
deleteFeedbackMutation.mutate(comment.id.toString());
}
}}
disabled={deleteFeedbackMutation.isPending}
className="text-xs px-2 py-1 bg-red-600 hover:bg-red-700 text-white rounded"
>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* No feedback message */}
{(!feedbackData.feedback || feedbackData.feedback.length === 0) && (
<p className="text-neutral-400 text-sm">No feedback for this photo yet.</p>
)}
</div>
)}
{/* Navigation info */}
<div className="mt-6 pt-6 border-t border-neutral-700">
<p className="text-neutral-400 text-sm text-center">
@@ -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<number | null>(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 (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-neutral-900">Photo Categories</h3>
<h3 className="text-lg font-semibold text-neutral-900">{t('categories.title')}</h3>
{!isAdding && (
<Button
variant="primary"
@@ -107,7 +109,7 @@ export const CategoryManager: React.FC = () => {
onClick={() => setIsAdding(true)}
leftIcon={<Plus className="w-4 h-4" />}
>
Add Category
{t('categories.addCategory')}
</Button>
)}
</div>
@@ -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 ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Create'
t('common.save')
)}
</Button>
<Button
@@ -144,7 +146,7 @@ export const CategoryManager: React.FC = () => {
setNewCategoryName('');
}}
>
Cancel
{t('common.cancel')}
</Button>
</div>
)}
@@ -153,7 +155,7 @@ export const CategoryManager: React.FC = () => {
<div className="space-y-2">
{categories.length === 0 ? (
<p className="text-neutral-500 text-center py-8">
No categories yet. Create your first category to organize photos.
{t('categories.noCategoriesYet')}
</p>
) : (
categories.map((category) => (
@@ -183,7 +185,7 @@ export const CategoryManager: React.FC = () => {
{updateMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Save'
t('common.save')
)}
</Button>
<Button
@@ -191,7 +193,7 @@ export const CategoryManager: React.FC = () => {
size="sm"
onClick={cancelEdit}
>
Cancel
{t('common.cancel')}
</Button>
</div>
) : (
@@ -204,14 +206,14 @@ export const CategoryManager: React.FC = () => {
<button
onClick={() => startEdit(category)}
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 rounded transition-colors"
title="Edit category"
title={t('common.edit')}
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(category)}
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
title="Delete category"
title={t('common.delete')}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? (
@@ -0,0 +1,341 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Shield, Eye, Lock, AlertTriangle, Info } from 'lucide-react';
import { Button, Card, Toggle, Select, Input, Textarea } from '../common';
import { settingsService } from '../../services/settings.service';
import { toast } from 'react-toastify';
interface ProtectionSettings {
default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
default_image_quality: number;
enable_devtools_protection: boolean;
max_image_requests_per_minute: number;
suspicious_activity_threshold: number;
enable_canvas_rendering: boolean;
default_fragmentation_level: number;
enable_overlay_protection: boolean;
protection_warning_message: string;
}
export const ImageProtectionSettings: React.FC = () => {
const { t } = useTranslation();
const [settings, setSettings] = useState<ProtectionSettings>({
default_protection_level: 'standard',
default_image_quality: 85,
enable_devtools_protection: true,
max_image_requests_per_minute: 30,
suspicious_activity_threshold: 10,
enable_canvas_rendering: false,
default_fragmentation_level: 3,
enable_overlay_protection: true,
protection_warning_message: 'Images in this gallery are protected from unauthorized download.'
});
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
loadSettings();
}, []);
const loadSettings = async () => {
try {
setIsLoading(true);
const response = await settingsService.getSettings();
// Map settings from API response
const protectionSettings: ProtectionSettings = {
default_protection_level: response.default_protection_level || 'standard',
default_image_quality: parseInt(response.default_image_quality) || 85,
enable_devtools_protection: response.enable_devtools_protection !== false,
max_image_requests_per_minute: parseInt(response.max_image_requests_per_minute) || 30,
suspicious_activity_threshold: parseInt(response.suspicious_activity_threshold) || 10,
enable_canvas_rendering: response.enable_canvas_rendering === true,
default_fragmentation_level: parseInt(response.default_fragmentation_level) || 3,
enable_overlay_protection: response.enable_overlay_protection !== false,
protection_warning_message: response.protection_warning_message || settings.protection_warning_message
};
setSettings(protectionSettings);
} catch (error) {
console.error('Failed to load protection settings:', error);
toast.error('Failed to load protection settings');
} finally {
setIsLoading(false);
}
};
const saveSettings = async () => {
try {
setIsSaving(true);
// Convert settings to API format
const apiSettings = Object.entries(settings).reduce((acc, [key, value]) => {
acc[key] = typeof value === 'boolean' ? value : value.toString();
return acc;
}, {} as Record<string, string | boolean>);
await settingsService.updateSettings(apiSettings);
toast.success('Protection settings saved successfully');
} catch (error) {
console.error('Failed to save protection settings:', error);
toast.error('Failed to save protection settings');
} finally {
setIsSaving(false);
}
};
const updateSetting = (key: keyof ProtectionSettings, value: any) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
const protectionLevels = [
{ value: 'basic', label: 'Basic - Minimal protection, best performance' },
{ value: 'standard', label: 'Standard - Balanced protection and performance' },
{ value: 'enhanced', label: 'Enhanced - Strong protection with good performance' },
{ value: 'maximum', label: 'Maximum - Strongest protection, may impact performance' }
];
const getProtectionLevelIcon = (level: string) => {
switch (level) {
case 'basic': return <Eye className="w-4 h-4 text-green-500" />;
case 'standard': return <Shield className="w-4 h-4 text-blue-500" />;
case 'enhanced': return <Lock className="w-4 h-4 text-orange-500" />;
case 'maximum': return <AlertTriangle className="w-4 h-4 text-red-500" />;
default: return <Shield className="w-4 h-4 text-gray-500" />;
}
};
const getProtectionLevelDescription = (level: string) => {
switch (level) {
case 'basic':
return 'Prevents drag/drop and basic right-click. Good for public galleries.';
case 'standard':
return 'Adds keyboard shortcut blocking and user selection prevention.';
case 'enhanced':
return 'Includes DevTools detection, rate limiting, and overlay protection.';
case 'maximum':
return 'Canvas rendering, image fragmentation, and comprehensive monitoring.';
default:
return '';
}
};
if (isLoading) {
return (
<Card className="p-6">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</div>
</Card>
);
}
return (
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center gap-3 mb-6">
<Shield className="w-6 h-6 text-blue-500" />
<div>
<h2 className="text-xl font-semibold text-gray-900">Image Protection Settings</h2>
<p className="text-sm text-gray-600">Configure security measures for photo galleries</p>
</div>
</div>
<div className="space-y-6">
{/* Protection Level */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Default Protection Level
</label>
<Select
value={settings.default_protection_level}
onChange={(value) => updateSetting('default_protection_level', value as any)}
options={protectionLevels}
className="w-full"
/>
<div className="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-200">
<div className="flex items-center gap-2 mb-1">
{getProtectionLevelIcon(settings.default_protection_level)}
<span className="font-medium text-sm capitalize">
{settings.default_protection_level} Protection
</span>
</div>
<p className="text-sm text-gray-600">
{getProtectionLevelDescription(settings.default_protection_level)}
</p>
</div>
</div>
{/* Image Quality */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Default Image Quality ({settings.default_image_quality}%)
</label>
<input
type="range"
min="30"
max="100"
step="5"
value={settings.default_image_quality}
onChange={(e) => updateSetting('default_image_quality', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Lower quality = Better protection</span>
<span>Higher quality = Better image</span>
</div>
</div>
{/* DevTools Protection */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
DevTools Protection
</label>
<p className="text-xs text-gray-500">
Detect and respond to browser developer tools
</p>
</div>
<Toggle
checked={settings.enable_devtools_protection}
onChange={(checked) => updateSetting('enable_devtools_protection', checked)}
/>
</div>
{/* Canvas Rendering */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
Canvas Rendering
</label>
<p className="text-xs text-gray-500">
Render images on canvas instead of img tags (stronger protection)
</p>
</div>
<Toggle
checked={settings.enable_canvas_rendering}
onChange={(checked) => updateSetting('enable_canvas_rendering', checked)}
/>
</div>
{/* Fragmentation Level */}
{settings.enable_canvas_rendering && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Image Fragmentation Level ({settings.default_fragmentation_level})
</label>
<input
type="range"
min="1"
max="5"
step="1"
value={settings.default_fragmentation_level}
onChange={(e) => updateSetting('default_fragmentation_level', parseInt(e.target.value))}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-500 mt-1">
<span>Low fragmentation</span>
<span>High fragmentation</span>
</div>
</div>
)}
{/* Rate Limiting */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Max Requests Per Minute
</label>
<Input
type="number"
min="5"
max="100"
value={settings.max_image_requests_per_minute}
onChange={(e) => updateSetting('max_image_requests_per_minute', parseInt(e.target.value))}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Suspicious Activity Threshold
</label>
<Input
type="number"
min="3"
max="50"
value={settings.suspicious_activity_threshold}
onChange={(e) => updateSetting('suspicious_activity_threshold', parseInt(e.target.value))}
/>
</div>
</div>
{/* Overlay Protection */}
<div className="flex items-center justify-between">
<div>
<label className="block text-sm font-medium text-gray-700">
Overlay Protection
</label>
<p className="text-xs text-gray-500">
Add transparent overlays to prevent easy screenshot extraction
</p>
</div>
<Toggle
checked={settings.enable_overlay_protection}
onChange={(checked) => updateSetting('enable_overlay_protection', checked)}
/>
</div>
{/* Warning Message */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Protection Warning Message
</label>
<Textarea
value={settings.protection_warning_message}
onChange={(e) => updateSetting('protection_warning_message', e.target.value)}
placeholder="Message shown when protection is triggered"
rows={3}
/>
</div>
</div>
{/* Warning Box */}
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-amber-600 mt-0.5" />
<div>
<h4 className="font-medium text-amber-800 mb-1">Important Notes</h4>
<ul className="text-sm text-amber-700 space-y-1">
<li> Higher protection levels may impact page performance</li>
<li> Canvas rendering disables browser image caching</li>
<li> Maximum protection may cause accessibility issues</li>
<li> Test thoroughly with your target browsers</li>
</ul>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="mt-6 flex justify-end gap-3">
<Button
variant="outline"
onClick={loadSettings}
disabled={isSaving}
>
Reset
</Button>
<Button
variant="primary"
onClick={saveSettings}
loading={isSaving}
>
Save Settings
</Button>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,239 @@
import React, { useState } from 'react';
import { Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card } from '../common';
import { adminService } from '../../services/admin.service';
import { useAdminAuth } from '../../contexts';
export const MandatoryPasswordChangeModal: React.FC = () => {
const { t } = useTranslation();
const { updatePasswordChanged } = useAdminAuth();
const [formData, setFormData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [showPasswords, setShowPasswords] = useState({
current: false,
new: false,
confirm: false
});
const [errors, setErrors] = useState<Record<string, string>>({});
const changePasswordMutation = useMutation({
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success(t('mandatoryPasswordChange.success'));
updatePasswordChanged();
// Reset form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setErrors({});
},
onError: (error: any) => {
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error(t('passwordChange.failed'));
}
}
});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.currentPassword) {
newErrors.currentPassword = t('passwordChange.currentRequired');
}
if (!formData.newPassword) {
newErrors.newPassword = t('passwordChange.newRequired');
} else if (formData.newPassword.length < 12) {
newErrors.newPassword = t('mandatoryPasswordChange.minLengthError');
} else {
// Check for character types
if (!/[a-z]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainLowercase');
} else if (!/[A-Z]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainUppercase');
} else if (!/[0-9]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainNumbersError');
} else if (!/[!@#$%^&*()_+\-=\[\]{}|;:,.<>?]/.test(formData.newPassword)) {
newErrors.newPassword = t('mandatoryPasswordChange.mustContainSpecialError');
}
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = t('passwordChange.confirmRequired');
} else if (formData.newPassword !== formData.confirmPassword) {
newErrors.confirmPassword = t('passwordChange.noMatch');
}
if (formData.currentPassword === formData.newPassword) {
newErrors.newPassword = t('passwordChange.mustBeDifferent');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
changePasswordMutation.mutate({
currentPassword: formData.currentPassword,
newPassword: formData.newPassword
});
};
const handleInputChange = (field: keyof typeof formData) => (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData(prev => ({ ...prev, [field]: e.target.value }));
// Clear error when user types
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: '' }));
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<div className="p-6">
<div className="mb-6 text-center">
<div className="mx-auto w-12 h-12 bg-amber-100 rounded-full flex items-center justify-center mb-4">
<AlertCircle className="w-6 h-6 text-amber-600" />
</div>
<h2 className="text-xl font-semibold text-neutral-900 mb-2">{t('mandatoryPasswordChange.title')}</h2>
<p className="text-sm text-neutral-600">
{t('mandatoryPasswordChange.description')}
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Current Password */}
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('passwordChange.currentPassword')}
</label>
<div className="relative">
<Input
id="currentPassword"
type={showPasswords.current ? 'text' : 'password'}
value={formData.currentPassword}
onChange={handleInputChange('currentPassword')}
error={errors.currentPassword}
placeholder={t('passwordChange.currentPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, current: !prev.current }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.current ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* New Password */}
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('passwordChange.newPassword')}
</label>
<div className="relative">
<Input
id="newPassword"
type={showPasswords.new ? 'text' : 'password'}
value={formData.newPassword}
onChange={handleInputChange('newPassword')}
error={errors.newPassword}
placeholder={t('passwordChange.newPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, new: !prev.new }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.new ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
{t('passwordChange.confirmPassword')}
</label>
<div className="relative">
<Input
id="confirmPassword"
type={showPasswords.confirm ? 'text' : 'password'}
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
type="button"
onClick={() => setShowPasswords(prev => ({ ...prev, confirm: !prev.confirm }))}
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 rounded"
>
{showPasswords.confirm ?
<EyeOff className="w-4 h-4 text-neutral-500" /> :
<Eye className="w-4 h-4 text-neutral-500" />
}
</button>
</div>
</div>
{/* Password Requirements */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium">{t('passwordChange.requirements')}</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>{t('mandatoryPasswordChange.minLength')}</li>
<li>{t('mandatoryPasswordChange.mustContainUpperLower')}</li>
<li>{t('mandatoryPasswordChange.mustContainNumbers')}</li>
<li>{t('mandatoryPasswordChange.mustContainSpecial')}</li>
<li>{t('passwordChange.mustDiffer')}</li>
</ul>
</div>
</div>
</div>
{/* Action Button */}
<div className="pt-2">
<Button
type="submit"
variant="primary"
className="w-full"
isLoading={changePasswordMutation.isPending}
>
{t('passwordChange.title')}
</Button>
</div>
</form>
</div>
</Card>
</div>
);
};
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { X, Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
import { toast } from 'react-toastify';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Button, Input, Card } from '../common';
import { adminService } from '../../services/admin.service';
@@ -12,6 +13,7 @@ interface PasswordChangeModalProps {
}
export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen, onClose }) => {
const { t } = useTranslation();
const [formData, setFormData] = useState({
currentPassword: '',
newPassword: '',
@@ -27,7 +29,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
const changePasswordMutation = useMutation({
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success('Password changed successfully');
toast.success(t('passwordChange.success'));
onClose();
// Reset form
setFormData({
@@ -41,7 +43,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
if (error.response?.data?.error) {
toast.error(error.response.data.error);
} else {
toast.error('Failed to change password');
toast.error(t('passwordChange.failed'));
}
}
});
@@ -50,23 +52,23 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
const newErrors: Record<string, string> = {};
if (!formData.currentPassword) {
newErrors.currentPassword = 'Current password is required';
newErrors.currentPassword = t('passwordChange.currentRequired');
}
if (!formData.newPassword) {
newErrors.newPassword = 'New password is required';
newErrors.newPassword = t('passwordChange.newRequired');
} else if (formData.newPassword.length < 6) {
newErrors.newPassword = 'Password must be at least 6 characters';
newErrors.newPassword = t('passwordChange.minLengthError');
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = 'Please confirm your new password';
newErrors.confirmPassword = t('passwordChange.confirmRequired');
} else if (formData.newPassword !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwords do not match';
newErrors.confirmPassword = t('passwordChange.noMatch');
}
if (formData.currentPassword === formData.newPassword) {
newErrors.newPassword = 'New password must be different from current password';
newErrors.newPassword = t('passwordChange.mustBeDifferent');
}
setErrors(newErrors);
@@ -101,7 +103,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
<Card className="w-full max-w-md">
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">Change Password</h2>
<h2 className="text-xl font-semibold text-neutral-900">{t('passwordChange.title')}</h2>
<button
onClick={onClose}
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
@@ -114,7 +116,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
{/* Current Password */}
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
Current Password
{t('passwordChange.currentPassword')}
</label>
<div className="relative">
<Input
@@ -123,7 +125,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
value={formData.currentPassword}
onChange={handleInputChange('currentPassword')}
error={errors.currentPassword}
placeholder="Enter current password"
placeholder={t('passwordChange.currentPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
@@ -142,7 +144,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
{/* New Password */}
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
New Password
{t('passwordChange.newPassword')}
</label>
<div className="relative">
<Input
@@ -151,7 +153,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
value={formData.newPassword}
onChange={handleInputChange('newPassword')}
error={errors.newPassword}
placeholder="Enter new password"
placeholder={t('passwordChange.newPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
@@ -170,7 +172,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
{/* Confirm Password */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
Confirm New Password
{t('passwordChange.confirmPassword')}
</label>
<div className="relative">
<Input
@@ -179,7 +181,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
value={formData.confirmPassword}
onChange={handleInputChange('confirmPassword')}
error={errors.confirmPassword}
placeholder="Confirm new password"
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button
@@ -200,10 +202,10 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-medium">Password Requirements:</p>
<p className="font-medium">{t('passwordChange.requirements')}</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>At least 6 characters long</li>
<li>Must be different from current password</li>
<li>{t('passwordChange.minLength')}</li>
<li>{t('passwordChange.mustDiffer')}</li>
</ul>
</div>
</div>
@@ -216,14 +218,14 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
variant="outline"
onClick={onClose}
>
Cancel
{t('passwordChange.cancel')}
</Button>
<Button
type="submit"
variant="primary"
isLoading={changePasswordMutation.isPending}
>
Change Password
{t('passwordChange.title')}
</Button>
</div>
</form>
@@ -6,11 +6,11 @@ interface PhotoFiltersProps {
categories: Array<{ id: number; name: string; slug: string }>;
selectedCategory: number | null | undefined;
searchTerm: string;
sortBy: 'date' | 'name' | 'size';
sortBy: 'date' | 'name' | 'size' | 'rating';
sortOrder: 'asc' | 'desc';
onCategoryChange: (categoryId: number | null | undefined) => void;
onSearchChange: (search: string) => void;
onSortChange: (sort: 'date' | 'name' | 'size', order: 'asc' | 'desc') => void;
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void;
}
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
@@ -63,12 +63,13 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<div className="flex items-center gap-2">
<select
value={sortBy}
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size', sortOrder)}
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="date">Sort by Date</option>
<option value="name">Sort by Name</option>
<option value="size">Sort by Size</option>
<option value="rating">Sort by Rating</option>
</select>
<button
@@ -106,7 +106,6 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
});
totalUploaded += chunk.length;
console.log(`Chunk ${chunkIndex + 1}/${chunks.length} uploaded:`, response.data);
} catch (error: any) {
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
failedFiles.push(...chunk.map(f => f.name));
@@ -0,0 +1,388 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Plus,
Trash2,
Shield,
AlertTriangle,
XCircle,
Edit2,
Save,
X,
Search
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Card, Button, Input, Loading } from '../common';
import { feedbackService } from '../../services/feedback.service';
interface WordFilter {
id: number;
word: string;
severity: 'low' | 'moderate' | 'high' | 'block';
is_active: boolean;
created_at: string;
updated_at: string;
}
export const WordFilterManager: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [newWord, setNewWord] = useState('');
const [newSeverity, setNewSeverity] = useState<'low' | 'moderate' | 'high' | 'block'>('moderate');
const [searchTerm, setSearchTerm] = useState('');
const [editingId, setEditingId] = useState<number | null>(null);
const [editWord, setEditWord] = useState('');
const [editSeverity, setEditSeverity] = useState<'low' | 'moderate' | 'high' | 'block'>('moderate');
// Fetch word filters
const { data: filters = [], isLoading } = useQuery({
queryKey: ['word-filters'],
queryFn: () => feedbackService.getWordFilters()
});
// Add word filter mutation
const addMutation = useMutation({
mutationFn: (data: { word: string; severity: string }) =>
feedbackService.addWordFilter(data.word, data.severity),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
toast.success(t('settings.moderation.filterAdded', 'Word filter added successfully'));
setNewWord('');
setNewSeverity('moderate');
},
onError: (error: any) => {
if (error.response?.status === 409) {
toast.error(t('settings.moderation.filterExists', 'This word filter already exists'));
} else {
toast.error(t('settings.moderation.addError', 'Failed to add word filter'));
}
}
});
// Update word filter mutation
const updateMutation = useMutation({
mutationFn: ({ id, updates }: { id: number; updates: Partial<WordFilter> }) =>
feedbackService.updateWordFilter(id, updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
toast.success(t('settings.moderation.filterUpdated', 'Word filter updated successfully'));
setEditingId(null);
},
onError: () => {
toast.error(t('settings.moderation.updateError', 'Failed to update word filter'));
}
});
// Delete word filter mutation
const deleteMutation = useMutation({
mutationFn: (id: number) => feedbackService.deleteWordFilter(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
toast.success(t('settings.moderation.filterDeleted', 'Word filter deleted successfully'));
},
onError: () => {
toast.error(t('settings.moderation.deleteError', 'Failed to delete word filter'));
}
});
const handleAdd = () => {
if (!newWord.trim()) {
toast.error(t('settings.moderation.wordRequired', 'Please enter a word to filter'));
return;
}
addMutation.mutate({ word: newWord.trim(), severity: newSeverity });
};
const handleEdit = (filter: WordFilter) => {
setEditingId(filter.id);
setEditWord(filter.word);
setEditSeverity(filter.severity);
};
const handleSaveEdit = () => {
if (!editWord.trim()) {
toast.error(t('settings.moderation.wordRequired', 'Please enter a word to filter'));
return;
}
if (editingId) {
updateMutation.mutate({
id: editingId,
updates: { word: editWord.trim(), severity: editSeverity }
});
}
};
const handleCancelEdit = () => {
setEditingId(null);
setEditWord('');
setEditSeverity('moderate');
};
const handleToggleActive = (filter: WordFilter) => {
updateMutation.mutate({
id: filter.id,
updates: { is_active: !filter.is_active }
});
};
const handleDelete = (id: number) => {
if (confirm(t('settings.moderation.confirmDelete', 'Are you sure you want to delete this word filter?'))) {
deleteMutation.mutate(id);
}
};
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'low':
return <Shield className="w-4 h-4 text-blue-500" />;
case 'moderate':
return <AlertTriangle className="w-4 h-4 text-yellow-500" />;
case 'high':
return <XCircle className="w-4 h-4 text-orange-500" />;
case 'block':
return <XCircle className="w-4 h-4 text-red-600" />;
default:
return <Shield className="w-4 h-4 text-gray-500" />;
}
};
const getSeverityBadgeClass = (severity: string) => {
switch (severity) {
case 'low':
return 'bg-blue-100 text-blue-800';
case 'moderate':
return 'bg-yellow-100 text-yellow-800';
case 'high':
return 'bg-orange-100 text-orange-800';
case 'block':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const filteredFilters = filters.filter((filter: WordFilter) =>
filter.word.toLowerCase().includes(searchTerm.toLowerCase())
);
if (isLoading) {
return (
<Card>
<div className="p-6">
<Loading text={t('settings.moderation.loading', 'Loading word filters...')} />
</div>
</Card>
);
}
return (
<>
<Card>
<div className="p-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
{t('settings.moderation.wordFilters', 'Word Filters')}
</h2>
<p className="text-sm text-neutral-600">
{t('settings.moderation.description', 'Manage words that should be filtered or blocked in comments')}
</p>
</div>
{/* Add new filter */}
<div className="mb-6 p-4 bg-neutral-50 rounded-lg">
<h3 className="text-sm font-medium text-neutral-900 mb-3">
{t('settings.moderation.addFilter', 'Add New Filter')}
</h3>
<div className="flex gap-3">
<Input
type="text"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
placeholder={t('settings.moderation.enterWord', 'Enter word to filter')}
className="flex-1"
onKeyPress={(e) => e.key === 'Enter' && handleAdd()}
/>
<select
value={newSeverity}
onChange={(e) => setNewSeverity(e.target.value as any)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
<option value="high">{t('settings.moderation.severityHigh', 'High')}</option>
<option value="block">{t('settings.moderation.severityBlock', 'Block')}</option>
</select>
<Button
variant="primary"
leftIcon={<Plus className="w-4 h-4" />}
onClick={handleAdd}
isLoading={addMutation.isPending}
>
{t('common.add', 'Add')}
</Button>
</div>
</div>
{/* Search */}
<div className="mb-4">
<Input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={t('settings.moderation.searchFilters', 'Search filters...')}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
/>
</div>
{/* Filters list */}
<div className="space-y-2">
{filteredFilters.length === 0 ? (
<div className="text-center py-8 text-neutral-500">
{searchTerm ?
t('settings.moderation.noMatchingFilters', 'No matching filters found') :
t('settings.moderation.noFilters', 'No word filters configured yet')
}
</div>
) : (
filteredFilters.map((filter: WordFilter) => (
<div
key={filter.id}
className={`flex items-center justify-between p-3 rounded-lg border ${
filter.is_active ? 'border-neutral-200 bg-white' : 'border-neutral-100 bg-neutral-50 opacity-60'
}`}
>
{editingId === filter.id ? (
<>
<div className="flex items-center gap-3 flex-1">
<Input
type="text"
value={editWord}
onChange={(e) => setEditWord(e.target.value)}
className="flex-1 max-w-xs"
/>
<select
value={editSeverity}
onChange={(e) => setEditSeverity(e.target.value as any)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
<option value="high">{t('settings.moderation.severityHigh', 'High')}</option>
<option value="block">{t('settings.moderation.severityBlock', 'Block')}</option>
</select>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
leftIcon={<Save className="w-4 h-4" />}
onClick={handleSaveEdit}
isLoading={updateMutation.isPending}
>
{t('common.save', 'Save')}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<X className="w-4 h-4" />}
onClick={handleCancelEdit}
>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</>
) : (
<>
<div className="flex items-center gap-3">
<input
type="checkbox"
checked={filter.is_active}
onChange={() => handleToggleActive(filter)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="font-medium text-neutral-900">{filter.word}</span>
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getSeverityBadgeClass(filter.severity)}`}>
{getSeverityIcon(filter.severity)}
{filter.severity}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
leftIcon={<Edit2 className="w-4 h-4" />}
onClick={() => handleEdit(filter)}
>
{t('common.edit', 'Edit')}
</Button>
<Button
size="sm"
variant="ghost"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => handleDelete(filter.id)}
isLoading={deleteMutation.isPending}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
{t('common.delete', 'Delete')}
</Button>
</div>
</>
)}
</div>
))
)}
</div>
</div>
</Card>
{/* Severity explanation */}
<Card>
<div className="p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">
{t('settings.moderation.severityLevels', 'Severity Levels')}
</h3>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-3">
{getSeverityIcon('low')}
<div>
<span className="font-medium text-neutral-900">{t('settings.moderation.severityLow', 'Low')}: </span>
<span className="text-neutral-600">
{t('settings.moderation.lowDescription', 'Word is flagged for review but not automatically blocked')}
</span>
</div>
</div>
<div className="flex items-start gap-3">
{getSeverityIcon('moderate')}
<div>
<span className="font-medium text-neutral-900">{t('settings.moderation.severityModerate', 'Moderate')}: </span>
<span className="text-neutral-600">
{t('settings.moderation.moderateDescription', 'Comment requires manual approval before being visible')}
</span>
</div>
</div>
<div className="flex items-start gap-3">
{getSeverityIcon('high')}
<div>
<span className="font-medium text-neutral-900">{t('settings.moderation.severityHigh', 'High')}: </span>
<span className="text-neutral-600">
{t('settings.moderation.highDescription', 'Comment is automatically hidden and requires admin review')}
</span>
</div>
</div>
<div className="flex items-start gap-3">
{getSeverityIcon('block')}
<div>
<span className="font-medium text-neutral-900">{t('settings.moderation.severityBlock', 'Block')}: </span>
<span className="text-neutral-600">
{t('settings.moderation.blockDescription', 'Comment is rejected immediately and cannot be submitted')}
</span>
</div>
</div>
</div>
</div>
</Card>
</>
);
};
+2 -1
View File
@@ -28,4 +28,5 @@ export { BackupConfiguration } from './BackupConfiguration';
export { BackupHistory } from './BackupHistory';
export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings';
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { WordFilterManager } from './WordFilterManager';
@@ -25,10 +25,12 @@ export class ErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
console.error('Component stack:', errorInfo.componentStack);
console.error('Error message:', error.message);
console.error('Error stack:', error.stack);
if (process.env.NODE_ENV === 'development') {
console.error('Error caught by boundary:', error, errorInfo);
console.error('Component stack:', errorInfo.componentStack);
console.error('Error message:', error.message);
console.error('Error stack:', error.stack);
}
}
handleReset = () => {
@@ -79,7 +81,9 @@ export class PageErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Page error:', error, errorInfo);
if (process.env.NODE_ENV === 'development') {
console.error('Page error:', error, errorInfo);
}
}
handleReset = () => {
@@ -1,8 +1,10 @@
import React, { useEffect, useState } from 'react';
import { WifiOff, Wifi } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { cn } from '../../lib/utils';
export const OfflineIndicator: React.FC = () => {
const { t } = useTranslation();
const [isOnline, setIsOnline] = useState(navigator.onLine);
const [showIndicator, setShowIndicator] = useState(false);
@@ -56,12 +58,12 @@ export const OfflineIndicator: React.FC = () => {
{isOnline ? (
<>
<Wifi className="w-5 h-5" />
<span className="text-sm font-medium">Back online</span>
<span className="text-sm font-medium">{t('offline.backOnline')}</span>
</>
) : (
<>
<WifiOff className="w-5 h-5" />
<span className="text-sm font-medium">No internet connection</span>
<span className="text-sm font-medium">{t('offline.noConnection')}</span>
</>
)}
</div>
@@ -0,0 +1,209 @@
import React, { useState, useCallback } from 'react';
import { Key, RefreshCw, Copy, Check, Zap } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { generateEventPassword, generatePasswordSuggestions, validatePassword } from '../../utils/passwordGenerator';
import { Button } from './Button';
interface PasswordGeneratorProps {
eventName?: string;
eventDate?: string;
eventType?: string;
onPasswordGenerated: (password: string) => void;
className?: string;
disabled?: boolean;
passwordComplexity?: 'simple' | 'moderate' | 'strong' | 'very_strong';
}
export const PasswordGenerator: React.FC<PasswordGeneratorProps> = ({
eventName = '',
eventDate = '',
eventType = 'wedding',
onPasswordGenerated,
className = '',
disabled = false,
passwordComplexity = 'moderate'
}) => {
const { t } = useTranslation();
const [isGenerating, setIsGenerating] = useState(false);
const [showSuggestions, setShowSuggestions] = useState(false);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
const generatePassword = useCallback(() => {
setIsGenerating(true);
// Simulate some processing time for better UX
setTimeout(() => {
const config = {
complexity: passwordComplexity,
minLength: passwordComplexity === 'simple' ? 6 : passwordComplexity === 'moderate' ? 8 : 12,
requireSpecialChars: passwordComplexity === 'very_strong'
};
const password = generateEventPassword({
eventName,
eventDate,
eventType,
config
});
onPasswordGenerated(password);
setIsGenerating(false);
}, 300);
}, [eventName, eventDate, eventType, passwordComplexity, onPasswordGenerated]);
const generateSuggestions = useCallback(() => {
const newSuggestions = generatePasswordSuggestions({
eventName,
eventDate,
eventType
});
setSuggestions(newSuggestions);
setShowSuggestions(true);
}, [eventName, eventDate, eventType]);
const copyToClipboard = async (password: string, index: number) => {
try {
await navigator.clipboard.writeText(password);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = password;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
setCopiedIndex(index);
setTimeout(() => setCopiedIndex(null), 2000);
}
};
const selectPassword = (password: string) => {
onPasswordGenerated(password);
setShowSuggestions(false);
};
const getPasswordStrength = (password: string) => {
const validation = validatePassword(password, {
complexity: passwordComplexity,
minLength: passwordComplexity === 'simple' ? 6 : passwordComplexity === 'moderate' ? 8 : 12
});
if (validation.score <= 1) return { label: t('passwordGenerator.weak'), color: 'text-red-600' };
if (validation.score <= 2) return { label: t('passwordGenerator.fair'), color: 'text-yellow-600' };
if (validation.score <= 3) return { label: t('passwordGenerator.good'), color: 'text-blue-600' };
return { label: t('passwordGenerator.strong'), color: 'text-green-600' };
};
return (
<div className={`relative ${className}`}>
{/* Generate Button */}
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={generatePassword}
disabled={disabled || isGenerating}
className="flex items-center gap-2"
>
{isGenerating ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Key className="w-4 h-4" />
)}
{isGenerating ? t('passwordGenerator.generating') : t('passwordGenerator.generatePassword')}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={generateSuggestions}
disabled={disabled}
className="flex items-center gap-2"
title={t('passwordGenerator.showSuggestions')}
>
<Zap className="w-4 h-4" />
{t('passwordGenerator.moreOptions')}
</Button>
</div>
{/* Password Suggestions Modal */}
{showSuggestions && (
<div className="absolute top-full left-0 right-0 mt-2 z-50">
<div className="bg-white border border-neutral-200 rounded-lg shadow-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-neutral-900">{t('passwordGenerator.suggestions')}</h3>
<button
onClick={() => setShowSuggestions(false)}
className="text-neutral-400 hover:text-neutral-600"
>
×
</button>
</div>
<div className="space-y-2">
{suggestions.map((password, index) => {
const strength = getPasswordStrength(password);
return (
<div
key={index}
className="flex items-center justify-between p-2 border border-neutral-100 rounded-md hover:bg-neutral-50"
>
<div className="flex-1 min-w-0">
<code className="text-sm font-mono text-neutral-800 break-all">
{password}
</code>
<div className="flex items-center gap-2 mt-1">
<span className={`text-xs font-medium ${strength.color}`}>
{strength.label}
</span>
<span className="text-xs text-neutral-500">
{password.length} {t('passwordGenerator.characters')}
</span>
</div>
</div>
<div className="flex items-center gap-1 ml-2">
<button
onClick={() => copyToClipboard(password, index)}
className="p-1 text-neutral-400 hover:text-neutral-600"
title={t('passwordGenerator.copyPassword')}
>
{copiedIndex === index ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
<Button
type="button"
variant="outline"
size="xs"
onClick={() => selectPassword(password)}
>
{t('passwordGenerator.use')}
</Button>
</div>
</div>
);
})}
</div>
<div className="mt-3 p-2 bg-blue-50 rounded-md">
<p className="text-xs text-blue-800">
<strong>{t('passwordGenerator.pattern')}</strong> {t('passwordGenerator.patternDescription')}
</p>
</div>
</div>
</div>
)}
</div>
);
};
export default PasswordGenerator;
@@ -0,0 +1,405 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { ProtectionLevel } from '../../hooks/useImageProtection';
interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasElement> {
src: string;
alt: string;
protectionLevel?: ProtectionLevel;
watermarkText?: string;
fragmentGrid?: boolean;
gridSize?: number;
scrambleFragments?: boolean;
invisibleWatermark?: boolean;
onProtectionViolation?: (violationType: string) => void;
fallbackSrc?: string;
crossOrigin?: 'anonymous' | 'use-credentials';
}
export const ProtectedImage: React.FC<ProtectedImageProps> = ({
src,
alt,
protectionLevel = 'standard',
watermarkText,
fragmentGrid = false,
gridSize = 4,
scrambleFragments = false,
invisibleWatermark = false,
onProtectionViolation,
fallbackSrc,
crossOrigin = 'anonymous',
...canvasProps
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const imageRef = useRef<HTMLImageElement | null>(null);
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
const reportViolation = useCallback((violationType: string) => {
onProtectionViolation?.(violationType);
if (process.env.NODE_ENV === 'development') {
console.warn(`Image protection violation: ${violationType}`);
}
}, [onProtectionViolation]);
// Apply invisible watermark using steganography
const applyInvisibleWatermark = useCallback((
ctx: CanvasRenderingContext2D,
width: number,
height: number,
text: string
) => {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
const message = text + '\0'; // Null-terminated string
const messageBytes = new TextEncoder().encode(message);
let byteIndex = 0;
let bitIndex = 0;
for (let i = 0; i < data.length && byteIndex < messageBytes.length; i += 4) {
if (bitIndex === 8) {
bitIndex = 0;
byteIndex++;
if (byteIndex >= messageBytes.length) break;
}
// Modify the least significant bit of the red channel
const bit = (messageBytes[byteIndex] >> bitIndex) & 1;
data[i] = (data[i] & 0xFE) | bit;
bitIndex++;
}
ctx.putImageData(imageData, 0, 0);
}, []);
// Apply visible watermark
const applyVisibleWatermark = useCallback((
ctx: CanvasRenderingContext2D,
width: number,
height: number,
text: string
) => {
const fontSize = Math.max(12, Math.min(width, height) / 20);
ctx.font = `${fontSize}px Arial, sans-serif`;
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
ctx.strokeStyle = 'rgba(0, 0, 0, 0.3)';
ctx.lineWidth = 1;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Add shadow for better visibility
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)';
ctx.shadowBlur = 2;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
// Draw watermark in multiple positions for maximum protection
const positions = [
{ x: width * 0.5, y: height * 0.5 }, // Center
{ x: width * 0.2, y: height * 0.2 }, // Top-left
{ x: width * 0.8, y: height * 0.2 }, // Top-right
{ x: width * 0.2, y: height * 0.8 }, // Bottom-left
{ x: width * 0.8, y: height * 0.8 }, // Bottom-right
];
positions.forEach(pos => {
ctx.strokeText(text, pos.x, pos.y);
ctx.fillText(text, pos.x, pos.y);
});
// Reset shadow
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
}, []);
// Fragment and scramble image for maximum protection
const renderFragmentedImage = useCallback((
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
width: number,
height: number
) => {
const fragmentWidth = width / gridSize;
const fragmentHeight = height / gridSize;
const fragments: Array<{ x: number; y: number; destX: number; destY: number }> = [];
// Create fragment map
for (let row = 0; row < gridSize; row++) {
for (let col = 0; col < gridSize; col++) {
fragments.push({
x: col * fragmentWidth,
y: row * fragmentHeight,
destX: col * fragmentWidth,
destY: row * fragmentHeight,
});
}
}
// Scramble fragments if requested
if (scrambleFragments) {
for (let i = fragments.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = fragments[i].destX;
const tempY = fragments[i].destY;
fragments[i].destX = fragments[j].destX;
fragments[i].destY = fragments[j].destY;
fragments[j].destX = temp;
fragments[j].destY = tempY;
}
}
// Draw fragments
fragments.forEach(fragment => {
ctx.drawImage(
img,
fragment.x, fragment.y, fragmentWidth, fragmentHeight,
fragment.destX, fragment.destY, fragmentWidth, fragmentHeight
);
});
}, [gridSize, scrambleFragments]);
// Main canvas rendering function - wrapped in useCallback to prevent infinite re-renders
const renderToCanvas = useCallback(() => {
if (!canvasRef.current || !imageRef.current) {
return;
}
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d'); // Remove willReadFrequently option
const img = imageRef.current;
if (!ctx || !img.complete || img.naturalWidth === 0) {
return;
}
// Use natural dimensions from the loaded image
const width = img.naturalWidth;
const height = img.naturalHeight;
// Don't render if dimensions are invalid
if (width <= 0 || height <= 0) {
return;
}
// IMPORTANT: Set canvas dimensions to match image
canvas.width = width;
canvas.height = height;
// Clear canvas and reset context state
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.globalAlpha = 1.0; // Reset alpha
ctx.globalCompositeOperation = 'source-over'; // Reset composite operation
try {
if (fragmentGrid && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
// Render fragmented image
renderFragmentedImage(ctx, img, canvas.width, canvas.height);
} else {
// Render normal image - ensure image is valid before drawing
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// Verify the image was drawn by checking a pixel
const pixelData = ctx.getImageData(10, 10, 1, 1).data;
}
}
// Apply watermarks
if (watermarkText) {
if (invisibleWatermark && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
applyInvisibleWatermark(ctx, canvas.width, canvas.height, watermarkText);
} else {
applyVisibleWatermark(ctx, canvas.width, canvas.height, watermarkText);
}
}
// Apply additional protection measures
if (protectionLevel === 'maximum') {
// Add random noise to make pixel-perfect copying harder
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// Add subtle random noise (±1 to RGB values)
const noise = Math.random() * 2 - 1;
data[i] = Math.max(0, Math.min(255, data[i] + noise)); // R
data[i + 1] = Math.max(0, Math.min(255, data[i + 1] + noise)); // G
data[i + 2] = Math.max(0, Math.min(255, data[i + 2] + noise)); // B
}
ctx.putImageData(imageData, 0, 0);
}
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering protected image:', error);
}
reportViolation('canvas_rendering_error');
setError(true);
}
}, [fragmentGrid, protectionLevel, renderFragmentedImage, watermarkText, invisibleWatermark, applyInvisibleWatermark, applyVisibleWatermark, reportViolation]);
// Set up protection event listeners
useEffect(() => {
if (!canvasRef.current) return;
const canvas = canvasRef.current;
const handleContextMenu = (e: MouseEvent) => {
e.preventDefault();
reportViolation('canvas_context_menu');
return false;
};
const handleDragStart = (e: DragEvent) => {
e.preventDefault();
reportViolation('canvas_drag_start');
return false;
};
const handleSelectStart = (e: Event) => {
e.preventDefault();
reportViolation('canvas_selection');
return false;
};
// Canvas-specific protection
const handleCanvasClick = (e: MouseEvent) => {
if (protectionLevel === 'maximum') {
// Block all interactions in maximum protection mode
e.preventDefault();
e.stopPropagation();
reportViolation('canvas_interaction_blocked');
return false;
}
};
canvas.addEventListener('contextmenu', handleContextMenu);
canvas.addEventListener('dragstart', handleDragStart);
canvas.addEventListener('selectstart', handleSelectStart);
if (protectionLevel === 'maximum') {
canvas.addEventListener('click', handleCanvasClick);
canvas.addEventListener('mousedown', handleCanvasClick);
canvas.addEventListener('mouseup', handleCanvasClick);
}
// Apply CSS protection
canvas.style.userSelect = 'none';
canvas.style.webkitUserSelect = 'none';
canvas.style.webkitTouchCallout = 'none';
canvas.style.webkitUserDrag = 'none';
canvas.style.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
return () => {
canvas.removeEventListener('contextmenu', handleContextMenu);
canvas.removeEventListener('dragstart', handleDragStart);
canvas.removeEventListener('selectstart', handleSelectStart);
canvas.removeEventListener('click', handleCanvasClick);
canvas.removeEventListener('mousedown', handleCanvasClick);
canvas.removeEventListener('mouseup', handleCanvasClick);
};
}, [protectionLevel, reportViolation]);
// Load and render image
useEffect(() => {
setIsLoading(true);
setError(false);
const img = new Image();
// Don't set crossOrigin for blob URLs as they don't support CORS
if (!src.startsWith('blob:')) {
img.crossOrigin = crossOrigin;
}
img.onload = () => {
try {
imageRef.current = img;
// Always render to canvas once image is loaded
renderToCanvas();
setIsLoading(false);
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.error('[ProtectedImage] Critical error in onload handler:', error);
}
setError(true);
setIsLoading(false);
}
};
img.onerror = () => {
if (process.env.NODE_ENV === 'development') {
console.error('ProtectedImage failed to load:', src);
}
if (fallbackSrc && src !== fallbackSrc) {
// Try fallback
img.src = fallbackSrc;
} else {
setError(true);
setIsLoading(false);
reportViolation('image_load_error');
}
};
img.src = src;
return () => {
if (imageRef.current) {
imageRef.current.onload = null;
imageRef.current.onerror = null;
}
};
}, [src, fallbackSrc, crossOrigin, renderToCanvas, reportViolation]);
// Apply protection CSS classes
const protectionClass = `protected-image protection-${protectionLevel}`;
// Always render the canvas element so the ref is available
// Show error state if there's an error
if (error) {
return (
<div
className="protected-image-error"
style={{
width: canvasProps.width || '100%',
height: canvasProps.height || 'auto',
backgroundColor: '#fee2e2',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#dc2626',
...canvasProps.style
}}
role="img"
aria-label={`Error loading ${alt}`}
>
<span>Image unavailable</span>
</div>
);
}
// Always render canvas to ensure ref is available
// Simply hide canvas with opacity while loading, no wrapper needed
return (
<canvas
ref={canvasRef}
{...canvasProps}
role="img"
aria-label={alt}
className={`${canvasProps.className || ''} ${protectionClass}`.trim()}
style={{
maxWidth: '100%',
height: 'auto',
opacity: isLoading ? 0 : 1,
transition: 'opacity 0.2s',
backgroundColor: isLoading ? '#f3f4f6' : 'transparent',
...canvasProps.style
}}
/>
);
};
@@ -0,0 +1,113 @@
import React from 'react';
import { Shield, AlertTriangle, X } from 'lucide-react';
interface ProtectionWarningProps {
type: 'devtools' | 'screenshot' | 'violation' | 'general';
message?: string;
onClose?: () => void;
severity?: 'low' | 'medium' | 'high';
autoClose?: boolean;
autoCloseDelay?: number;
}
export const ProtectionWarning: React.FC<ProtectionWarningProps> = ({
type,
message,
onClose,
severity = 'medium',
autoClose = false,
autoCloseDelay = 5000
}) => {
// Auto close functionality
React.useEffect(() => {
if (autoClose && autoCloseDelay > 0 && onClose) {
const timer = setTimeout(() => {
onClose();
}, autoCloseDelay);
return () => clearTimeout(timer);
}
}, [autoClose, autoCloseDelay, onClose]);
const getWarningConfig = () => {
switch (type) {
case 'devtools':
return {
icon: <AlertTriangle className="w-5 h-5" />,
title: 'Developer Tools Detected',
defaultMessage: 'Developer tools access has been detected. This action has been logged for security purposes.',
bgColor: 'bg-red-500',
textColor: 'text-white'
};
case 'screenshot':
return {
icon: <Shield className="w-5 h-5" />,
title: 'Screenshot Attempt Detected',
defaultMessage: 'A screenshot attempt has been detected. This gallery is protected from unauthorized copying.',
bgColor: 'bg-orange-500',
textColor: 'text-white'
};
case 'violation':
return {
icon: <Shield className="w-5 h-5" />,
title: 'Protection Violation',
defaultMessage: 'An unauthorized action has been detected and blocked.',
bgColor: severity === 'high' ? 'bg-red-500' : severity === 'medium' ? 'bg-orange-500' : 'bg-yellow-500',
textColor: 'text-white'
};
default:
return {
icon: <Shield className="w-5 h-5" />,
title: 'Security Notice',
defaultMessage: 'This content is protected. Unauthorized access attempts are monitored.',
bgColor: 'bg-blue-500',
textColor: 'text-white'
};
}
};
const config = getWarningConfig();
return (
<div className={`fixed top-4 right-4 ${config.bgColor} ${config.textColor} p-4 rounded-lg shadow-lg z-50 max-w-sm`}>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">
{config.icon}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
{config.title}
</div>
<div className="text-xs mt-1 opacity-90">
{message || config.defaultMessage}
</div>
</div>
{onClose && (
<button
onClick={onClose}
className="flex-shrink-0 ml-2 -mr-1 -mt-1 p-1 rounded-full hover:bg-white/20 transition-colors"
aria-label="Close warning"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{autoClose && (
<div
className="absolute bottom-0 left-0 h-0.5 bg-white/30 animate-pulse"
style={{
width: '100%',
animation: `shrink ${autoCloseDelay}ms linear`
}}
/>
)}
<style jsx>{`
@keyframes shrink {
from { width: 100%; }
to { width: 0%; }
}
`}</style>
</div>
);
};
@@ -0,0 +1,236 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ProtectedImage } from '../ProtectedImage';
// Mock canvas and image APIs
const mockCanvas = {
getContext: jest.fn(() => ({
clearRect: jest.fn(),
drawImage: jest.fn(),
getImageData: jest.fn(() => ({
data: new Uint8ClampedArray(4).fill(255)
})),
putImageData: jest.fn(),
fillRect: jest.fn(),
fillText: jest.fn(),
strokeText: jest.fn(),
measureText: jest.fn(() => ({ width: 100 }))
})),
width: 100,
height: 100,
style: {},
addEventListener: jest.fn(),
removeEventListener: jest.fn()
};
// Mock HTMLCanvasElement
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
value: () => mockCanvas.getContext()
});
// Mock Image constructor
global.Image = class {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
src = '';
naturalWidth = 100;
naturalHeight = 100;
width = 100;
height = 100;
crossOrigin = '';
constructor() {
// Simulate image loading
setTimeout(() => {
if (this.onload) this.onload();
}, 10);
}
} as any;
describe('ProtectedImage', () => {
const defaultProps = {
src: '/test-image.jpg',
alt: 'Test image'
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders loading state initially', () => {
render(<ProtectedImage {...defaultProps} />);
expect(screen.getByRole('img', { name: /loading test image/i })).toBeInTheDocument();
});
it('renders canvas after image loads', async () => {
render(<ProtectedImage {...defaultProps} />);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
});
});
it('applies protection level classes and events', async () => {
const onViolation = jest.fn();
render(
<ProtectedImage
{...defaultProps}
protectionLevel="enhanced"
onProtectionViolation={onViolation}
/>
);
await waitFor(() => {
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toBeInTheDocument();
});
// Test context menu blocking
const canvas = screen.getByRole('img', { name: 'Test image' });
fireEvent.contextMenu(canvas);
expect(onViolation).toHaveBeenCalledWith('canvas_context_menu');
});
it('applies watermark text when specified', async () => {
render(
<ProtectedImage
{...defaultProps}
watermarkText="Test Watermark"
protectionLevel="standard"
/>
);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
});
// Verify canvas context methods were called for watermark
expect(mockCanvas.getContext().fillText).toHaveBeenCalled();
});
it('handles fragment grid rendering', async () => {
render(
<ProtectedImage
{...defaultProps}
fragmentGrid={true}
gridSize={4}
protectionLevel="enhanced"
/>
);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
});
// Verify multiple drawImage calls for fragments
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
});
it('blocks interactions in maximum protection mode', async () => {
const onViolation = jest.fn();
render(
<ProtectedImage
{...defaultProps}
protectionLevel="maximum"
onProtectionViolation={onViolation}
/>
);
await waitFor(() => {
const canvas = screen.getByRole('img', { name: 'Test image' });
expect(canvas).toBeInTheDocument();
// Test click blocking
fireEvent.click(canvas);
expect(onViolation).toHaveBeenCalledWith('canvas_interaction_blocked');
});
});
it('handles image loading errors gracefully', async () => {
// Mock image error
global.Image = class {
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
src = '';
constructor() {
setTimeout(() => {
if (this.onerror) this.onerror();
}, 10);
}
} as any;
const onViolation = jest.fn();
render(
<ProtectedImage
{...defaultProps}
onProtectionViolation={onViolation}
fallbackSrc="/fallback.jpg"
/>
);
await waitFor(() => {
expect(screen.getByText('Image unavailable')).toBeInTheDocument();
});
expect(onViolation).toHaveBeenCalledWith('image_load_error');
});
it('applies invisible watermark for enhanced protection', async () => {
render(
<ProtectedImage
{...defaultProps}
watermarkText="Hidden"
invisibleWatermark={true}
protectionLevel="enhanced"
/>
);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
});
// Verify getImageData and putImageData called for steganography
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
});
it('scrambles fragments when enabled', async () => {
render(
<ProtectedImage
{...defaultProps}
fragmentGrid={true}
scrambleFragments={true}
protectionLevel="maximum"
/>
);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
});
// Fragment scrambling should result in multiple drawImage calls
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
});
it('adds random noise in maximum protection', async () => {
render(
<ProtectedImage
{...defaultProps}
protectionLevel="maximum"
/>
);
await waitFor(() => {
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
});
// Noise injection requires getImageData and putImageData
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
});
});
+4 -1
View File
@@ -16,4 +16,7 @@ export { SkipLink } from './SkipLink';
export { DynamicFavicon } from './DynamicFavicon';
export { LanguageSelector } from './LanguageSelector';
export { AuthenticatedImage } from './AuthenticatedImage';
export { ReCaptcha } from './ReCaptcha';
export { ProtectedImage } from './ProtectedImage';
export { ProtectionWarning } from './ProtectionWarning';
export { ReCaptcha } from './ReCaptcha';
export { PasswordGenerator } from './PasswordGenerator';
@@ -1,5 +1,6 @@
import React from 'react';
import { Download, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface DownloadProgressProps {
isDownloading: boolean;
@@ -14,6 +15,8 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
fileName,
onCancel,
}) => {
const { t } = useTranslation();
if (!isDownloading) return null;
return (
@@ -22,7 +25,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
<div className="flex items-center gap-2">
<Download className="w-5 h-5 text-primary-600 animate-bounce" />
<div>
<p className="text-sm font-medium text-neutral-900">Downloading...</p>
<p className="text-sm font-medium text-neutral-900">{t('download.downloading')}</p>
{fileName && (
<p className="text-xs text-neutral-500 truncate max-w-[200px]">{fileName}</p>
)}
@@ -46,7 +49,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
</div>
{progress > 0 && (
<p className="text-xs text-neutral-500 mt-1">{Math.round(progress)}% complete</p>
<p className="text-xs text-neutral-500 mt-1">{Math.round(progress)}{t('download.percentComplete')}</p>
)}
</div>
);
@@ -29,6 +29,7 @@ interface GalleryLayoutProps {
showDownloadAll?: boolean;
onDownloadAll?: () => void;
isDownloading?: boolean;
isExpired?: boolean;
headerExtra?: React.ReactNode;
menuButton?: React.ReactNode;
children: React.ReactNode;
@@ -42,6 +43,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
showDownloadAll = false,
onDownloadAll,
isDownloading = false,
isExpired = false,
headerExtra,
menuButton,
children,
@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react';
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload } from 'lucide-react';
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload, Star } from 'lucide-react';
import { Button } from '../common';
import { PhotoCategory } from '../../types';
import { useTranslation } from 'react-i18next';
@@ -12,14 +12,16 @@ interface GallerySidebarProps {
onCategoryChange: (categoryId: number | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size';
onSortChange: (sort: 'date' | 'name' | 'size') => void;
sortBy: 'date' | 'name' | 'size' | 'rating';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
isSelectionMode: boolean;
onToggleSelectionMode: () => void;
selectedCount: number;
onDownloadAll: () => void;
onDownloadSelected: () => void;
isDownloading: boolean;
isExpired?: boolean;
allowDownloads?: boolean;
photoCounts?: Record<number, number>;
totalPhotos: number;
isMobile: boolean;
@@ -44,6 +46,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
onDownloadAll,
onDownloadSelected,
isDownloading,
isExpired = false,
allowDownloads = true,
photoCounts = {},
totalPhotos,
isMobile,
@@ -81,7 +85,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
const sortOptions = [
{ value: 'date', label: t('gallery.sortByDate'), icon: Calendar },
{ value: 'name', label: t('gallery.sortByName'), icon: Type },
{ value: 'size', label: t('gallery.sortBySize'), icon: HardDrive }
{ value: 'size', label: t('gallery.sortBySize'), icon: HardDrive },
{ value: 'rating', label: t('gallery.sortByRating', 'Rating'), icon: Star }
];
return (
@@ -151,48 +156,50 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
</div>
)}
{/* Download Section */}
<div className="p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Download className="w-4 h-4" />
{t('gallery.download')}
</h3>
<div className="space-y-2">
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadAll}
disabled={isDownloading || totalPhotos === 0}
className="w-full"
>
{t('gallery.downloadAll')} ({totalPhotos})
</Button>
<Button
variant={isSelectionMode ? 'secondary' : 'outline'}
size="sm"
onClick={onToggleSelectionMode}
className="w-full"
>
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button>
{isSelectionMode && selectedCount > 0 && (
{/* Download Section - Hidden if gallery is expired or downloads disabled */}
{allowDownloads && (
<div className="p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Download className="w-4 h-4" />
{t('gallery.download')}
</h3>
<div className="space-y-2">
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadSelected}
disabled={isDownloading}
onClick={onDownloadAll}
disabled={isDownloading || totalPhotos === 0}
className="w-full"
>
{t('gallery.downloadSelected')} ({selectedCount})
{t('gallery.downloadAll')} ({totalPhotos})
</Button>
)}
<Button
variant={isSelectionMode ? 'secondary' : 'outline'}
size="sm"
onClick={onToggleSelectionMode}
className="w-full"
>
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
</Button>
{isSelectionMode && selectedCount > 0 && (
<Button
variant="primary"
size="sm"
leftIcon={<Download className="w-4 h-4" />}
onClick={onDownloadSelected}
disabled={isDownloading}
className="w-full"
>
{t('gallery.downloadSelected')} ({selectedCount})
</Button>
)}
</div>
</div>
</div>
)}
{/* Categories Section - Hidden for carousel layout */}
{galleryLayout !== 'carousel' && categories.length > 0 && (
@@ -268,7 +275,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<button
key={option.value}
onClick={() => {
onSortChange(option.value as 'date' | 'name' | 'size');
onSortChange(option.value as 'date' | 'name' | 'size' | 'rating');
if (isMobile) onClose();
}}
className={`
@@ -14,6 +14,7 @@ import { GallerySidebar } from './GallerySidebar';
import { PhotoFilterBar } from './PhotoFilterBar';
import { UserPhotoUpload } from './UserPhotoUpload';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { api } from '../../config/api';
import { Upload, Menu } from 'lucide-react';
@@ -34,6 +35,7 @@ interface GalleryViewProps {
allow_user_uploads?: boolean;
upload_category_id?: number | null;
hero_photo_id?: number | null;
allow_downloads?: boolean;
};
}
@@ -43,7 +45,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { setTheme, theme } = useTheme();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size'>('date');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -52,10 +54,45 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const [feedbackEnabled, setFeedbackEnabled] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const { watermarkEnabled } = useWatermarkSettings();
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
// Fetch photos
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
// Set protection level when data is available
useEffect(() => {
if (data?.event?.protection_level) {
setProtectionLevel(data.event.protection_level);
}
}, [data?.event?.protection_level]);
// DevTools protection for enhanced and maximum levels
useDevToolsProtection({
enabled: protectionLevel === 'enhanced' || protectionLevel === 'maximum',
detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium',
onDevToolsDetected: () => {
console.warn('DevTools detected in gallery view');
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('gallery_devtools_detected', {
gallery: slug,
protectionLevel,
eventId: data?.event?.id
});
}
// For maximum protection, redirect away from gallery
if (protectionLevel === 'maximum') {
setTimeout(() => {
window.location.href = '/';
}, 100);
}
},
redirectOnDetection: protectionLevel === 'maximum',
redirectUrl: '/'
});
// Data updates are handled by React Query
const downloadAllMutation = useDownloadAllPhotos();
@@ -85,18 +122,25 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
try {
// Use public endpoint to get feedback settings
const response = await api.get(`/gallery/${slug}/feedback-settings`);
console.log('Feedback settings response:', response.data);
return response.data;
} catch (error) {
console.error('Error fetching feedback settings:', error);
// If endpoint doesn't exist or returns error, default to disabled
return { feedback_enabled: false };
}
},
onSuccess: (data) => {
setFeedbackEnabled(data?.feedback_enabled || false);
},
enabled: !!event.id,
});
// Update feedbackEnabled when settings change
useEffect(() => {
if (feedbackSettings) {
console.log('Setting feedbackEnabled to:', feedbackSettings.feedback_enabled);
setFeedbackEnabled(feedbackSettings.feedback_enabled || false);
}
}, [feedbackSettings]);
// Apply branding settings
useEffect(() => {
if (settingsData) {
@@ -170,6 +214,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Calculate days until expiration
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
const showUrgentWarning = daysUntilExpiration <= 7;
const isExpired = daysUntilExpiration < 0;
// Filter and sort photos
const filteredPhotos = useMemo(() => {
@@ -197,6 +242,15 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
return a.filename.localeCompare(b.filename);
case 'size':
return b.size - a.size;
case 'rating':
// Sort by rating (highest first), then by comment count
const ratingA = a.average_rating || 0;
const ratingB = b.average_rating || 0;
if (ratingA !== ratingB) {
return ratingB - ratingA;
}
// If ratings are equal, sort by comment count
return (b.comment_count || 0) - (a.comment_count || 0);
case 'date':
default:
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
@@ -215,7 +269,15 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug]);
// Check if downloads are allowed (both event setting and not expired)
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
const handleDownloadAll = () => {
// Prevent downloads if gallery is expired or downloads disabled
if (!allowDownloads) {
return;
}
downloadAllMutation.mutate(slug);
// Track download all action
@@ -229,6 +291,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const handleDownloadSelected = async () => {
if (selectedPhotos.size === 0) return;
// Prevent downloads if gallery is expired or downloads disabled
if (!allowDownloads) {
return;
}
const selectedPhotosList = filteredPhotos.filter(p => selectedPhotos.has(p.id));
// Track bulk download
@@ -349,6 +416,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onDownloadAll={handleDownloadAll}
onDownloadSelected={handleDownloadSelected}
isDownloading={downloadAllMutation.isPending}
isExpired={isExpired}
allowDownloads={allowDownloads}
photoCounts={photoCounts}
totalPhotos={data?.photos.length || 0}
isMobile={isMobile}
@@ -363,9 +432,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
brandingSettings={brandingSettings}
showLogout={true}
onLogout={logout}
showDownloadAll={!showSidebar}
showDownloadAll={!showSidebar && allowDownloads}
onDownloadAll={handleDownloadAll}
isDownloading={downloadAllMutation.isPending}
isExpired={isExpired}
menuButton={showSidebar ? (
<Button
variant="ghost"
@@ -460,6 +530,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
eventLogo={brandingSettings?.logo_url}
eventDate={event.event_date}
expiresAt={event.expires_at}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={protectionLevel !== 'basic'}
/>
</div>
@@ -139,9 +139,9 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
{/* Comment Form */}
{showCommentForm && (
<form onSubmit={handleSubmitComment} className="space-y-3 p-3 bg-neutral-50 rounded-lg">
<form onSubmit={handleSubmitComment} className="space-y-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200">
{requireNameEmail && (
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
placeholder={t('feedback.yourName', 'Your name')}
value={guestName}
@@ -166,10 +166,10 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={t('feedback.writeComment', 'Write a comment...')}
className={`w-full px-3 py-2 text-sm border rounded-lg resize-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
className={`w-full px-3 py-2 text-sm border rounded-lg resize-vertical min-h-[100px] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 ${
errors.comment_text ? 'border-red-500' : 'border-neutral-300'
}`}
rows={2}
rows={4}
maxLength={500}
/>
{errors.comment_text && (
@@ -101,8 +101,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
photoId={photoId}
gallerySlug={gallerySlug}
currentRating={currentRating}
averageRating={feedbackData?.summary.average_rating}
totalRatings={feedbackData?.summary.total_ratings}
averageRating={Number(feedbackData?.summary?.average_rating) || 0}
totalRatings={Number(feedbackData?.summary?.total_ratings) || 0}
isEnabled={true}
onRatingChange={handleRatingChange}
/>
@@ -140,7 +140,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
<PhotoComments
photoId={photoId}
gallerySlug={gallerySlug}
comments={feedbackData?.feedback || []}
comments={feedbackData?.feedback?.filter(f => f.feedback_type === 'comment') || []}
isEnabled={true}
requireNameEmail={settings.require_name_email || false}
showToGuests={settings.show_feedback_to_guests || false}
@@ -22,8 +22,8 @@ interface PhotoFilterBarProps {
onCategoryChange: (categoryId: number | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size';
onSortChange: (sort: 'date' | 'name' | 'size') => void;
sortBy: 'date' | 'name' | 'size' | 'rating';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
photoCount: number;
}
@@ -67,7 +67,10 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
className="w-full sm:w-auto text-sm sm:text-base"
>
<span className="hidden sm:inline">{t('common.sortBy')} </span>
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') : sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') : t('gallery.sortBySize').replace('Sort by ', '')}
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
t('gallery.sortByRating', 'Rating')}
</Button>
{showSortMenu && (
@@ -105,6 +108,17 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
>
{t('gallery.sortBySize')}
</button>
<button
onClick={() => {
onSortChange('rating');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 ${
sortBy === 'rating' ? 'text-primary-600 bg-primary-50' : 'text-neutral-700'
}`}
>
{t('gallery.sortByRating', 'Sort by Rating')}
</button>
</div>
)}
</div>
+78 -9
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { Download, Maximize2, Check, Package } from 'lucide-react';
import { Download, Maximize2, Check, Package, MessageSquare, Star } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { toast as toastify } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -16,9 +16,20 @@ interface PhotoGridProps {
slug: string;
categoryId?: number | null;
feedbackEnabled?: boolean;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
}
export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId, feedbackEnabled = false }) => {
export const PhotoGrid: React.FC<PhotoGridProps> = ({
photos,
slug,
categoryId,
feedbackEnabled = false,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false
}) => {
const { t } = useTranslation();
const [selectedPhotoIndex, setSelectedPhotoIndex] = useState<number | null>(null);
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
@@ -194,6 +205,10 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId,
isSelectionMode={isSelectionMode}
onClick={(e) => handlePhotoClick(index, e)}
onDownload={(e) => handleDownload(photo, e)}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
slug={slug}
/>
))}
</div>
@@ -206,6 +221,9 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId,
onClose={() => setSelectedPhotoIndex(null)}
slug={slug}
feedbackEnabled={feedbackEnabled}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
/>
)}
</>
@@ -218,6 +236,10 @@ interface PhotoThumbnailProps {
isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void;
onDownload: (e: React.MouseEvent) => void;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
slug: string; // Add slug as required prop
}
const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
@@ -226,6 +248,10 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
isSelectionMode,
onClick,
onDownload,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
slug
}) => {
const { ref, inView } = useInView({
triggerOnce: true,
@@ -246,8 +272,49 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
className="w-full h-full object-cover rounded-lg transition-transform duration-200 group-hover:scale-105"
loading="lazy"
isGallery={true}
slug={slug}
photoId={photo.id}
requiresToken={photo.requires_token}
secureUrlTemplate={photo.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection}
detectDevTools={protectionLevel === 'maximum'}
watermarkText={useEnhancedProtection ? 'Protected' : undefined}
onProtectionViolation={(violationType) => {
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('thumbnail_protection_violation', {
photoId: photo.id,
violationType,
protectionLevel
});
}
}}
/>
{/* Feedback Indicators */}
{feedbackEnabled && (photo.has_feedback || photo.average_rating > 0 || photo.comment_count > 0) && (
<div className="absolute top-2 left-2 flex gap-1 z-10">
{photo.comment_count > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
</div>
)}
{photo.average_rating > 0 && (
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
</div>
)}
</div>
)}
{/* Overlay on hover/tap - Always visible on mobile for better UX */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
{!isSelectionMode && (
@@ -262,13 +329,15 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
>
<Maximize2 className="w-5 h-5 text-neutral-800" />
</button>
<button
className="p-2 sm:p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
{allowDownloads && (
<button
className="p-2 sm:p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
</>
)}
</div>
@@ -35,6 +35,9 @@ interface PhotoGridWithLayoutsProps {
eventDate?: string;
expiresAt?: string;
feedbackEnabled?: boolean;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -44,6 +47,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
isSelectionMode: parentSelectionMode,
selectedPhotos: parentSelectedPhotos,
feedbackEnabled,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
onSelectionChange,
onToggleSelectionMode: parentToggleSelectionMode,
showSelectionControls = true,
@@ -58,7 +64,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
const [localSelectedPhotos, setLocalSelectedPhotos] = useState<Set<number>>(new Set());
const [localSelectionMode, setLocalSelectionMode] = useState(false);
const downloadPhotoMutation = useDownloadPhoto();
// Use parent state if provided, otherwise use local state
const selectedPhotos = parentSelectedPhotos ?? localSelectedPhotos;
const isSelectionMode = parentSelectionMode ?? localSelectionMode;
@@ -162,12 +168,16 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
onPhotoClick: handlePhotoClick,
onDownload: handleDownload,
selectedPhotos,
allowDownloads,
protectionLevel,
useEnhancedProtection,
isSelectionMode,
onPhotoSelect: handlePhotoSelect,
eventName,
eventLogo,
eventDate,
expiresAt,
feedbackEnabled,
};
let LayoutComponent;
@@ -262,6 +272,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
onClose={() => setSelectedPhotoIndex(null)}
slug={slug}
feedbackEnabled={feedbackEnabled || false}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
/>
)}
</>
+111 -13
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
@@ -11,6 +12,9 @@ interface PhotoLightboxProps {
onClose: () => void;
slug: string;
feedbackEnabled?: boolean;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
}
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
@@ -19,6 +23,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
onClose,
slug,
feedbackEnabled = false,
allowDownloads = true,
protectionLevel = 'standard',
useEnhancedProtection = false,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
@@ -28,8 +35,36 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [touchDistance, setTouchDistance] = useState<number | null>(null);
const [showFeedback, setShowFeedback] = useState(false);
// Debug logging
console.log('PhotoLightbox feedbackEnabled:', feedbackEnabled);
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
// DevTools protection for the lightbox when enhanced protection is enabled
useDevToolsProtection({
enabled: useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum'),
detectionSensitivity: protectionLevel === 'maximum' ? 'high' : 'medium',
onDevToolsDetected: () => {
console.warn('DevTools detected in photo lightbox');
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_devtools_detected', {
photoId: currentPhoto.id,
protectionLevel,
zoom,
gallery: slug
});
}
// Close lightbox immediately for maximum protection
if (protectionLevel === 'maximum') {
onClose();
}
},
redirectOnDetection: false, // Don't redirect, just close lightbox
});
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -53,17 +88,29 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
break;
case 'd':
case 'D':
handleDownload();
if (allowDownloads) {
handleDownload();
}
break;
}
};
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
// Add protection class to body for maximum security
if (protectionLevel === 'maximum') {
document.body.classList.add('protection-maximum');
} else if (protectionLevel === 'enhanced') {
document.body.classList.add('protection-enhanced');
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
// Remove protection classes from body
document.body.classList.remove('protection-maximum', 'protection-enhanced');
};
}, [currentIndex]);
@@ -94,6 +141,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
};
const handleDownload = () => {
if (!allowDownloads) return;
downloadPhotoMutation.mutate({
slug,
photoId: currentPhoto.id,
@@ -161,8 +209,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
setTouchDistance(null);
};
// Apply protection class to the lightbox container
const lightboxClass = useEnhancedProtection ?
`fixed inset-0 bg-black z-50 flex items-center justify-center protected-image protection-${protectionLevel}` :
'fixed inset-0 bg-black z-50 flex items-center justify-center';
return (
<div className="fixed inset-0 bg-black z-50 flex items-center justify-center">
<div className={lightboxClass}>
{/* Close button */}
<button
onClick={onClose}
@@ -221,21 +274,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
<div className="w-px h-6 bg-white/20 mx-2" />
<button
onClick={handleDownload}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
aria-label="Download photo"
>
<Download className="w-5 h-5 text-white" />
</button>
{allowDownloads && (
<button
onClick={handleDownload}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
aria-label="Download photo"
>
<Download className="w-5 h-5 text-white" />
</button>
)}
{/* Feedback button with indicator */}
{feedbackEnabled && (
<button
onClick={() => setShowFeedback(!showFeedback)}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
onClick={() => {
console.log('Feedback button clicked, current feedbackEnabled:', feedbackEnabled);
setShowFeedback(!showFeedback);
}}
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
aria-label="Toggle feedback"
title={`Photo feedback${currentPhoto.comment_count > 0 ? ` (${currentPhoto.comment_count} comments)` : ''}`}
>
<MessageSquare className="w-5 h-5 text-white" />
{(currentPhoto.comment_count > 0 || currentPhoto.average_rating > 0) && (
<span className="absolute -top-1 -right-1 bg-primary-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{currentPhoto.comment_count > 0 ? currentPhoto.comment_count : '★'}
</span>
)}
</button>
)}
</div>
@@ -264,8 +329,40 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
transition: isDragging ? 'none' : 'transform 0.2s',
}}
draggable={false}
useWatermark={true}
useWatermark={useEnhancedProtection}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
isGallery={true}
slug={slug}
photoId={currentPhoto.id}
requiresToken={currentPhoto.requires_token}
secureUrlTemplate={currentPhoto.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection}
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
onProtectionViolation={(violationType) => {
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
// Track analytics
if (typeof window !== 'undefined' && (window as any).umami) {
(window as any).umami.track('lightbox_protection_violation', {
photoId: currentPhoto.id,
violationType,
protectionLevel,
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
onClose();
}
}}
/>
</div>
@@ -276,7 +373,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Feedback Panel */}
{showFeedback && (
<div className="absolute right-0 top-0 bottom-0 w-96 bg-white shadow-xl z-20 overflow-y-auto">
<div className="absolute right-0 top-0 bottom-0 w-full sm:w-96 lg:w-[28rem] bg-white shadow-xl z-20 overflow-y-auto">
<div className="sticky top-0 bg-white border-b px-4 py-3 flex items-center justify-between">
<h3 className="font-semibold text-neutral-900">Photo Feedback</h3>
<button
@@ -292,6 +389,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
photoId={currentPhoto.id}
gallerySlug={slug}
showComments={true}
className="space-y-4"
/>
</div>
</div>
@@ -24,6 +24,8 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
isEnabled,
onRatingChange
}) => {
// Ensure averageRating is a valid number
const safeAverageRating = typeof averageRating === 'number' && !isNaN(averageRating) ? averageRating : 0;
const { t } = useTranslation();
const queryClient = useQueryClient();
const [hoveredRating, setHoveredRating] = useState(0);
@@ -102,7 +104,7 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
{/* Average Rating Display */}
{totalRatings > 0 && (
<div className="text-sm text-neutral-600">
<span className="font-medium">{averageRating.toFixed(1)}</span>
<span className="font-medium">{safeAverageRating.toFixed(1)}</span>
<span className="text-neutral-400 ml-1">
({t('feedback.ratingsCount', '{{count}} ratings', { count: totalRatings })})
</span>
@@ -13,6 +13,10 @@ export interface BaseGalleryLayoutProps {
eventLogo?: string | null;
eventDate?: string;
expiresAt?: string;
allowDownloads?: boolean;
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
useEnhancedProtection?: boolean;
feedbackEnabled?: boolean;
}
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {

Some files were not shown because too many files have changed in this diff Show More