Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c943bc69a | |||
| 29a8ff914c | |||
| a73d217273 | |||
| 1b4b497fdf | |||
| 827eb4819b | |||
| 086a4ca342 | |||
| 6de64a1df1 | |||
| 3074748bbc | |||
| 934d6ddc58 | |||
| a699a0477b | |||
| ed0243ec39 | |||
| ac31798bf5 | |||
| 65d796b9f0 | |||
| 6389b9df3f | |||
| 87d1761091 | |||
| fda132eed4 | |||
| 1cadce196b | |||
| 840b8870ec | |||
| ad495a92c4 | |||
| b428543452 | |||
| 6492cb9ec8 |
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Development Environment with PostgreSQL
|
|
||||||
# Copy this to .env for PostgreSQL development with Docker Compose
|
|
||||||
|
|
||||||
# JWT Secret (development only)
|
|
||||||
JWT_SECRET=dev-secret-key-do-not-use-in-production
|
|
||||||
|
|
||||||
# Database Configuration (PostgreSQL)
|
|
||||||
DATABASE_CLIENT=pg
|
|
||||||
DB_USER=picpeak_dev
|
|
||||||
DB_PASSWORD=dev_password_123
|
|
||||||
DB_NAME=picpeak_dev
|
|
||||||
|
|
||||||
# Redis Configuration
|
|
||||||
REDIS_PASSWORD=dev_redis_pass
|
|
||||||
|
|
||||||
# Admin Account (initial setup)
|
|
||||||
ADMIN_USERNAME=admin
|
|
||||||
ADMIN_EMAIL=admin@localhost
|
|
||||||
|
|
||||||
# Email Configuration (Disabled for development)
|
|
||||||
# To enable email, configure a real SMTP server
|
|
||||||
SMTP_HOST=
|
|
||||||
SMTP_PORT=
|
|
||||||
SMTP_SECURE=false
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASS=
|
|
||||||
EMAIL_FROM=noreply@picpeak.local
|
|
||||||
|
|
||||||
# Application URLs
|
|
||||||
FRONTEND_URL=http://localhost:3000
|
|
||||||
ADMIN_URL=http://localhost:3001
|
|
||||||
VITE_API_URL=http://localhost:3001/api
|
|
||||||
|
|
||||||
# Timezone
|
|
||||||
TZ=UTC
|
|
||||||
|
|
||||||
# Analytics (Optional - leave empty for development)
|
|
||||||
VITE_UMAMI_URL=
|
|
||||||
VITE_UMAMI_WEBSITE_ID=
|
|
||||||
VITE_UMAMI_SHARE_URL=
|
|
||||||
@@ -1,65 +1,53 @@
|
|||||||
# PicPeak Development Environment Configuration
|
# PicPeak Environment Configuration
|
||||||
# Copy this file to .env for local development
|
# Copy this file to .env and update with your values
|
||||||
|
|
||||||
# SECURITY WARNING: This configuration is for development only!
|
# Environment
|
||||||
# For production, use .env.production.example
|
NODE_ENV=production
|
||||||
|
|
||||||
# JWT Secret (Change in production!)
|
# JWT Secret (generate with: openssl rand -base64 64)
|
||||||
# Generate secure secret with: openssl rand -base64 32
|
JWT_SECRET=your_very_long_random_jwt_secret_here
|
||||||
JWT_SECRET=dev-secret-DO-NOT-USE-IN-PRODUCTION
|
|
||||||
|
|
||||||
# Application URLs (Docker Compose development setup)
|
# Database Configuration (PostgreSQL)
|
||||||
ADMIN_URL=http://localhost:3005
|
|
||||||
FRONTEND_URL=http://localhost:3005
|
|
||||||
BACKEND_URL=http://localhost:3001
|
|
||||||
|
|
||||||
# Database Configuration (PostgreSQL for development)
|
|
||||||
DATABASE_CLIENT=pg
|
DATABASE_CLIENT=pg
|
||||||
DB_HOST=db
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_NAME=picpeak
|
|
||||||
DB_USER=picpeak
|
DB_USER=picpeak
|
||||||
DB_PASSWORD=picpeak
|
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
|
||||||
|
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
|
||||||
|
DB_PASSWORD=your_secure_postgres_password_here
|
||||||
|
DB_NAME=picpeak_prod
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
# IMPORTANT: Same warning applies - avoid $ or escape as $$
|
||||||
|
REDIS_PASSWORD=your_secure_redis_password_here
|
||||||
|
|
||||||
|
# Admin Account (initial setup)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
|
||||||
# Email Configuration
|
# Email Configuration
|
||||||
# For development with docker-compose.dev.yml:
|
# For Gmail: use app-specific password
|
||||||
# Access Mailhog UI at: http://localhost:8025
|
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
|
||||||
SMTP_HOST=mailhog
|
SMTP_HOST=smtp.gmail.com
|
||||||
SMTP_PORT=1025
|
SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=
|
SMTP_USER=your-email@gmail.com
|
||||||
SMTP_PASS=
|
SMTP_PASS=your-app-specific-password
|
||||||
EMAIL_FROM=noreply@localhost
|
EMAIL_FROM=noreply@yourdomain.com
|
||||||
|
|
||||||
# For development without Docker, use real SMTP:
|
# Application URLs
|
||||||
# SMTP_HOST=smtp.gmail.com
|
FRONTEND_URL=https://yourdomain.com
|
||||||
# SMTP_PORT=587
|
ADMIN_URL=https://yourdomain.com:3001
|
||||||
# SMTP_SECURE=false
|
VITE_API_URL=https://yourdomain.com:3001/api
|
||||||
# SMTP_USER=your-email@gmail.com
|
|
||||||
# SMTP_PASS=your-app-password
|
|
||||||
# EMAIL_FROM=PicPeak Dev <your-email@gmail.com>
|
|
||||||
|
|
||||||
# Backend Port Configuration
|
# Port Configuration (optional)
|
||||||
PORT=3001
|
# BACKEND_PORT=3001
|
||||||
|
# FRONTEND_PORT=3000
|
||||||
|
# DB_PORT=5432
|
||||||
|
# REDIS_PORT=6379
|
||||||
|
|
||||||
# Optional: Umami Analytics Backend Config
|
# Timezone
|
||||||
# NOTE: Primary configuration through Admin UI > Settings > Analytics
|
TZ=UTC
|
||||||
# These are fallback values for server-side tracking
|
|
||||||
# UMAMI_URL=https://analytics.example.com
|
|
||||||
# UMAMI_WEBSITE_ID=your-website-id
|
|
||||||
# UMAMI_HASH_SALT=your-hash-salt
|
|
||||||
|
|
||||||
# Development Features
|
# Analytics (Optional - Umami)
|
||||||
NODE_ENV=development
|
VITE_UMAMI_URL=
|
||||||
LOG_LEVEL=debug
|
VITE_UMAMI_WEBSITE_ID=
|
||||||
|
VITE_UMAMI_SHARE_URL=
|
||||||
# Storage Settings (optional)
|
|
||||||
DEFAULT_EXPIRATION_DAYS=30
|
|
||||||
WARNING_DAYS_BEFORE_EXPIRY=7
|
|
||||||
|
|
||||||
# Admin Setup Notes:
|
|
||||||
# 1. Run 'docker-compose -f docker-compose.dev.yml up -d'
|
|
||||||
# 2. Run 'docker-compose -f docker-compose.dev.yml exec backend npm run migrate'
|
|
||||||
# 3. Admin credentials will be auto-generated
|
|
||||||
# 4. Check backend/ADMIN_CREDENTIALS.txt for login details
|
|
||||||
# 5. Change password on first login (required)
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
# PicPeak Production Configuration
|
|
||||||
# Copy this file to .env and update with your production values
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# CRITICAL SECURITY - MUST CHANGE ALL VALUES!
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
# JWT Secret - REQUIRED (minimum 32 characters)
|
|
||||||
# Generate with: openssl rand -base64 32
|
|
||||||
JWT_SECRET=CHANGE-THIS-PRODUCTION-SECRET-USE-OPENSSL-COMMAND
|
|
||||||
|
|
||||||
# Application URLs - REQUIRED (your actual domain)
|
|
||||||
FRONTEND_URL=https://your-domain.com
|
|
||||||
BACKEND_URL=https://your-domain.com
|
|
||||||
ADMIN_URL=https://your-domain.com
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# DATABASE CONFIGURATION - REQUIRED
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
# PostgreSQL Configuration (Recommended for production)
|
|
||||||
DATABASE_CLIENT=pg
|
|
||||||
DB_HOST=db # Use 'db' for Docker Compose, or external host
|
|
||||||
DB_PORT=5432
|
|
||||||
DB_NAME=picpeak
|
|
||||||
DB_USER=picpeak
|
|
||||||
DB_PASSWORD=CHANGE-THIS-SECURE-DATABASE-PASSWORD
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# EMAIL CONFIGURATION - REQUIRED
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
# Example: Gmail with App Password
|
|
||||||
# SMTP_HOST=smtp.gmail.com
|
|
||||||
# SMTP_PORT=587
|
|
||||||
# SMTP_SECURE=false
|
|
||||||
# SMTP_USER=your-email@gmail.com
|
|
||||||
# SMTP_PASS=your-16-char-app-password
|
|
||||||
# EMAIL_FROM=Your Name <your-email@gmail.com>
|
|
||||||
|
|
||||||
# Example: SendGrid
|
|
||||||
SMTP_HOST=smtp.sendgrid.net
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_SECURE=false
|
|
||||||
SMTP_USER=apikey
|
|
||||||
SMTP_PASS=YOUR-SENDGRID-API-KEY
|
|
||||||
EMAIL_FROM=PicPeak <noreply@your-domain.com>
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# ADMIN SETUP - AUTO-GENERATED
|
|
||||||
# ============================================
|
|
||||||
# NOTE: Admin credentials are automatically generated during setup
|
|
||||||
# DO NOT set ADMIN_EMAIL or ADMIN_PASSWORD anymore!
|
|
||||||
# Run 'npm run migrate' and check ADMIN_CREDENTIALS.txt
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# OPTIONAL CONFIGURATION
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
# Umami Analytics (Optional - Fallback values)
|
|
||||||
# Primary config via Admin UI > Settings > Analytics
|
|
||||||
# UMAMI_URL=https://analytics.your-domain.com
|
|
||||||
# UMAMI_WEBSITE_ID=your-website-id
|
|
||||||
# UMAMI_HASH_SALT=your-hash-salt # Required if using Umami
|
|
||||||
|
|
||||||
# Frontend Analytics (Optional - Fallback values)
|
|
||||||
# VITE_UMAMI_URL=https://analytics.your-domain.com
|
|
||||||
# VITE_UMAMI_WEBSITE_ID=your-website-id
|
|
||||||
# VITE_UMAMI_SHARE_URL=https://analytics.your-domain.com/share/xyz/gallery
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# PERFORMANCE & SECURITY TUNING
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
NODE_ENV=production
|
|
||||||
PORT=3001
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# Backend URL (if different from frontend)
|
|
||||||
# BACKEND_URL=https://api.your-domain.com
|
|
||||||
|
|
||||||
# Storage Settings
|
|
||||||
DEFAULT_EXPIRATION_DAYS=30
|
|
||||||
WARNING_DAYS_BEFORE_EXPIRY=7
|
|
||||||
|
|
||||||
# Security Settings (Defaults are secure)
|
|
||||||
BCRYPT_ROUNDS=12
|
|
||||||
SESSION_TIMEOUT_MINUTES=60
|
|
||||||
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
|
|
||||||
RATE_LIMIT_MAX_REQUESTS=100 # per window
|
|
||||||
|
|
||||||
# Connection Pool (Adjust based on load)
|
|
||||||
DB_POOL_MIN=5
|
|
||||||
DB_POOL_MAX=25
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# DOCKER COMPOSE SPECIFIC
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
# Traefik Configuration (if using Traefik)
|
|
||||||
DOMAIN=your-domain.com
|
|
||||||
LETSENCRYPT_EMAIL=admin@your-domain.com
|
|
||||||
|
|
||||||
# Volume Paths (Docker)
|
|
||||||
STORAGE_PATH=/app/storage
|
|
||||||
EVENTS_PATH=/app/storage/events
|
|
||||||
ARCHIVE_PATH=/app/storage/events/archived
|
|
||||||
@@ -9,6 +9,10 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
mirror:
|
mirror:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
@@ -34,7 +38,10 @@ jobs:
|
|||||||
rm -rf photo-sharing-prd.md || true
|
rm -rf photo-sharing-prd.md || true
|
||||||
rm -rf CLAUDE.md || true
|
rm -rf CLAUDE.md || true
|
||||||
rm -rf storage/ || 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"
|
echo "Sensitive files removal completed"
|
||||||
@@ -57,29 +64,68 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
if [ -z "$GITHUBTOKEN" ]; then
|
if [ -z "$GITHUBTOKEN" ]; then
|
||||||
echo "ERROR: GITHUBTOKEN secret is not set!"
|
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
|
exit 1
|
||||||
else
|
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
|
fi
|
||||||
|
|
||||||
- name: Push to GitHub
|
- name: Push to GitHub
|
||||||
env:
|
env:
|
||||||
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
|
||||||
|
GIT_TRACE: 1 # Enable Git trace for debugging if needed
|
||||||
run: |
|
run: |
|
||||||
# Remove existing github remote if it exists
|
# Remove existing github remote if it exists
|
||||||
git remote remove github || true
|
git remote remove github || true
|
||||||
|
|
||||||
# Add GitHub remote
|
# Configure Git to use the token for authentication
|
||||||
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
|
# 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
|
# Verify remote was added
|
||||||
echo "GitHub remote added:"
|
echo "GitHub remote configuration:"
|
||||||
git remote -v
|
git remote -v
|
||||||
|
|
||||||
# Push to GitHub main branch
|
# Push to GitHub main branch with error handling
|
||||||
echo "Pushing to GitHub..."
|
echo "Pushing to GitHub..."
|
||||||
git push github main --force
|
if git push github main --force 2>&1; then
|
||||||
echo "✅ Push to GitHub completed!"
|
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
|
- name: Workflow completed
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 209 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 181 KiB |
@@ -334,6 +334,23 @@ Run backup service test: `npm run test-backup`
|
|||||||
- `backup_runs`: Tracks each backup execution with statistics
|
- `backup_runs`: Tracks each backup execution with statistics
|
||||||
- `backup_file_states`: Stores file checksums for change detection
|
- `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)
|
## Success Metrics (from PRD)
|
||||||
- Time to generate gallery: <2 minutes
|
- Time to generate gallery: <2 minutes
|
||||||
- Guest satisfaction: >90%
|
- Guest satisfaction: >90%
|
||||||
@@ -365,4 +382,5 @@ Run backup service test: `npm run test-backup`
|
|||||||
- [ ] Backup strategy confirmed
|
- [ ] Backup strategy confirmed
|
||||||
- [ ] Monitoring alerts configured
|
- [ ] Monitoring alerts configured
|
||||||
- [ ] Rollback procedure documented
|
- [ ] Rollback procedure documented
|
||||||
- [ ] Stakeholders notified of maintenance window
|
- [ ] Stakeholders notified of maintenance window
|
||||||
|
- always use docker deployment for testing
|
||||||
@@ -40,6 +40,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
|||||||
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
- ⬇️ **Bulk Downloads** - Download all photos with one click
|
||||||
- 🔍 **Smart Search** - Find photos quickly
|
- 🔍 **Smart Search** - Find photos quickly
|
||||||
- 📤 **Guest Uploads** - Optional client photo uploads
|
- 📤 **Guest Uploads** - Optional client photo uploads
|
||||||
|
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
|
||||||
|
|
||||||
### Technical Excellence
|
### Technical Excellence
|
||||||
- 🐳 **Docker Ready** - Deploy in minutes
|
- 🐳 **Docker Ready** - Deploy in minutes
|
||||||
@@ -71,7 +72,7 @@ docker-compose up -d
|
|||||||
|
|
||||||
## 📖 Documentation
|
## 📖 Documentation
|
||||||
|
|
||||||
- 📘 [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions
|
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||||
- 📜 [**License**](LICENSE) - MIT License
|
- 📜 [**License**](LICENSE) - MIT License
|
||||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||||
@@ -177,6 +178,17 @@ Organize and manage your photo galleries with intuitive event management tools.
|
|||||||
|
|
||||||
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
|
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
|
||||||
|
|
||||||
|
### 🚧 Beta Features (Use at your own risk)
|
||||||
|
|
||||||
|
These features are currently in beta testing and may have limited functionality or stability:
|
||||||
|
|
||||||
|
| Feature | Description | Status |
|
||||||
|
|---------|-------------|--------|
|
||||||
|
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, and right-click prevention to protect your photos from unauthorized downloads | 🧪 Beta |
|
||||||
|
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
|
||||||
|
|
||||||
|
### 📋 Future Enhancements
|
||||||
|
|
||||||
| Feature | Description | Priority | Status |
|
| Feature | Description | Priority | Status |
|
||||||
|---------|-------------|----------|---------|
|
|---------|-------------|----------|---------|
|
||||||
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
|
||||||
@@ -209,7 +221,7 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
|||||||
## 🚀 Ready to Get Started?
|
## 🚀 Ready to Get Started?
|
||||||
|
|
||||||
1. ⭐ **Star this repository** to show your support
|
1. ⭐ **Star this repository** to show your support
|
||||||
2. 📖 Read the [Deployment Guide](DEPLOYMENT.md)
|
2. 📖 Read the [Deployment Guide](DEPLOYMENT_GUIDE.md)
|
||||||
3. 🐛 Report issues or request features
|
3. 🐛 Report issues or request features
|
||||||
4. 🤝 Join our community and contribute!
|
4. 🤝 Join our community and contribute!
|
||||||
|
|
||||||
@@ -219,6 +231,6 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
|
|||||||
Made with ❤️ by photographers, for photographers
|
Made with ❤️ by photographers, for photographers
|
||||||
<br>
|
<br>
|
||||||
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
|
||||||
<a href="DEPLOYMENT.md">Documentation</a> •
|
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
|
||||||
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -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)
|
||||||
@@ -18,10 +18,15 @@ exports.up = async function(knex) {
|
|||||||
const generatedPassword = generateReadablePassword();
|
const generatedPassword = generateReadablePassword();
|
||||||
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
|
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
|
||||||
|
|
||||||
|
// Get admin credentials from environment or use defaults
|
||||||
|
const adminUsername = process.env.ADMIN_USERNAME || 'admin';
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||||
|
|
||||||
await knex('admin_users').insert({
|
await knex('admin_users').insert({
|
||||||
username: 'admin',
|
username: adminUsername,
|
||||||
email: 'admin@example.com',
|
email: adminEmail,
|
||||||
password_hash: passwordHash,
|
password_hash: passwordHash,
|
||||||
|
must_change_password: true,
|
||||||
created_at: new Date()
|
created_at: new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,7 +41,7 @@ PicPeak Admin Credentials
|
|||||||
|
|
||||||
Your admin account has been created with these credentials:
|
Your admin account has been created with these credentials:
|
||||||
|
|
||||||
Username: admin
|
Email: ${adminEmail}
|
||||||
Password: ${generatedPassword}
|
Password: ${generatedPassword}
|
||||||
|
|
||||||
IMPORTANT SECURITY NOTES:
|
IMPORTANT SECURITY NOTES:
|
||||||
@@ -47,6 +52,8 @@ IMPORTANT SECURITY NOTES:
|
|||||||
|
|
||||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
||||||
|
|
||||||
|
Login with the email address shown above
|
||||||
|
|
||||||
Generated on: ${new Date().toISOString()}
|
Generated on: ${new Date().toISOString()}
|
||||||
========================================
|
========================================
|
||||||
`;
|
`;
|
||||||
@@ -65,7 +72,7 @@ Generated on: ${new Date().toISOString()}
|
|||||||
console.log('\n========================================');
|
console.log('\n========================================');
|
||||||
console.log('✅ Admin user created successfully!');
|
console.log('✅ Admin user created successfully!');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('Username: admin');
|
console.log(`Email: ${adminEmail}`);
|
||||||
console.log(`Password: ${generatedPassword}`);
|
console.log(`Password: ${generatedPassword}`);
|
||||||
console.log('\n⚠️ IMPORTANT:');
|
console.log('\n⚠️ IMPORTANT:');
|
||||||
console.log('1. Save these credentials securely');
|
console.log('1. Save these credentials securely');
|
||||||
|
|||||||
@@ -3,102 +3,125 @@
|
|||||||
exports.up = async function(knex) {
|
exports.up = async function(knex) {
|
||||||
console.log('Adding gallery feedback tables...');
|
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
|
// Create event_feedback_settings table
|
||||||
await knex.schema.createTable('event_feedback_settings', (table) => {
|
if (!hasEventFeedbackSettingsTable) {
|
||||||
table.increments('id').primary();
|
await knex.schema.createTable('event_feedback_settings', (table) => {
|
||||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
table.increments('id').primary();
|
||||||
table.boolean('feedback_enabled').defaultTo(false);
|
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||||
table.boolean('allow_ratings').defaultTo(true);
|
table.boolean('feedback_enabled').defaultTo(false);
|
||||||
table.boolean('allow_likes').defaultTo(true);
|
table.boolean('allow_ratings').defaultTo(true);
|
||||||
table.boolean('allow_comments').defaultTo(false);
|
table.boolean('allow_likes').defaultTo(true);
|
||||||
table.boolean('allow_favorites').defaultTo(true);
|
table.boolean('allow_comments').defaultTo(false);
|
||||||
table.boolean('require_name_email').defaultTo(false);
|
table.boolean('allow_favorites').defaultTo(true);
|
||||||
table.boolean('moderate_comments').defaultTo(true);
|
table.boolean('require_name_email').defaultTo(false);
|
||||||
table.boolean('show_feedback_to_guests').defaultTo(true);
|
table.boolean('moderate_comments').defaultTo(true);
|
||||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
table.boolean('show_feedback_to_guests').defaultTo(true);
|
||||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
table.unique(['event_id']);
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
});
|
table.unique(['event_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create photo_feedback table
|
// Create photo_feedback table
|
||||||
await knex.schema.createTable('photo_feedback', (table) => {
|
if (!hasPhotoFeedbackTable) {
|
||||||
table.increments('id').primary();
|
await knex.schema.createTable('photo_feedback', (table) => {
|
||||||
table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
table.increments('id').primary();
|
||||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
|
||||||
table.string('feedback_type', 20).notNullable();
|
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||||
table.integer('rating');
|
table.string('feedback_type', 20).notNullable();
|
||||||
table.text('comment_text');
|
table.integer('rating');
|
||||||
table.string('guest_name', 100);
|
table.text('comment_text');
|
||||||
table.string('guest_email', 255);
|
table.string('guest_name', 100);
|
||||||
table.string('guest_identifier', 64);
|
table.string('guest_email', 255);
|
||||||
table.string('ip_address', 45);
|
table.string('guest_identifier', 64);
|
||||||
table.text('user_agent');
|
table.string('ip_address', 45);
|
||||||
table.boolean('is_approved').defaultTo(true);
|
table.text('user_agent');
|
||||||
table.boolean('is_hidden').defaultTo(false);
|
table.boolean('is_approved').defaultTo(true);
|
||||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
table.boolean('is_hidden').defaultTo(false);
|
||||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
|
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||||
// Add indexes
|
|
||||||
table.index(['photo_id']);
|
// Add indexes
|
||||||
table.index(['event_id']);
|
table.index(['photo_id']);
|
||||||
table.index(['feedback_type']);
|
table.index(['event_id']);
|
||||||
table.index(['guest_identifier']);
|
table.index(['feedback_type']);
|
||||||
|
table.index(['guest_identifier']);
|
||||||
// Add check constraint for rating (PostgreSQL)
|
|
||||||
if (knex.client.config.client === 'pg') {
|
// Add check constraint for rating (PostgreSQL)
|
||||||
table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']);
|
if (knex.client.config.client === 'pg') {
|
||||||
}
|
table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create feedback_rate_limits table
|
// Create feedback_rate_limits table
|
||||||
await knex.schema.createTable('feedback_rate_limits', (table) => {
|
if (!hasFeedbackRateLimitsTable) {
|
||||||
table.increments('id').primary();
|
await knex.schema.createTable('feedback_rate_limits', (table) => {
|
||||||
table.string('identifier', 64).notNullable();
|
table.increments('id').primary();
|
||||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
table.string('identifier', 64).notNullable();
|
||||||
table.string('action_type', 20).notNullable();
|
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||||
table.integer('action_count').defaultTo(1);
|
table.string('action_type', 20).notNullable();
|
||||||
table.timestamp('window_start').defaultTo(knex.fn.now());
|
table.integer('action_count').defaultTo(1);
|
||||||
|
table.timestamp('window_start').defaultTo(knex.fn.now());
|
||||||
// Add indexes
|
|
||||||
table.index(['identifier', 'event_id', 'action_type']);
|
// Add indexes
|
||||||
table.index(['window_start']);
|
table.index(['identifier', 'event_id', 'action_type']);
|
||||||
});
|
table.index(['window_start']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create feedback_word_filters table
|
// Create feedback_word_filters table
|
||||||
await knex.schema.createTable('feedback_word_filters', (table) => {
|
if (!hasFeedbackWordFiltersTable) {
|
||||||
table.increments('id').primary();
|
await knex.schema.createTable('feedback_word_filters', (table) => {
|
||||||
table.string('word', 100).notNullable();
|
table.increments('id').primary();
|
||||||
table.string('severity', 20).defaultTo('moderate');
|
table.string('word', 100).notNullable();
|
||||||
table.boolean('is_active').defaultTo(true);
|
table.string('severity', 20).defaultTo('moderate');
|
||||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
table.boolean('is_active').defaultTo(true);
|
||||||
table.unique(['word']);
|
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||||
});
|
table.unique(['word']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add feedback summary columns to photos table
|
// Add feedback summary columns to photos table
|
||||||
await knex.schema.alterTable('photos', (table) => {
|
if (!hasFeedbackCountColumn) {
|
||||||
table.integer('feedback_count').defaultTo(0);
|
await knex.schema.alterTable('photos', (table) => {
|
||||||
table.integer('like_count').defaultTo(0);
|
table.integer('feedback_count').defaultTo(0);
|
||||||
table.decimal('average_rating', 3, 2).defaultTo(0);
|
table.integer('like_count').defaultTo(0);
|
||||||
table.integer('favorite_count').defaultTo(0);
|
table.decimal('average_rating', 3, 2).defaultTo(0);
|
||||||
});
|
table.integer('favorite_count').defaultTo(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add feedback notification settings to app_settings
|
// Add feedback notification settings to app_settings
|
||||||
await knex('app_settings').insert([
|
const hasFeedbackNotificationEmail = await knex('app_settings')
|
||||||
{
|
.where('setting_key', 'feedback_notification_email')
|
||||||
setting_key: 'feedback_notification_email',
|
.first();
|
||||||
setting_value: JSON.stringify(''),
|
|
||||||
setting_type: 'feedback'
|
if (!hasFeedbackNotificationEmail) {
|
||||||
},
|
await knex('app_settings').insert([
|
||||||
{
|
{
|
||||||
setting_key: 'feedback_rate_limits',
|
setting_key: 'feedback_notification_email',
|
||||||
setting_value: JSON.stringify({
|
setting_value: JSON.stringify(''),
|
||||||
rating: { max: 100, window: 3600 }, // 100 ratings per hour
|
setting_type: 'feedback'
|
||||||
comment: { max: 20, window: 3600 }, // 20 comments per hour
|
},
|
||||||
like: { max: 200, window: 3600 } // 200 likes per hour
|
{
|
||||||
}),
|
setting_key: 'feedback_rate_limits',
|
||||||
setting_type: 'feedback'
|
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');
|
console.log('Gallery feedback tables created successfully');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Fix missing columns identified in GitHub issues
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Adding missing columns to database tables...');
|
||||||
|
|
||||||
|
// Add must_change_password column to admin_users table
|
||||||
|
const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password');
|
||||||
|
if (!hasMustChangePassword) {
|
||||||
|
console.log('Adding must_change_password column to admin_users table...');
|
||||||
|
await knex.schema.table('admin_users', (table) => {
|
||||||
|
table.boolean('must_change_password').defaultTo(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add password_changed_at column to admin_users table
|
||||||
|
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
|
||||||
|
if (!hasPasswordChangedAt) {
|
||||||
|
console.log('Adding password_changed_at column to admin_users table...');
|
||||||
|
await knex.schema.table('admin_users', (table) => {
|
||||||
|
table.datetime('password_changed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add require_moderation column to event_feedback_settings table
|
||||||
|
const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings');
|
||||||
|
if (hasEventFeedbackSettings) {
|
||||||
|
const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation');
|
||||||
|
if (!hasRequireModeration) {
|
||||||
|
console.log('Adding require_moderation column to event_feedback_settings table...');
|
||||||
|
await knex.schema.table('event_feedback_settings', (table) => {
|
||||||
|
table.boolean('require_moderation').defaultTo(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add host_name column to events table if missing
|
||||||
|
const hasHostName = await knex.schema.hasColumn('events', 'host_name');
|
||||||
|
if (!hasHostName) {
|
||||||
|
console.log('Adding host_name column to events table...');
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.string('host_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Missing columns have been added successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Removing added columns...');
|
||||||
|
|
||||||
|
// Remove must_change_password column from admin_users table
|
||||||
|
const hasMustChangePassword = await knex.schema.hasColumn('admin_users', 'must_change_password');
|
||||||
|
if (hasMustChangePassword) {
|
||||||
|
await knex.schema.table('admin_users', (table) => {
|
||||||
|
table.dropColumn('must_change_password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove password_changed_at column from admin_users table
|
||||||
|
const hasPasswordChangedAt = await knex.schema.hasColumn('admin_users', 'password_changed_at');
|
||||||
|
if (hasPasswordChangedAt) {
|
||||||
|
await knex.schema.table('admin_users', (table) => {
|
||||||
|
table.dropColumn('password_changed_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove require_moderation column from event_feedback_settings table
|
||||||
|
const hasEventFeedbackSettings = await knex.schema.hasTable('event_feedback_settings');
|
||||||
|
if (hasEventFeedbackSettings) {
|
||||||
|
const hasRequireModeration = await knex.schema.hasColumn('event_feedback_settings', 'require_moderation');
|
||||||
|
if (hasRequireModeration) {
|
||||||
|
await knex.schema.table('event_feedback_settings', (table) => {
|
||||||
|
table.dropColumn('require_moderation');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove host_name column from events table
|
||||||
|
const hasHostName = await knex.schema.hasColumn('events', 'host_name');
|
||||||
|
if (hasHostName) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.dropColumn('host_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Columns removed');
|
||||||
|
};
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Add download control features to events table
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
console.log('Adding download control columns to events table...');
|
||||||
|
|
||||||
|
// Add download control columns to events table
|
||||||
|
const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads');
|
||||||
|
if (!hasAllowDownloads) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.boolean('allow_downloads').defaultTo(true);
|
||||||
|
table.boolean('disable_right_click').defaultTo(false);
|
||||||
|
table.boolean('watermark_downloads').defaultTo(false);
|
||||||
|
table.text('watermark_text');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add download control settings to app_settings
|
||||||
|
const downloadSettingExists = await knex('app_settings')
|
||||||
|
.where('setting_key', 'default_allow_downloads')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!downloadSettingExists) {
|
||||||
|
await knex('app_settings').insert([
|
||||||
|
{
|
||||||
|
setting_key: 'default_allow_downloads',
|
||||||
|
setting_value: JSON.stringify(true),
|
||||||
|
setting_type: 'gallery'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'default_disable_right_click',
|
||||||
|
setting_value: JSON.stringify(false),
|
||||||
|
setting_type: 'gallery'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'default_watermark_downloads',
|
||||||
|
setting_value: JSON.stringify(false),
|
||||||
|
setting_type: 'gallery'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Download control features added successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
console.log('Removing download control columns...');
|
||||||
|
|
||||||
|
// Remove app settings
|
||||||
|
await knex('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'default_allow_downloads',
|
||||||
|
'default_disable_right_click',
|
||||||
|
'default_watermark_downloads'
|
||||||
|
])
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
// Remove columns from events table
|
||||||
|
const hasAllowDownloads = await knex.schema.hasColumn('events', 'allow_downloads');
|
||||||
|
if (hasAllowDownloads) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.dropColumn('allow_downloads');
|
||||||
|
table.dropColumn('disable_right_click');
|
||||||
|
table.dropColumn('watermark_downloads');
|
||||||
|
table.dropColumn('watermark_text');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Download control columns removed');
|
||||||
|
};
|
||||||
@@ -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() {
|
async function up() {
|
||||||
console.log('Adding photo categories and CMS tables...');
|
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
|
// Create photo_categories table
|
||||||
await db.schema.createTable('photo_categories', (table) => {
|
if (!hasPhotoCategoriesTable) {
|
||||||
table.increments('id').primary();
|
await db.schema.createTable('photo_categories', (table) => {
|
||||||
table.string('name', 100).notNullable();
|
table.increments('id').primary();
|
||||||
table.string('slug', 100).notNullable();
|
table.string('name', 100).notNullable();
|
||||||
table.boolean('is_global').defaultTo(true);
|
table.string('slug', 100).notNullable();
|
||||||
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
table.boolean('is_global').defaultTo(true);
|
||||||
table.timestamp('created_at').defaultTo(db.fn.now());
|
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']);
|
// Unique constraint for slug within event scope
|
||||||
});
|
table.unique(['slug', 'event_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create cms_pages table
|
// Create cms_pages table
|
||||||
await db.schema.createTable('cms_pages', (table) => {
|
if (!hasCmsPagesTable) {
|
||||||
table.increments('id').primary();
|
await db.schema.createTable('cms_pages', (table) => {
|
||||||
table.string('slug', 100).unique().notNullable();
|
table.increments('id').primary();
|
||||||
table.text('title_en');
|
table.string('slug', 100).unique().notNullable();
|
||||||
table.text('title_de');
|
table.text('title_en');
|
||||||
table.text('content_en');
|
table.text('title_de');
|
||||||
table.text('content_de');
|
table.text('content_en');
|
||||||
table.timestamp('updated_at').defaultTo(db.fn.now());
|
table.text('content_de');
|
||||||
});
|
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add category_id to photos table
|
// Add category_id to photos table
|
||||||
await db.schema.alterTable('photos', (table) => {
|
if (!hasCategoryIdColumn) {
|
||||||
table.integer('category_id').references('id').inTable('photo_categories');
|
await db.schema.alterTable('photos', (table) => {
|
||||||
});
|
table.integer('category_id').references('id').inTable('photo_categories');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add language preference to admin_users
|
// Add language preference to admin_users
|
||||||
await db.schema.alterTable('admin_users', (table) => {
|
if (!hasLanguageColumn) {
|
||||||
table.string('language', 2).defaultTo('en');
|
await db.schema.alterTable('admin_users', (table) => {
|
||||||
});
|
table.string('language', 2).defaultTo('en');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add language preference to app_settings for global default
|
// Add language preference to app_settings for global default
|
||||||
await db('app_settings').insert({
|
const hasDefaultLanguageSetting = await db('app_settings')
|
||||||
setting_key: 'default_language',
|
.where('setting_key', 'default_language')
|
||||||
setting_value: JSON.stringify('en'),
|
.first();
|
||||||
setting_type: 'general',
|
|
||||||
updated_at: new Date()
|
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
|
// Insert default global categories
|
||||||
const defaultCategories = [
|
if (!hasPhotoCategoriesTable) {
|
||||||
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
|
const defaultCategories = [
|
||||||
{ name: 'Reception', slug: 'reception', is_global: true },
|
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
|
||||||
{ name: 'Portraits', slug: 'portraits', is_global: true },
|
{ name: 'Reception', slug: 'reception', is_global: true },
|
||||||
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
|
{ name: 'Portraits', slug: 'portraits', is_global: true },
|
||||||
{ name: 'Details', slug: 'details', is_global: true },
|
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
|
||||||
{ name: 'Party', slug: 'party', 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
|
// Insert default legal pages
|
||||||
await db('cms_pages').insert([
|
if (!hasCmsPagesTable) {
|
||||||
{
|
await db('cms_pages').insert([
|
||||||
slug: 'impressum',
|
{
|
||||||
title_en: 'Legal Notice',
|
slug: 'impressum',
|
||||||
title_de: 'Impressum',
|
title_en: 'Legal Notice',
|
||||||
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
|
title_de: 'Impressum',
|
||||||
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
|
||||||
updated_at: new Date()
|
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',
|
slug: 'datenschutz',
|
||||||
title_de: 'Datenschutzerklärung',
|
title_en: 'Privacy Policy',
|
||||||
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
|
title_de: 'Datenschutzerklärung',
|
||||||
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
|
||||||
updated_at: new Date()
|
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');
|
console.log('Photo categories and CMS tables created successfully');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
exports.up = function(knex) {
|
exports.up = async function(knex) {
|
||||||
return knex.schema.createTable('login_attempts', table => {
|
const hasLoginAttemptsTable = await knex.schema.hasTable('login_attempts');
|
||||||
table.increments('id').primary();
|
|
||||||
table.string('identifier').notNullable(); // username or email
|
if (!hasLoginAttemptsTable) {
|
||||||
table.string('ip_address', 45).notNullable(); // IPv4 or IPv6
|
return knex.schema.createTable('login_attempts', table => {
|
||||||
table.text('user_agent');
|
table.increments('id').primary();
|
||||||
table.timestamp('attempt_time').defaultTo(knex.fn.now());
|
table.string('identifier').notNullable(); // username or email
|
||||||
table.boolean('success').defaultTo(false);
|
table.string('ip_address', 45).notNullable(); // IPv4 or IPv6
|
||||||
|
table.text('user_agent');
|
||||||
// Indexes for performance
|
table.timestamp('attempt_time').defaultTo(knex.fn.now());
|
||||||
table.index('identifier');
|
table.boolean('success').defaultTo(false);
|
||||||
table.index('attempt_time');
|
|
||||||
table.index(['identifier', 'success', 'attempt_time']);
|
// Indexes for performance
|
||||||
});
|
table.index('identifier');
|
||||||
|
table.index('attempt_time');
|
||||||
|
table.index(['identifier', 'success', 'attempt_time']);
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.down = function(knex) {
|
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 => {
|
return knex.schema.table('admin_users', table => {
|
||||||
// Add password change tracking
|
// 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
|
// Add last login IP for security monitoring
|
||||||
table.string('last_login_ip', 45).nullable();
|
if (!hasLastLoginIp) {
|
||||||
|
table.string('last_login_ip', 45).nullable();
|
||||||
|
}
|
||||||
|
|
||||||
// Add account security flags
|
// Add account security flags
|
||||||
table.boolean('two_factor_enabled').defaultTo(false);
|
if (!hasTwoFactorEnabled) {
|
||||||
table.string('two_factor_secret').nullable();
|
table.boolean('two_factor_enabled').defaultTo(false);
|
||||||
|
}
|
||||||
// Add index for performance
|
if (!hasTwoFactorSecret) {
|
||||||
table.index('password_changed_at');
|
table.string('two_factor_secret').nullable();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
exports.up = function(knex) {
|
exports.up = async function(knex) {
|
||||||
return knex.schema
|
// Check if tables already exist to avoid conflicts
|
||||||
// Table for individual token revocations
|
const hasRevokedTokensTable = await knex.schema.hasTable('revoked_tokens');
|
||||||
.createTable('revoked_tokens', table => {
|
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.increments('id').primary();
|
||||||
table.string('token_id').notNullable().unique(); // JWT ID or generated ID
|
table.string('token_id').notNullable().unique(); // JWT ID or generated ID
|
||||||
table.integer('user_id').nullable(); // User who owned the token
|
table.integer('user_id').nullable(); // User who owned the token
|
||||||
@@ -15,9 +19,12 @@ exports.up = function(knex) {
|
|||||||
table.index('token_id');
|
table.index('token_id');
|
||||||
table.index('user_id');
|
table.index('user_id');
|
||||||
table.index('expires_at'); // For cleanup
|
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.integer('user_id').primary();
|
||||||
table.timestamp('revoked_at').notNullable();
|
table.timestamp('revoked_at').notNullable();
|
||||||
table.string('reason', 100);
|
table.string('reason', 100);
|
||||||
@@ -25,6 +32,28 @@ exports.up = function(knex) {
|
|||||||
// Index for quick lookups
|
// Index for quick lookups
|
||||||
table.index('revoked_at');
|
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) {
|
exports.down = function(knex) {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.97",
|
"version": "1.0.103",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.97",
|
"version": "1.0.103",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.97",
|
"version": "1.0.103",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,11 +8,8 @@
|
|||||||
"dev": "nodemon server.js",
|
"dev": "nodemon server.js",
|
||||||
"migrate": "node migrations/run-migrations.js",
|
"migrate": "node migrations/run-migrations.js",
|
||||||
"migrate:safe": "node migrations/run-migrations-safe.js",
|
"migrate:safe": "node migrations/run-migrations-safe.js",
|
||||||
"fix-temp-photos": "node scripts/fix-temp-photos.js",
|
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/"
|
||||||
"test-backup": "node scripts/test-backup-service.js",
|
|
||||||
"test-restore": "node scripts/test-restore-service.js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
|
|||||||
@@ -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();
|
|
||||||
@@ -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);
|
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -39,8 +39,9 @@ async function showAdminCredentials(resetPassword = false) {
|
|||||||
updated_at: new Date()
|
updated_at: new Date()
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`Password: ${newPassword} (NEWLY RESET)`);
|
// Password logging removed for security - check logs or database if needed
|
||||||
console.log('\n⚠️ IMPORTANT: Please save this password securely!');
|
console.log('Password: [NEWLY RESET - stored in database]');
|
||||||
|
console.log('\n⚠️ IMPORTANT: New password has been set in database!');
|
||||||
} else {
|
} else {
|
||||||
console.log('Password: [hidden - use --reset flag to generate new password]');
|
console.log('Password: [hidden - use --reset flag to generate new password]');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -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();
|
|
||||||
@@ -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();
|
|
||||||
@@ -32,6 +32,7 @@ const eventRoutes = require('./src/routes/events');
|
|||||||
const galleryRoutes = require('./src/routes/gallery');
|
const galleryRoutes = require('./src/routes/gallery');
|
||||||
const adminRoutes = require('./src/routes/admin');
|
const adminRoutes = require('./src/routes/admin');
|
||||||
const adminAuthRoutes = require('./src/routes/adminAuth');
|
const adminAuthRoutes = require('./src/routes/adminAuth');
|
||||||
|
const secureImagesRoutes = require('./src/routes/secureImages');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -199,22 +200,36 @@ app.get('/health', async (req, res) => {
|
|||||||
// Routes
|
// Routes
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/events', eventRoutes);
|
app.use('/api/events', eventRoutes);
|
||||||
|
// Gallery routes - main routes first, then feedback routes
|
||||||
app.use('/api/gallery', galleryRoutes);
|
app.use('/api/gallery', galleryRoutes);
|
||||||
|
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/admin/auth', adminAuthRoutes);
|
app.use('/api/admin/auth', adminAuthRoutes);
|
||||||
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
||||||
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
||||||
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
||||||
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
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/settings', require('./src/routes/publicSettings'));
|
||||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||||
app.use('/api/images', require('./src/routes/protectedImages'));
|
app.use('/api/images', require('./src/routes/protectedImages'));
|
||||||
|
app.use('/api/secure-images', secureImagesRoutes);
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
logger.error(err.stack);
|
console.error('EXPRESS ERROR HANDLER:', err);
|
||||||
res.status(500).json({ error: 'Something went wrong!' });
|
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
|
// Initialize services
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const knex = require('knex');
|
const knex = require('knex');
|
||||||
const knexConfig = require('../../knexfile');
|
const knexConfig = require('../../knexfile');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
// Create database connection with built-in retry logic
|
// Create database connection with built-in retry logic
|
||||||
const db = knex(knexConfig);
|
const db = knex(knexConfig);
|
||||||
@@ -22,7 +23,7 @@ async function withRetry(queryFn, retries = MAX_RETRIES) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (isConnectionError && i < retries - 1) {
|
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)));
|
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,7 @@ async function initializeDatabase() {
|
|||||||
await db.raw('ALTER TABLE events_new RENAME TO events');
|
await db.raw('ALTER TABLE events_new RENAME TO events');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If the migration fails, it might already have been applied
|
// 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('email').unique().notNullable();
|
||||||
table.string('password_hash').notNullable();
|
table.string('password_hash').notNullable();
|
||||||
table.boolean('is_active').defaultTo(true);
|
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('created_at').defaultTo(db.fn.now());
|
||||||
table.datetime('updated_at').defaultTo(db.fn.now());
|
table.datetime('updated_at').defaultTo(db.fn.now());
|
||||||
table.datetime('last_login');
|
table.datetime('last_login');
|
||||||
|
table.string('last_login_ip');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Check if updated_at column exists
|
// Check if updated_at column exists
|
||||||
@@ -167,6 +171,62 @@ async function initializeDatabase() {
|
|||||||
// Set default value for existing rows
|
// Set default value for existing rows
|
||||||
await db('admin_users').update({ updated_at: new Date() });
|
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
|
// Email configuration table
|
||||||
@@ -248,7 +308,7 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
|
|||||||
event_id: eventId
|
event_id: eventId
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to log activity:', error);
|
logger.error('Failed to log activity:', { error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,19 @@ async function adminAuth(req, res, next) {
|
|||||||
|
|
||||||
let decoded;
|
let decoded;
|
||||||
try {
|
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) {
|
} catch (jwtError) {
|
||||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||||
req.headers['x-real-ip'] ||
|
req.headers['x-real-ip'] ||
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ async function getRateLimitSettings() {
|
|||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (settings && settings.setting_value) {
|
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
|
// Default settings
|
||||||
@@ -120,8 +123,8 @@ async function recordAction(identifier, eventId, actionType) {
|
|||||||
function feedbackRateLimit(actionType) {
|
function feedbackRateLimit(actionType) {
|
||||||
return async (req, res, next) => {
|
return async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
// Extract event ID from params or body
|
// Extract event ID from params, body or event object (set by verifyGalleryAccess)
|
||||||
const eventId = req.params.eventId || req.body?.event_id;
|
const eventId = req.params.eventId || req.body?.event_id || req.event?.id;
|
||||||
if (!eventId) {
|
if (!eventId) {
|
||||||
return res.status(400).json({ error: 'Event ID required' });
|
return res.status(400).json({ error: 'Event ID required' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,82 @@ const { formatBoolean } = require('../utils/dbCompat');
|
|||||||
// Middleware to verify gallery access
|
// Middleware to verify gallery access
|
||||||
async function verifyGalleryAccess(req, res, next) {
|
async function verifyGalleryAccess(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const authHeader = req.headers.authorization;
|
||||||
|
const token = authHeader?.split(' ')[1];
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
const event = await withRetry(async () => {
|
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||||
return await db('events')
|
let decoded;
|
||||||
.where({
|
try {
|
||||||
id: decoded.eventId,
|
decoded = jwt.verify(token, process.env.JWT_SECRET, {
|
||||||
is_active: formatBoolean(true),
|
issuer: 'picpeak-auth'
|
||||||
is_archived: formatBoolean(false)
|
});
|
||||||
})
|
} catch (error) {
|
||||||
.first();
|
// 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) {
|
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' });
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[verifyGalleryAccess] Event found:', event.id, event.slug);
|
||||||
req.event = event;
|
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();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying gallery access:', error);
|
console.error('Error verifying gallery access:', error);
|
||||||
|
|||||||
@@ -24,7 +24,20 @@ async function photoAuth(req, res, next) {
|
|||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
const token = authHeader.replace('Bearer ', '');
|
const token = authHeader.replace('Bearer ', '');
|
||||||
try {
|
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
|
// Check if it's a gallery token
|
||||||
if (decoded.type === 'gallery') {
|
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;
|
||||||
@@ -166,7 +166,7 @@ router.get('/health', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
const [failedEmails] = await db('email_queue')
|
const [failedEmails] = await db('email_queue')
|
||||||
.where('status', 'failed')
|
.where('status', 'failed')
|
||||||
.where('created_at', '>=', twentyFourHoursAgo.toISOString())
|
.where('scheduled_at', '>=', twentyFourHoursAgo.toISOString())
|
||||||
.count('* as count');
|
.count('* as count');
|
||||||
|
|
||||||
const emailStatus = failedEmails.count > 10 ? 'warning' : 'healthy';
|
const emailStatus = failedEmails.count > 10 ? 'warning' : 'healthy';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { body, query, validationResult } = require('express-validator');
|
const { body, query, validationResult } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
@@ -12,7 +13,6 @@ const { queueEmail } = require('../services/emailProcessor');
|
|||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
// formatDate import removed - dates are formatted by email processor
|
// formatDate import removed - dates are formatted by email processor
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
|
||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
@@ -27,7 +27,11 @@ router.post('/', adminAuth, [
|
|||||||
body('color_theme').optional().trim(),
|
body('color_theme').optional().trim(),
|
||||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||||
body('host_name').notEmpty().trim()
|
body('host_name').notEmpty().trim(),
|
||||||
|
body('allow_downloads').optional().isBoolean(),
|
||||||
|
body('disable_right_click').optional().isBoolean(),
|
||||||
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
|
body('watermark_text').optional().trim()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log('Create event request body:', req.body);
|
console.log('Create event request body:', req.body);
|
||||||
@@ -49,9 +53,35 @@ router.post('/', adminAuth, [
|
|||||||
color_theme = null,
|
color_theme = null,
|
||||||
expiration_days = 30,
|
expiration_days = 30,
|
||||||
allow_user_uploads = false,
|
allow_user_uploads = false,
|
||||||
upload_category_id = null
|
upload_category_id = null,
|
||||||
|
allow_downloads = true,
|
||||||
|
disable_right_click = false,
|
||||||
|
watermark_downloads = false,
|
||||||
|
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;
|
} = req.body;
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('Download control values:', {
|
||||||
|
allow_downloads,
|
||||||
|
disable_right_click,
|
||||||
|
watermark_downloads,
|
||||||
|
watermark_text,
|
||||||
|
types: {
|
||||||
|
allow_downloads: typeof allow_downloads,
|
||||||
|
disable_right_click: typeof disable_right_click,
|
||||||
|
watermark_downloads: typeof watermark_downloads
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Validate password strength
|
// Validate password strength
|
||||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||||
eventName: event_name
|
eventName: event_name
|
||||||
@@ -121,12 +151,33 @@ router.post('/', adminAuth, [
|
|||||||
expires_at: expires_at.toISOString(),
|
expires_at: expires_at.toISOString(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
allow_user_uploads,
|
allow_user_uploads,
|
||||||
upload_category_id
|
upload_category_id,
|
||||||
|
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||||
|
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||||
|
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||||
|
watermark_text
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||||
const eventId = insertResult[0]?.id || insertResult[0];
|
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
|
// Log activity
|
||||||
await logActivity('event_created',
|
await logActivity('event_created',
|
||||||
{ event_type, expires_at },
|
{ event_type, expires_at },
|
||||||
@@ -341,7 +392,11 @@ router.put('/:id', adminAuth, [
|
|||||||
// Check if it's a number or can be converted to a valid integer
|
// Check if it's a number or can be converted to a valid integer
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
return !isNaN(num) && Number.isInteger(num);
|
return !isNaN(num) && Number.isInteger(num);
|
||||||
}).withMessage('hero_photo_id must be an integer or null')
|
}).withMessage('hero_photo_id must be an integer or null'),
|
||||||
|
body('allow_downloads').optional().isBoolean(),
|
||||||
|
body('disable_right_click').optional().isBoolean(),
|
||||||
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
|
body('watermark_text').optional().trim()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -414,10 +469,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
|||||||
// 4. Delete photos (this will also handle hero_photo_id foreign key)
|
// 4. Delete photos (this will also handle hero_photo_id foreign key)
|
||||||
await trx('photos').where('event_id', id).del();
|
await trx('photos').where('event_id', id).del();
|
||||||
|
|
||||||
// 5. Delete categories (photo_categories has CASCADE delete for event_id)
|
// 5. Finally delete the event
|
||||||
await trx('photo_categories').where('event_id', id).del();
|
|
||||||
|
|
||||||
// 6. Finally delete the event
|
|
||||||
await trx('events').where('id', id).del();
|
await trx('events').where('id', id).del();
|
||||||
|
|
||||||
// Delete event folder from storage if it exists
|
// Delete event folder from storage if it exists
|
||||||
@@ -526,16 +578,15 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate new password
|
// Generate new password
|
||||||
const { generatePassword } = require('../utils/passwordGenerator');
|
const { generateReadablePassword } = require('../utils/passwordGenerator');
|
||||||
const newPassword = generatePassword();
|
const newPassword = generateReadablePassword();
|
||||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
|
||||||
// Update event with new password
|
// Update event with new password
|
||||||
await db('events')
|
await db('events')
|
||||||
.where('id', id)
|
.where('id', id)
|
||||||
.update({
|
.update({
|
||||||
password_hash: passwordHash,
|
password_hash: passwordHash
|
||||||
updated_at: new Date()
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
|
|||||||
@@ -111,7 +111,29 @@ router.get('/events/:eventId/feedback',
|
|||||||
|
|
||||||
// Pagination
|
// Pagination
|
||||||
const offset = (page - 1) * limit;
|
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
|
const feedback = await query
|
||||||
.orderBy('photo_feedback.created_at', 'desc')
|
.orderBy('photo_feedback.created_at', 'desc')
|
||||||
@@ -314,7 +336,7 @@ router.get('/feedback/pending-moderation',
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Word filter management
|
// Word filter management
|
||||||
router.get('/feedback/word-filters',
|
router.get('/word-filters',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -327,7 +349,7 @@ router.get('/feedback/word-filters',
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.post('/feedback/word-filters',
|
router.post('/word-filters',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
validateWordFilter,
|
validateWordFilter,
|
||||||
checkValidation,
|
checkValidation,
|
||||||
@@ -339,8 +361,8 @@ router.post('/feedback/word-filters',
|
|||||||
|
|
||||||
await logActivity('word_filter_added', { word, severity }, null, {
|
await logActivity('word_filter_added', { word, severity }, null, {
|
||||||
type: 'admin',
|
type: 'admin',
|
||||||
id: req.user.id,
|
id: req.user?.id || req.admin?.id,
|
||||||
name: req.user.username
|
name: req.user?.username || req.admin?.username
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({ success: true });
|
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,
|
adminAuth,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -371,7 +393,7 @@ router.put('/feedback/word-filters/:id',
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
router.delete('/feedback/word-filters/:id',
|
router.delete('/word-filters/:id',
|
||||||
adminAuth,
|
adminAuth,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -160,21 +160,22 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
|||||||
// Parse category_id to number if provided
|
// Parse category_id to number if provided
|
||||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||||
|
|
||||||
// Get category details if provided
|
// Determine photo type from category_id parameter (for backwards compatibility)
|
||||||
let category = null;
|
let photoType = 'individual'; // default
|
||||||
if (parsedCategoryId) {
|
let categoryName = 'individual';
|
||||||
category = await db('photo_categories').where({ id: parsedCategoryId }).first();
|
|
||||||
if (!category) {
|
if (parsedCategoryId === 1 || category_id === 'collage') {
|
||||||
// Clean up temp files
|
photoType = 'collage';
|
||||||
if (req.tempUploadPath) {
|
categoryName = 'collages';
|
||||||
try {
|
} else if (parsedCategoryId === 2 || category_id === 'individual') {
|
||||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
photoType = 'individual';
|
||||||
} catch (e) {
|
categoryName = 'individual';
|
||||||
console.error('Failed to clean up temp path:', e);
|
}
|
||||||
}
|
|
||||||
}
|
// For backwards compatibility, accept string values
|
||||||
return res.status(400).json({ error: 'Invalid category' });
|
if (category_id === 'collage') {
|
||||||
}
|
photoType = 'collage';
|
||||||
|
categoryName = 'collages';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create final destination directory
|
// Create final destination directory
|
||||||
@@ -194,22 +195,12 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
|||||||
const trx = await db.transaction();
|
const trx = await db.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get initial counter for this batch
|
// Get initial counter for this batch based on photo type
|
||||||
let batchCounter = 1;
|
const existingCount = await trx('photos')
|
||||||
if (category) {
|
.where({ event_id: eventId, type: photoType })
|
||||||
const categoryData = await trx('photo_categories')
|
.count('id as count')
|
||||||
.where({ id: parsedCategoryId })
|
.first();
|
||||||
.forUpdate()
|
let batchCounter = (parseInt(existingCount.count) || 0) + 1;
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const batchPhotos = [];
|
const batchPhotos = [];
|
||||||
const fileRenameOperations = []; // Store rename operations to do after commit
|
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 extension = path.extname(file.originalname);
|
||||||
const newFilename = generatePhotoFilename(
|
const newFilename = generatePhotoFilename(
|
||||||
event.event_name,
|
event.event_name,
|
||||||
category ? category.name : 'uncategorized',
|
categoryName,
|
||||||
counter,
|
counter,
|
||||||
extension
|
extension
|
||||||
);
|
);
|
||||||
@@ -247,8 +238,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
|||||||
filename: newFilename,
|
filename: newFilename,
|
||||||
path: relativePath,
|
path: relativePath,
|
||||||
thumbnail_path: null, // Will generate after successful commit
|
thumbnail_path: null, // Will generate after successful commit
|
||||||
category_id: parsedCategoryId ? parseInt(parsedCategoryId) : null,
|
type: photoType,
|
||||||
type: 'individual',
|
|
||||||
size_bytes: tempStats.size // Use actual file size from stat
|
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
|
// Insert all photos in this batch
|
||||||
if (batchPhotos.length > 0) {
|
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');
|
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
|
||||||
|
|
||||||
// Update category counter if needed
|
// No need to update counter as we calculate it dynamically
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit the transaction first
|
// Commit the transaction first
|
||||||
await trx.commit();
|
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;
|
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||||
|
|
||||||
let query = db('photos')
|
let query = db('photos')
|
||||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
|
||||||
.where({ 'photos.event_id': eventId })
|
.where({ 'photos.event_id': eventId })
|
||||||
.select(
|
.select('photos.*');
|
||||||
'photos.*',
|
|
||||||
'photo_categories.name as category_name',
|
|
||||||
'photo_categories.slug as category_slug'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter by category (including uncategorized)
|
// Filter by type (individual/collage) - category_id maps to type
|
||||||
if (category_id !== undefined) {
|
if (category_id !== undefined) {
|
||||||
if (category_id === '' || category_id === '0') {
|
if (category_id === '' || category_id === '0') {
|
||||||
query = query.whereNull('photos.category_id');
|
// For backwards compatibility, empty category means no filter
|
||||||
} else {
|
// Don't filter anything
|
||||||
query = query.where({ 'photos.category_id': category_id });
|
} 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);
|
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({
|
res.json({
|
||||||
photos: photos.map(photo => ({
|
photos: photos.map(photo => ({
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
@@ -693,11 +687,17 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
|||||||
url: `/admin/events/${eventId}/photo/${photo.id}`,
|
url: `/admin/events/${eventId}/photo/${photo.id}`,
|
||||||
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
|
||||||
type: photo.type,
|
type: photo.type,
|
||||||
category_id: photo.category_id,
|
category_id: photo.type,
|
||||||
category_name: photo.category_name,
|
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||||
category_slug: photo.category_slug,
|
category_slug: photo.type,
|
||||||
size: photo.size_bytes,
|
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) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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
|
// Update branding settings
|
||||||
router.put('/branding', adminAuth, async (req, res) => {
|
router.put('/branding', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -71,13 +71,13 @@ router.post('/gallery/verify', [
|
|||||||
|
|
||||||
const { slug, password, recaptchaToken } = req.body;
|
const { slug, password, recaptchaToken } = req.body;
|
||||||
|
|
||||||
// Verify reCAPTCHA
|
// Verify reCAPTCHA - temporarily disabled for testing
|
||||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
// const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||||
if (!recaptchaValid) {
|
// if (!recaptchaValid) {
|
||||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
// 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) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
}
|
}
|
||||||
@@ -108,20 +108,25 @@ router.post('/gallery/verify', [
|
|||||||
type: 'gallery'
|
type: 'gallery'
|
||||||
}, process.env.JWT_SECRET, { expiresIn: '24h' });
|
}, 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({
|
res.json({
|
||||||
token,
|
token,
|
||||||
event: {
|
event: 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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Verification failed' });
|
res.status(500).json({ error: 'Verification failed' });
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ router.post('/', adminAuth, [
|
|||||||
counter++;
|
counter++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate share link
|
// Generate share link (just slug/token, not full URL)
|
||||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||||
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
|
const shareLink = `${slug}/${shareToken}`;
|
||||||
|
|
||||||
// Hash password
|
// Hash password
|
||||||
const password_hash = await bcrypt.hash(password, 10);
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ const path = require('path');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const watermarkService = require('../services/watermarkService');
|
const watermarkService = require('../services/watermarkService');
|
||||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
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
|
// 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
|
// Verify share token
|
||||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
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 { slug, token } = req.params;
|
||||||
|
|
||||||
const event = await db('events')
|
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')
|
.select('id', 'share_link')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -45,8 +48,9 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
const { token } = req.query;
|
const { token } = req.query;
|
||||||
|
|
||||||
const event = await db('events')
|
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')
|
.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();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -78,7 +82,11 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
is_active: event.is_active,
|
is_active: event.is_active,
|
||||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||||
requires_password: true,
|
requires_password: true,
|
||||||
color_theme: event.color_theme
|
color_theme: event.color_theme,
|
||||||
|
allow_downloads: event.allow_downloads !== false,
|
||||||
|
disable_right_click: event.disable_right_click === true,
|
||||||
|
watermark_downloads: event.watermark_downloads === true,
|
||||||
|
watermark_text: event.watermark_text
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching gallery info:', error);
|
console.error('Error fetching gallery info:', error);
|
||||||
@@ -89,24 +97,41 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
// Get all photos
|
// Get all photos
|
||||||
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
// First get all photos
|
||||||
const photos = await db('photos')
|
const photos = await db('photos')
|
||||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
|
||||||
.where('photos.event_id', req.event.id)
|
.where('photos.event_id', req.event.id)
|
||||||
.select(
|
.select('photos.*')
|
||||||
'photos.*',
|
|
||||||
'photo_categories.name as category_name',
|
|
||||||
'photo_categories.slug as category_slug'
|
|
||||||
)
|
|
||||||
.orderBy('photos.uploaded_at', 'desc');
|
.orderBy('photos.uploaded_at', 'desc');
|
||||||
|
|
||||||
// Get all categories for this event
|
// Then get comment counts separately
|
||||||
const categories = await db('photo_categories')
|
const commentCounts = await db('photo_feedback')
|
||||||
.where(function() {
|
.whereIn('photo_id', photos.map(p => p.id))
|
||||||
this.where('is_global', formatBoolean(true))
|
.where('feedback_type', 'comment')
|
||||||
.orWhere('event_id', req.event.id);
|
.where('is_approved', true)
|
||||||
})
|
.where('is_hidden', false)
|
||||||
.orderBy('is_global', 'desc')
|
.groupBy('photo_id')
|
||||||
.orderBy('name', 'asc');
|
.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
|
// Log view
|
||||||
await db('access_logs').insert({
|
await db('access_logs').insert({
|
||||||
@@ -116,6 +141,23 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
action: 'view'
|
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({
|
res.json({
|
||||||
event: {
|
event: {
|
||||||
id: req.event.id,
|
id: req.event.id,
|
||||||
@@ -125,26 +167,45 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
welcome_message: req.event.welcome_message,
|
welcome_message: req.event.welcome_message,
|
||||||
color_theme: req.event.color_theme,
|
color_theme: req.event.color_theme,
|
||||||
expires_at: req.event.expires_at,
|
expires_at: req.event.expires_at,
|
||||||
hero_photo_id: req.event.hero_photo_id
|
hero_photo_id: req.event.hero_photo_id,
|
||||||
|
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,
|
||||||
|
...protectionSettings
|
||||||
},
|
},
|
||||||
categories: categories.map(cat => ({
|
categories: categories,
|
||||||
id: cat.id,
|
photos: photos.map(photo => {
|
||||||
name: cat.name,
|
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||||
slug: cat.slug,
|
const photoUrl = useJwtUrl ?
|
||||||
is_global: cat.is_global
|
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||||
})),
|
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
||||||
photos: photos.map(photo => ({
|
|
||||||
id: photo.id,
|
console.log(`[Photo ${photo.id}] Protection: ${protectionSettings.protection_level}, Use JWT: ${useJwtUrl}, URL: ${photoUrl}`);
|
||||||
filename: photo.filename,
|
|
||||||
url: `/api/gallery/${req.params.slug}/photo/${photo.id}`,
|
return {
|
||||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
id: photo.id,
|
||||||
type: photo.type,
|
filename: photo.filename,
|
||||||
category_id: photo.category_id,
|
url: photoUrl,
|
||||||
category_name: photo.category_name,
|
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
||||||
category_slug: photo.category_slug,
|
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
|
||||||
size: photo.size_bytes,
|
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||||
uploaded_at: photo.uploaded_at
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching photos:', error);
|
console.error('Error fetching photos:', error);
|
||||||
@@ -157,6 +218,11 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
|||||||
try {
|
try {
|
||||||
const { photoId } = req.params;
|
const { photoId } = req.params;
|
||||||
|
|
||||||
|
// Check if downloads are allowed for this event
|
||||||
|
if (req.event.allow_downloads === false) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
const photo = await db('photos')
|
const photo = await db('photos')
|
||||||
.where({ id: photoId, event_id: req.event.id })
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
.first();
|
.first();
|
||||||
@@ -177,7 +243,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
|||||||
photo_id: photoId
|
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
|
// Get watermark settings
|
||||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
@@ -205,25 +281,25 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
|||||||
// Download all photos as ZIP
|
// Download all photos as ZIP
|
||||||
router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Fetch photos with category information
|
// Check if downloads are allowed for this event
|
||||||
|
if (req.event.allow_downloads === false) {
|
||||||
|
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch photos
|
||||||
const photos = await db('photos')
|
const photos = await db('photos')
|
||||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
|
||||||
.where('photos.event_id', req.event.id)
|
.where('photos.event_id', req.event.id)
|
||||||
.select(
|
.select('photos.*')
|
||||||
'photos.*',
|
.orderBy('photos.type', 'asc')
|
||||||
'photo_categories.name as category_name',
|
|
||||||
'photo_categories.slug as category_slug'
|
|
||||||
)
|
|
||||||
.orderBy('photo_categories.name', 'asc')
|
|
||||||
.orderBy('photos.uploaded_at', 'desc');
|
.orderBy('photos.uploaded_at', 'desc');
|
||||||
|
|
||||||
if (photos.length === 0) {
|
if (photos.length === 0) {
|
||||||
return res.status(404).json({ error: 'No photos found' });
|
return res.status(404).json({ error: 'No photos found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count unique categories (excluding null)
|
// Count unique types
|
||||||
const uniqueCategories = new Set(photos.filter(p => p.category_id).map(p => p.category_id)).size;
|
const uniqueTypes = new Set(photos.map(p => p.type)).size;
|
||||||
const hasMultipleCategories = uniqueCategories > 1;
|
const hasMultipleTypes = uniqueTypes > 1;
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'application/zip');
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
|
||||||
@@ -240,19 +316,24 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
|||||||
|
|
||||||
// Add photos to archive
|
// Add photos to archive
|
||||||
for (const photo of photos) {
|
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
|
// Determine the file name in the archive
|
||||||
let archiveName;
|
let archiveName;
|
||||||
if (hasMultipleCategories) {
|
if (hasMultipleTypes) {
|
||||||
if (photo.category_name) {
|
// Use photo type as folder
|
||||||
// Use category name as folder (sanitize for filesystem)
|
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
|
||||||
const folderName = photo.category_name.replace(/[^a-zA-Z0-9-_ ]/g, '').trim();
|
archiveName = path.join(folderName, photo.filename);
|
||||||
archiveName = path.join(folderName, photo.filename);
|
|
||||||
} else {
|
|
||||||
// Put uncategorized photos in 'Uncategorized' folder
|
|
||||||
archiveName = path.join('Uncategorized', photo.filename);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No folders, just the filename
|
// No folders, just the filename
|
||||||
archiveName = photo.filename;
|
archiveName = photo.filename;
|
||||||
@@ -282,77 +363,173 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// View single photo (with watermark if enabled)
|
// Test route
|
||||||
router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/photo-test/:photoId',
|
||||||
try {
|
verifyGalleryAccess,
|
||||||
const { photoId } = req.params;
|
(req, res) => {
|
||||||
|
console.log('TEST ROUTE EXECUTED!');
|
||||||
const photo = await db('photos')
|
res.json({ message: 'Test route works!', photoId: req.params.photoId });
|
||||||
.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' });
|
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
|
// 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
|
// Serve thumbnail
|
||||||
router.get('/:slug/thumbnail/:photoId', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/thumbnail/:photoId',
|
||||||
try {
|
verifyGalleryAccess,
|
||||||
const { photoId } = req.params;
|
async (req, res) => {
|
||||||
|
|
||||||
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 {
|
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) {
|
} 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.json({
|
||||||
res.setHeader('Content-Type', 'image/jpeg');
|
feedback_enabled: settings.feedback_enabled || false,
|
||||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
allow_ratings: settings.allow_ratings,
|
||||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
allow_likes: settings.allow_likes,
|
||||||
|
allow_comments: settings.allow_comments,
|
||||||
// Send file
|
allow_favorites: settings.allow_favorites,
|
||||||
res.sendFile(path.resolve(thumbPath));
|
show_feedback_to_guests: settings.show_feedback_to_guests
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error serving thumbnail:', error);
|
console.error('Error fetching feedback settings:', error);
|
||||||
res.status(500).json({ error: 'Failed to serve thumbnail' });
|
res.status(500).json({ error: 'Failed to fetch feedback settings' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const { db } = require('../database/db');
|
|||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||||
const watermarkService = require('../services/watermarkService');
|
const watermarkService = require('../services/watermarkService');
|
||||||
|
const secureImageService = require('../services/secureImageService');
|
||||||
const { getStoragePath } = require('../config/storage');
|
const { getStoragePath } = require('../config/storage');
|
||||||
const crypto = require('crypto');
|
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) => {
|
router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { photoId } = req.params;
|
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
|
// Get photo details
|
||||||
const photo = await db('photos')
|
const photo = await db('photos')
|
||||||
@@ -65,35 +75,123 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
|
|||||||
if (!photo) {
|
if (!photo) {
|
||||||
return res.status(404).json({ error: 'Photo not found' });
|
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
|
// Log access
|
||||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
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
|
// Build full path to photo
|
||||||
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
|
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
|
||||||
|
|
||||||
// Apply watermark if enabled
|
// Process image with protection
|
||||||
const imageBuffer = await watermarkService.applyWatermark(photoPath, watermarkSettings);
|
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({
|
res.set({
|
||||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||||
'Content-Length': imageBuffer.length,
|
'Content-Length': finalImage.length,
|
||||||
'Cache-Control': 'private, max-age=3600',
|
'Cache-Control': 'private, no-cache, no-store, must-revalidate',
|
||||||
'X-Content-Type-Options': 'nosniff'
|
'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
|
// Send the protected image
|
||||||
res.send(imageBuffer);
|
res.send(finalImage);
|
||||||
|
|
||||||
} catch (error) {
|
} 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' });
|
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) => {
|
router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -120,7 +120,7 @@ class FeedbackService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Insert new feedback
|
// Insert new feedback
|
||||||
const [id] = await db('photo_feedback').insert({
|
const result = await db('photo_feedback').insert({
|
||||||
photo_id: photoId,
|
photo_id: photoId,
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
feedback_type,
|
feedback_type,
|
||||||
@@ -134,7 +134,9 @@ class FeedbackService {
|
|||||||
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
|
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
|
||||||
created_at: new Date(),
|
created_at: new Date(),
|
||||||
updated_at: new Date()
|
updated_at: new Date()
|
||||||
});
|
}).returning('id');
|
||||||
|
|
||||||
|
const id = result[0]?.id || result[0];
|
||||||
|
|
||||||
// Update photo stats
|
// Update photo stats
|
||||||
await this.updatePhotoFeedbackStats(photoId);
|
await this.updatePhotoFeedbackStats(photoId);
|
||||||
@@ -175,7 +177,7 @@ class FeedbackService {
|
|||||||
|
|
||||||
const feedback = await query
|
const feedback = await query
|
||||||
.orderBy('created_at', 'desc')
|
.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;
|
return feedback;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,21 +2,69 @@ const sharp = require('sharp');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
|
||||||
// Configure sharp for better memory management with large batches
|
// Configure sharp for better memory management with large batches
|
||||||
sharp.cache(false); // Disable cache to prevent memory buildup
|
sharp.cache(false); // Disable cache to prevent memory buildup
|
||||||
sharp.concurrency(2); // Limit concurrent operations
|
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 getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
|
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 = {}) {
|
async function generateThumbnail(imagePath, options = {}) {
|
||||||
const filename = path.basename(imagePath);
|
const filename = path.basename(imagePath);
|
||||||
const thumbnailFilename = `thumb_${filename}`;
|
const thumbnailFilename = `thumb_${filename}`;
|
||||||
const thumbnailDir = getThumbnailPath();
|
const thumbnailDir = getThumbnailPath();
|
||||||
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
|
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
|
||||||
|
|
||||||
|
// Get thumbnail settings
|
||||||
|
const settings = await getThumbnailSettings();
|
||||||
|
|
||||||
// Ensure thumbnail directory exists
|
// Ensure thumbnail directory exists
|
||||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
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');
|
throw new Error('Invalid image metadata - file may be incomplete');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate thumbnail with memory-efficient settings and error handling
|
// Create sharp instance with memory-efficient settings
|
||||||
await sharp(imagePath, {
|
let sharpInstance = sharp(imagePath, {
|
||||||
limitInputPixels: 268402689, // ~16k x 16k max
|
limitInputPixels: 268402689, // ~16k x 16k max
|
||||||
sequentialRead: true, // More memory efficient for large images
|
sequentialRead: true, // More memory efficient for large images
|
||||||
failOnError: false // Don't fail on minor issues
|
failOnError: false // Don't fail on minor issues
|
||||||
})
|
});
|
||||||
.resize(THUMBNAIL_WIDTH, null, {
|
|
||||||
withoutEnlargement: true,
|
// Apply resize with configured settings
|
||||||
fit: 'inside'
|
// For square thumbnails with 'cover' fit, we crop to center
|
||||||
})
|
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
|
||||||
.jpeg({
|
withoutEnlargement: true,
|
||||||
quality: 80,
|
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
|
progressive: true, // Progressive JPEG for better loading
|
||||||
mozjpeg: true // Better compression
|
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
|
// Verify the thumbnail was created successfully
|
||||||
const stats = await fs.stat(thumbnailPath);
|
const stats = await fs.stat(thumbnailPath);
|
||||||
|
|||||||
@@ -21,39 +21,29 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
const trx = await db.transaction();
|
const trx = await db.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get category info if provided
|
// Count existing photos to generate sequence number
|
||||||
let category = null;
|
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
const parsedCategoryId = categoryId ? parseInt(categoryId) : null;
|
let photoType = 'individual'; // default type
|
||||||
|
|
||||||
if (parsedCategoryId) {
|
// If categoryId is provided and matches photo types, use it as type
|
||||||
// Get category and update counter
|
if (categoryId === 'collage') {
|
||||||
category = await trx('photo_categories')
|
photoType = 'collage';
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Generate new filename
|
||||||
const extension = path.extname(file.originalname);
|
const extension = path.extname(file.originalname);
|
||||||
|
const categoryName = photoType === 'collage' ? 'collages' : 'individual';
|
||||||
const newFilename = generatePhotoFilename(
|
const newFilename = generatePhotoFilename(
|
||||||
event.event_name,
|
event.event_name,
|
||||||
category ? category.name : 'uncategorized',
|
categoryName,
|
||||||
counter,
|
counter,
|
||||||
extension
|
extension
|
||||||
);
|
);
|
||||||
@@ -81,10 +71,8 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
filename: newFilename,
|
filename: newFilename,
|
||||||
path: relativePath,
|
path: relativePath,
|
||||||
thumbnail_path: relativeThumbPath,
|
thumbnail_path: relativeThumbPath,
|
||||||
category_id: parsedCategoryId || null,
|
type: photoType,
|
||||||
type: 'individual',
|
size_bytes: file.size
|
||||||
size_bytes: file.size,
|
|
||||||
uploaded_by: uploadedBy
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Commit transaction
|
// Commit transaction
|
||||||
@@ -94,8 +82,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
id: photoId,
|
id: photoId,
|
||||||
filename: newFilename,
|
filename: newFilename,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
category_id: parsedCategoryId || null,
|
type: photoType
|
||||||
uploaded_by: uploadedBy
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error processing file ${file.originalname}:`, error);
|
console.error(`Error processing file ${file.originalname}:`, error);
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -152,18 +152,25 @@ const validateFeedbackSubmission = [
|
|||||||
|
|
||||||
body('guest_name')
|
body('guest_name')
|
||||||
.optional()
|
.optional()
|
||||||
.trim()
|
.custom((value) => {
|
||||||
.isLength({ max: 100 })
|
// Allow empty or whitespace-only strings
|
||||||
.withMessage('Name must be less than 100 characters')
|
if (!value || value.trim() === '') return true;
|
||||||
.matches(/^[a-zA-Z0-9\s\-'.]+$/)
|
// If not empty, check length and pattern
|
||||||
.withMessage('Name contains invalid characters'),
|
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')
|
body('guest_email')
|
||||||
.optional()
|
.optional()
|
||||||
.trim()
|
.custom((value) => {
|
||||||
.isEmail()
|
// Allow empty or whitespace-only strings
|
||||||
.normalizeEmail()
|
if (!value || value.trim() === '') return true;
|
||||||
.withMessage('Invalid email address')
|
// If not empty, validate as email
|
||||||
|
if (!validator.isEmail(value.trim())) throw new Error('Invalid email address');
|
||||||
|
return true;
|
||||||
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: ./backend
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picpeak-backend-dev
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=development
|
|
||||||
- PORT=3001
|
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
|
||||||
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
|
|
||||||
- DATABASE_CLIENT=pg
|
|
||||||
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
|
|
||||||
- DB_TYPE=postgresql
|
|
||||||
- DB_HOST=postgres
|
|
||||||
- DB_PORT=5432
|
|
||||||
- DB_USER=${DB_USER}
|
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
|
||||||
- DB_NAME=${DB_NAME}
|
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
|
||||||
- SMTP_PORT=${SMTP_PORT}
|
|
||||||
- SMTP_SECURE=${SMTP_SECURE:-false}
|
|
||||||
- SMTP_USER=${SMTP_USER}
|
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
|
||||||
- EMAIL_FROM=${EMAIL_FROM:-noreply@picpeak.local}
|
|
||||||
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
|
|
||||||
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
|
|
||||||
- TZ=${TZ:-UTC}
|
|
||||||
- STORAGE_PATH=/app/storage
|
|
||||||
volumes:
|
|
||||||
- ./events:/app/events
|
|
||||||
- ./data:/app/data
|
|
||||||
- ./logs:/app/logs
|
|
||||||
- ./backup:/backup
|
|
||||||
- ./storage:/app/storage
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3001/api/health"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 40s
|
|
||||||
networks:
|
|
||||||
- picpeak-network
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
container_name: picpeak-postgres-dev
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=${DB_USER}
|
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
|
||||||
- POSTGRES_DB=${DB_NAME}
|
|
||||||
- PGDATA=/var/lib/postgresql/data/pgdata
|
|
||||||
- TZ=${TZ:-UTC}
|
|
||||||
volumes:
|
|
||||||
- postgres-data:/var/lib/postgresql/data
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 20s
|
|
||||||
networks:
|
|
||||||
- picpeak-network
|
|
||||||
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: picpeak-redis-dev
|
|
||||||
restart: unless-stopped
|
|
||||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-picpeak_redis_pass}
|
|
||||||
volumes:
|
|
||||||
- redis-data:/data
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
networks:
|
|
||||||
- picpeak-network
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build:
|
|
||||||
context: ./frontend
|
|
||||||
dockerfile: Dockerfile.dev
|
|
||||||
args:
|
|
||||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:3001/api}
|
|
||||||
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
|
||||||
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
|
||||||
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
|
||||||
container_name: picpeak-frontend-dev
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- NODE_ENV=development
|
|
||||||
volumes:
|
|
||||||
- ./frontend:/app
|
|
||||||
- /app/node_modules
|
|
||||||
ports:
|
|
||||||
- "3000:3005"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3005"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
networks:
|
|
||||||
- picpeak-network
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres-data:
|
|
||||||
driver: local
|
|
||||||
redis-data:
|
|
||||||
driver: local
|
|
||||||
|
|
||||||
networks:
|
|
||||||
picpeak-network:
|
|
||||||
driver: bridge
|
|
||||||
@@ -1,125 +1,125 @@
|
|||||||
# docker-compose.yml - Production configuration
|
|
||||||
#
|
|
||||||
# For development, use: docker-compose -f docker-compose.dev.yml up -d
|
|
||||||
# For local customizations, create docker-compose.override.yml (see docker-compose.override.yml.example)
|
|
||||||
#
|
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
image: picpeak-backend:latest
|
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
container_name: picpeak-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=${NODE_ENV:-production}
|
||||||
- PORT=3001
|
- PORT=3001
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- ADMIN_URL=${ADMIN_URL}
|
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
|
||||||
- FRONTEND_URL=${FRONTEND_URL}
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.com}
|
||||||
- BACKEND_URL=${BACKEND_URL}
|
|
||||||
# Database
|
|
||||||
- DATABASE_CLIENT=pg
|
- DATABASE_CLIENT=pg
|
||||||
- DB_HOST=db
|
- DATABASE_URL=postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
|
||||||
|
- DB_TYPE=postgresql
|
||||||
|
- DB_HOST=postgres
|
||||||
- DB_PORT=5432
|
- DB_PORT=5432
|
||||||
- DB_USER=${DB_USER:-picpeak}
|
- DB_USER=${DB_USER}
|
||||||
- DB_PASSWORD=${DB_PASSWORD}
|
- DB_PASSWORD=${DB_PASSWORD}
|
||||||
- DB_NAME=${DB_NAME:-picpeak}
|
- DB_NAME=${DB_NAME}
|
||||||
# Email
|
|
||||||
- SMTP_HOST=${SMTP_HOST}
|
- SMTP_HOST=${SMTP_HOST}
|
||||||
- SMTP_PORT=${SMTP_PORT}
|
- SMTP_PORT=${SMTP_PORT}
|
||||||
- SMTP_SECURE=${SMTP_SECURE}
|
- SMTP_SECURE=${SMTP_SECURE:-false}
|
||||||
- SMTP_USER=${SMTP_USER}
|
- SMTP_USER=${SMTP_USER}
|
||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- EMAIL_FROM=${EMAIL_FROM}
|
- EMAIL_FROM=${EMAIL_FROM:-noreply@picpeak.local}
|
||||||
# Analytics
|
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:3000}
|
||||||
- UMAMI_URL=${UMAMI_URL}
|
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
|
||||||
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
|
- TZ=${TZ:-UTC}
|
||||||
# Storage paths
|
|
||||||
- STORAGE_PATH=/app/storage
|
- STORAGE_PATH=/app/storage
|
||||||
- EVENTS_PATH=/app/storage/events
|
|
||||||
- ARCHIVE_PATH=/app/storage/events/archived
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./storage:/app/storage
|
- ./events:/app/events
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
|
- ./backup:/backup
|
||||||
|
- ./storage:/app/storage
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-3001}:3001"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1:3001/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
networks:
|
networks:
|
||||||
- picpeak
|
- picpeak-network
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: picpeak-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${DB_USER}
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=${DB_NAME}
|
||||||
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
- TZ=${TZ:-UTC}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- picpeak-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: picpeak-redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-picpeak_redis_pass}
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
ports:
|
||||||
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
networks:
|
||||||
|
- picpeak-network
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: picpeak-frontend:latest
|
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
- VITE_API_URL=/api
|
- VITE_API_URL=${VITE_API_URL:-http://localhost:3001/api}
|
||||||
restart: unless-stopped
|
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
||||||
depends_on:
|
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
||||||
- backend
|
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
||||||
networks:
|
container_name: picpeak-frontend
|
||||||
- picpeak
|
|
||||||
|
|
||||||
nginx:
|
|
||||||
image: nginx:alpine
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=${NODE_ENV:-production}
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "${FRONTEND_PORT:-3000}:80"
|
||||||
- "443:443"
|
|
||||||
volumes:
|
|
||||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
|
|
||||||
- ./nginx/sites-enabled:/etc/nginx/sites-enabled
|
|
||||||
- ./certbot/conf:/etc/letsencrypt
|
|
||||||
- ./certbot/www:/var/www/certbot
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- frontend
|
|
||||||
- backend
|
- backend
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://127.0.0.1/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
networks:
|
networks:
|
||||||
- picpeak
|
- picpeak-network
|
||||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
|
||||||
|
|
||||||
certbot:
|
|
||||||
image: certbot/certbot
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
- ./certbot/conf:/etc/letsencrypt
|
|
||||||
- ./certbot/www:/var/www/certbot
|
|
||||||
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
|
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
- POSTGRES_USER=${DB_USER:-picpeak}
|
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
|
||||||
- POSTGRES_DB=${DB_NAME:-picpeak}
|
|
||||||
# Allow connections from any host with password authentication
|
|
||||||
- POSTGRES_HOST_AUTH_METHOD=scram-sha-256
|
|
||||||
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
networks:
|
|
||||||
- picpeak
|
|
||||||
# Allow connections without SSL requirement from Docker network
|
|
||||||
command: postgres -c ssl=off
|
|
||||||
|
|
||||||
umami:
|
|
||||||
image: ghcr.io/umami-software/umami:postgresql-latest
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://${DB_USER:-picpeak}:${DB_PASSWORD}@db:5432/umami
|
|
||||||
DATABASE_TYPE: postgresql
|
|
||||||
HASH_SALT: ${UMAMI_HASH_SALT}
|
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
networks:
|
|
||||||
- picpeak
|
|
||||||
|
|
||||||
networks:
|
|
||||||
picpeak:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres-data:
|
||||||
|
driver: local
|
||||||
|
redis-data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
picpeak-network:
|
||||||
|
driver: bridge
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
# Backend API URL
|
# Backend API URL
|
||||||
# For local development:
|
# For local development with Docker:
|
||||||
VITE_API_URL=http://localhost:3001
|
VITE_API_URL=http://localhost:3001/api
|
||||||
|
|
||||||
|
# For local development without Docker:
|
||||||
|
# VITE_API_URL=http://localhost:3001
|
||||||
|
|
||||||
# For production behind reverse proxy (Traefik, nginx, etc):
|
# For production behind reverse proxy (Traefik, nginx, etc):
|
||||||
# VITE_API_URL=/api
|
# VITE_API_URL=/api
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ server {
|
|||||||
|
|
||||||
# API proxy
|
# API proxy
|
||||||
location /api {
|
location /api {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection 'upgrade';
|
proxy_set_header Connection 'upgrade';
|
||||||
@@ -53,7 +53,7 @@ server {
|
|||||||
|
|
||||||
# Photo serving proxy
|
# Photo serving proxy
|
||||||
location /photos {
|
location /photos {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -67,7 +67,7 @@ server {
|
|||||||
|
|
||||||
# Thumbnail serving proxy
|
# Thumbnail serving proxy
|
||||||
location /thumbnails {
|
location /thumbnails {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -81,7 +81,7 @@ server {
|
|||||||
|
|
||||||
# Uploads serving proxy (logos, favicons, watermarks)
|
# Uploads serving proxy (logos, favicons, watermarks)
|
||||||
location /uploads {
|
location /uploads {
|
||||||
proxy_pass http://backend:3000;
|
proxy_pass http://backend:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.97",
|
"version": "1.0.103",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.0.97",
|
"version": "1.0.103",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^2.26.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.97",
|
"version": "1.0.103",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -80,19 +80,25 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
<Menu className="w-6 h-6" />
|
<Menu className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Date display */}
|
{/* Date display - hidden on small screens */}
|
||||||
<div className="hidden lg:block">
|
<div className="hidden xl:block">
|
||||||
<p className="text-base text-neutral-700">
|
<p className="text-base text-neutral-700">
|
||||||
{format(new Date(), 'PPPP')}
|
{format(new Date(), 'PPPP')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center - Logo and PicPeak text */}
|
{/* Center - Logo and PicPeak text - hidden on small screens to prevent overlap */}
|
||||||
<div className="absolute left-1/2 transform -translate-x-1/2 flex items-center gap-3">
|
<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" />
|
<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>
|
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
|
||||||
</div>
|
</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 */}
|
{/* Right side actions */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { useSessionTimeout } from '../../hooks/useSessionTimeout';
|
|||||||
import { AdminSidebar } from './AdminSidebar';
|
import { AdminSidebar } from './AdminSidebar';
|
||||||
import { AdminHeader } from './AdminHeader';
|
import { AdminHeader } from './AdminHeader';
|
||||||
import { MaintenanceBanner } from './MaintenanceBanner';
|
import { MaintenanceBanner } from './MaintenanceBanner';
|
||||||
|
import { MandatoryPasswordChangeModal } from './MandatoryPasswordChangeModal';
|
||||||
|
|
||||||
export const AdminLayout: React.FC = () => {
|
export const AdminLayout: React.FC = () => {
|
||||||
const { isAuthenticated, isLoading } = useAdminAuth();
|
const { isAuthenticated, isLoading, mustChangePassword } = useAdminAuth();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
// Handle session timeout
|
// Handle session timeout
|
||||||
@@ -31,6 +32,9 @@ export const AdminLayout: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen bg-neutral-50 flex overflow-hidden">
|
<div className="h-screen bg-neutral-50 flex overflow-hidden">
|
||||||
|
{/* Mandatory Password Change Modal */}
|
||||||
|
{mustChangePassword && <MandatoryPasswordChangeModal />}
|
||||||
|
|
||||||
{/* Mobile sidebar backdrop */}
|
{/* Mobile sidebar backdrop */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
@@ -39,19 +43,23 @@ export const AdminLayout: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar - disabled when password change required */}
|
||||||
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
|
||||||
|
<AdminSidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
<div className="flex-1 flex flex-col min-w-0 h-screen">
|
||||||
{/* Header */}
|
{/* Header - disabled when password change required */}
|
||||||
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
|
<div className={mustChangePassword ? 'pointer-events-none opacity-50' : ''}>
|
||||||
|
<AdminHeader onMenuClick={() => setSidebarOpen(true)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Maintenance mode banner */}
|
{/* Maintenance mode banner */}
|
||||||
<MaintenanceBanner />
|
<MaintenanceBanner />
|
||||||
|
|
||||||
{/* Page content */}
|
{/* 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">
|
<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 />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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 { toast } from 'react-toastify';
|
||||||
|
|
||||||
import { AdminPhoto } from '../../services/photos.service';
|
import { AdminPhoto } from '../../services/photos.service';
|
||||||
@@ -246,6 +246,24 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useState } from 'react';
|
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 { format } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { AdminPhoto } from '../../services/photos.service';
|
import { AdminPhoto } from '../../services/photos.service';
|
||||||
import { photosService } from '../../services/photos.service';
|
import { photosService } from '../../services/photos.service';
|
||||||
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||||
|
|
||||||
@@ -28,9 +30,21 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
|
const [showCategoryMenu, setShowCategoryMenu] = useState(false);
|
||||||
|
const [expandedComments, setExpandedComments] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const currentPhoto = photos[currentIndex];
|
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 = () => {
|
const goToPrevious = () => {
|
||||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
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(() => {
|
React.useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
@@ -256,6 +294,177 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Navigation info */}
|
||||||
<div className="mt-6 pt-6 border-t border-neutral-700">
|
<div className="mt-6 pt-6 border-t border-neutral-700">
|
||||||
<p className="text-neutral-400 text-sm text-center">
|
<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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
|
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
|
|
||||||
export const CategoryManager: React.FC = () => {
|
export const CategoryManager: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
@@ -24,12 +26,12 @@ export const CategoryManager: React.FC = () => {
|
|||||||
categoriesService.createCategory({ name, is_global: true }),
|
categoriesService.createCategory({ name, is_global: true }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
||||||
toast.success('Category created successfully');
|
toast.success(t('categories.categoryCreatedSuccess'));
|
||||||
setNewCategoryName('');
|
setNewCategoryName('');
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
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),
|
categoriesService.updateCategory(id, name),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
||||||
toast.success('Category updated successfully');
|
toast.success(t('toast.categoryUpdated'));
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
setEditingName('');
|
setEditingName('');
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
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,
|
mutationFn: categoriesService.deleteCategory,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
|
||||||
toast.success('Category deleted successfully');
|
toast.success(t('categories.categoryDeletedSuccess'));
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
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) => {
|
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);
|
deleteMutation.mutate(category.id);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -99,7 +101,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center">
|
<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 && (
|
{!isAdding && (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -107,7 +109,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
onClick={() => setIsAdding(true)}
|
onClick={() => setIsAdding(true)}
|
||||||
leftIcon={<Plus className="w-4 h-4" />}
|
leftIcon={<Plus className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
Add Category
|
{t('categories.addCategory')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +122,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
value={newCategoryName}
|
value={newCategoryName}
|
||||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleCreate()}
|
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"
|
className="flex-1 px-3 py-2 border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -133,7 +135,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
{createMutation.isPending ? (
|
{createMutation.isPending ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
'Create'
|
t('common.save')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -144,7 +146,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
setNewCategoryName('');
|
setNewCategoryName('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -153,7 +155,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{categories.length === 0 ? (
|
{categories.length === 0 ? (
|
||||||
<p className="text-neutral-500 text-center py-8">
|
<p className="text-neutral-500 text-center py-8">
|
||||||
No categories yet. Create your first category to organize photos.
|
{t('categories.noCategoriesYet')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
categories.map((category) => (
|
categories.map((category) => (
|
||||||
@@ -183,7 +185,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
{updateMutation.isPending ? (
|
{updateMutation.isPending ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
'Save'
|
t('common.save')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -191,7 +193,7 @@ export const CategoryManager: React.FC = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={cancelEdit}
|
onClick={cancelEdit}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -204,14 +206,14 @@ export const CategoryManager: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => startEdit(category)}
|
onClick={() => startEdit(category)}
|
||||||
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 rounded transition-colors"
|
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" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(category)}
|
onClick={() => handleDelete(category)}
|
||||||
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 rounded transition-colors"
|
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}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
{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 { X, Lock, Eye, EyeOff, AlertCircle } from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Button, Input, Card } from '../common';
|
import { Button, Input, Card } from '../common';
|
||||||
import { adminService } from '../../services/admin.service';
|
import { adminService } from '../../services/admin.service';
|
||||||
@@ -12,6 +13,7 @@ interface PasswordChangeModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen, onClose }) => {
|
export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
@@ -27,7 +29,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
const changePasswordMutation = useMutation({
|
const changePasswordMutation = useMutation({
|
||||||
mutationFn: adminService.changePassword,
|
mutationFn: adminService.changePassword,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Password changed successfully');
|
toast.success(t('passwordChange.success'));
|
||||||
onClose();
|
onClose();
|
||||||
// Reset form
|
// Reset form
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -41,7 +43,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
if (error.response?.data?.error) {
|
if (error.response?.data?.error) {
|
||||||
toast.error(error.response.data.error);
|
toast.error(error.response.data.error);
|
||||||
} else {
|
} 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> = {};
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
if (!formData.currentPassword) {
|
if (!formData.currentPassword) {
|
||||||
newErrors.currentPassword = 'Current password is required';
|
newErrors.currentPassword = t('passwordChange.currentRequired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.newPassword) {
|
if (!formData.newPassword) {
|
||||||
newErrors.newPassword = 'New password is required';
|
newErrors.newPassword = t('passwordChange.newRequired');
|
||||||
} else if (formData.newPassword.length < 6) {
|
} else if (formData.newPassword.length < 6) {
|
||||||
newErrors.newPassword = 'Password must be at least 6 characters';
|
newErrors.newPassword = t('passwordChange.minLengthError');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.confirmPassword) {
|
if (!formData.confirmPassword) {
|
||||||
newErrors.confirmPassword = 'Please confirm your new password';
|
newErrors.confirmPassword = t('passwordChange.confirmRequired');
|
||||||
} else if (formData.newPassword !== formData.confirmPassword) {
|
} else if (formData.newPassword !== formData.confirmPassword) {
|
||||||
newErrors.confirmPassword = 'Passwords do not match';
|
newErrors.confirmPassword = t('passwordChange.noMatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.currentPassword === formData.newPassword) {
|
if (formData.currentPassword === formData.newPassword) {
|
||||||
newErrors.newPassword = 'New password must be different from current password';
|
newErrors.newPassword = t('passwordChange.mustBeDifferent');
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
@@ -101,7 +103,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||||
@@ -114,7 +116,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
{/* Current Password */}
|
{/* Current Password */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
Current Password
|
{t('passwordChange.currentPassword')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
@@ -123,7 +125,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
value={formData.currentPassword}
|
value={formData.currentPassword}
|
||||||
onChange={handleInputChange('currentPassword')}
|
onChange={handleInputChange('currentPassword')}
|
||||||
error={errors.currentPassword}
|
error={errors.currentPassword}
|
||||||
placeholder="Enter current password"
|
placeholder={t('passwordChange.currentPasswordPlaceholder')}
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -142,7 +144,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
{/* New Password */}
|
{/* New Password */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
New Password
|
{t('passwordChange.newPassword')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
@@ -151,7 +153,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
value={formData.newPassword}
|
value={formData.newPassword}
|
||||||
onChange={handleInputChange('newPassword')}
|
onChange={handleInputChange('newPassword')}
|
||||||
error={errors.newPassword}
|
error={errors.newPassword}
|
||||||
placeholder="Enter new password"
|
placeholder={t('passwordChange.newPasswordPlaceholder')}
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -170,7 +172,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
{/* Confirm Password */}
|
{/* Confirm Password */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
Confirm New Password
|
{t('passwordChange.confirmPassword')}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
@@ -179,7 +181,7 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
value={formData.confirmPassword}
|
value={formData.confirmPassword}
|
||||||
onChange={handleInputChange('confirmPassword')}
|
onChange={handleInputChange('confirmPassword')}
|
||||||
error={errors.confirmPassword}
|
error={errors.confirmPassword}
|
||||||
placeholder="Confirm new password"
|
placeholder={t('passwordChange.confirmPasswordPlaceholder')}
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -200,10 +202,10 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
<div className="text-sm text-blue-800">
|
<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">
|
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||||
<li>At least 6 characters long</li>
|
<li>{t('passwordChange.minLength')}</li>
|
||||||
<li>Must be different from current password</li>
|
<li>{t('passwordChange.mustDiffer')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,14 +218,14 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Cancel
|
{t('passwordChange.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
isLoading={changePasswordMutation.isPending}
|
isLoading={changePasswordMutation.isPending}
|
||||||
>
|
>
|
||||||
Change Password
|
{t('passwordChange.title')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ interface PhotoFiltersProps {
|
|||||||
categories: Array<{ id: number; name: string; slug: string }>;
|
categories: Array<{ id: number; name: string; slug: string }>;
|
||||||
selectedCategory: number | null | undefined;
|
selectedCategory: number | null | undefined;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
sortBy: 'date' | 'name' | 'size';
|
sortBy: 'date' | 'name' | 'size' | 'rating';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
onCategoryChange: (categoryId: number | null | undefined) => void;
|
onCategoryChange: (categoryId: number | null | undefined) => void;
|
||||||
onSearchChange: (search: string) => 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> = ({
|
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
||||||
@@ -63,12 +63,13 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
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"
|
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="date">Sort by Date</option>
|
||||||
<option value="name">Sort by Name</option>
|
<option value="name">Sort by Name</option>
|
||||||
<option value="size">Sort by Size</option>
|
<option value="size">Sort by Size</option>
|
||||||
|
<option value="rating">Sort by Rating</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
});
|
});
|
||||||
|
|
||||||
totalUploaded += chunk.length;
|
totalUploaded += chunk.length;
|
||||||
console.log(`Chunk ${chunkIndex + 1}/${chunks.length} uploaded:`, response.data);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
|
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
|
||||||
failedFiles.push(...chunk.map(f => f.name));
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -28,4 +28,5 @@ export { BackupConfiguration } from './BackupConfiguration';
|
|||||||
export { BackupHistory } from './BackupHistory';
|
export { BackupHistory } from './BackupHistory';
|
||||||
export { RestoreWizard } from './RestoreWizard';
|
export { RestoreWizard } from './RestoreWizard';
|
||||||
export { FeedbackSettings } from './FeedbackSettings';
|
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) {
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||||
console.error('Error caught by boundary:', error, errorInfo);
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error('Component stack:', errorInfo.componentStack);
|
console.error('Error caught by boundary:', error, errorInfo);
|
||||||
console.error('Error message:', error.message);
|
console.error('Component stack:', errorInfo.componentStack);
|
||||||
console.error('Error stack:', error.stack);
|
console.error('Error message:', error.message);
|
||||||
|
console.error('Error stack:', error.stack);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReset = () => {
|
handleReset = () => {
|
||||||
@@ -79,7 +81,9 @@ export class PageErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
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 = () => {
|
handleReset = () => {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { WifiOff, Wifi } from 'lucide-react';
|
import { WifiOff, Wifi } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { cn } from '../../lib/utils';
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
export const OfflineIndicator: React.FC = () => {
|
export const OfflineIndicator: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
const [isOnline, setIsOnline] = useState(navigator.onLine);
|
||||||
const [showIndicator, setShowIndicator] = useState(false);
|
const [showIndicator, setShowIndicator] = useState(false);
|
||||||
|
|
||||||
@@ -56,12 +58,12 @@ export const OfflineIndicator: React.FC = () => {
|
|||||||
{isOnline ? (
|
{isOnline ? (
|
||||||
<>
|
<>
|
||||||
<Wifi className="w-5 h-5" />
|
<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" />
|
<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>
|
</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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,4 +16,7 @@ export { SkipLink } from './SkipLink';
|
|||||||
export { DynamicFavicon } from './DynamicFavicon';
|
export { DynamicFavicon } from './DynamicFavicon';
|
||||||
export { LanguageSelector } from './LanguageSelector';
|
export { LanguageSelector } from './LanguageSelector';
|
||||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
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 React from 'react';
|
||||||
import { Download, X } from 'lucide-react';
|
import { Download, X } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface DownloadProgressProps {
|
interface DownloadProgressProps {
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
@@ -14,6 +15,8 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
|
|||||||
fileName,
|
fileName,
|
||||||
onCancel,
|
onCancel,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!isDownloading) return null;
|
if (!isDownloading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -22,7 +25,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Download className="w-5 h-5 text-primary-600 animate-bounce" />
|
<Download className="w-5 h-5 text-primary-600 animate-bounce" />
|
||||||
<div>
|
<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 && (
|
{fileName && (
|
||||||
<p className="text-xs text-neutral-500 truncate max-w-[200px]">{fileName}</p>
|
<p className="text-xs text-neutral-500 truncate max-w-[200px]">{fileName}</p>
|
||||||
)}
|
)}
|
||||||
@@ -46,7 +49,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{progress > 0 && (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface GalleryLayoutProps {
|
|||||||
showDownloadAll?: boolean;
|
showDownloadAll?: boolean;
|
||||||
onDownloadAll?: () => void;
|
onDownloadAll?: () => void;
|
||||||
isDownloading?: boolean;
|
isDownloading?: boolean;
|
||||||
|
isExpired?: boolean;
|
||||||
headerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
menuButton?: React.ReactNode;
|
menuButton?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -42,6 +43,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
showDownloadAll = false,
|
showDownloadAll = false,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
isDownloading = false,
|
isDownloading = false,
|
||||||
|
isExpired = false,
|
||||||
headerExtra,
|
headerExtra,
|
||||||
menuButton,
|
menuButton,
|
||||||
children,
|
children,
|
||||||
|
|||||||