Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 69538b86ea | |||
| b76e45cb54 | |||
| 5b5e431b08 | |||
| 07759a0e40 | |||
| 31fd64c83c | |||
| 775c5159ea | |||
| 8f297e25c4 | |||
| ccb65b892b | |||
| 52f8f1f738 | |||
| e731e7b47c | |||
| 2bccb1a439 | |||
| df10fc677e |
+10
-7
@@ -7,9 +7,9 @@ This guide covers multiple deployment options for PicPeak, from simple local set
|
||||
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
||||
chmod +x setup.sh && \
|
||||
sudo ./setup.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
This automated script handles everything including:
|
||||
@@ -219,14 +219,17 @@ Update `.env` with:
|
||||
- **URL Configuration** (for backend CORS):
|
||||
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
|
||||
Notes:
|
||||
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
|
||||
- Always include the scheme (`http://` or `https://`).
|
||||
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
|
||||
|
||||
#### Authentication Security
|
||||
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
|
||||
|
||||
#### External Database Example
|
||||
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
|
||||
|
||||
@@ -424,10 +427,10 @@ If you lose your admin credentials after the first login, you'll need to manuall
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./setup.sh --native --force-admin-password-reset
|
||||
sudo ./picpeak-setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
sudo ./setup.sh --docker --force-admin-password-reset
|
||||
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
|
||||
```
|
||||
|
||||
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||
|
||||
@@ -85,6 +85,8 @@ Note on Docker file permissions (PUID/PGID)
|
||||
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
|
||||
- 📚 [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints
|
||||
- 🛠️ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
|
||||
+14
-14
@@ -8,9 +8,9 @@ This guide provides easy installation instructions for PicPeak on Linux servers
|
||||
|
||||
```bash
|
||||
# Download and run the unified setup script
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
||||
chmod +x setup.sh && \
|
||||
sudo ./setup.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will automatically detect your environment and recommend the best installation method.
|
||||
@@ -21,7 +21,7 @@ The script will automatically detect your environment and recommend the best ins
|
||||
Best for: Most users, easy updates, isolated environment
|
||||
|
||||
```bash
|
||||
sudo ./setup.sh --docker
|
||||
sudo ./picpeak-setup.sh --docker
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
@@ -38,7 +38,7 @@ sudo ./setup.sh --docker
|
||||
Best for: Resource-constrained systems, Raspberry Pi, direct control
|
||||
|
||||
```bash
|
||||
sudo ./setup.sh --native
|
||||
sudo ./picpeak-setup.sh --native
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
@@ -73,7 +73,7 @@ sudo ./setup.sh --native
|
||||
|
||||
### Interactive Mode (Default)
|
||||
```bash
|
||||
sudo ./setup.sh
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will prompt you to choose:
|
||||
@@ -87,7 +87,7 @@ The script will prompt you to choose:
|
||||
|
||||
#### Docker with full configuration:
|
||||
```bash
|
||||
sudo ./setup.sh --docker --unattended \
|
||||
sudo ./picpeak-setup.sh --docker --unattended \
|
||||
--domain photos.example.com \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123 \
|
||||
@@ -100,7 +100,7 @@ sudo ./setup.sh --docker --unattended \
|
||||
|
||||
#### Native with minimal configuration:
|
||||
```bash
|
||||
sudo ./setup.sh --native --unattended \
|
||||
sudo ./picpeak-setup.sh --native --unattended \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123
|
||||
```
|
||||
@@ -293,7 +293,7 @@ sudo systemctl restart picpeak-backend picpeak-workers
|
||||
|
||||
# Update PicPeak
|
||||
# (reruns migrations to pick up schema fixes for native installs)
|
||||
sudo ./setup.sh --update
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
@@ -385,14 +385,14 @@ docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo ./setup.sh --update
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# Will prompt for confirmation and data removal options
|
||||
sudo ./setup.sh --uninstall
|
||||
sudo ./picpeak-setup.sh --uninstall
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
@@ -508,13 +508,13 @@ sudo systemctl restart picpeak-backend
|
||||
### Home/Office Network
|
||||
```bash
|
||||
# Simple local setup without domain
|
||||
sudo ./setup.sh --native --email admin@local.com
|
||||
sudo ./picpeak-setup.sh --native --email admin@local.com
|
||||
```
|
||||
|
||||
### Public Website with HTTPS
|
||||
```bash
|
||||
# Full production setup
|
||||
sudo ./setup.sh --docker \
|
||||
sudo ./picpeak-setup.sh --docker \
|
||||
--domain photos.company.com \
|
||||
--email admin@company.com \
|
||||
--enable-ssl
|
||||
@@ -523,7 +523,7 @@ sudo ./setup.sh --docker \
|
||||
### Raspberry Pi Setup
|
||||
```bash
|
||||
# Optimized for ARM devices
|
||||
sudo ./setup.sh --native \
|
||||
sudo ./picpeak-setup.sh --native \
|
||||
--port 8080 \
|
||||
--email pi@local.com
|
||||
```
|
||||
|
||||
@@ -1831,8 +1831,8 @@
|
||||
}
|
||||
},
|
||||
"nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"nodemon": {
|
||||
@@ -2086,8 +2086,8 @@
|
||||
"version": "4.0.1"
|
||||
},
|
||||
"tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"tunnel-agent": {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
const settingKey = 'general_max_files_per_upload';
|
||||
|
||||
const existing = await knex('app_settings')
|
||||
.where({ setting_key: settingKey })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
// Normalize existing value into allowed bounds
|
||||
let parsedValue;
|
||||
try {
|
||||
parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null;
|
||||
} catch {
|
||||
parsedValue = existing.setting_value;
|
||||
}
|
||||
|
||||
const numeric = Number(parsedValue);
|
||||
let normalized = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
if (Number.isFinite(numeric) && numeric >= 1) {
|
||||
normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric));
|
||||
}
|
||||
|
||||
if (normalized !== numeric) {
|
||||
await knex('app_settings')
|
||||
.where({ setting_key: settingKey })
|
||||
.update({
|
||||
setting_value: JSON.stringify(normalized),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await knex('app_settings').insert({
|
||||
setting_key: settingKey,
|
||||
setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
await knex('app_settings')
|
||||
.where({ setting_key: 'general_max_files_per_upload' })
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => {
|
||||
table.string('customer_name');
|
||||
});
|
||||
|
||||
await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => {
|
||||
table.string('customer_email');
|
||||
});
|
||||
|
||||
// Backfill new columns from legacy host_* fields
|
||||
const client = knex?.client?.config?.client;
|
||||
|
||||
if (client === 'pg') {
|
||||
await knex.raw(`
|
||||
UPDATE events
|
||||
SET customer_name = COALESCE(customer_name, host_name),
|
||||
customer_email = COALESCE(customer_email, host_email)
|
||||
`);
|
||||
} else {
|
||||
// SQLite fallback
|
||||
await knex('events').update({
|
||||
customer_name: knex.raw('COALESCE(customer_name, host_name)'),
|
||||
customer_email: knex.raw('COALESCE(customer_email, host_email)')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name');
|
||||
if (hasCustomerName) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('customer_name');
|
||||
});
|
||||
}
|
||||
|
||||
const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email');
|
||||
if (hasCustomerEmail) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('customer_email');
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
+31
-54
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.11",
|
||||
"version": "1.1.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.11",
|
||||
"version": "1.1.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -5154,29 +5154,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -5620,13 +5597,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-validator": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
|
||||
"integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==",
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
|
||||
"integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"validator": "~13.12.0"
|
||||
"validator": "~13.15.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
@@ -8355,9 +8332,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
|
||||
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
|
||||
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -9008,24 +8985,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -10246,6 +10205,24 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
@@ -10624,9 +10601,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.12.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
|
||||
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
|
||||
"version": "13.15.20",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
|
||||
"integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.11",
|
||||
"version": "1.1.14",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -39,7 +39,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -55,5 +55,10 @@
|
||||
"mock-fs": "^5.5.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"overrides": {
|
||||
"prebuild-install": {
|
||||
"tar-fs": "2.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const path = require('path');
|
||||
const knex = require('knex');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
const { extractShareToken } = require('../utils/shareLinkUtils');
|
||||
|
||||
// Ensure SQLite directory exists when using file-based DB (native installs)
|
||||
try {
|
||||
@@ -63,12 +64,16 @@ async function initializeDatabase() {
|
||||
table.string('event_type').notNullable();
|
||||
table.string('event_name').notNullable();
|
||||
table.date('event_date').notNullable();
|
||||
table.string('customer_name');
|
||||
table.string('customer_email');
|
||||
table.string('host_email').notNullable();
|
||||
table.string('host_name');
|
||||
table.string('admin_email').notNullable();
|
||||
table.string('password_hash').notNullable();
|
||||
table.text('welcome_message');
|
||||
table.text('color_theme');
|
||||
table.string('share_link').unique().notNullable();
|
||||
table.string('share_token').unique();
|
||||
table.datetime('created_at').defaultTo(db.fn.now());
|
||||
table.datetime('expires_at').notNullable();
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
@@ -99,12 +104,16 @@ async function initializeDatabase() {
|
||||
event_type TEXT NOT NULL,
|
||||
event_name TEXT NOT NULL,
|
||||
event_date DATE NOT NULL,
|
||||
customer_name TEXT,
|
||||
customer_email TEXT,
|
||||
host_name TEXT,
|
||||
host_email TEXT NOT NULL,
|
||||
admin_email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
welcome_message TEXT,
|
||||
color_theme TEXT,
|
||||
share_link TEXT UNIQUE NOT NULL,
|
||||
share_token TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
@@ -157,6 +166,37 @@ async function initializeDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
const hasShareTokenColumn = await db.schema.hasColumn('events', 'share_token');
|
||||
if (!hasShareTokenColumn) {
|
||||
await db.schema.table('events', (table) => {
|
||||
table.string('share_token').unique();
|
||||
});
|
||||
}
|
||||
|
||||
const hasHostNameColumn = await db.schema.hasColumn('events', 'host_name');
|
||||
if (!hasHostNameColumn) {
|
||||
await db.schema.table('events', (table) => {
|
||||
table.string('host_name');
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const eventsWithoutToken = await db('events')
|
||||
.whereNull('share_token')
|
||||
.select('id', 'share_link');
|
||||
|
||||
for (const event of eventsWithoutToken) {
|
||||
const token = extractShareToken(event.share_link);
|
||||
if (token) {
|
||||
await db('events')
|
||||
.where({ id: event.id })
|
||||
.update({ share_token: token });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Share token backfill skipped', { error: error.message });
|
||||
}
|
||||
|
||||
// Photo metadata table
|
||||
const hasPhotosTable = await db.schema.hasTable('photos');
|
||||
if (!hasPhotosTable) {
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
// Only the relevant parts are shown - merge with existing adminEvents.js
|
||||
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
|
||||
// Enhanced event creation with password validation
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail().normalizeEmail(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail().normalizeEmail(),
|
||||
body('password').notEmpty(), // Remove the weak isLength validation
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||
@@ -16,7 +17,7 @@ router.post('/', adminAuth, [
|
||||
body('color_theme').optional().trim(),
|
||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
body('host_name').notEmpty().trim()
|
||||
body('customer_name').notEmpty().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Create event request body:', req.body);
|
||||
@@ -30,8 +31,8 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
@@ -65,9 +66,9 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link
|
||||
// Generate share link based on configured style
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
@@ -88,13 +89,16 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
host_name: customer_name,
|
||||
host_email: customer_email,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLink,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
@@ -121,4 +125,4 @@ router.post('/', adminAuth, [
|
||||
console.error('Error creating event:', error);
|
||||
res.status(500).json({ error: 'Failed to create event' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
// formatDate import removed - dates are formatted by email processor
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const logger = require('../utils/logger');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
|
||||
const parseBooleanInput = (value, defaultValue = true) => {
|
||||
if (value === undefined || value === null) {
|
||||
@@ -37,12 +38,67 @@ const parseBooleanInput = (value, defaultValue = true) => {
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const getCustomerNameFromPayload = (payload = {}) => {
|
||||
if (typeof payload.customer_name === 'string') {
|
||||
const trimmed = payload.customer_name.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCustomerEmailFromPayload = (payload = {}) => {
|
||||
if (typeof payload.customer_email === 'string') {
|
||||
const trimmed = payload.customer_email.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to detect customer_email column', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail().normalizeEmail(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail().normalizeEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
@@ -73,7 +129,6 @@ router.post('/', adminAuth, [
|
||||
body('color_theme').optional().trim(),
|
||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
body('host_name').notEmpty().trim(),
|
||||
body('allow_downloads').optional().isBoolean(),
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
@@ -91,8 +146,6 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
@@ -115,7 +168,16 @@ router.post('/', adminAuth, [
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true
|
||||
} = req.body;
|
||||
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
const customerEmail = getCustomerEmailFromPayload(req.body);
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
if (!customerName || !customerEmail) {
|
||||
return res.status(400).json({ error: 'customer_name and customer_email are required' });
|
||||
}
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
// Debug logging
|
||||
@@ -133,7 +195,6 @@ router.post('/', adminAuth, [
|
||||
});
|
||||
|
||||
let passwordValidation = null;
|
||||
let galleryPassword = password;
|
||||
|
||||
if (requirePassword) {
|
||||
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
@@ -148,8 +209,6 @@ router.post('/', adminAuth, [
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
} else {
|
||||
galleryPassword = '';
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
@@ -167,11 +226,9 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link
|
||||
// Generate share link respecting configured format
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds (random placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
@@ -201,13 +258,15 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLink,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
@@ -251,13 +310,15 @@ router.post('/', adminAuth, [
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: host_email,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify({
|
||||
host_name: host_name,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
@@ -272,8 +333,10 @@ router.post('/', adminAuth, [
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
share_link: shareLink,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
@@ -356,7 +419,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
created_at: event.created_at ? new Date(event.created_at).toISOString() : null,
|
||||
expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null,
|
||||
archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null
|
||||
}));
|
||||
})).map(mapEventForApi);
|
||||
|
||||
res.json({
|
||||
events: eventsWithCounts,
|
||||
@@ -418,7 +481,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
.where('event_id', id)
|
||||
.countDistinct('ip_address as uniqueVisitors');
|
||||
|
||||
res.json({
|
||||
res.json(mapEventForApi({
|
||||
...event,
|
||||
photo_count: parseInt(photoCount) || 0,
|
||||
total_size: parseInt(totalSize) || 0,
|
||||
@@ -426,7 +489,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
total_downloads: parseInt(totalDownloads) || 0,
|
||||
unique_visitors: parseInt(uniqueVisitors) || 0,
|
||||
recent_photos: recentPhotos
|
||||
});
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching event:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch event details' });
|
||||
@@ -442,7 +505,8 @@ router.put('/:id', adminAuth, [
|
||||
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||
body('color_theme').optional({ nullable: true }),
|
||||
body('allow_user_uploads').optional().isBoolean(),
|
||||
body('host_name').optional().trim().notEmpty(),
|
||||
body('customer_name').optional().trim().notEmpty(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('upload_category_id').optional().custom((value) => {
|
||||
// Accept null, undefined, or integer values
|
||||
if (value === null || value === undefined) return true;
|
||||
@@ -481,6 +545,39 @@ router.put('/:id', adminAuth, [
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = { ...req.body };
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
|
||||
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
|
||||
const nextName = getCustomerNameFromPayload(updates);
|
||||
if (nextName) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
updates.host_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
|
||||
const nextEmail = getCustomerEmailFromPayload(updates);
|
||||
if (nextEmail) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
updates.host_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
}
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
@@ -715,10 +812,13 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
|
||||
// Queue email notification if requested
|
||||
if (sendEmail) {
|
||||
// For password reset, we'll need to create a template or use a different approach
|
||||
// For now, let's use the gallery_created template with updated password
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_email.split('@')[0],
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
@@ -773,8 +873,13 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
// Dates will be formatted by the email processor based on recipient language
|
||||
|
||||
// Queue the email
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_name || event.host_email.split('@')[0],
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
@@ -789,7 +894,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
try {
|
||||
await logActivity('email_resent', {
|
||||
email_type: 'gallery_created',
|
||||
recipient: event.host_email,
|
||||
recipient: recipientEmail,
|
||||
ip_address: req.ip || '0.0.0.0',
|
||||
user_agent: req.get('user-agent') || 'Unknown'
|
||||
}, id, {
|
||||
|
||||
@@ -8,6 +8,7 @@ const { generateThumbnail, ensureThumbnail } = require('../services/imageProcess
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
@@ -48,7 +49,7 @@ const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit per file
|
||||
files: 500, // Maximum 500 files
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
// Add part size limits to prevent incomplete uploads
|
||||
@@ -99,17 +100,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
};
|
||||
|
||||
// Upload photos for an event
|
||||
// Increased limit to 500 files, but recommend chunked uploads for better performance
|
||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
|
||||
upload.array('photos', 500)(req, res, (err) => {
|
||||
// Max file count is configurable via general settings
|
||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
||||
let maxFilesPerUpload;
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve max files per upload:', error);
|
||||
return res.status(500).json({ error: 'Unable to determine upload limits' });
|
||||
}
|
||||
|
||||
upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
|
||||
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
|
||||
}
|
||||
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
||||
}
|
||||
|
||||
@@ -18,7 +18,10 @@ const {
|
||||
getRawPublicSiteSettings,
|
||||
} = require('../services/publicSiteService');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||
const router = express.Router();
|
||||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
@@ -472,9 +475,24 @@ router.put('/theme', adminAuth, async (req, res) => {
|
||||
router.put('/general', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = { ...req.body };
|
||||
let uploadLimitTouched = false;
|
||||
|
||||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
|
||||
uploadLimitTouched = true;
|
||||
const rawValue = Number(settings.general_max_files_per_upload);
|
||||
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
|
||||
|
||||
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
|
||||
return res.status(400).json({
|
||||
error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}`
|
||||
});
|
||||
}
|
||||
|
||||
settings.general_max_files_per_upload = normalizedValue;
|
||||
}
|
||||
|
||||
if (publicSiteKeysTouched) {
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
||||
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
||||
@@ -529,6 +547,12 @@ router.put('/general', adminAuth, async (req, res) => {
|
||||
if (publicSiteKeysTouched) {
|
||||
clearPublicSiteCache();
|
||||
}
|
||||
if (uploadLimitTouched) {
|
||||
clearMaxFilesPerUploadCache();
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
|
||||
clearShareLinkSettingsCache();
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
@@ -567,6 +591,8 @@ router.put('/security', adminAuth, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
resetSecurityConfigCache();
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'security_settings_updated',
|
||||
|
||||
@@ -12,13 +12,14 @@ const {
|
||||
checkSuspiciousActivity,
|
||||
getGenericAuthError
|
||||
} = require('../utils/authSecurity');
|
||||
const {
|
||||
const {
|
||||
validatePasswordInContext,
|
||||
getBcryptRounds,
|
||||
logPasswordValidationFailure
|
||||
} = require('../utils/passwordValidation');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const logger = require('../utils/logger');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login with enhanced security
|
||||
@@ -33,7 +34,7 @@ router.post('/admin/login', [
|
||||
}
|
||||
|
||||
const { username, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check account lockout first
|
||||
@@ -175,7 +176,7 @@ router.post('/admin/change-password', [
|
||||
logger.info('Admin password changed', {
|
||||
userId: adminId,
|
||||
username: admin.username,
|
||||
ip: req.ip
|
||||
ip: ipAddress
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -229,14 +230,14 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
|
||||
|
||||
if (requiresPassword) {
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
|
||||
@@ -22,6 +22,8 @@ const {
|
||||
getAdminTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
} = require('../utils/tokenUtils');
|
||||
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login with enhanced security
|
||||
@@ -36,7 +38,7 @@ router.post('/admin/login', [
|
||||
}
|
||||
|
||||
const { username, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check account lockout first
|
||||
@@ -171,7 +173,7 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
@@ -185,7 +187,7 @@ router.post('/gallery/verify', [
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
if (requiresPassword) {
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
@@ -281,21 +283,25 @@ router.post('/gallery/share-login', [
|
||||
}
|
||||
|
||||
const { slug, token } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
const event = await db('events')
|
||||
let event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
const resolved = await resolveShareIdentifier(slug);
|
||||
if (resolved?.event) {
|
||||
event = resolved.event;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
let expectedToken = event.share_link;
|
||||
if (expectedToken && expectedToken.includes('/')) {
|
||||
expectedToken = expectedToken.split('/').pop();
|
||||
}
|
||||
const expectedToken = getEventShareToken(event);
|
||||
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
@@ -312,7 +318,7 @@ router.post('/gallery/share-login', [
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
||||
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
|
||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
+125
-16
@@ -9,6 +9,7 @@ const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
|
||||
const parseBooleanInput = (value, defaultValue = true) => {
|
||||
if (value === undefined || value === null) {
|
||||
@@ -32,12 +33,66 @@ const parseBooleanInput = (value, defaultValue = true) => {
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const getCustomerNameFromPayload = (payload = {}) => {
|
||||
if (typeof payload.customer_name === 'string') {
|
||||
const trimmed = payload.customer_name.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCustomerEmailFromPayload = (payload = {}) => {
|
||||
if (typeof payload.customer_email === 'string') {
|
||||
const trimmed = payload.customer_email.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty(),
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
@@ -62,7 +117,6 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_email,
|
||||
admin_email,
|
||||
password,
|
||||
require_password: requirePasswordInput = true,
|
||||
@@ -71,6 +125,15 @@ router.post('/', adminAuth, [
|
||||
expiration_days = 30
|
||||
} = req.body;
|
||||
|
||||
const customerEmail = getCustomerEmailFromPayload(req.body);
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
|
||||
if (!customerName || !customerEmail) {
|
||||
return res.status(400).json({ error: 'customer_name and customer_email are required' });
|
||||
}
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
if (requirePassword) {
|
||||
@@ -98,12 +161,9 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link (just slug/token, not full URL)
|
||||
// Generate share link variants (auto-detects short URL preference)
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||
const shareLinkSlug = `${slug}/${shareToken}`;
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password (or placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
@@ -126,12 +186,15 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_email,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLinkSlug,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
@@ -141,11 +204,13 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Queue creation email
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
await queueEmail(eventId, host_email, 'gallery_created', {
|
||||
host_name: host_email.split('@')[0], // Extract name from email
|
||||
await queueEmail(eventId, customerEmail, 'gallery_created', {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName,
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: fullShareLink,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
@@ -154,9 +219,11 @@ router.post('/', adminAuth, [
|
||||
res.json({
|
||||
id: eventId,
|
||||
slug,
|
||||
share_link: fullShareLink,
|
||||
share_link: shareUrl,
|
||||
expires_at,
|
||||
require_password: requirePassword
|
||||
require_password: requirePassword,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -185,17 +252,27 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
event.photo_count = photoCount.count;
|
||||
}
|
||||
|
||||
res.json(events);
|
||||
res.json(events.map(mapEventForApi));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch events' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, async (req, res) => {
|
||||
router.put('/:id', adminAuth, [
|
||||
body('customer_name').optional().trim().notEmpty(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('require_password').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = { ...req.body };
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
// Don't allow updating certain fields
|
||||
delete updates.id;
|
||||
@@ -203,6 +280,38 @@ router.put('/:id', adminAuth, async (req, res) => {
|
||||
delete updates.created_at;
|
||||
delete updates.password_confirmation;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
|
||||
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
|
||||
const nextName = getCustomerNameFromPayload(updates);
|
||||
if (nextName) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
updates.host_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
|
||||
const nextEmail = getCustomerEmailFromPayload(updates);
|
||||
if (nextEmail) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
updates.host_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
}
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
|
||||
@@ -9,10 +9,41 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
|
||||
// Resolve gallery identifier (slug or token) to canonical data
|
||||
router.get('/resolve/:identifier', async (req, res) => {
|
||||
try {
|
||||
const { identifier } = req.params;
|
||||
const result = await resolveShareIdentifier(identifier);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const { event, matchType, shareToken } = result;
|
||||
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
slug: event.slug,
|
||||
token: shareToken,
|
||||
matchType,
|
||||
share_link: event.share_link,
|
||||
share_path: linkVariants.sharePath,
|
||||
share_url: linkVariants.shareUrl,
|
||||
short_enabled: linkVariants.shortEnabled,
|
||||
requires_password: requiresPassword
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error resolving gallery identifier:', error);
|
||||
res.status(500).json({ error: 'Failed to resolve gallery link' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify share token
|
||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
try {
|
||||
@@ -20,15 +51,14 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('id', 'share_link')
|
||||
.select('id', 'share_link', 'share_token')
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// Extract token from share link and verify
|
||||
const expectedToken = event.share_link.split('/').pop();
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (token !== expectedToken) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
@@ -56,6 +86,7 @@ router.get('/:slug/info', async (req, res) => {
|
||||
'is_active',
|
||||
'is_archived',
|
||||
'share_link',
|
||||
'share_token',
|
||||
'allow_downloads',
|
||||
'disable_right_click',
|
||||
'watermark_downloads',
|
||||
@@ -76,12 +107,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
|
||||
// If token provided, verify it matches the share link
|
||||
if (token) {
|
||||
let expectedToken = event.share_link;
|
||||
// Handle both formats: full URL or just token
|
||||
if (event.share_link && event.share_link.includes('/')) {
|
||||
expectedToken = event.share_link.split('/').pop();
|
||||
}
|
||||
if (token !== expectedToken) {
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,15 @@ async function queueExpirationWarning(event) {
|
||||
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Determine language based on email domain
|
||||
const emailLang = event.host_email.endsWith('.de') ? 'de' : 'en';
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
const emailLang = recipientEmail && recipientEmail.endsWith('.de') ? 'de' : 'en';
|
||||
|
||||
// Queue email to host
|
||||
await queueEmail(event.id, event.host_email, 'expiration_warning', {
|
||||
host_name: event.host_name || event.host_email.split('@')[0],
|
||||
// Queue email to customer
|
||||
await queueEmail(event.id, recipientEmail, 'expiration_warning', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
days_remaining: daysRemaining.toString(),
|
||||
expiration_date: await formatDate(event.expires_at, emailLang),
|
||||
@@ -78,9 +82,14 @@ async function handleExpiredEvent(event) {
|
||||
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
|
||||
|
||||
// Queue expiration emails
|
||||
await queueEmail(event.id, event.host_email, 'gallery_expired', {
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
|
||||
await queueEmail(event.id, recipientEmail, 'gallery_expired', {
|
||||
event_name: event.event_name,
|
||||
admin_email: event.admin_email
|
||||
admin_email: event.admin_email,
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail
|
||||
});
|
||||
|
||||
// Also notify admin
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
|
||||
|
||||
const SETTING_KEY = 'general_short_gallery_urls';
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
let cachedSetting = null;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
const parseSettingValue = (rawValue) => {
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'boolean') {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'number') {
|
||||
return rawValue !== 0;
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parseSettingValue(parsed);
|
||||
} catch {
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'object') {
|
||||
try {
|
||||
return parseSettingValue(JSON.parse(JSON.stringify(rawValue)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getRawSettingValue = async () => {
|
||||
try {
|
||||
const setting = await db('app_settings').where({ setting_key: SETTING_KEY }).first();
|
||||
return setting?.setting_value ?? null;
|
||||
} catch (error) {
|
||||
console.error('Failed to read gallery URL setting:', error.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isShortGalleryUrlsEnabled = async () => {
|
||||
if (cachedSetting !== null && Date.now() < cacheExpiresAt) {
|
||||
return cachedSetting;
|
||||
}
|
||||
|
||||
const rawValue = await getRawSettingValue();
|
||||
const parsed = parseSettingValue(rawValue);
|
||||
cachedSetting = parsed === null ? false : Boolean(parsed);
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return cachedSetting;
|
||||
};
|
||||
|
||||
const clearShareLinkSettingsCache = () => {
|
||||
cachedSetting = null;
|
||||
cacheExpiresAt = 0;
|
||||
};
|
||||
|
||||
const buildShareLinkVariants = async ({ slug, shareToken }) => {
|
||||
if (!shareToken) {
|
||||
throw new Error('shareToken is required to build share link variants');
|
||||
}
|
||||
|
||||
const shortEnabled = await isShortGalleryUrlsEnabled();
|
||||
const sharePath = buildSharePath(slug, shareToken, shortEnabled);
|
||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||
const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||
|
||||
return {
|
||||
shortEnabled,
|
||||
sharePath,
|
||||
shareUrl,
|
||||
shareLinkToStore: sharePath
|
||||
};
|
||||
};
|
||||
|
||||
const getEventShareToken = (event) => {
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.share_token) {
|
||||
return event.share_token;
|
||||
}
|
||||
|
||||
return extractShareToken(event.share_link);
|
||||
};
|
||||
|
||||
const ACTIVE_EVENT_FILTER = {
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
};
|
||||
|
||||
const resolveShareIdentifier = async (identifier) => {
|
||||
if (!identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = String(identifier).trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseQuery = db('events')
|
||||
.select(
|
||||
'id',
|
||||
'slug',
|
||||
'share_link',
|
||||
'share_token',
|
||||
'require_password',
|
||||
'event_name',
|
||||
'event_type',
|
||||
'event_date',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'is_archived'
|
||||
)
|
||||
.where(ACTIVE_EVENT_FILTER);
|
||||
|
||||
let event = await baseQuery.clone().where({ slug: trimmed }).first();
|
||||
if (event) {
|
||||
return { event, matchType: 'slug', shareToken: getEventShareToken(event) };
|
||||
}
|
||||
|
||||
event = await baseQuery.clone().where({ share_token: trimmed }).first();
|
||||
if (event) {
|
||||
return { event, matchType: 'token', shareToken: getEventShareToken(event) };
|
||||
}
|
||||
|
||||
event = await baseQuery.clone().where({ share_link: trimmed }).first();
|
||||
if (event) {
|
||||
return { event, matchType: 'link', shareToken: getEventShareToken(event) };
|
||||
}
|
||||
|
||||
event = await baseQuery.clone().where('share_link', 'like', `%/${trimmed}`).first();
|
||||
if (event) {
|
||||
return { event, matchType: 'link_partial', shareToken: getEventShareToken(event) };
|
||||
}
|
||||
|
||||
// As a final fallback, if identifier looks like a token but we did not match via share_token
|
||||
if (isPotentialShareToken(trimmed)) {
|
||||
event = await baseQuery.clone().whereRaw('LOWER(share_token) = ?', [trimmed.toLowerCase()]).first();
|
||||
if (event) {
|
||||
return { event, matchType: 'token_case_insensitive', shareToken: getEventShareToken(event) };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
isShortGalleryUrlsEnabled,
|
||||
clearShareLinkSettingsCache,
|
||||
buildShareLinkVariants,
|
||||
getEventShareToken,
|
||||
resolveShareIdentifier
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
const { db } = require('../database/db');
|
||||
|
||||
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||
const MAX_ALLOWED_FILES_PER_UPLOAD = 2000;
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
const parseSettingValue = (setting) => {
|
||||
if (!setting || setting.setting_value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rawValue = setting.setting_value;
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
try {
|
||||
rawValue = JSON.parse(rawValue);
|
||||
} catch {
|
||||
// keep original string
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'number') {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeLimit = (value) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
|
||||
const intValue = Math.floor(value);
|
||||
if (intValue < 1) {
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
if (intValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
|
||||
return MAX_ALLOWED_FILES_PER_UPLOAD;
|
||||
}
|
||||
return intValue;
|
||||
};
|
||||
|
||||
const getMaxFilesPerUpload = async () => {
|
||||
if (Date.now() < cacheExpiresAt) {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const setting = await db('app_settings')
|
||||
.where({ setting_key: 'general_max_files_per_upload' })
|
||||
.first();
|
||||
|
||||
const parsedValue = normalizeLimit(parseSettingValue(setting));
|
||||
cachedValue = parsedValue;
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return parsedValue;
|
||||
} catch (error) {
|
||||
console.error('Failed to read max files per upload setting:', error.message);
|
||||
cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
};
|
||||
|
||||
const clearMaxFilesPerUploadCache = () => {
|
||||
cacheExpiresAt = 0;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMaxFilesPerUpload,
|
||||
clearMaxFilesPerUploadCache,
|
||||
DEFAULT_MAX_FILES_PER_UPLOAD,
|
||||
MAX_ALLOWED_FILES_PER_UPLOAD
|
||||
};
|
||||
@@ -7,10 +7,140 @@ const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('./dbCompat');
|
||||
const logger = require('./logger');
|
||||
|
||||
// Configuration constants
|
||||
const MAX_LOGIN_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
|
||||
const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes window for counting attempts
|
||||
const DEFAULT_SECURITY_CONFIG = Object.freeze({
|
||||
maxAttempts: 5,
|
||||
lockoutDurationMs: 30 * 60 * 1000, // 30 minutes
|
||||
attemptWindowMs: 15 * 60 * 1000 // 15 minutes
|
||||
});
|
||||
|
||||
const SECURITY_CONFIG_CACHE_MS = 60 * 1000; // 1 minute cache
|
||||
let cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
|
||||
let cachedConfigFetchedAt = 0;
|
||||
|
||||
function parseStoredValue(rawValue) {
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof rawValue !== 'string') {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawValue);
|
||||
} catch (error) {
|
||||
logger.warn(`Unable to parse stored security setting value "${rawValue}", using raw string.`);
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(name, value, fallback, options = {}) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const numericValue = Number(value);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
logger.warn(`Invalid numeric value for ${name}: ${value}. Falling back to default (${fallback}).`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let adjustedValue = Math.floor(numericValue);
|
||||
|
||||
if (options.min !== undefined && adjustedValue < options.min) {
|
||||
logger.warn(`Value for ${name} below minimum (${options.min}). Clamping to minimum.`);
|
||||
adjustedValue = options.min;
|
||||
}
|
||||
|
||||
if (options.max !== undefined && adjustedValue > options.max) {
|
||||
logger.warn(`Value for ${name} exceeds maximum (${options.max}). Clamping to maximum.`);
|
||||
adjustedValue = options.max;
|
||||
}
|
||||
|
||||
if (adjustedValue <= 0) {
|
||||
logger.warn(`Value for ${name} must be positive. Falling back to default (${fallback}).`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return adjustedValue;
|
||||
}
|
||||
|
||||
async function loadSecurityConfigFromSettings() {
|
||||
const rows = await db('app_settings').whereIn('setting_key', [
|
||||
'security_max_login_attempts',
|
||||
'security_lockout_duration_minutes',
|
||||
'security_attempt_window_minutes'
|
||||
]);
|
||||
|
||||
const config = { ...DEFAULT_SECURITY_CONFIG };
|
||||
|
||||
rows.forEach(row => {
|
||||
const value = parseStoredValue(row.setting_value);
|
||||
|
||||
switch (row.setting_key) {
|
||||
case 'security_max_login_attempts': {
|
||||
config.maxAttempts = normalizePositiveInteger(
|
||||
'security_max_login_attempts',
|
||||
value,
|
||||
DEFAULT_SECURITY_CONFIG.maxAttempts,
|
||||
{ min: 1, max: 50 }
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'security_lockout_duration_minutes': {
|
||||
const minutes = normalizePositiveInteger(
|
||||
'security_lockout_duration_minutes',
|
||||
value,
|
||||
DEFAULT_SECURITY_CONFIG.lockoutDurationMs / (60 * 1000),
|
||||
{ min: 1, max: 24 * 60 }
|
||||
);
|
||||
config.lockoutDurationMs = minutes * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
case 'security_attempt_window_minutes': {
|
||||
const minutes = normalizePositiveInteger(
|
||||
'security_attempt_window_minutes',
|
||||
value,
|
||||
DEFAULT_SECURITY_CONFIG.attemptWindowMs / (60 * 1000),
|
||||
{ min: 1, max: 24 * 60 }
|
||||
);
|
||||
config.attemptWindowMs = minutes * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function getSecurityConfig(options = {}) {
|
||||
const now = Date.now();
|
||||
const forceRefresh = options.forceRefresh === true;
|
||||
|
||||
if (!forceRefresh && cachedSecurityConfig && (now - cachedConfigFetchedAt) < SECURITY_CONFIG_CACHE_MS) {
|
||||
return cachedSecurityConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await loadSecurityConfigFromSettings();
|
||||
cachedSecurityConfig = config;
|
||||
cachedConfigFetchedAt = now;
|
||||
return cachedSecurityConfig;
|
||||
} catch (error) {
|
||||
logger.error('Error loading security configuration:', error);
|
||||
cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
|
||||
cachedConfigFetchedAt = now;
|
||||
return cachedSecurityConfig;
|
||||
}
|
||||
}
|
||||
|
||||
function resetSecurityConfigCache() {
|
||||
cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
|
||||
cachedConfigFetchedAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track failed login attempt
|
||||
@@ -59,6 +189,8 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
||||
if (!tableExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { attemptWindowMs } = await getSecurityConfig();
|
||||
|
||||
await db('login_attempts').insert({
|
||||
identifier,
|
||||
@@ -69,7 +201,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
||||
});
|
||||
|
||||
// Clear old failed attempts for this user
|
||||
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
|
||||
const cutoffTime = new Date(Date.now() - attemptWindowMs);
|
||||
await db('login_attempts')
|
||||
.where('identifier', identifier)
|
||||
.where('success', formatBoolean(false))
|
||||
@@ -83,30 +215,39 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
||||
/**
|
||||
* Check if account is locked due to too many failed attempts
|
||||
* @param {string} identifier - Username or email
|
||||
* @param {string} [ipAddress] - Optional IP address scope
|
||||
* @returns {Promise<{isLocked: boolean, remainingTime?: number}>}
|
||||
*/
|
||||
async function checkAccountLockout(identifier) {
|
||||
async function checkAccountLockout(identifier, ipAddress) {
|
||||
try {
|
||||
// Check if table exists first
|
||||
const tableExists = await db.schema.hasTable('login_attempts');
|
||||
if (!tableExists) {
|
||||
return { isLocked: false };
|
||||
}
|
||||
|
||||
const { attemptWindowMs, maxAttempts, lockoutDurationMs } = await getSecurityConfig();
|
||||
|
||||
const recentWindow = new Date(Date.now() - ATTEMPT_WINDOW);
|
||||
const recentWindow = new Date(Date.now() - attemptWindowMs);
|
||||
|
||||
// Get recent failed attempts
|
||||
const failedAttempts = await db('login_attempts')
|
||||
const failedAttemptsQuery = db('login_attempts')
|
||||
.where('identifier', identifier)
|
||||
.where('success', formatBoolean(false))
|
||||
.where('attempt_time', '>=', recentWindow.toISOString())
|
||||
.orderBy('attempt_time', 'desc')
|
||||
.limit(MAX_LOGIN_ATTEMPTS);
|
||||
.where('attempt_time', '>=', recentWindow.toISOString());
|
||||
|
||||
if (failedAttempts.length >= MAX_LOGIN_ATTEMPTS) {
|
||||
if (ipAddress) {
|
||||
failedAttemptsQuery.andWhere('ip_address', ipAddress);
|
||||
}
|
||||
|
||||
const failedAttempts = await failedAttemptsQuery
|
||||
.orderBy('attempt_time', 'desc')
|
||||
.limit(maxAttempts);
|
||||
|
||||
if (failedAttempts.length >= maxAttempts) {
|
||||
// Check if still within lockout period
|
||||
const oldestAttempt = failedAttempts[failedAttempts.length - 1];
|
||||
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + LOCKOUT_DURATION;
|
||||
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + lockoutDurationMs;
|
||||
const now = Date.now();
|
||||
|
||||
if (now < lockoutEnd) {
|
||||
@@ -216,6 +357,6 @@ module.exports = {
|
||||
checkSuspiciousActivity,
|
||||
getGenericAuthError,
|
||||
initializeCleanupJob,
|
||||
MAX_LOGIN_ATTEMPTS,
|
||||
LOCKOUT_DURATION
|
||||
};
|
||||
getSecurityConfig,
|
||||
resetSecurityConfigCache
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Resolve the originating client IP address, accounting for reverse proxies.
|
||||
* Returns the first entry from X-Forwarded-For when available, otherwise falls back
|
||||
* to Express/Node connection properties.
|
||||
* @param {import('express').Request} req
|
||||
* @returns {string}
|
||||
*/
|
||||
function getClientIp(req) {
|
||||
if (!req) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
|
||||
if (typeof forwardedFor === 'string' && forwardedFor.length > 0) {
|
||||
const [firstIp] = forwardedFor.split(',').map(part => part.trim()).filter(Boolean);
|
||||
if (firstIp) {
|
||||
return firstIp;
|
||||
}
|
||||
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
|
||||
const [firstIp] = forwardedFor;
|
||||
if (firstIp) {
|
||||
return firstIp.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
req.ip ||
|
||||
req.connection?.remoteAddress ||
|
||||
req.socket?.remoteAddress ||
|
||||
req.connection?.socket?.remoteAddress ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { getClientIp };
|
||||
@@ -0,0 +1,63 @@
|
||||
const SHARE_TOKEN_REGEX = /^[0-9a-fA-F]{32}$/;
|
||||
|
||||
/**
|
||||
* Extracts the share token portion from a stored share link.
|
||||
* Supports full URLs, absolute paths, and legacy slug/token formats.
|
||||
* @param {string|null|undefined} shareLink
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function extractShareToken(shareLink) {
|
||||
if (!shareLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = String(shareLink).trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove protocol + host when a full URL is stored
|
||||
const path = trimmed.replace(/^https?:\/\/[^/]+/i, '');
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = segments[segments.length - 1];
|
||||
return candidate || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the provided identifier looks like a generated share token.
|
||||
* @param {string|null|undefined} identifier
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPotentialShareToken(identifier) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
return SHARE_TOKEN_REGEX.test(String(identifier).trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the gallery share path depending on whether short URLs are enabled.
|
||||
* @param {string} slug
|
||||
* @param {string} shareToken
|
||||
* @param {boolean} useShort
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildSharePath(slug, shareToken, useShort) {
|
||||
if (!shareToken) {
|
||||
throw new Error('shareToken is required to build share path');
|
||||
}
|
||||
if (useShort || !slug) {
|
||||
return `/gallery/${shareToken}`;
|
||||
}
|
||||
return `/gallery/${slug}/${shareToken}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractShareToken,
|
||||
isPotentialShareToken,
|
||||
buildSharePath
|
||||
};
|
||||
@@ -109,7 +109,7 @@ If ADMIN_CREDENTIALS.txt is missing:
|
||||
- File is created in the backend directory root
|
||||
- File might have been deleted for security (as recommended)
|
||||
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
|
||||
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||
- When using the unified `picpeak-setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# PicPeak Admin API Quickstart
|
||||
|
||||
This guide explains how to authenticate against the PicPeak Admin API, use the OpenAPI documentation, and exercise the three automation endpoints (`create event`, `photo upload`, `resend email`) that now ship with machine-readable docs.
|
||||
|
||||
> **Prerequisites**
|
||||
>
|
||||
> - PicPeak backend running (Docker or local `node backend/server.js`)
|
||||
> - An admin account (see `data/ADMIN_CREDENTIALS.txt` for the seeded defaults)
|
||||
> - API base URL (defaults to `http://localhost:3001/api`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Obtain an Admin API Token
|
||||
|
||||
1. Determine whether reCAPTCHA is enabled in **Admin → Settings → Security**. If disabled (the default), you can skip the `recaptchaToken` field shown below.
|
||||
2. Authenticate with your admin username/email and password:
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "http://localhost:3001/api/auth/admin/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "admin",
|
||||
"password": "BoldTiger5872%",
|
||||
"recaptchaToken": ""
|
||||
}' | jq
|
||||
```
|
||||
|
||||
Successful responses look like:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"email": "admin@example.com",
|
||||
"mustChangePassword": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- PicPeak also sets the `admin_token` cookie; however, when scripting you typically pass the token in an `Authorization: Bearer <token>` header.
|
||||
- Tokens expire after 24 hours. Log in again to refresh them.
|
||||
|
||||
---
|
||||
|
||||
## 2. Use the OpenAPI Documentation
|
||||
|
||||
The machine-readable spec lives at `docs/picpeak-admin-api.openapi.yaml`. You can:
|
||||
|
||||
- Preview it interactively with Redocly:
|
||||
|
||||
```bash
|
||||
npx --yes @redocly/cli preview-docs docs/picpeak-admin-api.openapi.yaml
|
||||
```
|
||||
|
||||
- Import it into Postman, Insomnia, or VS Code REST client.
|
||||
- Validate changes as part of CI with:
|
||||
|
||||
```bash
|
||||
npx --yes @apidevtools/swagger-cli@4.0.4 validate docs/picpeak-admin-api.openapi.yaml
|
||||
```
|
||||
|
||||
Keep this file in sync whenever the backend endpoints evolve.
|
||||
|
||||
---
|
||||
|
||||
## 3. Call the Key Admin Endpoints
|
||||
|
||||
Below are minimal `curl` examples that rely on the bearer token captured earlier.
|
||||
|
||||
### 3.1 Create an Event
|
||||
|
||||
```bash
|
||||
API_URL="http://localhost:3001/api"
|
||||
TOKEN="REPLACE_WITH_JWT"
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "$API_URL/admin/events" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event_type": "wedding",
|
||||
"event_name": "Emily & Jordan Celebration",
|
||||
"event_date": "2025-06-07",
|
||||
"customer_name": "Emily Carter",
|
||||
"customer_email": "emily@example.com",
|
||||
"admin_email": "studio@example.com",
|
||||
"require_password": true,
|
||||
"password": "Shutter123",
|
||||
"expiration_days": 45
|
||||
}' | jq
|
||||
```
|
||||
|
||||
### 3.2 Upload Photos to the Event
|
||||
|
||||
```bash
|
||||
EVENT_ID=512
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "$API_URL/admin/events/$EVENT_ID/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "photos=@/path/to/DSC_2031.jpg" \
|
||||
-F "photos=@/path/to/DSC_2032.jpg" \
|
||||
-F "category_id=individual" | jq
|
||||
```
|
||||
|
||||
- Files must be JPEG/PNG/WebP, each ≤ 50 MB.
|
||||
- The per-request file count respects the `general_max_files_per_upload` admin setting (default 500).
|
||||
|
||||
### 3.3 Resend the Gallery Email
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "$API_URL/admin/events/$EVENT_ID/resend-email" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password": "Shutter123"}' | jq
|
||||
```
|
||||
|
||||
Omit `"password"` to send the standard security message instead.
|
||||
|
||||
---
|
||||
|
||||
## 4. Quick Testing Checklist
|
||||
|
||||
- ✅ Login succeeds and returns a token (HTTP 200).
|
||||
- ✅ Creating an event returns `id`, `slug`, and `share_link`.
|
||||
- ✅ Uploading more files than allowed returns HTTP 400 with a helpful message.
|
||||
- ✅ Resending email for a missing event returns HTTP 404.
|
||||
- ✅ `swagger-cli validate` passes after any spec edits.
|
||||
|
||||
Automate these checks using your preferred test harness or CI pipeline to catch regressions early.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migrating From `host_*`
|
||||
|
||||
- Run backend migrations to add the new `customer_name` / `customer_email` columns: `npm --prefix backend run migrate` (or your existing deployment flow). The migration copies legacy data automatically, so upgrades remain seamless.
|
||||
- All admin APIs now require the `customer_*` fields. Older `host_*` payloads are rejected, which makes downstream client issues obvious during testing instead of silently dropping data.
|
||||
- API responses still mirror `customer_*` even if migrations have not run yet (the server falls back to legacy columns until the upgrade is complete), so existing frontends can move over incrementally.
|
||||
- Once every consumer writes and reads the new fields, you can safely plan the removal of the legacy `host_*` columns in a future release.
|
||||
|
||||
---
|
||||
|
||||
Need deeper integration examples or language-specific SDKs? Import the OpenAPI spec into code generators such as `openapi-generator` or `orval` to scaffold API clients quickly.
|
||||
@@ -0,0 +1,584 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: PicPeak Admin API
|
||||
version: 1.1.11
|
||||
summary: High-level administrative endpoints for creating events, uploading photos, and resending gallery access emails.
|
||||
description: |
|
||||
This document describes the core administrative endpoints that power PicPeak automations.
|
||||
It focuses on the three workflows requested by integrators:
|
||||
|
||||
1. Creating events with customer access credentials.
|
||||
2. Uploading photos in bulk to an event gallery.
|
||||
3. Resending the customer-facing gallery email.
|
||||
|
||||
The specification follows the latest [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) best practices
|
||||
and is intended to be kept in sync with backend changes.
|
||||
contact:
|
||||
name: PicPeak Maintainers
|
||||
url: https://github.com/the-luap/picpeak
|
||||
servers:
|
||||
- url: https://api.picpeak.example.com/api
|
||||
description: Example production deployment
|
||||
- url: http://localhost:3001/api
|
||||
description: Local development
|
||||
tags:
|
||||
- name: Admin Events
|
||||
description: Administrative endpoints for managing event galleries.
|
||||
components:
|
||||
securitySchemes:
|
||||
CookieAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: admin_token
|
||||
description: >
|
||||
Session cookie issued by the admin authentication flow. When present, the backend mirrors
|
||||
it into the `Authorization` header automatically.
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: >
|
||||
JSON Web Token created by the admin login endpoint. You can also pass the token explicitly
|
||||
as `Authorization: Bearer <token>` instead of using the admin cookie.
|
||||
parameters:
|
||||
EventId:
|
||||
name: eventId
|
||||
in: path
|
||||
description: Numeric identifier of the event.
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
example: 341
|
||||
schemas:
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: Human readable error message.
|
||||
details:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Additional context (when available).
|
||||
required:
|
||||
- error
|
||||
example:
|
||||
error: Invalid token
|
||||
ValidationErrorItem:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Validation error type reported by express-validator.
|
||||
msg:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
description: Dot-delimited path to the invalid field.
|
||||
value:
|
||||
description: Value that failed validation.
|
||||
location:
|
||||
type: string
|
||||
description: Location of the invalid value (always `body` for these endpoints).
|
||||
required:
|
||||
- msg
|
||||
- path
|
||||
- location
|
||||
example:
|
||||
type: field
|
||||
msg: Event date must be a valid ISO 8601 date
|
||||
path: event_date
|
||||
value: 2025/05/01
|
||||
location: body
|
||||
ValidationErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationErrorItem'
|
||||
required:
|
||||
- errors
|
||||
example:
|
||||
errors:
|
||||
- type: field
|
||||
msg: Customer email must be a valid address
|
||||
path: customer_email
|
||||
value: example@invalid
|
||||
location: body
|
||||
CreateEventRequest:
|
||||
type: object
|
||||
required:
|
||||
- event_type
|
||||
- event_name
|
||||
- event_date
|
||||
- customer_name
|
||||
- customer_email
|
||||
- admin_email
|
||||
properties:
|
||||
event_type:
|
||||
type: string
|
||||
description: Type of event. Controls default theme and copy in the UI.
|
||||
enum: [wedding, birthday, corporate, other]
|
||||
event_name:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Display name for the gallery shown to end customers.
|
||||
event_date:
|
||||
type: string
|
||||
format: date
|
||||
description: Event date (YYYY-MM-DD). Used to calculate the default expiration.
|
||||
customer_name:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Name of the customer receiving gallery access.
|
||||
customer_email:
|
||||
type: string
|
||||
format: email
|
||||
description: Email address of the customer who will receive the gallery link.
|
||||
admin_email:
|
||||
type: string
|
||||
format: email
|
||||
description: Admin contact email included in notification messages.
|
||||
require_password:
|
||||
type: boolean
|
||||
default: true
|
||||
description: When true, the gallery requires `password`; when false a random placeholder is stored.
|
||||
password:
|
||||
type: string
|
||||
minLength: 6
|
||||
description: >
|
||||
Gallery password issued to the customer. Required when `require_password` is `true`.
|
||||
Left unset to auto-generate a placeholder when password protection is disabled.
|
||||
expiration_days:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
default: 30
|
||||
description: Number of days after the event date before the gallery expires.
|
||||
welcome_message:
|
||||
type: string
|
||||
description: Optional welcome message displayed in the gallery.
|
||||
color_theme:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Optional theme identifier or CSS color settings.
|
||||
allow_user_uploads:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Allow gallery guests to upload their own photos.
|
||||
upload_category_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: ID of the default category for user uploads.
|
||||
allow_downloads:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Allow guests to download photos.
|
||||
disable_right_click:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Disable right-click in the gallery view.
|
||||
watermark_downloads:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Enable watermarking on downloaded images.
|
||||
watermark_text:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Custom watermark text when `watermark_downloads` is true.
|
||||
feedback_enabled:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Enable the feedback module for this gallery.
|
||||
allow_ratings:
|
||||
type: boolean
|
||||
default: true
|
||||
allow_likes:
|
||||
type: boolean
|
||||
default: true
|
||||
allow_comments:
|
||||
type: boolean
|
||||
default: true
|
||||
allow_favorites:
|
||||
type: boolean
|
||||
default: true
|
||||
require_name_email:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Require guests to provide name and email when leaving feedback.
|
||||
moderate_comments:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Hold guest comments for moderation.
|
||||
show_feedback_to_guests:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Display aggregated feedback metrics back to guests.
|
||||
example:
|
||||
event_type: wedding
|
||||
event_name: Emily & Jordan Celebration
|
||||
event_date: 2025-06-07
|
||||
customer_name: Emily Carter
|
||||
customer_email: emily@example.com
|
||||
admin_email: studio@example.com
|
||||
require_password: true
|
||||
password: Shutter123
|
||||
expiration_days: 45
|
||||
welcome_message: >
|
||||
We loved capturing your day! Use the password below to view and download your photos.
|
||||
allow_user_uploads: false
|
||||
allow_downloads: true
|
||||
feedback_enabled: true
|
||||
allow_comments: true
|
||||
show_feedback_to_guests: true
|
||||
EventSummary:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: Database identifier of the newly created event.
|
||||
slug:
|
||||
type: string
|
||||
description: Unique slug used to build the gallery URL.
|
||||
event_name:
|
||||
type: string
|
||||
event_type:
|
||||
type: string
|
||||
enum: [wedding, birthday, corporate, other]
|
||||
customer_name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Name of the customer associated with the event.
|
||||
customer_email:
|
||||
type: string
|
||||
format: email
|
||||
nullable: true
|
||||
description: Email address of the customer associated with the event.
|
||||
require_password:
|
||||
type: boolean
|
||||
share_link:
|
||||
type: string
|
||||
description: Absolute or relative URL guests can use to reach the gallery.
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: ISO 8601 timestamp when the gallery expires.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: ISO 8601 timestamp when the event was created.
|
||||
required:
|
||||
- id
|
||||
- slug
|
||||
- event_name
|
||||
- event_type
|
||||
- require_password
|
||||
- share_link
|
||||
- expires_at
|
||||
- created_at
|
||||
example:
|
||||
id: 512
|
||||
slug: wedding-emily-jordan-2025-06-07
|
||||
event_name: Emily & Jordan Celebration
|
||||
event_type: wedding
|
||||
customer_name: Emily Carter
|
||||
customer_email: emily@example.com
|
||||
require_password: true
|
||||
share_link: https://app.picpeak.io/gallery/wedding-emily-jordan-2025-06-07/2f3c8a4d90bb11ef9b2e0242ac120002
|
||||
expires_at: 2025-07-22T00:00:00.000Z
|
||||
created_at: 2025-05-01T14:32:45.000Z
|
||||
UploadPhotosResponse:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
photos:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadedPhotoSummary'
|
||||
description: Metadata for each photo that was persisted successfully.
|
||||
totalFiles:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Total number of files included in the request (valid + invalid).
|
||||
successCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
failureCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadFailure'
|
||||
description: Present when some files failed validation or processing.
|
||||
required:
|
||||
- message
|
||||
- photos
|
||||
- totalFiles
|
||||
- successCount
|
||||
- failureCount
|
||||
example:
|
||||
message: Uploaded 18 of 20 photos. 2 failed.
|
||||
photos:
|
||||
- id: 9821
|
||||
filename: DSC_2031.jpg
|
||||
size: 4812096
|
||||
category_id: 2
|
||||
- id: 9822
|
||||
filename: DSC_2032.jpg
|
||||
size: 5216743
|
||||
category_id: 2
|
||||
totalFiles: 20
|
||||
successCount: 18
|
||||
failureCount: 2
|
||||
errors:
|
||||
- filename: DSC_2020.raw
|
||||
error: Only JPEG, PNG and WebP images are allowed
|
||||
- filename: portrait.png
|
||||
error: File is empty
|
||||
UploadedPhotoSummary:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
filename:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
description: File size in bytes.
|
||||
category_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
required:
|
||||
- id
|
||||
- filename
|
||||
- size
|
||||
example:
|
||||
id: 9821
|
||||
filename: DSC_2031.jpg
|
||||
size: 4812096
|
||||
category_id: 2
|
||||
UploadFailure:
|
||||
type: object
|
||||
properties:
|
||||
filename:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
required:
|
||||
- filename
|
||||
- error
|
||||
example:
|
||||
filename: DSC_2031.gif
|
||||
error: Only JPEG, PNG and WebP images are allowed
|
||||
ResendEmailRequest:
|
||||
type: object
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: >
|
||||
Optional plain-text password to include in the email. When omitted a security notice
|
||||
placeholder is inserted because the stored hash cannot be reversed.
|
||||
example:
|
||||
password: Shutter123
|
||||
ResendEmailResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- success
|
||||
- message
|
||||
example:
|
||||
success: true
|
||||
message: Creation email has been queued for sending
|
||||
paths:
|
||||
/admin/events:
|
||||
post:
|
||||
tags: [Admin Events]
|
||||
operationId: createAdminEvent
|
||||
summary: Create a new event
|
||||
description: >
|
||||
Creates a new event, provisions storage folders, stores the gallery password, and queues
|
||||
the initial gallery email for the customer. Requires admin authentication.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateEventRequest'
|
||||
examples:
|
||||
weddingExample:
|
||||
summary: Wedding with password protection
|
||||
value:
|
||||
event_type: wedding
|
||||
event_name: Emily & Jordan Celebration
|
||||
event_date: 2025-06-07
|
||||
customer_name: Emily Carter
|
||||
customer_email: emily@example.com
|
||||
admin_email: studio@example.com
|
||||
require_password: true
|
||||
password: Shutter123
|
||||
expiration_days: 45
|
||||
welcome_message: >
|
||||
We loved capturing your day! Use the password below to view and download your photos.
|
||||
allow_user_uploads: false
|
||||
allow_downloads: true
|
||||
feedback_enabled: true
|
||||
allow_comments: true
|
||||
show_feedback_to_guests: true
|
||||
responses:
|
||||
'200':
|
||||
description: Event created successfully.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EventSummary'
|
||||
'400':
|
||||
description: Validation failed. At least one field is invalid or missing.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationErrorResponse'
|
||||
'401':
|
||||
description: Authentication required or token invalid.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected server error while creating the event.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/admin/events/{eventId}/upload:
|
||||
post:
|
||||
tags: [Admin Events]
|
||||
operationId: uploadEventPhotos
|
||||
summary: Upload photos to an event gallery
|
||||
description: |
|
||||
Uploads one or more photos to the specified event. Files are validated, moved into the
|
||||
event storage directory, and thumbnails are generated asynchronously.
|
||||
|
||||
The maximum number of files per upload is controlled via the `general_max_files_per_upload`
|
||||
setting (default 500, capped at 2000). Files exceeding 50 MB are rejected.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/EventId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
photos:
|
||||
type: array
|
||||
description: >
|
||||
One or more image files (JPEG, PNG, WebP). Each file must be <= 50 MB.
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
category_id:
|
||||
oneOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
description: >
|
||||
Optional category assignment. Accepts numeric IDs or the string values `collage`
|
||||
and `individual` for backward compatibility.
|
||||
required:
|
||||
- photos
|
||||
encoding:
|
||||
photos:
|
||||
style: form
|
||||
explode: false
|
||||
responses:
|
||||
'200':
|
||||
description: Upload completed. Failed files (if any) are listed in the response.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UploadPhotosResponse'
|
||||
'400':
|
||||
description: Request failed validation (invalid files, too many files, etc.).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Authentication required or token invalid.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: The referenced event does not exist.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected server error while processing uploads.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/admin/events/{eventId}/resend-email:
|
||||
post:
|
||||
tags: [Admin Events]
|
||||
operationId: resendEventEmail
|
||||
summary: Resend the gallery access email to the customer
|
||||
description: >
|
||||
Queues the standard `gallery_created` email for the event's customer. Useful when resending
|
||||
credentials to the customer or communicating an updated password. Requires admin authentication.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/EventId'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResendEmailRequest'
|
||||
example:
|
||||
password: NewSecurePassword!
|
||||
responses:
|
||||
'200':
|
||||
description: Email successfully queued for delivery.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResendEmailResponse'
|
||||
'401':
|
||||
description: Authentication required or token invalid.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Event not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected server error while queuing the email.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
@@ -19,5 +19,28 @@ export default tseslint.config([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'no-useless-escape': 'off',
|
||||
'no-case-declarations': 'off',
|
||||
'prefer-const': 'off',
|
||||
'no-control-regex': 'off',
|
||||
'no-useless-catch': 'off',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-debugger': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+1122
-406
File diff suppressed because it is too large
Load Diff
+10
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.12",
|
||||
"version": "1.1.14",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:check": "tsc -b && vite build",
|
||||
"build": "node ./scripts/build.js",
|
||||
"build:check": "tsc -b && node ./scripts/build.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@@ -49,8 +49,9 @@
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitejs/plugin-react": "^4.5.3",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
@@ -59,6 +60,10 @@
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.34.1",
|
||||
"vite": "^7.1.6"
|
||||
"vite": "^7.1.12",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.45.1"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
import { execSync } from 'node:child_process';
|
||||
import { resolve, join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import https from 'node:https';
|
||||
|
||||
const TARGET_NODE_VERSION = '20.19.1';
|
||||
const env = { ...process.env, ROLLUP_USE_NODE_JS: 'true' };
|
||||
const viteBin = resolve(process.cwd(), 'node_modules', 'vite', 'bin', 'vite.js');
|
||||
|
||||
async function ensureNodeBinary(version) {
|
||||
const platformMap = {
|
||||
linux: 'linux',
|
||||
darwin: 'darwin',
|
||||
win32: 'win'
|
||||
};
|
||||
const archMap = {
|
||||
x64: 'x64',
|
||||
arm64: 'arm64'
|
||||
};
|
||||
|
||||
const platform = platformMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
|
||||
if (!platform || !arch) {
|
||||
throw new Error(`Unsupported platform/architecture combination: ${process.platform} ${process.arch}`);
|
||||
}
|
||||
|
||||
if (platform === 'win') {
|
||||
throw new Error('Automatic Node.js download is not supported on Windows runners. Please upgrade Node.js to >=20.19 manually.');
|
||||
}
|
||||
|
||||
const cacheDir = join(process.cwd(), 'node_modules', '.cache', `node-v${version}-${platform}-${arch}`);
|
||||
const nodeBinary = join(cacheDir, `node-v${version}-${platform}-${arch}`, 'bin', 'node');
|
||||
|
||||
try {
|
||||
await fs.access(nodeBinary);
|
||||
return nodeBinary;
|
||||
} catch {
|
||||
// continue with download
|
||||
}
|
||||
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
const archiveExt = platform === 'win' ? 'zip' : 'tar.xz';
|
||||
const archiveName = `node-v${version}-${platform}-${arch}.${archiveExt}`;
|
||||
const archivePath = join(cacheDir, archiveName);
|
||||
const downloadUrl = `https://nodejs.org/dist/v${version}/${archiveName}`;
|
||||
|
||||
await downloadFile(downloadUrl, archivePath);
|
||||
|
||||
if (archiveExt === 'tar.xz') {
|
||||
execSync(`tar -xf "${archivePath}" -C "${cacheDir}"`, { stdio: 'inherit' });
|
||||
} else {
|
||||
throw new Error('ZIP extraction not implemented. Please upgrade Node.js manually.');
|
||||
}
|
||||
|
||||
await fs.rm(archivePath, { force: true });
|
||||
return nodeBinary;
|
||||
}
|
||||
|
||||
async function downloadFile(url, destination) {
|
||||
await new Promise((resolvePromise, rejectPromise) => {
|
||||
const fileStream = createWriteStream(destination);
|
||||
https.get(url, (response) => {
|
||||
if (response.statusCode && response.statusCode >= 400) {
|
||||
rejectPromise(new Error(`Failed to download ${url}: HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
pipeline(response, fileStream).then(resolvePromise).catch(rejectPromise);
|
||||
}).on('error', rejectPromise);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Node.js ${process.version} detected; forcing Rollup's JavaScript fallback for compatibility.`);
|
||||
|
||||
if (!process.env.USE_DOWNLOADED_NODE) {
|
||||
const [major] = process.versions.node.split('.').map(Number);
|
||||
if (major < 20) {
|
||||
const nodeBinary = await ensureNodeBinary(TARGET_NODE_VERSION);
|
||||
const childEnv = { ...env, USE_DOWNLOADED_NODE: '1' };
|
||||
execSync(`"${nodeBinary}" "${viteBin}" build`, { stdio: 'inherit', env: childEnv });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
execSync(`node "${viteBin}" build`, { stdio: 'inherit', env });
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -25,7 +25,7 @@ export const MaintenanceMode: React.FC = () => {
|
||||
try {
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Return empty object if settings can't be fetched
|
||||
return {};
|
||||
}
|
||||
@@ -110,4 +110,4 @@ export const MaintenanceMode: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
|
||||
if (isMounted) {
|
||||
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setHasAdminSession(false);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setImageSrc(null);
|
||||
|
||||
// Make authenticated request to get the image
|
||||
const response = await api.get(src, {
|
||||
@@ -31,11 +33,11 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
|
||||
if (!cancelled) {
|
||||
// Create object URL from blob
|
||||
const imageUrl = URL.createObjectURL(response.data);
|
||||
setImageSrc(imageUrl);
|
||||
objectUrl = URL.createObjectURL(response.data);
|
||||
setImageSrc(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// Image loading failed - handled by error state
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
@@ -51,8 +53,8 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
// Cleanup function
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (imageSrc) {
|
||||
URL.revokeObjectURL(imageSrc);
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
@@ -74,4 +76,4 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
}
|
||||
|
||||
return <img src={imageSrc || ''} alt={alt} {...props} />;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
await photosService.deletePhoto(eventId, photo.id);
|
||||
toast.success('Photo deleted successfully');
|
||||
onPhotosDeleted();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to delete photo');
|
||||
setDeletingPhotos(prev => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -90,7 +90,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
onPhotosDeleted();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to delete photos');
|
||||
setDeletingPhotos(new Set());
|
||||
} finally {
|
||||
@@ -103,7 +103,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
try {
|
||||
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
|
||||
toast.success('Download started');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to download photo');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../../config/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
@@ -13,6 +14,9 @@ interface PhotoUploadProps {
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||
|
||||
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -29,6 +33,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
});
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const maxFilesPerUpload = React.useMemo(() => {
|
||||
const rawValue = settings?.general_max_files_per_upload;
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
|
||||
}, [settings]);
|
||||
|
||||
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const imageFiles = files.filter(file =>
|
||||
@@ -37,13 +57,19 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
// Check total file count with existing files
|
||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||
if (totalFiles > 500) {
|
||||
const allowedNewFiles = 500 - selectedFiles.length;
|
||||
if (totalFiles > maxFilesPerUpload) {
|
||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||
if (allowedNewFiles <= 0) {
|
||||
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
|
||||
toast.error(
|
||||
t('upload.maxFilesReached', { limit: maxFilesPerUpload }) ||
|
||||
`Maximum ${maxFilesPerUpload} files allowed`
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
|
||||
toast.warning(
|
||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
|
||||
);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
return;
|
||||
}
|
||||
@@ -59,8 +85,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
// Validate file count
|
||||
if (selectedFiles.length > 500) {
|
||||
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
|
||||
if (selectedFiles.length > maxFilesPerUpload) {
|
||||
toast.error(
|
||||
t('upload.tooManyFiles', { limit: maxFilesPerUpload }) ||
|
||||
`Maximum ${maxFilesPerUpload} files can be uploaded at once`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +97,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
setUploadProgress(0);
|
||||
|
||||
// For large uploads, chunk the files to prevent memory issues
|
||||
const CHUNK_SIZE = 50; // Upload 50 files at a time
|
||||
const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
||||
@@ -187,7 +216,21 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('upload.fileRequirements')}
|
||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
"text-xs mt-2",
|
||||
remainingSlots === 0 ? "text-red-600" : "text-neutral-500"
|
||||
)}
|
||||
>
|
||||
{remainingSlots === 0
|
||||
? t('upload.limitReached', { limit: maxFilesPerUpload })
|
||||
: t('upload.limitInfo', {
|
||||
selected: selectedFiles.length,
|
||||
limit: maxFilesPerUpload,
|
||||
remaining: remainingSlots,
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import {
|
||||
getActiveGallerySlug,
|
||||
getGalleryToken,
|
||||
inferGallerySlugFromLocation,
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../../utils/galleryAuthStorage';
|
||||
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
@@ -52,7 +58,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
}) => {
|
||||
const unusedProps = {
|
||||
protectFromDownload,
|
||||
slug,
|
||||
photoId,
|
||||
requiresToken,
|
||||
secureUrlTemplate,
|
||||
@@ -76,7 +81,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
let aborted = false;
|
||||
const objectUrls: string[] = [];
|
||||
|
||||
// Determine which token to use based on context
|
||||
if (!src) {
|
||||
@@ -88,37 +94,79 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
|
||||
// Create a new URL with auth header
|
||||
const resolveSlug = (candidateSrc?: string): string | null => {
|
||||
if (slug) {
|
||||
return slug;
|
||||
}
|
||||
const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null;
|
||||
if (fromUrl) {
|
||||
return fromUrl;
|
||||
}
|
||||
return getActiveGallerySlug() || inferGallerySlugFromLocation();
|
||||
};
|
||||
|
||||
const fetchWithAuth = async (rawUrl: string | undefined | null): Promise<string> => {
|
||||
if (!rawUrl) {
|
||||
throw new Error('No URL provided');
|
||||
}
|
||||
|
||||
// Build full URL for the image
|
||||
const fullImageUrl = rawUrl.startsWith('/admin')
|
||||
? buildResourceUrl(`/api${rawUrl}`)
|
||||
: rawUrl.startsWith('/')
|
||||
? buildResourceUrl(rawUrl)
|
||||
: rawUrl;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const slugForRequest = resolveSlug(rawUrl);
|
||||
const token = getGalleryToken(slugForRequest);
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(fullImageUrl, {
|
||||
credentials: 'include',
|
||||
headers: Object.keys(headers).length ? headers : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
objectUrls.push(objectUrl);
|
||||
return objectUrl;
|
||||
};
|
||||
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
// Use the src as-is since it should already be the correct endpoint
|
||||
let imageUrl = src;
|
||||
|
||||
// Build full URL for the image
|
||||
// For API paths that start with /admin, we need to prepend /api
|
||||
const fullImageUrl = imageUrl.startsWith('/admin')
|
||||
? buildResourceUrl(`/api${imageUrl}`)
|
||||
: imageUrl.startsWith('/')
|
||||
? buildResourceUrl(imageUrl)
|
||||
: imageUrl;
|
||||
|
||||
// Fetch authenticated image
|
||||
const response = await fetch(fullImageUrl, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
||||
const primaryUrl = await fetchWithAuth(src);
|
||||
if (!aborted) {
|
||||
setImageSrc(primaryUrl);
|
||||
setError(false);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageSrc(objectUrl);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
// Image loading failed - use fallback
|
||||
setError(true);
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
if (fallbackSrc && fallbackSrc !== src) {
|
||||
try {
|
||||
const fallbackUrl = await fetchWithAuth(fallbackSrc);
|
||||
if (!aborted) {
|
||||
setImageSrc(fallbackUrl);
|
||||
setError(false);
|
||||
}
|
||||
return;
|
||||
} catch (fallbackError) {
|
||||
// Swallow and mark error below
|
||||
}
|
||||
}
|
||||
if (!aborted) {
|
||||
setError(true);
|
||||
setImageSrc('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -127,11 +175,11 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
aborted = true;
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [src, fallbackSrc, useWatermark, isGallery]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [src, fallbackSrc, slug]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -209,7 +209,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Invalid theme format - use default
|
||||
// Fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
|
||||
@@ -12,7 +12,7 @@ interface GridPhotoProps {
|
||||
photo: Photo;
|
||||
isSelected: boolean;
|
||||
isSelectionMode: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onClick: () => void;
|
||||
onDownload: (e: React.MouseEvent) => void;
|
||||
onToggleSelect: () => void;
|
||||
animationType?: string;
|
||||
@@ -57,6 +57,79 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
liked = false,
|
||||
onLikeSuccess
|
||||
}) => {
|
||||
const [overlayVisible, setOverlayVisible] = React.useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
|
||||
const overlayTimeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(hover: none) and (pointer: coarse)');
|
||||
const updateTouchState = () => {
|
||||
const hasNavigator = typeof navigator !== 'undefined';
|
||||
setIsTouchDevice(
|
||||
mediaQuery.matches ||
|
||||
('ontouchstart' in window) ||
|
||||
(hasNavigator && navigator.maxTouchPoints > 0)
|
||||
);
|
||||
};
|
||||
|
||||
updateTouchState();
|
||||
|
||||
const listener = (event: MediaQueryListEvent) => {
|
||||
setIsTouchDevice(event.matches);
|
||||
};
|
||||
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
} else if (mediaQuery.addListener) {
|
||||
mediaQuery.addListener(listener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mediaQuery.removeEventListener) {
|
||||
mediaQuery.removeEventListener('change', listener);
|
||||
} else if (mediaQuery.removeListener) {
|
||||
mediaQuery.removeListener(listener);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hideOverlay = React.useCallback(() => {
|
||||
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(overlayTimeoutRef.current);
|
||||
}
|
||||
overlayTimeoutRef.current = null;
|
||||
setOverlayVisible(false);
|
||||
}, []);
|
||||
|
||||
const showOverlayTemporarily = React.useCallback(() => {
|
||||
setOverlayVisible(true);
|
||||
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(overlayTimeoutRef.current);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
overlayTimeoutRef.current = window.setTimeout(() => {
|
||||
overlayTimeoutRef.current = null;
|
||||
setOverlayVisible(false);
|
||||
}, 2500);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(overlayTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSelectionMode) {
|
||||
hideOverlay();
|
||||
}
|
||||
}, [isSelectionMode, hideOverlay]);
|
||||
|
||||
// handled by parent layout; kept here for type completeness but not used
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
@@ -73,11 +146,34 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
const commentCount = photo.comment_count ?? 0;
|
||||
const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions);
|
||||
|
||||
const overlayVisibilityClass = overlayVisible
|
||||
? 'opacity-100 md:opacity-100'
|
||||
: 'opacity-0 md:opacity-0';
|
||||
|
||||
const checkboxVisibilityClass =
|
||||
isSelected || isSelectionMode || overlayVisible
|
||||
? 'opacity-100 md:opacity-100'
|
||||
: 'opacity-0 md:opacity-0';
|
||||
|
||||
const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isTouchDevice && !overlayVisible && !isSelectionMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showOverlayTemporarily();
|
||||
return;
|
||||
}
|
||||
|
||||
onClick();
|
||||
if (isTouchDevice) {
|
||||
hideOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative group cursor-pointer aspect-square ${animationClass}`}
|
||||
onClick={onClick}
|
||||
onClick={handlePhotoClick}
|
||||
style={{
|
||||
opacity: !inView && animationType === 'fade' ? 0 : 1
|
||||
}}
|
||||
@@ -108,14 +204,15 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||
<div className={`absolute inset-0 bg-black/40 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2 ${overlayVisibilityClass} md:group-hover:opacity-100`}>
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
onClick();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
@@ -124,7 +221,11 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
{allowDownloads && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={onDownload}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload(e);
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
@@ -133,7 +234,11 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
{showFeedbackActions && onQuickComment && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onQuickComment();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Comment on photo"
|
||||
title="Comment"
|
||||
>
|
||||
@@ -148,6 +253,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
e.stopPropagation();
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||
onRequireIdentity('like', photo.id);
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
// Optimistic UI: mark as liked immediately
|
||||
@@ -163,6 +269,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Like photo"
|
||||
aria-pressed={liked}
|
||||
@@ -182,9 +289,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
role="checkbox"
|
||||
aria-checked={isSelected}
|
||||
data-testid={`gallery-photo-checkbox-${photo.id}`}
|
||||
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
className={`absolute top-2 right-2 z-20 transition-opacity ${checkboxVisibilityClass} md:group-hover:opacity-100`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api } from '../config/api';
|
||||
import { authService, galleryService } from '../services';
|
||||
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
||||
@@ -61,52 +62,133 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get current gallery slug from URL
|
||||
const getCurrentGallerySlug = () => {
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
||||
return pathParts[2];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const [routeError, setRouteError] = useState<string | null>(null);
|
||||
const location = useLocation();
|
||||
const [routeInfo, setRouteInfo] = useState<{ slug: string | null; token?: string; identifier: string | null; ready: boolean }>({
|
||||
slug: null,
|
||||
token: undefined,
|
||||
identifier: null,
|
||||
ready: false,
|
||||
});
|
||||
const lastResolvedIdentifier = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOldGalleryAuth();
|
||||
}, []);
|
||||
|
||||
const slugAtMount = getCurrentGallerySlug();
|
||||
if (slugAtMount) {
|
||||
setActiveGallerySlug(slugAtMount);
|
||||
} else {
|
||||
clearActiveGallerySlug();
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const initialise = async () => {
|
||||
const currentSlug = getCurrentGallerySlug();
|
||||
const parseRoute = async () => {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (!currentSlug) {
|
||||
setIsLoading(false);
|
||||
if (segments[0] !== 'gallery') {
|
||||
if (!cancelled) {
|
||||
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||
setRouteError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveGallerySlug(currentSlug);
|
||||
const identifier = segments[1] || null;
|
||||
const tokenSegment = segments[2];
|
||||
|
||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||
if (storedEvent) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedEvent);
|
||||
if (parsed && parsed.id) {
|
||||
const normalizedStored = normalizeEvent(parsed);
|
||||
setEvent(normalizedStored);
|
||||
if (normalizedStored) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
if (!identifier) {
|
||||
if (!cancelled) {
|
||||
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const looksLikeToken = /^[0-9a-fA-F]{32}$/.test(identifier) && !tokenSegment;
|
||||
|
||||
if (looksLikeToken) {
|
||||
if (lastResolvedIdentifier.current === identifier) {
|
||||
setRouteInfo(prev => ({
|
||||
slug: prev.slug,
|
||||
token: prev.token,
|
||||
identifier,
|
||||
ready: true,
|
||||
}));
|
||||
setRouteError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await galleryService.resolveIdentifier(identifier);
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = identifier;
|
||||
setRouteInfo({
|
||||
slug: resolved.slug,
|
||||
token: resolved.token,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(null);
|
||||
} catch (err: any) {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = identifier;
|
||||
setRouteInfo({
|
||||
slug: null,
|
||||
token: undefined,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(err?.response?.data?.error || 'Unable to resolve gallery link');
|
||||
}
|
||||
} else {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setRouteInfo({
|
||||
slug: identifier,
|
||||
token: tokenSegment,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(null);
|
||||
}
|
||||
};
|
||||
|
||||
setRouteInfo(prev => ({ ...prev, ready: false }));
|
||||
parseRoute();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeInfo.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!routeInfo.slug) {
|
||||
clearActiveGallerySlug();
|
||||
setIsAuthenticated(false);
|
||||
setEvent(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSlug = routeInfo.slug;
|
||||
setActiveGallerySlug(currentSlug);
|
||||
|
||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||
if (storedEvent) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedEvent);
|
||||
if (parsed && parsed.id) {
|
||||
const normalizedStored = normalizeEvent(parsed);
|
||||
setEvent(normalizedStored);
|
||||
if (normalizedStored) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
}
|
||||
}
|
||||
|
||||
const initialise = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
|
||||
@@ -118,7 +200,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
setIsAuthenticated(true);
|
||||
|
||||
if (!storedEvent) {
|
||||
// Fetch gallery details to hydrate context
|
||||
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
||||
if (galleryData?.event) {
|
||||
const normalizedEvent = normalizeEvent(galleryData.event);
|
||||
@@ -132,14 +213,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
return;
|
||||
}
|
||||
|
||||
// If no active session, check for share token in URL
|
||||
const parts = window.location.pathname.split('/');
|
||||
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
|
||||
|
||||
if (urlToken) {
|
||||
const verify = await galleryService.verifyToken(currentSlug, urlToken);
|
||||
if (routeInfo.token) {
|
||||
const verify = await galleryService.verifyToken(currentSlug, routeInfo.token);
|
||||
if (verify?.valid) {
|
||||
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
||||
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
|
||||
if (response?.event) {
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
@@ -156,29 +233,33 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
}
|
||||
}
|
||||
|
||||
// No valid session found
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
setEvent(null);
|
||||
clearGalleryToken(currentSlug);
|
||||
} catch (error) {
|
||||
} catch (initialiseError: any) {
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
setEvent(null);
|
||||
clearGalleryToken(currentSlug);
|
||||
if (initialiseError?.response?.data?.error) {
|
||||
setError(initialiseError.response.data.error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initialise();
|
||||
|
||||
return () => {
|
||||
clearActiveGallerySlug();
|
||||
};
|
||||
}, []);
|
||||
}, [routeInfo]);
|
||||
|
||||
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
|
||||
try {
|
||||
setRouteError(null);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||
@@ -190,7 +271,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
}
|
||||
setActiveGallerySlug(slug);
|
||||
|
||||
// Store event data for quick reloads (non-sensitive)
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
@@ -203,7 +283,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const currentSlug = getCurrentGallerySlug();
|
||||
const currentSlug = routeInfo.slug;
|
||||
if (currentSlug) {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
clearGalleryToken(currentSlug);
|
||||
@@ -222,7 +302,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
login,
|
||||
logout,
|
||||
isLoading,
|
||||
error,
|
||||
error: routeError ?? error,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,12 +2,18 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { galleryService } from '../services';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useGalleryInfo = (slug: string, token?: string) => {
|
||||
export const useGalleryInfo = (slug?: string, token?: string, enabled: boolean = true) => {
|
||||
return useQuery({
|
||||
queryKey: ['gallery-info', slug, token],
|
||||
queryFn: () => galleryService.getGalleryInfo(slug, token),
|
||||
queryFn: () => {
|
||||
if (!slug) {
|
||||
throw new Error('Gallery slug is required');
|
||||
}
|
||||
return galleryService.getGalleryInfo(slug, token);
|
||||
},
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: Boolean(slug) && enabled,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"noCategory": "Keine Kategorie",
|
||||
"eventSpecific": "(Veranstaltungsspezifisch)",
|
||||
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
|
||||
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei)",
|
||||
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
|
||||
"selectedFiles": "Ausgewählte Dateien",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"uploadComplete": "Upload abgeschlossen!",
|
||||
@@ -59,9 +59,11 @@
|
||||
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
|
||||
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
|
||||
"importFromSelectedFolder": "Ausgewählten Ordner importieren",
|
||||
"maxFilesReached": "Maximal 500 Dateien erlaubt",
|
||||
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
|
||||
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden",
|
||||
"maxFilesReached": "Maximal {{limit}} Dateien erlaubt",
|
||||
"someFilesSkipped": "Nur {{allowed}} weitere Dateien erlaubt (Limit {{limit}})",
|
||||
"tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden",
|
||||
"limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)",
|
||||
"limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)",
|
||||
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..."
|
||||
},
|
||||
"navigation": {
|
||||
@@ -773,12 +775,16 @@
|
||||
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
|
||||
"maxFileSize": "Max. Dateigröße (MB)",
|
||||
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
|
||||
"maxFilesPerUpload": "Max. Dateien pro Upload",
|
||||
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
|
||||
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||
"featureToggles": "Funktionsschalter",
|
||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
||||
"enableAnalytics": "Analytics-Tracking aktivieren",
|
||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
||||
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
|
||||
"enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.",
|
||||
"maintenanceMode": "Wartungsmodus aktivieren",
|
||||
"language": "Sprache",
|
||||
"defaultLanguage": "Standardsprache",
|
||||
@@ -886,7 +892,11 @@
|
||||
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
||||
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
||||
"maxLoginAttempts": "Max. Anmeldeversuche",
|
||||
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche vor Sperrung",
|
||||
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche pro IP vor Sperrung",
|
||||
"attemptWindowMinutes": "Versuchsfenster (Minuten)",
|
||||
"attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden",
|
||||
"lockoutDurationMinutes": "Sperrdauer (Minuten)",
|
||||
"lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben",
|
||||
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
|
||||
"recaptchaSettings": "reCAPTCHA-Einstellungen",
|
||||
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"noCategory": "No category",
|
||||
"eventSpecific": "(Event specific)",
|
||||
"clickToUpload": "Click to upload or drag and drop",
|
||||
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file)",
|
||||
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)",
|
||||
"selectedFiles": "Selected files",
|
||||
"uploading": "Uploading...",
|
||||
"uploadComplete": "Upload complete!",
|
||||
@@ -59,9 +59,11 @@
|
||||
"externalImportInfo": "All pictures from the selected folder will be imported.",
|
||||
"selectExternalFolder": "Select external folder under /external-media",
|
||||
"importFromSelectedFolder": "Import from selected folder",
|
||||
"maxFilesReached": "Maximum 500 files allowed",
|
||||
"someFilesSkipped": "Some files were skipped (500 file limit)",
|
||||
"tooManyFiles": "Maximum 500 files can be uploaded at once",
|
||||
"maxFilesReached": "Maximum {{limit}} files allowed",
|
||||
"someFilesSkipped": "Only {{allowed}} more files can be added (limit {{limit}})",
|
||||
"tooManyFiles": "Maximum {{limit}} files can be uploaded at once",
|
||||
"limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)",
|
||||
"limitReached": "Upload limit reached ({{limit}} files per batch)",
|
||||
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..."
|
||||
},
|
||||
"navigation": {
|
||||
@@ -453,12 +455,16 @@
|
||||
"defaultExpirationHelp": "How long galleries remain active by default",
|
||||
"maxFileSize": "Max File Size (MB)",
|
||||
"maxFileSizeHelp": "Maximum size per uploaded photo",
|
||||
"maxFilesPerUpload": "Max Files per Upload",
|
||||
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
|
||||
"allowedFileTypes": "Allowed File Types",
|
||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||
"featureToggles": "Feature Toggles",
|
||||
"enableWatermark": "Enable watermark on photos",
|
||||
"enableAnalytics": "Enable analytics tracking",
|
||||
"enableRegistration": "Allow self-registration for admins",
|
||||
"enableShortGalleryUrls": "Use short gallery URLs",
|
||||
"enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.",
|
||||
"maintenanceMode": "Enable maintenance mode",
|
||||
"language": "Language",
|
||||
"defaultLanguage": "Default Language",
|
||||
@@ -566,7 +572,11 @@
|
||||
"sessionTimeout": "Session Timeout (minutes)",
|
||||
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
||||
"maxLoginAttempts": "Max Login Attempts",
|
||||
"maxLoginAttemptsHelp": "Maximum failed login attempts before lockout",
|
||||
"maxLoginAttemptsHelp": "Maximum failed login attempts per IP before lockout",
|
||||
"attemptWindowMinutes": "Attempt Window (minutes)",
|
||||
"attemptWindowMinutesHelp": "How long to look back when counting failed login attempts",
|
||||
"lockoutDurationMinutes": "Lockout Duration (minutes)",
|
||||
"lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures",
|
||||
"enable2FA": "Enable two-factor authentication for admins",
|
||||
"recaptchaSettings": "reCAPTCHA Settings",
|
||||
"enableRecaptcha": "Enable reCAPTCHA for login forms",
|
||||
|
||||
@@ -11,13 +11,14 @@ import { useGalleryAuth, useTheme } from '../contexts';
|
||||
import { useGalleryInfo } from '../hooks/useGallery';
|
||||
import { GalleryView } from '../components/gallery';
|
||||
import { analyticsService } from '../services/analytics.service';
|
||||
import { galleryService } from '../services';
|
||||
import { api } from '../config/api';
|
||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||
import { buildResourceUrl } from '../utils/url';
|
||||
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
export const GalleryPage: React.FC = () => {
|
||||
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||
const { slug: rawSlug, token: rawToken } = useParams<{ slug: string; token?: string }>();
|
||||
const { isAuthenticated, login, event } = useGalleryAuth();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
@@ -27,10 +28,82 @@ export const GalleryPage: React.FC = () => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
|
||||
const [resolvedSlug, setResolvedSlug] = useState<string | null>(() => {
|
||||
if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) {
|
||||
return null;
|
||||
}
|
||||
return rawSlug || null;
|
||||
});
|
||||
const [resolvedToken, setResolvedToken] = useState<string | undefined>(rawToken);
|
||||
const [isResolvingIdentifier, setIsResolvingIdentifier] = useState<boolean>(() =>
|
||||
Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug))
|
||||
);
|
||||
const [identifierError, setIdentifierError] = useState<string | null>(null);
|
||||
const lastResolvedIdentifier = React.useRef<string | null>(null);
|
||||
|
||||
// Fetch gallery info (public data)
|
||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const looksLikeToken = Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug));
|
||||
|
||||
if (!rawSlug) {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setResolvedSlug(null);
|
||||
setResolvedToken(rawToken);
|
||||
setIsResolvingIdentifier(false);
|
||||
setIdentifierError(null);
|
||||
} else if (!looksLikeToken) {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setResolvedSlug(rawSlug);
|
||||
setResolvedToken(rawToken);
|
||||
setIsResolvingIdentifier(false);
|
||||
setIdentifierError(null);
|
||||
} else if (lastResolvedIdentifier.current !== rawSlug) {
|
||||
setIsResolvingIdentifier(true);
|
||||
setIdentifierError(null);
|
||||
|
||||
galleryService.resolveIdentifier(rawSlug)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = rawSlug;
|
||||
setResolvedSlug(data.slug);
|
||||
setResolvedToken(data.token);
|
||||
setIdentifierError(null);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = rawSlug;
|
||||
setResolvedSlug(null);
|
||||
setResolvedToken(undefined);
|
||||
const message = error?.response?.data?.error || 'Unable to resolve gallery link';
|
||||
setIdentifierError(message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsResolvingIdentifier(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setIsResolvingIdentifier(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [rawSlug, rawToken]);
|
||||
|
||||
const canFetchGalleryInfo = Boolean(resolvedSlug) && !isResolvingIdentifier;
|
||||
const {
|
||||
data: galleryInfo,
|
||||
isLoading: isLoadingInfoQuery,
|
||||
error: infoError
|
||||
} = useGalleryInfo(canFetchGalleryInfo ? resolvedSlug ?? undefined : undefined, resolvedToken, canFetchGalleryInfo);
|
||||
const isLoadingInfo = isLoadingInfoQuery || isResolvingIdentifier;
|
||||
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setAutoLoginAttempted(false);
|
||||
}, [resolvedSlug]);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
@@ -91,14 +164,14 @@ export const GalleryPage: React.FC = () => {
|
||||
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
if (!resolvedSlug || isResolvingIdentifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
|
||||
setAutoLoginAttempted(true);
|
||||
setIsLoggingIn(true);
|
||||
login(slug, '')
|
||||
login(resolvedSlug, '')
|
||||
.then(() => {
|
||||
setLoginError(null);
|
||||
})
|
||||
@@ -112,7 +185,7 @@ export const GalleryPage: React.FC = () => {
|
||||
setIsLoggingIn(false);
|
||||
});
|
||||
}
|
||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]);
|
||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = galleryInfo
|
||||
@@ -131,11 +204,16 @@ export const GalleryPage: React.FC = () => {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
await login(slug!, requiresPassword ? password : '', recaptchaToken);
|
||||
if (!resolvedSlug) {
|
||||
setLoginError(t('errors.galleryNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken);
|
||||
|
||||
if (requiresPassword) {
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
gallery: resolvedSlug,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
@@ -158,7 +236,7 @@ export const GalleryPage: React.FC = () => {
|
||||
// Track failed password entry
|
||||
if (requiresPassword) {
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
gallery: resolvedSlug ?? rawSlug ?? 'unknown',
|
||||
success: false,
|
||||
statusCode
|
||||
});
|
||||
@@ -182,6 +260,59 @@ export const GalleryPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (identifierError && !resolvedSlug && !isResolvingIdentifier) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{t('errors.galleryNotFound')}
|
||||
</h2>
|
||||
<p className="text-neutral-600">
|
||||
{identifierError}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-8 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-neutral-500">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (infoError) {
|
||||
// Check if it's an archived gallery error
|
||||
@@ -299,9 +430,11 @@ export const GalleryPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const gallerySlugForView = resolvedSlug ?? rawSlug ?? '';
|
||||
|
||||
// Show gallery view if authenticated
|
||||
if (isAuthenticated && event) {
|
||||
return <GalleryView slug={slug!} event={event} />;
|
||||
return <GalleryView slug={gallerySlugForView} event={event} />;
|
||||
}
|
||||
|
||||
// Show login form
|
||||
|
||||
@@ -68,6 +68,7 @@ export const AdminLoginPage: React.FC = () => {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
toast.dismiss();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
|
||||
@@ -25,7 +25,7 @@ interface FormData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_email: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
require_password: boolean;
|
||||
password: string;
|
||||
@@ -122,7 +122,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
event_type: 'wedding',
|
||||
event_name: '',
|
||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
host_email: '',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
require_password: true,
|
||||
password: '',
|
||||
@@ -198,10 +198,10 @@ export const CreateEventPage: React.FC = () => {
|
||||
newErrors.event_name = t('validation.eventNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_email) {
|
||||
newErrors.host_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
|
||||
newErrors.host_email = t('validation.invalidEmailFormat');
|
||||
if (!formData.customer_email) {
|
||||
newErrors.customer_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
||||
newErrors.customer_email = t('validation.invalidEmailFormat');
|
||||
}
|
||||
|
||||
if (!formData.admin_email) {
|
||||
@@ -245,7 +245,8 @@ export const CreateEventPage: React.FC = () => {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
host_email: formData.host_email,
|
||||
customer_name: formData.customer_email.split('@')[0],
|
||||
customer_email: formData.customer_email,
|
||||
admin_email: formData.admin_email,
|
||||
require_password: formData.require_password,
|
||||
password: formData.require_password ? formData.password : undefined,
|
||||
@@ -390,15 +391,15 @@ export const CreateEventPage: React.FC = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Customer Email */}
|
||||
<div>
|
||||
<label htmlFor="host_email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
<label htmlFor="customer_email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.hostEmail')}
|
||||
</label>
|
||||
<Input
|
||||
id="host_email"
|
||||
id="customer_email"
|
||||
type="email"
|
||||
value={formData.host_email}
|
||||
onChange={handleInputChange('host_email')}
|
||||
error={errors.host_email}
|
||||
value={formData.customer_email}
|
||||
onChange={handleInputChange('customer_email')}
|
||||
error={errors.customer_email}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
|
||||
@@ -27,8 +27,8 @@ interface FormData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_name: string;
|
||||
host_email: string;
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
require_password: boolean;
|
||||
password: string;
|
||||
@@ -86,8 +86,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
event_type: 'wedding',
|
||||
event_name: '',
|
||||
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
|
||||
host_name: '',
|
||||
host_email: '',
|
||||
customer_name: '',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
require_password: true,
|
||||
password: '',
|
||||
@@ -184,14 +184,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
newErrors.event_date = t('validation.eventDateRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_name) {
|
||||
newErrors.host_name = t('validation.hostNameRequired');
|
||||
if (!formData.customer_name) {
|
||||
newErrors.customer_name = t('validation.hostNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_email) {
|
||||
newErrors.host_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
|
||||
newErrors.host_email = t('validation.invalidEmailFormat');
|
||||
if (!formData.customer_email) {
|
||||
newErrors.customer_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
||||
newErrors.customer_email = t('validation.invalidEmailFormat');
|
||||
}
|
||||
|
||||
if (!formData.admin_email) {
|
||||
@@ -236,8 +236,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
host_name: formData.host_name,
|
||||
host_email: formData.host_email,
|
||||
customer_name: formData.customer_name,
|
||||
customer_email: formData.customer_email,
|
||||
admin_email: formData.admin_email,
|
||||
require_password: formData.require_password,
|
||||
password: formData.require_password ? formData.password : undefined,
|
||||
@@ -472,9 +472,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
<Input
|
||||
label={t('events.hostName')}
|
||||
placeholder={t('events.hostNamePlaceholder')}
|
||||
value={formData.host_name}
|
||||
onChange={handleInputChange('host_name')}
|
||||
error={errors.host_name}
|
||||
value={formData.customer_name}
|
||||
onChange={handleInputChange('customer_name')}
|
||||
error={errors.customer_name}
|
||||
leftIcon={<Calendar className="w-5 h-5" />}
|
||||
/>
|
||||
|
||||
@@ -482,9 +482,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
type="email"
|
||||
label={t('events.hostEmail')}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
value={formData.host_email}
|
||||
onChange={handleInputChange('host_email')}
|
||||
error={errors.host_email}
|
||||
value={formData.customer_email}
|
||||
onChange={handleInputChange('customer_email')}
|
||||
error={errors.customer_email}
|
||||
leftIcon={<Mail className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +122,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_user_uploads: boolean;
|
||||
upload_category_id: number | null;
|
||||
hero_photo_id: number | null;
|
||||
host_name: string;
|
||||
customer_name: string;
|
||||
source_mode: 'managed' | 'reference';
|
||||
external_path: string;
|
||||
require_password: boolean;
|
||||
@@ -138,7 +138,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_user_uploads: false,
|
||||
upload_category_id: null,
|
||||
hero_photo_id: null,
|
||||
host_name: '',
|
||||
customer_name: '',
|
||||
source_mode: 'managed',
|
||||
external_path: '',
|
||||
require_password: true,
|
||||
@@ -282,7 +282,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_user_uploads: event.allow_user_uploads || false,
|
||||
upload_category_id: event.upload_category_id || null,
|
||||
hero_photo_id: event.hero_photo_id || null,
|
||||
host_name: event.host_name || '',
|
||||
customer_name: event.customer_name || '',
|
||||
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
||||
external_path: event.external_path || '',
|
||||
require_password: normalizeRequirePassword(event.require_password),
|
||||
@@ -389,8 +389,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
updateData.external_path = editForm.source_mode === 'reference'
|
||||
? externalPathToSave
|
||||
: null;
|
||||
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
||||
updateData.host_name = editForm.host_name;
|
||||
if (editForm.customer_name !== undefined && editForm.customer_name !== null) {
|
||||
updateData.customer_name = editForm.customer_name;
|
||||
}
|
||||
|
||||
if (editForm.new_password) {
|
||||
@@ -665,8 +665,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={editForm.host_name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, host_name: e.target.value }))}
|
||||
value={editForm.customer_name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, customer_name: e.target.value }))}
|
||||
placeholder={t('events.hostNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@@ -881,14 +881,14 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{event.host_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
||||
{event.customer_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">{event.host_email}</dd>
|
||||
<dd className="mt-1 text-sm text-neutral-900">{event.customer_email}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -159,7 +159,7 @@ export const EventsListPage: React.FC = () => {
|
||||
events = events.filter(e =>
|
||||
e.event_name.toLowerCase().includes(term) ||
|
||||
e.event_type.toLowerCase().includes(term) ||
|
||||
e.host_email.toLowerCase().includes(term)
|
||||
(e.customer_email || '').toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@ export const EventsListPage: React.FC = () => {
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
|
||||
<p className="text-xs text-neutral-500">{event.host_email}</p>
|
||||
<p className="text-xs text-neutral-500">{event.customer_email}</p>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
|
||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||
|
||||
const toBoolean = (value: unknown, defaultValue = false): boolean => {
|
||||
if (value === undefined || value === null) {
|
||||
@@ -93,11 +94,13 @@ export const SettingsPage: React.FC = () => {
|
||||
site_url: '',
|
||||
default_expiration_days: 30,
|
||||
max_file_size_mb: 50,
|
||||
max_files_per_upload: 500,
|
||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||
enable_watermark: false,
|
||||
enable_analytics: true,
|
||||
enable_registration: false,
|
||||
maintenance_mode: false,
|
||||
short_gallery_urls: false,
|
||||
default_language: 'en',
|
||||
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||
});
|
||||
@@ -109,6 +112,8 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_2fa: false,
|
||||
session_timeout_minutes: 60,
|
||||
max_login_attempts: 5,
|
||||
attempt_window_minutes: 15,
|
||||
lockout_duration_minutes: 30,
|
||||
enable_recaptcha: false,
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: ''
|
||||
@@ -145,11 +150,16 @@ export const SettingsPage: React.FC = () => {
|
||||
site_url: settings.general_site_url || '',
|
||||
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
||||
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
|
||||
max_files_per_upload: Math.min(
|
||||
MAX_FILES_PER_UPLOAD_LIMIT,
|
||||
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||
),
|
||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
||||
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
|
||||
default_language: settings.general_default_language || 'en',
|
||||
date_format: settings.general_date_format
|
||||
? (typeof settings.general_date_format === 'string'
|
||||
@@ -165,6 +175,8 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
||||
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
|
||||
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
|
||||
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
|
||||
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
|
||||
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||
@@ -645,7 +657,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.defaultExpiration')}
|
||||
@@ -670,6 +682,29 @@ export const SettingsPage: React.FC = () => {
|
||||
max="500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.maxFilesPerUpload')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={generalSettings.max_files_per_upload}
|
||||
onChange={(e) => {
|
||||
const parsed = parseInt(e.target.value, 10);
|
||||
setGeneralSettings(prev => ({
|
||||
...prev,
|
||||
max_files_per_upload: Number.isFinite(parsed)
|
||||
? Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, parsed))
|
||||
: prev.max_files_per_upload
|
||||
}));
|
||||
}}
|
||||
min="1"
|
||||
max={MAX_FILES_PER_UPLOAD_LIMIT}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -732,6 +767,21 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={generalSettings.short_gallery_urls}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableShortGalleryUrls')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 ml-6 mt-1">
|
||||
{t('settings.general.enableShortGalleryUrlsHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1300,7 +1350,7 @@ export const SettingsPage: React.FC = () => {
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.sessionTimeout')}
|
||||
@@ -1308,11 +1358,41 @@ export const SettingsPage: React.FC = () => {
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.session_timeout_minutes}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value) || 60 }))}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value, 10) || 60 }))}
|
||||
min="5"
|
||||
max="1440"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.attemptWindowMinutes')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.attempt_window_minutes}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, attempt_window_minutes: parseInt(e.target.value, 10) || 15 }))}
|
||||
min="1"
|
||||
max="1440"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{t('settings.security.attemptWindowMinutesHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.lockoutDurationMinutes')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.lockout_duration_minutes}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, lockout_duration_minutes: parseInt(e.target.value, 10) || 30 }))}
|
||||
min="1"
|
||||
max="1440"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{t('settings.security.lockoutDurationMinutesHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.maxLoginAttempts')}
|
||||
@@ -1320,10 +1400,13 @@ export const SettingsPage: React.FC = () => {
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.max_login_attempts}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value) || 5 }))}
|
||||
min="3"
|
||||
max="10"
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value, 10) || 5 }))}
|
||||
min="1"
|
||||
max="50"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{t('settings.security.maxLoginAttemptsHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,16 +2,27 @@ import { api } from '../config/api';
|
||||
import type { Event } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
const normalizeEvent = (event: Event): Event => ({
|
||||
...event,
|
||||
require_password: normalizeRequirePassword((event as any)?.require_password, true),
|
||||
});
|
||||
const normalizeEvent = (event: Event): Event => {
|
||||
const legacyHostName = (event as any)?.host_name;
|
||||
const legacyHostEmail = (event as any)?.host_email;
|
||||
|
||||
const customerName = event.customer_name ?? legacyHostName ?? undefined;
|
||||
const customerEmail = event.customer_email ?? legacyHostEmail ?? '';
|
||||
|
||||
return {
|
||||
...event,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: normalizeRequirePassword((event as any)?.require_password, true),
|
||||
};
|
||||
};
|
||||
|
||||
interface CreateEventData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_email: string;
|
||||
customer_name?: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
require_password?: boolean;
|
||||
password?: string;
|
||||
@@ -33,7 +44,8 @@ interface CreateEventData {
|
||||
interface UpdateEventData {
|
||||
event_name?: string;
|
||||
event_date?: string;
|
||||
host_email?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
admin_email?: string;
|
||||
require_password?: boolean;
|
||||
password?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from '../config/api';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
export const galleryService = {
|
||||
@@ -119,4 +119,9 @@ export const galleryService = {
|
||||
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async resolveIdentifier(identifier: string): Promise<ResolvedGalleryIdentifier> {
|
||||
const response = await api.get<ResolvedGalleryIdentifier>(`/gallery/resolve/${identifier}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ export interface Event {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_name?: string;
|
||||
host_email: string;
|
||||
customer_name?: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
@@ -111,6 +111,17 @@ export interface GalleryStats {
|
||||
unique_visitors: number;
|
||||
}
|
||||
|
||||
export interface ResolvedGalleryIdentifier {
|
||||
slug: string;
|
||||
token: string;
|
||||
matchType: string;
|
||||
share_link: string;
|
||||
share_path: string;
|
||||
share_url: string;
|
||||
short_enabled: boolean;
|
||||
requires_password: boolean;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
|
||||
Vendored
+1
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vitest" />
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/// <reference types="vitest" />
|
||||
// @ts-nocheck
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import type { UserConfig as VitestUserConfig } from 'vitest/config'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
const config: VitestUserConfig = {
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
@@ -28,5 +32,7 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(config as any)
|
||||
|
||||
Generated
+6
-5
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "wedding-photo-sharing",
|
||||
"name": "picpeak",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -558,7 +558,8 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz",
|
||||
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
@@ -1499,9 +1500,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"devDependencies": {
|
||||
"puppeteer": "^24.17.0",
|
||||
"@playwright/test": "^1.48.2"
|
||||
},
|
||||
"overrides": {
|
||||
"prebuild-install": {
|
||||
"tar-fs": "2.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 800 },
|
||||
ignoreHTTPSErrors: true,
|
||||
@@ -15,4 +15,3 @@ export default defineConfig({
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
################################################################################
|
||||
# PicPeak Unified Setup Script
|
||||
# Version: 2.0.0
|
||||
# Version: 2.1.0
|
||||
# Description: Universal installer for PicPeak with Docker and Native options
|
||||
# Supports: Ubuntu, Debian, Fedora, RHEL/CentOS, Raspberry Pi OS
|
||||
################################################################################
|
||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# Script configuration
|
||||
readonly SCRIPT_VERSION="2.0.0"
|
||||
readonly SCRIPT_VERSION="2.1.0"
|
||||
readonly APP_NAME="PicPeak"
|
||||
readonly REPO_URL="https://github.com/the-luap/picpeak.git"
|
||||
readonly NODE_VERSION="20"
|
||||
@@ -393,6 +393,29 @@ setup_docker_installation() {
|
||||
if [[ -d "$app_dir/.git" ]]; then
|
||||
cd "$app_dir"
|
||||
git pull
|
||||
elif [[ -d "$app_dir" ]]; then
|
||||
if [[ -z "$(ls -A "$app_dir" 2>/dev/null)" ]]; then
|
||||
log_warn "Existing directory $app_dir is empty but not a git repository; recreating it..."
|
||||
rm -rf "$app_dir"
|
||||
git clone "$REPO_URL" "$app_dir"
|
||||
else
|
||||
log_warn "Directory $app_dir already exists and is not a git repository."
|
||||
if [[ "$UNATTENDED" == "true" ]]; then
|
||||
local backup_dir="${app_dir}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||
log_warn "Unattended mode: backing up directory to $backup_dir and cloning a fresh copy."
|
||||
mv "$app_dir" "$backup_dir"
|
||||
git clone "$REPO_URL" "$app_dir"
|
||||
else
|
||||
if confirm "Replace existing directory $app_dir with a fresh clone? This will move the current contents to a backup folder." "y"; then
|
||||
local backup_dir="${app_dir}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||
mv "$app_dir" "$backup_dir"
|
||||
log_step "Existing directory moved to $backup_dir"
|
||||
git clone "$REPO_URL" "$app_dir"
|
||||
else
|
||||
die "Installation aborted because $app_dir already exists and is not a PicPeak git repository."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
git clone "$REPO_URL" "$app_dir"
|
||||
fi
|
||||
+139
-39
@@ -6,23 +6,32 @@ const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
async function createEventWithPhotos(page: Page) {
|
||||
async function createEventWithPhotos(page: Page, adminToken?: string, attempt = 1) {
|
||||
const api = page.request;
|
||||
const loginResponse = await api.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const { token } = await loginResponse.json();
|
||||
expect(token).toBeTruthy();
|
||||
let token = adminToken;
|
||||
|
||||
if (!token) {
|
||||
const loginResponse = await api.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const loginData = await loginResponse.json();
|
||||
token = loginData.token;
|
||||
expect(token).toBeTruthy();
|
||||
}
|
||||
|
||||
const eventName = `Playwright Smoke ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Failed to acquire admin token');
|
||||
}
|
||||
|
||||
const eventResponse = await api.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -32,6 +41,8 @@ async function createEventWithPhotos(page: Page) {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: 'host@example.com',
|
||||
host_name: 'Playwright Host',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
@@ -43,7 +54,17 @@ async function createEventWithPhotos(page: Page) {
|
||||
watermark_downloads: false,
|
||||
},
|
||||
});
|
||||
expect(eventResponse.ok()).toBeTruthy();
|
||||
if (!eventResponse.ok()) {
|
||||
const message = await eventResponse.text();
|
||||
if (
|
||||
attempt < 3 &&
|
||||
/UNIQUE constraint failed: events\.slug/i.test(message || '')
|
||||
) {
|
||||
await page.waitForTimeout(150);
|
||||
return createEventWithPhotos(page, token, attempt + 1);
|
||||
}
|
||||
throw new Error(`Event creation failed: ${eventResponse.status()} ${message}`);
|
||||
}
|
||||
const event = await eventResponse.json();
|
||||
|
||||
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
|
||||
@@ -67,11 +88,83 @@ async function createEventWithPhotos(page: Page) {
|
||||
event,
|
||||
shareLink: event.share_link,
|
||||
slug: event.slug,
|
||||
adminToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateShortGallerySetting(page: Page, adminToken: string, enabled: boolean) {
|
||||
const response = await page.request.put('/api/admin/settings/general', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
general_short_gallery_urls: enabled,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openGalleryShareLink(page: Page, shareLink: string) {
|
||||
await page.context().clearCookies();
|
||||
await page.goto(shareLink);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
try {
|
||||
await page.getByText(/Enter Gallery Password/i).first().waitFor({ timeout: 5000 });
|
||||
} catch {
|
||||
// No password prompt shown (public gallery)
|
||||
}
|
||||
|
||||
let passwordEntered = false;
|
||||
const passwordTextbox = page.getByRole('textbox', { name: /password/i }).first();
|
||||
if (await passwordTextbox.count()) {
|
||||
await passwordTextbox.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
}
|
||||
|
||||
const galleryPasswordField = page.getByPlaceholder(/gallery password/i);
|
||||
if (!passwordEntered && await galleryPasswordField.count()) {
|
||||
await galleryPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
} else if (!passwordEntered) {
|
||||
const genericPasswordField = page.getByPlaceholder(/password/i).first();
|
||||
if (await genericPasswordField.count()) {
|
||||
await genericPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
} else {
|
||||
const labelledPasswordField = page.getByLabel(/password/i).first();
|
||||
if (await labelledPasswordField.count()) {
|
||||
await labelledPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!passwordEntered) {
|
||||
const fallbackPasswordField = page.locator('input').first();
|
||||
if (await fallbackPasswordField.count()) {
|
||||
await fallbackPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
}
|
||||
}
|
||||
|
||||
const viewButton = page.getByRole('button', { name: /View Gallery/i });
|
||||
if (await viewButton.count()) {
|
||||
try {
|
||||
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
|
||||
} catch {
|
||||
// Already navigated into gallery view.
|
||||
}
|
||||
}
|
||||
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
return tiles;
|
||||
}
|
||||
|
||||
test('admin login and gallery viewing smoke test', async ({ page }) => {
|
||||
const { shareLink } = await createEventWithPhotos(page);
|
||||
const { shareLink, adminToken } = await createEventWithPhotos(page);
|
||||
|
||||
// Admin UI login
|
||||
await page.goto('/admin/login');
|
||||
@@ -83,32 +176,39 @@ test('admin login and gallery viewing smoke test', async ({ page }) => {
|
||||
}
|
||||
await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Visit gallery share link and authenticate
|
||||
await page.goto(shareLink);
|
||||
const passwordField = page.getByPlaceholder(/gallery password/i);
|
||||
if (await passwordField.count()) {
|
||||
try {
|
||||
await passwordField.fill(GALLERY_PASSWORD, { timeout: 2000 });
|
||||
} catch {
|
||||
// Field may disappear if gallery bypasses password; ignore.
|
||||
}
|
||||
let resetToken = adminToken;
|
||||
try {
|
||||
// Verify long-form share link works
|
||||
const tiles = await openGalleryShareLink(page, shareLink);
|
||||
await tiles.first().hover();
|
||||
await tiles.first().getByRole('button', { name: /View full size/i }).click();
|
||||
await expect(page.getByRole('button', { name: /Close/i })).toBeVisible();
|
||||
await page.getByRole('button', { name: /Close/i }).click();
|
||||
|
||||
// Enable short gallery URLs
|
||||
await updateShortGallerySetting(page, adminToken, true);
|
||||
|
||||
const settingsResponse = await page.request.get('/api/admin/settings', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
});
|
||||
expect(settingsResponse.ok()).toBeTruthy();
|
||||
const adminSettings = await settingsResponse.json();
|
||||
expect(adminSettings.general_short_gallery_urls === true || adminSettings.general_short_gallery_urls === 'true').toBeTruthy();
|
||||
|
||||
const { shareLink: shortShareLink, event: shortEvent } = await createEventWithPhotos(page, adminToken);
|
||||
expect(shortShareLink).toMatch(/\/gallery\/[0-9a-fA-F]{32}$/);
|
||||
expect(shortShareLink).not.toContain(shortEvent.slug);
|
||||
|
||||
// Verify short share link works
|
||||
await openGalleryShareLink(page, shortShareLink);
|
||||
|
||||
// Legacy share link should still work after enabling short URLs
|
||||
await openGalleryShareLink(page, shareLink);
|
||||
} finally {
|
||||
await updateShortGallerySetting(page, resetToken, false).catch(() => {
|
||||
/* noop */
|
||||
});
|
||||
}
|
||||
|
||||
const viewButton = page.getByRole('button', { name: /View Gallery/i });
|
||||
if (await viewButton.count()) {
|
||||
try {
|
||||
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
|
||||
} catch {
|
||||
// Already inside gallery view.
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for photos grid to appear
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Open lightbox to ensure media renders
|
||||
await tiles.first().hover();
|
||||
await tiles.first().getByRole('button', { name: /View full size/i }).click();
|
||||
await expect(page.getByRole('button', { name: /Close/i })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -46,8 +46,8 @@ async function createExternalGallery(page) {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
host_name: 'External Host',
|
||||
host_email: 'host@example.com',
|
||||
customer_name: 'External Host',
|
||||
customer_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
|
||||
@@ -43,8 +43,8 @@ async function createGalleryWithModeratedComments(page: Page): Promise<GallerySe
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
host_name: 'Playwright Host',
|
||||
host_email: 'host@example.com',
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
|
||||
@@ -32,8 +32,8 @@ async function ensureGalleryWithPhotos(page) {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
host_name: 'Playwright Host',
|
||||
host_email: 'host@example.com',
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 90,
|
||||
|
||||
@@ -27,8 +27,8 @@ test('clearing old notifications removes read entries', async ({ request }) => {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
host_name: 'Notification Test',
|
||||
host_email: 'notify@example.com',
|
||||
customer_name: 'Notification Test',
|
||||
customer_email: 'notify@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: 'NotifyClearPass!1',
|
||||
expiration_days: 30,
|
||||
|
||||
Reference in New Issue
Block a user