Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07759a0e40 | |||
| 31fd64c83c | |||
| 775c5159ea | |||
| 8f297e25c4 | |||
| ccb65b892b | |||
| 52f8f1f738 | |||
| e731e7b47c | |||
| 2bccb1a439 | |||
| df10fc677e | |||
| 8c690155bf | |||
| 1b1e4f715d | |||
| 68eb9ba552 | |||
| 7040865154 | |||
| 013be18d98 | |||
| 3c2a79a31a | |||
| f20472ca26 | |||
| 87f4526220 | |||
| d42a11680f | |||
| 38dd74b893 |
+5
-5
@@ -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**:
|
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||||
chmod +x setup.sh && \
|
chmod +x picpeak-setup.sh && \
|
||||||
sudo ./setup.sh
|
sudo ./picpeak-setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This automated script handles everything including:
|
This automated script handles everything including:
|
||||||
@@ -424,10 +424,10 @@ If you lose your admin credentials after the first login, you'll need to manuall
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Native reinstall example
|
# Native reinstall example
|
||||||
sudo ./setup.sh --native --force-admin-password-reset
|
sudo ./picpeak-setup.sh --native --force-admin-password-reset
|
||||||
|
|
||||||
# Docker reinstall example
|
# 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.
|
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
|
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||||
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
|
- 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
|
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||||
- 📜 [**License**](LICENSE) - MIT License
|
- 📜 [**License**](LICENSE) - MIT License
|
||||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||||
|
|||||||
+14
-14
@@ -8,9 +8,9 @@ This guide provides easy installation instructions for PicPeak on Linux servers
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Download and run the unified setup script
|
# Download and run the unified setup script
|
||||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||||
chmod +x setup.sh && \
|
chmod +x picpeak-setup.sh && \
|
||||||
sudo ./setup.sh
|
sudo ./picpeak-setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script will automatically detect your environment and recommend the best installation method.
|
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
|
Best for: Most users, easy updates, isolated environment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./setup.sh --docker
|
sudo ./picpeak-setup.sh --docker
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pros:**
|
**Pros:**
|
||||||
@@ -38,7 +38,7 @@ sudo ./setup.sh --docker
|
|||||||
Best for: Resource-constrained systems, Raspberry Pi, direct control
|
Best for: Resource-constrained systems, Raspberry Pi, direct control
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo ./setup.sh --native
|
sudo ./picpeak-setup.sh --native
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pros:**
|
**Pros:**
|
||||||
@@ -73,7 +73,7 @@ sudo ./setup.sh --native
|
|||||||
|
|
||||||
### Interactive Mode (Default)
|
### Interactive Mode (Default)
|
||||||
```bash
|
```bash
|
||||||
sudo ./setup.sh
|
sudo ./picpeak-setup.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The script will prompt you to choose:
|
The script will prompt you to choose:
|
||||||
@@ -87,7 +87,7 @@ The script will prompt you to choose:
|
|||||||
|
|
||||||
#### Docker with full configuration:
|
#### Docker with full configuration:
|
||||||
```bash
|
```bash
|
||||||
sudo ./setup.sh --docker --unattended \
|
sudo ./picpeak-setup.sh --docker --unattended \
|
||||||
--domain photos.example.com \
|
--domain photos.example.com \
|
||||||
--email admin@example.com \
|
--email admin@example.com \
|
||||||
--admin-password SecurePass123 \
|
--admin-password SecurePass123 \
|
||||||
@@ -100,7 +100,7 @@ sudo ./setup.sh --docker --unattended \
|
|||||||
|
|
||||||
#### Native with minimal configuration:
|
#### Native with minimal configuration:
|
||||||
```bash
|
```bash
|
||||||
sudo ./setup.sh --native --unattended \
|
sudo ./picpeak-setup.sh --native --unattended \
|
||||||
--email admin@example.com \
|
--email admin@example.com \
|
||||||
--admin-password SecurePass123
|
--admin-password SecurePass123
|
||||||
```
|
```
|
||||||
@@ -293,7 +293,7 @@ sudo systemctl restart picpeak-backend picpeak-workers
|
|||||||
|
|
||||||
# Update PicPeak
|
# Update PicPeak
|
||||||
# (reruns migrations to pick up schema fixes for native installs)
|
# (reruns migrations to pick up schema fixes for native installs)
|
||||||
sudo ./setup.sh --update
|
sudo ./picpeak-setup.sh --update
|
||||||
```
|
```
|
||||||
|
|
||||||
## ⚙️ Configuration
|
## ⚙️ Configuration
|
||||||
@@ -385,14 +385,14 @@ docker compose pull
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
# Native
|
# Native
|
||||||
sudo ./setup.sh --update
|
sudo ./picpeak-setup.sh --update
|
||||||
```
|
```
|
||||||
|
|
||||||
### Uninstall
|
### Uninstall
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Will prompt for confirmation and data removal options
|
# Will prompt for confirmation and data removal options
|
||||||
sudo ./setup.sh --uninstall
|
sudo ./picpeak-setup.sh --uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
@@ -508,13 +508,13 @@ sudo systemctl restart picpeak-backend
|
|||||||
### Home/Office Network
|
### Home/Office Network
|
||||||
```bash
|
```bash
|
||||||
# Simple local setup without domain
|
# 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
|
### Public Website with HTTPS
|
||||||
```bash
|
```bash
|
||||||
# Full production setup
|
# Full production setup
|
||||||
sudo ./setup.sh --docker \
|
sudo ./picpeak-setup.sh --docker \
|
||||||
--domain photos.company.com \
|
--domain photos.company.com \
|
||||||
--email admin@company.com \
|
--email admin@company.com \
|
||||||
--enable-ssl
|
--enable-ssl
|
||||||
@@ -523,7 +523,7 @@ sudo ./setup.sh --docker \
|
|||||||
### Raspberry Pi Setup
|
### Raspberry Pi Setup
|
||||||
```bash
|
```bash
|
||||||
# Optimized for ARM devices
|
# Optimized for ARM devices
|
||||||
sudo ./setup.sh --native \
|
sudo ./picpeak-setup.sh --native \
|
||||||
--port 8080 \
|
--port 8080 \
|
||||||
--email pi@local.com
|
--email pi@local.com
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1831,8 +1831,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nodemailer": {
|
"nodemailer": {
|
||||||
"version": "6.10.1",
|
"version": "7.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
|
||||||
"overridden": false
|
"overridden": false
|
||||||
},
|
},
|
||||||
"nodemon": {
|
"nodemon": {
|
||||||
@@ -2086,8 +2086,8 @@
|
|||||||
"version": "4.0.1"
|
"version": "4.0.1"
|
||||||
},
|
},
|
||||||
"tar-fs": {
|
"tar-fs": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||||
"overridden": false
|
"overridden": false
|
||||||
},
|
},
|
||||||
"tunnel-agent": {
|
"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
+29
-47
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.5",
|
"version": "1.1.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.5",
|
"version": "1.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"nodemailer": "7.0.5",
|
"nodemailer": "7.0.7",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
@@ -1034,6 +1034,7 @@
|
|||||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ampproject/remapping": "^2.2.0",
|
"@ampproject/remapping": "^2.2.0",
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
@@ -3626,6 +3627,7 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4189,6 +4191,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -5154,29 +5157,6 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/end-of-stream": {
|
||||||
"version": "1.4.5",
|
"version": "1.4.5",
|
||||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||||
@@ -5304,6 +5284,7 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -6346,6 +6327,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.27.6"
|
"@babel/runtime": "^7.27.6"
|
||||||
},
|
},
|
||||||
@@ -8355,9 +8337,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.5",
|
"version": "7.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
|
||||||
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
|
"integrity": "sha512-jGOaRznodf62TVzdyhKt/f1Q/c3kYynk8629sgJHpRzGZj01ezbgMMWJSAjHADcwTKxco3B68/R+KHJY2T5BaA==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -9008,24 +8990,6 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -10246,6 +10210,24 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/tar-stream": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.5",
|
"version": "1.1.13",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"nodemailer": "7.0.5",
|
"nodemailer": "7.0.7",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
@@ -55,5 +55,10 @@
|
|||||||
"mock-fs": "^5.5.0",
|
"mock-fs": "^5.5.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"supertest": "^6.3.3"
|
"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 knex = require('knex');
|
||||||
const knexConfig = require('../../knexfile');
|
const knexConfig = require('../../knexfile');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { extractShareToken } = require('../utils/shareLinkUtils');
|
||||||
|
|
||||||
// Ensure SQLite directory exists when using file-based DB (native installs)
|
// Ensure SQLite directory exists when using file-based DB (native installs)
|
||||||
try {
|
try {
|
||||||
@@ -63,12 +64,16 @@ async function initializeDatabase() {
|
|||||||
table.string('event_type').notNullable();
|
table.string('event_type').notNullable();
|
||||||
table.string('event_name').notNullable();
|
table.string('event_name').notNullable();
|
||||||
table.date('event_date').notNullable();
|
table.date('event_date').notNullable();
|
||||||
|
table.string('customer_name');
|
||||||
|
table.string('customer_email');
|
||||||
table.string('host_email').notNullable();
|
table.string('host_email').notNullable();
|
||||||
|
table.string('host_name');
|
||||||
table.string('admin_email').notNullable();
|
table.string('admin_email').notNullable();
|
||||||
table.string('password_hash').notNullable();
|
table.string('password_hash').notNullable();
|
||||||
table.text('welcome_message');
|
table.text('welcome_message');
|
||||||
table.text('color_theme');
|
table.text('color_theme');
|
||||||
table.string('share_link').unique().notNullable();
|
table.string('share_link').unique().notNullable();
|
||||||
|
table.string('share_token').unique();
|
||||||
table.datetime('created_at').defaultTo(db.fn.now());
|
table.datetime('created_at').defaultTo(db.fn.now());
|
||||||
table.datetime('expires_at').notNullable();
|
table.datetime('expires_at').notNullable();
|
||||||
table.boolean('is_active').defaultTo(true);
|
table.boolean('is_active').defaultTo(true);
|
||||||
@@ -99,12 +104,16 @@ async function initializeDatabase() {
|
|||||||
event_type TEXT NOT NULL,
|
event_type TEXT NOT NULL,
|
||||||
event_name TEXT NOT NULL,
|
event_name TEXT NOT NULL,
|
||||||
event_date DATE NOT NULL,
|
event_date DATE NOT NULL,
|
||||||
|
customer_name TEXT,
|
||||||
|
customer_email TEXT,
|
||||||
|
host_name TEXT,
|
||||||
host_email TEXT NOT NULL,
|
host_email TEXT NOT NULL,
|
||||||
admin_email TEXT NOT NULL,
|
admin_email TEXT NOT NULL,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
welcome_message TEXT,
|
welcome_message TEXT,
|
||||||
color_theme TEXT,
|
color_theme TEXT,
|
||||||
share_link TEXT UNIQUE NOT NULL,
|
share_link TEXT UNIQUE NOT NULL,
|
||||||
|
share_token TEXT UNIQUE,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at DATETIME NOT NULL,
|
expires_at DATETIME NOT NULL,
|
||||||
is_active BOOLEAN DEFAULT 1,
|
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
|
// Photo metadata table
|
||||||
const hasPhotosTable = await db.schema.hasTable('photos');
|
const hasPhotosTable = await db.schema.hasTable('photos');
|
||||||
if (!hasPhotosTable) {
|
if (!hasPhotosTable) {
|
||||||
|
|||||||
@@ -8,6 +8,93 @@ const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Change password
|
// Change password
|
||||||
|
router.get('/profile', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const admin = await db('admin_users')
|
||||||
|
.where('id', req.admin.id)
|
||||||
|
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
return res.status(404).json({ error: 'Admin user not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(admin);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin profile fetch error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch admin profile' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/profile', [
|
||||||
|
adminAuth,
|
||||||
|
body('username')
|
||||||
|
.trim()
|
||||||
|
.isLength({ min: 3, max: 50 })
|
||||||
|
.withMessage('Username must be between 3 and 50 characters'),
|
||||||
|
body('email')
|
||||||
|
.trim()
|
||||||
|
.isEmail()
|
||||||
|
.withMessage('A valid email address is required')
|
||||||
|
.normalizeEmail()
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = req.body.username.trim();
|
||||||
|
const email = req.body.email.trim().toLowerCase();
|
||||||
|
const adminId = req.admin.id;
|
||||||
|
|
||||||
|
const existingUsername = await db('admin_users')
|
||||||
|
.where('username', username)
|
||||||
|
.whereNot('id', adminId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existingUsername) {
|
||||||
|
return res.status(409).json({ error: 'Username is already in use' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEmail = await db('admin_users')
|
||||||
|
.where('email', email)
|
||||||
|
.whereNot('id', adminId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existingEmail) {
|
||||||
|
return res.status(409).json({ error: 'Email address is already in use' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('admin_users')
|
||||||
|
.where('id', adminId)
|
||||||
|
.update({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
await logActivity('admin_profile_updated',
|
||||||
|
{ username, email },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: adminId, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedAdmin = await db('admin_users')
|
||||||
|
.where('id', adminId)
|
||||||
|
.select('id', 'username', 'email', 'must_change_password as mustChangePassword')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Admin profile updated successfully',
|
||||||
|
user: updatedAdmin
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin profile update error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update admin profile' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/change-password', [
|
router.post('/change-password', [
|
||||||
adminAuth,
|
adminAuth,
|
||||||
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
||||||
@@ -96,4 +183,4 @@ router.post('/logout', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
// Only the relevant parts are shown - merge with existing adminEvents.js
|
// Only the relevant parts are shown - merge with existing adminEvents.js
|
||||||
|
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
|
|
||||||
// Enhanced event creation with password validation
|
// Enhanced event creation with password validation
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||||
body('event_name').notEmpty().trim(),
|
body('event_name').notEmpty().trim(),
|
||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('host_email').isEmail().normalizeEmail(),
|
body('customer_email').isEmail().normalizeEmail(),
|
||||||
body('admin_email').isEmail().normalizeEmail(),
|
body('admin_email').isEmail().normalizeEmail(),
|
||||||
body('password').notEmpty(), // Remove the weak isLength validation
|
body('password').notEmpty(), // Remove the weak isLength validation
|
||||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||||
@@ -16,7 +17,7 @@ router.post('/', adminAuth, [
|
|||||||
body('color_theme').optional().trim(),
|
body('color_theme').optional().trim(),
|
||||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||||
body('host_name').notEmpty().trim()
|
body('customer_name').notEmpty().trim()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log('Create event request body:', req.body);
|
console.log('Create event request body:', req.body);
|
||||||
@@ -30,8 +31,8 @@ router.post('/', adminAuth, [
|
|||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
event_date,
|
event_date,
|
||||||
host_name,
|
customer_name,
|
||||||
host_email,
|
customer_email,
|
||||||
admin_email,
|
admin_email,
|
||||||
password,
|
password,
|
||||||
welcome_message = '',
|
welcome_message = '',
|
||||||
@@ -65,9 +66,9 @@ router.post('/', adminAuth, [
|
|||||||
counter++;
|
counter++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate share link
|
// Generate share link based on configured style
|
||||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
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
|
// Hash password with configurable rounds
|
||||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||||
@@ -88,13 +89,16 @@ router.post('/', adminAuth, [
|
|||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
event_date,
|
event_date,
|
||||||
host_name,
|
customer_name,
|
||||||
host_email,
|
customer_email,
|
||||||
|
host_name: customer_name,
|
||||||
|
host_email: customer_email,
|
||||||
admin_email,
|
admin_email,
|
||||||
password_hash,
|
password_hash,
|
||||||
welcome_message,
|
welcome_message,
|
||||||
color_theme,
|
color_theme,
|
||||||
share_link: shareLink,
|
share_link: shareLinkToStore,
|
||||||
|
share_token: shareToken,
|
||||||
expires_at: expires_at.toISOString(),
|
expires_at: expires_at.toISOString(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
allow_user_uploads,
|
allow_user_uploads,
|
||||||
@@ -121,4 +125,4 @@ router.post('/', adminAuth, [
|
|||||||
console.error('Error creating event:', error);
|
console.error('Error creating event:', error);
|
||||||
res.status(500).json({ error: 'Failed to create event' });
|
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
|
// formatDate import removed - dates are formatted by email processor
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
|
|
||||||
const parseBooleanInput = (value, defaultValue = true) => {
|
const parseBooleanInput = (value, defaultValue = true) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
@@ -37,12 +38,67 @@ const parseBooleanInput = (value, defaultValue = true) => {
|
|||||||
return defaultValue;
|
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
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||||
body('event_name').notEmpty().trim(),
|
body('event_name').notEmpty().trim(),
|
||||||
body('event_date').isDate(),
|
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('admin_email').isEmail().normalizeEmail(),
|
||||||
body('require_password').optional().isBoolean(),
|
body('require_password').optional().isBoolean(),
|
||||||
body('password').optional().isString().custom((value, { req }) => {
|
body('password').optional().isString().custom((value, { req }) => {
|
||||||
@@ -73,7 +129,6 @@ router.post('/', adminAuth, [
|
|||||||
body('color_theme').optional().trim(),
|
body('color_theme').optional().trim(),
|
||||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||||
body('host_name').notEmpty().trim(),
|
|
||||||
body('allow_downloads').optional().isBoolean(),
|
body('allow_downloads').optional().isBoolean(),
|
||||||
body('disable_right_click').optional().isBoolean(),
|
body('disable_right_click').optional().isBoolean(),
|
||||||
body('watermark_downloads').optional().isBoolean(),
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
@@ -91,8 +146,6 @@ router.post('/', adminAuth, [
|
|||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
event_date,
|
event_date,
|
||||||
host_name,
|
|
||||||
host_email,
|
|
||||||
admin_email,
|
admin_email,
|
||||||
password,
|
password,
|
||||||
welcome_message = '',
|
welcome_message = '',
|
||||||
@@ -115,7 +168,16 @@ router.post('/', adminAuth, [
|
|||||||
moderate_comments = true,
|
moderate_comments = true,
|
||||||
show_feedback_to_guests = true
|
show_feedback_to_guests = true
|
||||||
} = req.body;
|
} = 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);
|
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
@@ -133,7 +195,6 @@ router.post('/', adminAuth, [
|
|||||||
});
|
});
|
||||||
|
|
||||||
let passwordValidation = null;
|
let passwordValidation = null;
|
||||||
let galleryPassword = password;
|
|
||||||
|
|
||||||
if (requirePassword) {
|
if (requirePassword) {
|
||||||
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||||
@@ -148,8 +209,6 @@ router.post('/', adminAuth, [
|
|||||||
feedback: passwordValidation.feedback
|
feedback: passwordValidation.feedback
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
galleryPassword = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique slug
|
// Generate unique slug
|
||||||
@@ -167,11 +226,9 @@ router.post('/', adminAuth, [
|
|||||||
counter++;
|
counter++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate share link
|
// Generate share link respecting configured format
|
||||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||||
const sharePath = `/gallery/${slug}/${shareToken}`;
|
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
|
||||||
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
|
||||||
|
|
||||||
// Hash password with configurable rounds (random placeholder when not required)
|
// Hash password with configurable rounds (random placeholder when not required)
|
||||||
const password_hash = requirePassword
|
const password_hash = requirePassword
|
||||||
@@ -201,13 +258,15 @@ router.post('/', adminAuth, [
|
|||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
event_date,
|
event_date,
|
||||||
host_name,
|
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||||
host_email,
|
host_name: customerName,
|
||||||
|
host_email: customerEmail,
|
||||||
admin_email,
|
admin_email,
|
||||||
password_hash,
|
password_hash,
|
||||||
welcome_message,
|
welcome_message,
|
||||||
color_theme,
|
color_theme,
|
||||||
share_link: shareLink,
|
share_link: shareLinkToStore,
|
||||||
|
share_token: shareToken,
|
||||||
expires_at: expires_at.toISOString(),
|
expires_at: expires_at.toISOString(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
allow_user_uploads,
|
allow_user_uploads,
|
||||||
@@ -251,13 +310,15 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
await db('email_queue').insert({
|
await db('email_queue').insert({
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
recipient_email: host_email,
|
recipient_email: customerEmail,
|
||||||
email_type: 'gallery_created',
|
email_type: 'gallery_created',
|
||||||
email_data: JSON.stringify({
|
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_name,
|
||||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
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',
|
gallery_password: requirePassword ? password : 'No password required',
|
||||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
@@ -272,8 +333,10 @@ router.post('/', adminAuth, [
|
|||||||
slug,
|
slug,
|
||||||
event_name,
|
event_name,
|
||||||
event_type,
|
event_type,
|
||||||
|
customer_name: customerName,
|
||||||
|
customer_email: customerEmail,
|
||||||
require_password: requirePassword,
|
require_password: requirePassword,
|
||||||
share_link: shareLink,
|
share_link: shareUrl,
|
||||||
expires_at: expires_at.toISOString(),
|
expires_at: expires_at.toISOString(),
|
||||||
created_at: new Date().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,
|
created_at: event.created_at ? new Date(event.created_at).toISOString() : null,
|
||||||
expires_at: event.expires_at ? new Date(event.expires_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
|
archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null
|
||||||
}));
|
})).map(mapEventForApi);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
events: eventsWithCounts,
|
events: eventsWithCounts,
|
||||||
@@ -418,7 +481,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
|||||||
.where('event_id', id)
|
.where('event_id', id)
|
||||||
.countDistinct('ip_address as uniqueVisitors');
|
.countDistinct('ip_address as uniqueVisitors');
|
||||||
|
|
||||||
res.json({
|
res.json(mapEventForApi({
|
||||||
...event,
|
...event,
|
||||||
photo_count: parseInt(photoCount) || 0,
|
photo_count: parseInt(photoCount) || 0,
|
||||||
total_size: parseInt(totalSize) || 0,
|
total_size: parseInt(totalSize) || 0,
|
||||||
@@ -426,7 +489,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
|||||||
total_downloads: parseInt(totalDownloads) || 0,
|
total_downloads: parseInt(totalDownloads) || 0,
|
||||||
unique_visitors: parseInt(uniqueVisitors) || 0,
|
unique_visitors: parseInt(uniqueVisitors) || 0,
|
||||||
recent_photos: recentPhotos
|
recent_photos: recentPhotos
|
||||||
});
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching event:', error);
|
console.error('Error fetching event:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch event details' });
|
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('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||||
body('color_theme').optional({ nullable: true }),
|
body('color_theme').optional({ nullable: true }),
|
||||||
body('allow_user_uploads').optional().isBoolean(),
|
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) => {
|
body('upload_category_id').optional().custom((value) => {
|
||||||
// Accept null, undefined, or integer values
|
// Accept null, undefined, or integer values
|
||||||
if (value === null || value === undefined) return true;
|
if (value === null || value === undefined) return true;
|
||||||
@@ -481,6 +545,39 @@ router.put('/:id', adminAuth, [
|
|||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updates = { ...req.body };
|
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');
|
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||||
let requirePasswordUpdate;
|
let requirePasswordUpdate;
|
||||||
@@ -715,10 +812,13 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
// Queue email notification if requested
|
// Queue email notification if requested
|
||||||
if (sendEmail) {
|
if (sendEmail) {
|
||||||
// For password reset, we'll need to create a template or use a different approach
|
const recipientEmail = event.customer_email || event.host_email;
|
||||||
// For now, let's use the gallery_created template with updated password
|
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
|
||||||
host_name: event.host_email.split('@')[0],
|
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||||
|
customer_name: recipientName,
|
||||||
|
customer_email: recipientEmail,
|
||||||
|
host_name: recipientName,
|
||||||
event_name: event.event_name,
|
event_name: event.event_name,
|
||||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: event.share_link,
|
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
|
// Dates will be formatted by the email processor based on recipient language
|
||||||
|
|
||||||
// Queue the email
|
// Queue the email
|
||||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
const recipientEmail = event.customer_email || event.host_email;
|
||||||
host_name: event.host_name || event.host_email.split('@')[0],
|
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_name: event.event_name,
|
||||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: event.share_link,
|
gallery_link: event.share_link,
|
||||||
@@ -789,7 +894,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
await logActivity('email_resent', {
|
await logActivity('email_resent', {
|
||||||
email_type: 'gallery_created',
|
email_type: 'gallery_created',
|
||||||
recipient: event.host_email,
|
recipient: recipientEmail,
|
||||||
ip_address: req.ip || '0.0.0.0',
|
ip_address: req.ip || '0.0.0.0',
|
||||||
user_agent: req.get('user-agent') || 'Unknown'
|
user_agent: req.get('user-agent') || 'Unknown'
|
||||||
}, id, {
|
}, id, {
|
||||||
|
|||||||
@@ -103,14 +103,51 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
|
|||||||
// Use database-agnostic date calculation
|
// Use database-agnostic date calculation
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||||
|
|
||||||
const deletedCount = await db('activity_logs')
|
let deletedCount = 0;
|
||||||
.whereNotNull('read_at')
|
const client = db?.client?.config?.client;
|
||||||
.where('created_at', '<', thirtyDaysAgo)
|
|
||||||
.delete();
|
if (client === 'pg') {
|
||||||
|
const primaryResult = await db.raw(
|
||||||
|
`
|
||||||
|
WITH deleted AS (
|
||||||
|
DELETE FROM activity_logs
|
||||||
|
WHERE read_at IS NOT NULL OR created_at < ?
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
SELECT COUNT(*)::int AS count FROM deleted
|
||||||
|
`,
|
||||||
|
[thirtyDaysAgo.toISOString()]
|
||||||
|
);
|
||||||
|
deletedCount = primaryResult.rows?.[0]?.count || 0;
|
||||||
|
|
||||||
|
if (deletedCount === 0) {
|
||||||
|
const fallbackResult = await db.raw(
|
||||||
|
`
|
||||||
|
WITH deleted AS (
|
||||||
|
DELETE FROM activity_logs
|
||||||
|
RETURNING id
|
||||||
|
)
|
||||||
|
SELECT COUNT(*)::int AS count FROM deleted
|
||||||
|
`
|
||||||
|
);
|
||||||
|
deletedCount = fallbackResult.rows?.[0]?.count || 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
deletedCount = await db('activity_logs')
|
||||||
|
.where(function () {
|
||||||
|
this.whereNotNull('read_at')
|
||||||
|
.orWhere('created_at', '<', thirtyDaysAgo);
|
||||||
|
})
|
||||||
|
.delete();
|
||||||
|
|
||||||
|
if (deletedCount === 0) {
|
||||||
|
deletedCount = await db('activity_logs').delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Old notifications cleared',
|
message: deletedCount > 0 ? 'Old notifications cleared' : 'No notifications to clear',
|
||||||
deletedCount
|
deletedCount
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -119,4 +156,4 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { generateThumbnail, ensureThumbnail } = require('../services/imageProcess
|
|||||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||||
|
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
@@ -48,7 +49,7 @@ const upload = multer({
|
|||||||
storage: storage,
|
storage: storage,
|
||||||
limits: {
|
limits: {
|
||||||
fileSize: 50 * 1024 * 1024, // 50MB limit per file
|
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
|
// Set a reasonable field size limit to prevent memory issues
|
||||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||||
// Add part size limits to prevent incomplete uploads
|
// Add part size limits to prevent incomplete uploads
|
||||||
@@ -99,17 +100,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Upload photos for an event
|
// Upload photos for an event
|
||||||
// Increased limit to 500 files, but recommend chunked uploads for better performance
|
// Max file count is configurable via general settings
|
||||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
|
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
||||||
upload.array('photos', 500)(req, res, (err) => {
|
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) {
|
if (err) {
|
||||||
console.error('Multer error:', err);
|
console.error('Multer error:', err);
|
||||||
if (err instanceof multer.MulterError) {
|
if (err instanceof multer.MulterError) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
|
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
|
||||||
}
|
}
|
||||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||||
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
|
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
|
||||||
}
|
}
|
||||||
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const {
|
|||||||
getRawPublicSiteSettings,
|
getRawPublicSiteSettings,
|
||||||
} = require('../services/publicSiteService');
|
} = require('../services/publicSiteService');
|
||||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||||
|
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||||
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
@@ -472,9 +474,24 @@ router.put('/theme', adminAuth, async (req, res) => {
|
|||||||
router.put('/general', adminAuth, async (req, res) => {
|
router.put('/general', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = { ...req.body };
|
const settings = { ...req.body };
|
||||||
|
let uploadLimitTouched = false;
|
||||||
|
|
||||||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
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 (publicSiteKeysTouched) {
|
||||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
||||||
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
||||||
@@ -529,6 +546,12 @@ router.put('/general', adminAuth, async (req, res) => {
|
|||||||
if (publicSiteKeysTouched) {
|
if (publicSiteKeysTouched) {
|
||||||
clearPublicSiteCache();
|
clearPublicSiteCache();
|
||||||
}
|
}
|
||||||
|
if (uploadLimitTouched) {
|
||||||
|
clearMaxFilesPerUploadCache();
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
|
||||||
|
clearShareLinkSettingsCache();
|
||||||
|
}
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await db('activity_logs').insert({
|
await db('activity_logs').insert({
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const {
|
|||||||
getAdminTokenFromRequest,
|
getAdminTokenFromRequest,
|
||||||
getGalleryTokenFromRequest,
|
getGalleryTokenFromRequest,
|
||||||
} = require('../utils/tokenUtils');
|
} = require('../utils/tokenUtils');
|
||||||
|
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Admin login with enhanced security
|
// Admin login with enhanced security
|
||||||
@@ -284,18 +285,22 @@ router.post('/gallery/share-login', [
|
|||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
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) })
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
const resolved = await resolveShareIdentifier(slug);
|
||||||
|
if (resolved?.event) {
|
||||||
|
event = resolved.event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found' });
|
return res.status(404).json({ error: 'Gallery not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let expectedToken = event.share_link;
|
const expectedToken = getEventShareToken(event);
|
||||||
if (expectedToken && expectedToken.includes('/')) {
|
|
||||||
expectedToken = expectedToken.split('/').pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!expectedToken || token !== expectedToken) {
|
if (!expectedToken || token !== expectedToken) {
|
||||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||||
@@ -312,7 +317,7 @@ router.post('/gallery/share-login', [
|
|||||||
issuer: 'picpeak-auth'
|
issuer: 'picpeak-auth'
|
||||||
});
|
});
|
||||||
|
|
||||||
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
|
||||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||||
|
|
||||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
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 fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
|
|
||||||
const parseBooleanInput = (value, defaultValue = true) => {
|
const parseBooleanInput = (value, defaultValue = true) => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
@@ -32,12 +33,66 @@ const parseBooleanInput = (value, defaultValue = true) => {
|
|||||||
return defaultValue;
|
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
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||||
body('event_name').notEmpty(),
|
body('event_name').notEmpty(),
|
||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('host_email').isEmail(),
|
body('customer_name').notEmpty().trim(),
|
||||||
|
body('customer_email').isEmail().normalizeEmail(),
|
||||||
body('admin_email').isEmail(),
|
body('admin_email').isEmail(),
|
||||||
body('require_password').optional().isBoolean(),
|
body('require_password').optional().isBoolean(),
|
||||||
body('password').optional().isString().custom((value, { req }) => {
|
body('password').optional().isString().custom((value, { req }) => {
|
||||||
@@ -62,7 +117,6 @@ router.post('/', adminAuth, [
|
|||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
event_date,
|
event_date,
|
||||||
host_email,
|
|
||||||
admin_email,
|
admin_email,
|
||||||
password,
|
password,
|
||||||
require_password: requirePasswordInput = true,
|
require_password: requirePasswordInput = true,
|
||||||
@@ -71,6 +125,15 @@ router.post('/', adminAuth, [
|
|||||||
expiration_days = 30
|
expiration_days = 30
|
||||||
} = req.body;
|
} = 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);
|
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||||
|
|
||||||
if (requirePassword) {
|
if (requirePassword) {
|
||||||
@@ -98,12 +161,9 @@ router.post('/', adminAuth, [
|
|||||||
counter++;
|
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 shareToken = crypto.randomBytes(16).toString('hex');
|
||||||
const sharePath = `/gallery/${slug}/${shareToken}`;
|
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
|
||||||
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
|
||||||
const shareLinkSlug = `${slug}/${shareToken}`;
|
|
||||||
|
|
||||||
// Hash password (or placeholder when not required)
|
// Hash password (or placeholder when not required)
|
||||||
const password_hash = requirePassword
|
const password_hash = requirePassword
|
||||||
@@ -126,12 +186,15 @@ router.post('/', adminAuth, [
|
|||||||
event_type,
|
event_type,
|
||||||
event_name,
|
event_name,
|
||||||
event_date,
|
event_date,
|
||||||
host_email,
|
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||||
|
host_name: customerName,
|
||||||
|
host_email: customerEmail,
|
||||||
admin_email,
|
admin_email,
|
||||||
password_hash,
|
password_hash,
|
||||||
welcome_message,
|
welcome_message,
|
||||||
color_theme,
|
color_theme,
|
||||||
share_link: shareLinkSlug,
|
share_link: shareLinkToStore,
|
||||||
|
share_token: shareToken,
|
||||||
expires_at,
|
expires_at,
|
||||||
require_password: formatBoolean(requirePassword)
|
require_password: formatBoolean(requirePassword)
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
@@ -141,11 +204,13 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
// Queue creation email
|
// Queue creation email
|
||||||
const { queueEmail } = require('../services/emailProcessor');
|
const { queueEmail } = require('../services/emailProcessor');
|
||||||
await queueEmail(eventId, host_email, 'gallery_created', {
|
await queueEmail(eventId, customerEmail, 'gallery_created', {
|
||||||
host_name: host_email.split('@')[0], // Extract name from email
|
customer_name: customerName,
|
||||||
|
customer_email: customerEmail,
|
||||||
|
host_name: customerName,
|
||||||
event_name,
|
event_name,
|
||||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
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',
|
gallery_password: requirePassword ? password : 'No password required',
|
||||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
@@ -154,9 +219,11 @@ router.post('/', adminAuth, [
|
|||||||
res.json({
|
res.json({
|
||||||
id: eventId,
|
id: eventId,
|
||||||
slug,
|
slug,
|
||||||
share_link: fullShareLink,
|
share_link: shareUrl,
|
||||||
expires_at,
|
expires_at,
|
||||||
require_password: requirePassword
|
require_password: requirePassword,
|
||||||
|
customer_name: customerName,
|
||||||
|
customer_email: customerEmail
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -185,17 +252,27 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
event.photo_count = photoCount.count;
|
event.photo_count = photoCount.count;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(events);
|
res.json(events.map(mapEventForApi));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch events' });
|
res.status(500).json({ error: 'Failed to fetch events' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update event
|
// 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 {
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updates = { ...req.body };
|
const updates = { ...req.body };
|
||||||
|
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||||
|
|
||||||
// Don't allow updating certain fields
|
// Don't allow updating certain fields
|
||||||
delete updates.id;
|
delete updates.id;
|
||||||
@@ -203,6 +280,38 @@ router.put('/:id', adminAuth, async (req, res) => {
|
|||||||
delete updates.created_at;
|
delete updates.created_at;
|
||||||
delete updates.password_confirmation;
|
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');
|
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||||
let requirePasswordUpdate;
|
let requirePasswordUpdate;
|
||||||
if (hasRequirePasswordUpdate) {
|
if (hasRequirePasswordUpdate) {
|
||||||
|
|||||||
@@ -9,10 +9,41 @@ const { verifyGalleryAccess } = require('../middleware/gallery');
|
|||||||
const secureImageService = require('../services/secureImageService');
|
const secureImageService = require('../services/secureImageService');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||||
|
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
|
|
||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||||
|
|
||||||
|
// 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
|
// Verify share token
|
||||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -20,15 +51,14 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
|
|||||||
|
|
||||||
const event = await db('events')
|
const event = await db('events')
|
||||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
.select('id', 'share_link')
|
.select('id', 'share_link', 'share_token')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found' });
|
return res.status(404).json({ error: 'Gallery not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract token from share link and verify
|
const expectedToken = getEventShareToken(event);
|
||||||
const expectedToken = event.share_link.split('/').pop();
|
|
||||||
if (token !== expectedToken) {
|
if (token !== expectedToken) {
|
||||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||||
}
|
}
|
||||||
@@ -56,6 +86,7 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
'is_active',
|
'is_active',
|
||||||
'is_archived',
|
'is_archived',
|
||||||
'share_link',
|
'share_link',
|
||||||
|
'share_token',
|
||||||
'allow_downloads',
|
'allow_downloads',
|
||||||
'disable_right_click',
|
'disable_right_click',
|
||||||
'watermark_downloads',
|
'watermark_downloads',
|
||||||
@@ -76,12 +107,8 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
|
|
||||||
// If token provided, verify it matches the share link
|
// If token provided, verify it matches the share link
|
||||||
if (token) {
|
if (token) {
|
||||||
let expectedToken = event.share_link;
|
const expectedToken = getEventShareToken(event);
|
||||||
// Handle both formats: full URL or just token
|
if (!expectedToken || token !== expectedToken) {
|
||||||
if (event.share_link && event.share_link.includes('/')) {
|
|
||||||
expectedToken = event.share_link.split('/').pop();
|
|
||||||
}
|
|
||||||
if (token !== expectedToken) {
|
|
||||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
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));
|
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
// Determine language based on email domain
|
// 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
|
// Queue email to customer
|
||||||
await queueEmail(event.id, event.host_email, 'expiration_warning', {
|
await queueEmail(event.id, recipientEmail, 'expiration_warning', {
|
||||||
host_name: event.host_name || event.host_email.split('@')[0],
|
customer_name: recipientName,
|
||||||
|
customer_email: recipientEmail,
|
||||||
|
host_name: recipientName,
|
||||||
event_name: event.event_name,
|
event_name: event.event_name,
|
||||||
days_remaining: daysRemaining.toString(),
|
days_remaining: daysRemaining.toString(),
|
||||||
expiration_date: await formatDate(event.expires_at, emailLang),
|
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) });
|
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
|
||||||
|
|
||||||
// Queue expiration emails
|
// 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,
|
event_name: event.event_name,
|
||||||
admin_email: event.admin_email
|
admin_email: event.admin_email,
|
||||||
|
customer_name: recipientName,
|
||||||
|
customer_email: recipientEmail
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also notify admin
|
// 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
|
||||||
|
};
|
||||||
@@ -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 is created in the backend directory root
|
||||||
- File might have been deleted for security (as recommended)
|
- 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`
|
- 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
|
## 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'
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.1.7",
|
"version": "1.1.13",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.1.7",
|
"version": "1.1.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^2.26.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.7",
|
"version": "1.1.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { api } from '../../config/api';
|
|||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { categoriesService } from '../../services/categories.service';
|
import { categoriesService } from '../../services/categories.service';
|
||||||
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
interface PhotoUploadProps {
|
interface PhotoUploadProps {
|
||||||
@@ -13,6 +14,9 @@ interface PhotoUploadProps {
|
|||||||
onUploadComplete?: () => void;
|
onUploadComplete?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||||
|
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||||
|
|
||||||
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
@@ -29,6 +33,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
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 handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
const imageFiles = files.filter(file =>
|
const imageFiles = files.filter(file =>
|
||||||
@@ -37,13 +57,19 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
|
|
||||||
// Check total file count with existing files
|
// Check total file count with existing files
|
||||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||||
if (totalFiles > 500) {
|
if (totalFiles > maxFilesPerUpload) {
|
||||||
const allowedNewFiles = 500 - selectedFiles.length;
|
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||||
if (allowedNewFiles <= 0) {
|
if (allowedNewFiles <= 0) {
|
||||||
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
|
toast.error(
|
||||||
|
t('upload.maxFilesReached', { limit: maxFilesPerUpload }) ||
|
||||||
|
`Maximum ${maxFilesPerUpload} files allowed`
|
||||||
|
);
|
||||||
return;
|
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)]);
|
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -59,8 +85,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
if (selectedFiles.length === 0) return;
|
if (selectedFiles.length === 0) return;
|
||||||
|
|
||||||
// Validate file count
|
// Validate file count
|
||||||
if (selectedFiles.length > 500) {
|
if (selectedFiles.length > maxFilesPerUpload) {
|
||||||
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
|
toast.error(
|
||||||
|
t('upload.tooManyFiles', { limit: maxFilesPerUpload }) ||
|
||||||
|
`Maximum ${maxFilesPerUpload} files can be uploaded at once`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +97,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
|
|
||||||
// For large uploads, chunk the files to prevent memory issues
|
// 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 = [];
|
const chunks = [];
|
||||||
|
|
||||||
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
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')}
|
{t('upload.clickToUpload')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-neutral-500">
|
<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>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { buildResourceUrl } from '../../utils/url';
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
|
import {
|
||||||
|
getActiveGallerySlug,
|
||||||
|
getGalleryToken,
|
||||||
|
inferGallerySlugFromLocation,
|
||||||
|
resolveSlugFromRequestUrl,
|
||||||
|
} from '../../utils/galleryAuthStorage';
|
||||||
|
|
||||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -52,7 +58,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const unusedProps = {
|
const unusedProps = {
|
||||||
protectFromDownload,
|
protectFromDownload,
|
||||||
slug,
|
|
||||||
photoId,
|
photoId,
|
||||||
requiresToken,
|
requiresToken,
|
||||||
secureUrlTemplate,
|
secureUrlTemplate,
|
||||||
@@ -76,7 +81,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let objectUrl: string | null = null;
|
let aborted = false;
|
||||||
|
const objectUrls: string[] = [];
|
||||||
|
|
||||||
// Determine which token to use based on context
|
// Determine which token to use based on context
|
||||||
if (!src) {
|
if (!src) {
|
||||||
@@ -88,37 +94,79 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(false);
|
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 () => {
|
const fetchImage = async () => {
|
||||||
try {
|
try {
|
||||||
// Use the src as-is since it should already be the correct endpoint
|
const primaryUrl = await fetchWithAuth(src);
|
||||||
let imageUrl = src;
|
if (!aborted) {
|
||||||
|
setImageSrc(primaryUrl);
|
||||||
// Build full URL for the image
|
setError(false);
|
||||||
// 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 blob = await response.blob();
|
|
||||||
objectUrl = URL.createObjectURL(blob);
|
|
||||||
setImageSrc(objectUrl);
|
|
||||||
setIsLoading(false);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Image loading failed - use fallback
|
setIsLoading(false);
|
||||||
setError(true);
|
if (fallbackSrc && fallbackSrc !== src) {
|
||||||
setImageSrc(fallbackSrc || '');
|
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);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -127,11 +175,11 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
|
|
||||||
// Cleanup function
|
// Cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
if (objectUrl) {
|
aborted = true;
|
||||||
URL.revokeObjectURL(objectUrl);
|
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [src, fallbackSrc, useWatermark, isGallery]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [src, fallbackSrc, slug]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface AdminAuthContextType {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
updatePasswordChanged: () => void;
|
updatePasswordChanged: () => void;
|
||||||
|
updateUserProfile: (updates: Partial<AdminUser>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
|
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
|
||||||
@@ -104,6 +105,17 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateUserProfile = (updates: Partial<AdminUser>) => {
|
||||||
|
setUser((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const nextUser = { ...prev, ...updates };
|
||||||
|
sessionStorage.setItem('admin_user', JSON.stringify(nextUser));
|
||||||
|
return nextUser;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminAuthContext.Provider
|
<AdminAuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -115,6 +127,7 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
|||||||
error,
|
error,
|
||||||
mustChangePassword,
|
mustChangePassword,
|
||||||
updatePasswordChanged,
|
updatePasswordChanged,
|
||||||
|
updateUserProfile,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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 type { ReactNode } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import { authService, galleryService } from '../services';
|
import { authService, galleryService } from '../services';
|
||||||
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
||||||
@@ -61,52 +62,133 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [routeError, setRouteError] = useState<string | null>(null);
|
||||||
// Get current gallery slug from URL
|
const location = useLocation();
|
||||||
const getCurrentGallerySlug = () => {
|
const [routeInfo, setRouteInfo] = useState<{ slug: string | null; token?: string; identifier: string | null; ready: boolean }>({
|
||||||
const pathParts = window.location.pathname.split('/');
|
slug: null,
|
||||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
token: undefined,
|
||||||
return pathParts[2];
|
identifier: null,
|
||||||
}
|
ready: false,
|
||||||
return null;
|
});
|
||||||
};
|
const lastResolvedIdentifier = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cleanupOldGalleryAuth();
|
cleanupOldGalleryAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const slugAtMount = getCurrentGallerySlug();
|
useEffect(() => {
|
||||||
if (slugAtMount) {
|
let cancelled = false;
|
||||||
setActiveGallerySlug(slugAtMount);
|
|
||||||
} else {
|
|
||||||
clearActiveGallerySlug();
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialise = async () => {
|
const parseRoute = async () => {
|
||||||
const currentSlug = getCurrentGallerySlug();
|
const segments = location.pathname.split('/').filter(Boolean);
|
||||||
|
|
||||||
if (!currentSlug) {
|
if (segments[0] !== 'gallery') {
|
||||||
setIsLoading(false);
|
if (!cancelled) {
|
||||||
|
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||||
|
setRouteError(null);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveGallerySlug(currentSlug);
|
const identifier = segments[1] || null;
|
||||||
|
const tokenSegment = segments[2];
|
||||||
|
|
||||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
if (!identifier) {
|
||||||
if (storedEvent) {
|
if (!cancelled) {
|
||||||
try {
|
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
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 {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
|
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
|
||||||
@@ -118,7 +200,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
|
||||||
if (!storedEvent) {
|
if (!storedEvent) {
|
||||||
// Fetch gallery details to hydrate context
|
|
||||||
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
||||||
if (galleryData?.event) {
|
if (galleryData?.event) {
|
||||||
const normalizedEvent = normalizeEvent(galleryData.event);
|
const normalizedEvent = normalizeEvent(galleryData.event);
|
||||||
@@ -132,14 +213,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no active session, check for share token in URL
|
if (routeInfo.token) {
|
||||||
const parts = window.location.pathname.split('/');
|
const verify = await galleryService.verifyToken(currentSlug, routeInfo.token);
|
||||||
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
|
|
||||||
|
|
||||||
if (urlToken) {
|
|
||||||
const verify = await galleryService.verifyToken(currentSlug, urlToken);
|
|
||||||
if (verify?.valid) {
|
if (verify?.valid) {
|
||||||
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
|
||||||
if (response?.event) {
|
if (response?.event) {
|
||||||
const normalizedEvent = normalizeEvent(response.event);
|
const normalizedEvent = normalizeEvent(response.event);
|
||||||
setEvent(normalizedEvent);
|
setEvent(normalizedEvent);
|
||||||
@@ -156,29 +233,33 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No valid session found
|
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
clearGalleryToken(currentSlug);
|
clearGalleryToken(currentSlug);
|
||||||
} catch (error) {
|
} catch (initialiseError: any) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
clearGalleryToken(currentSlug);
|
clearGalleryToken(currentSlug);
|
||||||
|
if (initialiseError?.response?.data?.error) {
|
||||||
|
setError(initialiseError.response.data.error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initialise();
|
initialise();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearActiveGallerySlug();
|
clearActiveGallerySlug();
|
||||||
};
|
};
|
||||||
}, []);
|
}, [routeInfo]);
|
||||||
|
|
||||||
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
|
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
|
||||||
try {
|
try {
|
||||||
|
setRouteError(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||||
@@ -190,7 +271,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
}
|
}
|
||||||
setActiveGallerySlug(slug);
|
setActiveGallerySlug(slug);
|
||||||
|
|
||||||
// Store event data for quick reloads (non-sensitive)
|
|
||||||
if (normalizedEvent) {
|
if (normalizedEvent) {
|
||||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||||
}
|
}
|
||||||
@@ -203,7 +283,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
const currentSlug = getCurrentGallerySlug();
|
const currentSlug = routeInfo.slug;
|
||||||
if (currentSlug) {
|
if (currentSlug) {
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
clearGalleryToken(currentSlug);
|
clearGalleryToken(currentSlug);
|
||||||
@@ -222,7 +302,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error: routeError ?? error,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
|||||||
import { galleryService } from '../services';
|
import { galleryService } from '../services';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
export const useGalleryInfo = (slug: string, token?: string) => {
|
export const useGalleryInfo = (slug?: string, token?: string, enabled: boolean = true) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['gallery-info', slug, token],
|
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,
|
retry: 1,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
enabled: Boolean(slug) && enabled,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"noCategory": "Keine Kategorie",
|
"noCategory": "Keine Kategorie",
|
||||||
"eventSpecific": "(Veranstaltungsspezifisch)",
|
"eventSpecific": "(Veranstaltungsspezifisch)",
|
||||||
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
|
"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",
|
"selectedFiles": "Ausgewählte Dateien",
|
||||||
"uploading": "Wird hochgeladen...",
|
"uploading": "Wird hochgeladen...",
|
||||||
"uploadComplete": "Upload abgeschlossen!",
|
"uploadComplete": "Upload abgeschlossen!",
|
||||||
@@ -59,9 +59,11 @@
|
|||||||
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
|
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
|
||||||
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
|
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
|
||||||
"importFromSelectedFolder": "Ausgewählten Ordner importieren",
|
"importFromSelectedFolder": "Ausgewählten Ordner importieren",
|
||||||
"maxFilesReached": "Maximal 500 Dateien erlaubt",
|
"maxFilesReached": "Maximal {{limit}} Dateien erlaubt",
|
||||||
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
|
"someFilesSkipped": "Nur {{allowed}} weitere Dateien erlaubt (Limit {{limit}})",
|
||||||
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden",
|
"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..."
|
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..."
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
@@ -566,8 +568,8 @@
|
|||||||
"eventName": "Veranstaltungsname",
|
"eventName": "Veranstaltungsname",
|
||||||
"eventType": "Veranstaltungstyp",
|
"eventType": "Veranstaltungstyp",
|
||||||
"eventDate": "Veranstaltungsdatum",
|
"eventDate": "Veranstaltungsdatum",
|
||||||
"hostEmail": "Gastgeber-E-Mail",
|
"hostEmail": "E-Mail des Kunden",
|
||||||
"hostName": "Name des Gastgebers",
|
"hostName": "Name des Kunden",
|
||||||
"hostNamePlaceholder": "Max Mustermann",
|
"hostNamePlaceholder": "Max Mustermann",
|
||||||
"adminEmail": "Admin-E-Mail",
|
"adminEmail": "Admin-E-Mail",
|
||||||
"expirationDate": "Ablaufdatum",
|
"expirationDate": "Ablaufdatum",
|
||||||
@@ -589,8 +591,8 @@
|
|||||||
"eventExpired": "Diese Veranstaltung ist abgelaufen",
|
"eventExpired": "Diese Veranstaltung ist abgelaufen",
|
||||||
"eventExpiresIn": "Diese Veranstaltung läuft in {{days}} Tagen ab",
|
"eventExpiresIn": "Diese Veranstaltung läuft in {{days}} Tagen ab",
|
||||||
"guestsNoAccess": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
|
"guestsNoAccess": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
|
||||||
"warningEmailsSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
"warningEmailsSent": "Warn-E-Mails wurden an den Kunden gesendet.",
|
||||||
"warningEmailsHaveBeenSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
"warningEmailsHaveBeenSent": "Warn-E-Mails wurden an den Kunden gesendet.",
|
||||||
"extendSevenDays": "Um 7 Tage verlängern",
|
"extendSevenDays": "Um 7 Tage verlängern",
|
||||||
"overview": "Übersicht",
|
"overview": "Übersicht",
|
||||||
"photos": "Fotos",
|
"photos": "Fotos",
|
||||||
@@ -631,7 +633,7 @@
|
|||||||
"organizeCategoriesInfo": "Organisieren Sie Ihre Fotos in Kategorien. Kategorien helfen Gästen, bestimmte Fototypen zu navigieren und zu finden.",
|
"organizeCategoriesInfo": "Organisieren Sie Ihre Fotos in Kategorien. Kategorien helfen Gästen, bestimmte Fototypen zu navigieren und zu finden.",
|
||||||
"categoriesTip": "Tipp: Kategorien sind spezifisch für jede Veranstaltung. Sie können auch globale Kategorien in den Einstellungen erstellen.",
|
"categoriesTip": "Tipp: Kategorien sind spezifisch für jede Veranstaltung. Sie können auch globale Kategorien in den Einstellungen erstellen.",
|
||||||
"contactInformation": "Kontaktinformationen",
|
"contactInformation": "Kontaktinformationen",
|
||||||
"hostEmailHelp": "Erhält Benachrichtigungen zur Galerie-Erstellung und zum Ablauf",
|
"hostEmailHelp": "Der Kunde erhält Benachrichtigungen zur Galerie-Erstellung und zum Ablauf",
|
||||||
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
||||||
"securityAccess": "Sicherheit & Zugriff",
|
"securityAccess": "Sicherheit & Zugriff",
|
||||||
"galleryPassword": "Galerie-Passwort",
|
"galleryPassword": "Galerie-Passwort",
|
||||||
@@ -686,7 +688,7 @@
|
|||||||
"eventNamePlaceholder": "z.B. Max & Maria's Hochzeit",
|
"eventNamePlaceholder": "z.B. Max & Maria's Hochzeit",
|
||||||
"welcomeMessageOptional": "Willkommensnachricht (Optional)",
|
"welcomeMessageOptional": "Willkommensnachricht (Optional)",
|
||||||
"welcomeMessagePlaceholder": "Willkommen zu unserem besonderen Tag! Laden Sie diese Erinnerungen gerne herunter und teilen Sie sie...",
|
"welcomeMessagePlaceholder": "Willkommen zu unserem besonderen Tag! Laden Sie diese Erinnerungen gerne herunter und teilen Sie sie...",
|
||||||
"hostEmailPlaceholder": "gastgeber@beispiel.de",
|
"hostEmailPlaceholder": "kunde@beispiel.de",
|
||||||
"adminEmailPlaceholder": "admin@beispiel.de",
|
"adminEmailPlaceholder": "admin@beispiel.de",
|
||||||
"securityAndAccess": "Sicherheit & Zugriff",
|
"securityAndAccess": "Sicherheit & Zugriff",
|
||||||
"accessAndSecurity": "Zugriff & Sicherheit",
|
"accessAndSecurity": "Zugriff & Sicherheit",
|
||||||
@@ -773,12 +775,16 @@
|
|||||||
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
|
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
|
||||||
"maxFileSize": "Max. Dateigröße (MB)",
|
"maxFileSize": "Max. Dateigröße (MB)",
|
||||||
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
|
"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",
|
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||||
"featureToggles": "Funktionsschalter",
|
"featureToggles": "Funktionsschalter",
|
||||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
||||||
"enableAnalytics": "Analytics-Tracking aktivieren",
|
"enableAnalytics": "Analytics-Tracking aktivieren",
|
||||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
"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",
|
"maintenanceMode": "Wartungsmodus aktivieren",
|
||||||
"language": "Sprache",
|
"language": "Sprache",
|
||||||
"defaultLanguage": "Standardsprache",
|
"defaultLanguage": "Standardsprache",
|
||||||
@@ -791,7 +797,18 @@
|
|||||||
"saveGeneralSettings": "Allgemeine Einstellungen speichern",
|
"saveGeneralSettings": "Allgemeine Einstellungen speichern",
|
||||||
"dateTimeFormat": "Datums- & Zeitformat",
|
"dateTimeFormat": "Datums- & Zeitformat",
|
||||||
"dateFormat": "Datumsformat",
|
"dateFormat": "Datumsformat",
|
||||||
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden"
|
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden",
|
||||||
|
"accountSection": "Admin-Konto",
|
||||||
|
"accountUsername": "Admin-Benutzername",
|
||||||
|
"accountUsernameHelp": "Wird im Admin-Bereich angezeigt und in Aktivitätsprotokollen verwendet.",
|
||||||
|
"accountUsernameRequired": "Benutzername ist erforderlich",
|
||||||
|
"accountUsernameLength": "Benutzername muss mindestens 3 Zeichen lang sein",
|
||||||
|
"accountEmail": "Admin-E-Mail",
|
||||||
|
"accountEmailHelp": "Wird für die Anmeldung und für Sicherheitsbenachrichtigungen verwendet.",
|
||||||
|
"accountEmailRequired": "E-Mail-Adresse ist erforderlich",
|
||||||
|
"accountEmailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben",
|
||||||
|
"accountSaveButton": "Kontodaten speichern",
|
||||||
|
"accountSaveSuccess": "Kontodaten aktualisiert"
|
||||||
},
|
},
|
||||||
"publicSite": {
|
"publicSite": {
|
||||||
"tabLabel": "Öffentliche Seite",
|
"tabLabel": "Öffentliche Seite",
|
||||||
@@ -1350,8 +1367,8 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"eventNameRequired": "Veranstaltungsname ist erforderlich",
|
"eventNameRequired": "Veranstaltungsname ist erforderlich",
|
||||||
"hostEmailRequired": "Gastgeber-E-Mail ist erforderlich",
|
"hostEmailRequired": "Die E-Mail des Kunden ist erforderlich",
|
||||||
"hostNameRequired": "Der Name des Gastgebers ist erforderlich",
|
"hostNameRequired": "Der Name des Kunden ist erforderlich",
|
||||||
"adminEmailRequired": "Admin-E-Mail ist erforderlich",
|
"adminEmailRequired": "Admin-E-Mail ist erforderlich",
|
||||||
"invalidEmailFormat": "Ungültiges E-Mail-Format",
|
"invalidEmailFormat": "Ungültiges E-Mail-Format",
|
||||||
"passwordRequired": "Passwort ist erforderlich",
|
"passwordRequired": "Passwort ist erforderlich",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"noCategory": "No category",
|
"noCategory": "No category",
|
||||||
"eventSpecific": "(Event specific)",
|
"eventSpecific": "(Event specific)",
|
||||||
"clickToUpload": "Click to upload or drag and drop",
|
"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",
|
"selectedFiles": "Selected files",
|
||||||
"uploading": "Uploading...",
|
"uploading": "Uploading...",
|
||||||
"uploadComplete": "Upload complete!",
|
"uploadComplete": "Upload complete!",
|
||||||
@@ -59,9 +59,11 @@
|
|||||||
"externalImportInfo": "All pictures from the selected folder will be imported.",
|
"externalImportInfo": "All pictures from the selected folder will be imported.",
|
||||||
"selectExternalFolder": "Select external folder under /external-media",
|
"selectExternalFolder": "Select external folder under /external-media",
|
||||||
"importFromSelectedFolder": "Import from selected folder",
|
"importFromSelectedFolder": "Import from selected folder",
|
||||||
"maxFilesReached": "Maximum 500 files allowed",
|
"maxFilesReached": "Maximum {{limit}} files allowed",
|
||||||
"someFilesSkipped": "Some files were skipped (500 file limit)",
|
"someFilesSkipped": "Only {{allowed}} more files can be added (limit {{limit}})",
|
||||||
"tooManyFiles": "Maximum 500 files can be uploaded at once",
|
"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..."
|
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..."
|
||||||
},
|
},
|
||||||
"navigation": {
|
"navigation": {
|
||||||
@@ -225,7 +227,7 @@
|
|||||||
"eventNamePlaceholder": "e.g., John & Jane's Wedding",
|
"eventNamePlaceholder": "e.g., John & Jane's Wedding",
|
||||||
"welcomeMessageOptional": "Welcome Message (Optional)",
|
"welcomeMessageOptional": "Welcome Message (Optional)",
|
||||||
"welcomeMessagePlaceholder": "Welcome to our special day! Feel free to download and share these memories...",
|
"welcomeMessagePlaceholder": "Welcome to our special day! Feel free to download and share these memories...",
|
||||||
"hostEmailPlaceholder": "host@example.com",
|
"hostEmailPlaceholder": "customer@example.com",
|
||||||
"adminEmailPlaceholder": "admin@example.com",
|
"adminEmailPlaceholder": "admin@example.com",
|
||||||
"securityAndAccess": "Security & Access",
|
"securityAndAccess": "Security & Access",
|
||||||
"accessAndSecurity": "Access & Security",
|
"accessAndSecurity": "Access & Security",
|
||||||
@@ -251,8 +253,8 @@
|
|||||||
"eventName": "Event Name",
|
"eventName": "Event Name",
|
||||||
"eventType": "Event Type",
|
"eventType": "Event Type",
|
||||||
"eventDate": "Event Date",
|
"eventDate": "Event Date",
|
||||||
"hostEmail": "Host Email",
|
"hostEmail": "Customer Email",
|
||||||
"hostName": "Host Name",
|
"hostName": "Customer Name",
|
||||||
"hostNamePlaceholder": "John Smith",
|
"hostNamePlaceholder": "John Smith",
|
||||||
"adminEmail": "Admin Email",
|
"adminEmail": "Admin Email",
|
||||||
"adminNotificationEmail": "Admin Notification Email",
|
"adminNotificationEmail": "Admin Notification Email",
|
||||||
@@ -275,7 +277,7 @@
|
|||||||
"eventExpired": "This event has expired",
|
"eventExpired": "This event has expired",
|
||||||
"eventExpiresIn": "This event expires in {{days}} days",
|
"eventExpiresIn": "This event expires in {{days}} days",
|
||||||
"guestsNoAccess": "Guests can no longer access the gallery. Consider archiving this event.",
|
"guestsNoAccess": "Guests can no longer access the gallery. Consider archiving this event.",
|
||||||
"warningEmailsSent": "Warning emails have been sent to the host.",
|
"warningEmailsSent": "Warning emails have been sent to the customer.",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
@@ -315,7 +317,7 @@
|
|||||||
"organizeCategoriesInfo": "Organize your photos into categories. Categories help guests navigate and find specific types of photos.",
|
"organizeCategoriesInfo": "Organize your photos into categories. Categories help guests navigate and find specific types of photos.",
|
||||||
"categoriesTip": "Tip: Categories are specific to each event. You can also create global categories in Settings.",
|
"categoriesTip": "Tip: Categories are specific to each event. You can also create global categories in Settings.",
|
||||||
"contactInformation": "Contact Information",
|
"contactInformation": "Contact Information",
|
||||||
"hostEmailHelp": "Will receive gallery creation and expiration notifications",
|
"hostEmailHelp": "Customer will receive gallery creation and expiration notifications",
|
||||||
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
||||||
"securityAccess": "Security & Access",
|
"securityAccess": "Security & Access",
|
||||||
"galleryPassword": "Gallery Password",
|
"galleryPassword": "Gallery Password",
|
||||||
@@ -406,13 +408,13 @@
|
|||||||
"tryAgain": "Try Again",
|
"tryAgain": "Try Again",
|
||||||
"eventExpiredMessage": "This event has expired",
|
"eventExpiredMessage": "This event has expired",
|
||||||
"guestsCannotAccessGallery": "Guests can no longer access the gallery. Consider archiving this event.",
|
"guestsCannotAccessGallery": "Guests can no longer access the gallery. Consider archiving this event.",
|
||||||
"warningEmailsHaveBeenSent": "Warning emails have been sent to the host.",
|
"warningEmailsHaveBeenSent": "Warning emails have been sent to the customer.",
|
||||||
"extendSevenDays": "Extend 7 Days",
|
"extendSevenDays": "Extend 7 Days",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"eventInformation": "Event Information",
|
"eventInformation": "Event Information",
|
||||||
"welcomeMessageLabel": "Welcome Message",
|
"welcomeMessageLabel": "Welcome Message",
|
||||||
"noWelcomeMessageSet": "No welcome message set",
|
"noWelcomeMessageSet": "No welcome message set",
|
||||||
"hostEmail": "Host Email",
|
"hostEmail": "Customer Email",
|
||||||
"adminEmail": "Admin Email",
|
"adminEmail": "Admin Email",
|
||||||
"createdOn": "Created",
|
"createdOn": "Created",
|
||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
@@ -453,12 +455,16 @@
|
|||||||
"defaultExpirationHelp": "How long galleries remain active by default",
|
"defaultExpirationHelp": "How long galleries remain active by default",
|
||||||
"maxFileSize": "Max File Size (MB)",
|
"maxFileSize": "Max File Size (MB)",
|
||||||
"maxFileSizeHelp": "Maximum size per uploaded photo",
|
"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",
|
"allowedFileTypes": "Allowed File Types",
|
||||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||||
"featureToggles": "Feature Toggles",
|
"featureToggles": "Feature Toggles",
|
||||||
"enableWatermark": "Enable watermark on photos",
|
"enableWatermark": "Enable watermark on photos",
|
||||||
"enableAnalytics": "Enable analytics tracking",
|
"enableAnalytics": "Enable analytics tracking",
|
||||||
"enableRegistration": "Allow self-registration for admins",
|
"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",
|
"maintenanceMode": "Enable maintenance mode",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"defaultLanguage": "Default Language",
|
"defaultLanguage": "Default Language",
|
||||||
@@ -471,7 +477,18 @@
|
|||||||
"saveGeneralSettings": "Save General Settings",
|
"saveGeneralSettings": "Save General Settings",
|
||||||
"dateTimeFormat": "Date & Time Format",
|
"dateTimeFormat": "Date & Time Format",
|
||||||
"dateFormat": "Date Format",
|
"dateFormat": "Date Format",
|
||||||
"dateFormatHelp": "How dates are displayed in emails and throughout the application"
|
"dateFormatHelp": "How dates are displayed in emails and throughout the application",
|
||||||
|
"accountSection": "Admin Account",
|
||||||
|
"accountUsername": "Admin Username",
|
||||||
|
"accountUsernameHelp": "Displayed in the admin interface and used in activity logs.",
|
||||||
|
"accountUsernameRequired": "Username is required",
|
||||||
|
"accountUsernameLength": "Username must be at least 3 characters",
|
||||||
|
"accountEmail": "Admin Email",
|
||||||
|
"accountEmailHelp": "Used for login and receiving security notifications.",
|
||||||
|
"accountEmailRequired": "Email address is required",
|
||||||
|
"accountEmailInvalid": "Enter a valid email address",
|
||||||
|
"accountSaveButton": "Save account details",
|
||||||
|
"accountSaveSuccess": "Account details updated"
|
||||||
},
|
},
|
||||||
"publicSite": {
|
"publicSite": {
|
||||||
"tabLabel": "Public Site",
|
"tabLabel": "Public Site",
|
||||||
@@ -955,8 +972,8 @@
|
|||||||
},
|
},
|
||||||
"validation": {
|
"validation": {
|
||||||
"eventNameRequired": "Event name is required",
|
"eventNameRequired": "Event name is required",
|
||||||
"hostEmailRequired": "Host email is required",
|
"hostEmailRequired": "Customer email is required",
|
||||||
"hostNameRequired": "Host name is required",
|
"hostNameRequired": "Customer name is required",
|
||||||
"adminEmailRequired": "Admin email is required",
|
"adminEmailRequired": "Admin email is required",
|
||||||
"invalidEmailFormat": "Invalid email format",
|
"invalidEmailFormat": "Invalid email format",
|
||||||
"passwordRequired": "Password is required",
|
"passwordRequired": "Password is required",
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ import { useGalleryAuth, useTheme } from '../contexts';
|
|||||||
import { useGalleryInfo } from '../hooks/useGallery';
|
import { useGalleryInfo } from '../hooks/useGallery';
|
||||||
import { GalleryView } from '../components/gallery';
|
import { GalleryView } from '../components/gallery';
|
||||||
import { analyticsService } from '../services/analytics.service';
|
import { analyticsService } from '../services/analytics.service';
|
||||||
|
import { galleryService } from '../services';
|
||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||||
import { buildResourceUrl } from '../utils/url';
|
import { buildResourceUrl } from '../utils/url';
|
||||||
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
export const GalleryPage: React.FC = () => {
|
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 { isAuthenticated, login, event } = useGalleryAuth();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
@@ -27,10 +28,82 @@ export const GalleryPage: React.FC = () => {
|
|||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
|
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)
|
React.useEffect(() => {
|
||||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
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);
|
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setAutoLoginAttempted(false);
|
||||||
|
}, [resolvedSlug]);
|
||||||
|
|
||||||
// Fetch branding settings
|
// Fetch branding settings
|
||||||
const { data: settingsData } = useQuery({
|
const { data: settingsData } = useQuery({
|
||||||
@@ -91,14 +164,14 @@ export const GalleryPage: React.FC = () => {
|
|||||||
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!slug) {
|
if (!resolvedSlug || isResolvingIdentifier) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
|
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
|
||||||
setAutoLoginAttempted(true);
|
setAutoLoginAttempted(true);
|
||||||
setIsLoggingIn(true);
|
setIsLoggingIn(true);
|
||||||
login(slug, '')
|
login(resolvedSlug, '')
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
})
|
})
|
||||||
@@ -112,7 +185,7 @@ export const GalleryPage: React.FC = () => {
|
|||||||
setIsLoggingIn(false);
|
setIsLoggingIn(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]);
|
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
|
||||||
|
|
||||||
// Calculate days until expiration
|
// Calculate days until expiration
|
||||||
const daysUntilExpiration = galleryInfo
|
const daysUntilExpiration = galleryInfo
|
||||||
@@ -131,11 +204,16 @@ export const GalleryPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setIsLoggingIn(true);
|
setIsLoggingIn(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
await login(slug!, requiresPassword ? password : '', recaptchaToken);
|
if (!resolvedSlug) {
|
||||||
|
setLoginError(t('errors.galleryNotFound'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken);
|
||||||
|
|
||||||
if (requiresPassword) {
|
if (requiresPassword) {
|
||||||
analyticsService.trackGalleryEvent('password_entry', {
|
analyticsService.trackGalleryEvent('password_entry', {
|
||||||
gallery: slug,
|
gallery: resolvedSlug,
|
||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -158,7 +236,7 @@ export const GalleryPage: React.FC = () => {
|
|||||||
// Track failed password entry
|
// Track failed password entry
|
||||||
if (requiresPassword) {
|
if (requiresPassword) {
|
||||||
analyticsService.trackGalleryEvent('password_entry', {
|
analyticsService.trackGalleryEvent('password_entry', {
|
||||||
gallery: slug,
|
gallery: resolvedSlug ?? rawSlug ?? 'unknown',
|
||||||
success: false,
|
success: false,
|
||||||
statusCode
|
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
|
// Show error state
|
||||||
if (infoError) {
|
if (infoError) {
|
||||||
// Check if it's an archived gallery error
|
// 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
|
// Show gallery view if authenticated
|
||||||
if (isAuthenticated && event) {
|
if (isAuthenticated && event) {
|
||||||
return <GalleryView slug={slug!} event={event} />;
|
return <GalleryView slug={gallerySlugForView} event={event} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show login form
|
// Show login form
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const AdminLoginPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
toast.dismiss();
|
||||||
|
|
||||||
if (!validateForm()) {
|
if (!validateForm()) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface FormData {
|
|||||||
event_type: string;
|
event_type: string;
|
||||||
event_name: string;
|
event_name: string;
|
||||||
event_date: string;
|
event_date: string;
|
||||||
host_email: string;
|
customer_email: string;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
require_password: boolean;
|
require_password: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -122,7 +122,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
event_type: 'wedding',
|
event_type: 'wedding',
|
||||||
event_name: '',
|
event_name: '',
|
||||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
event_date: format(new Date(), 'yyyy-MM-dd'),
|
||||||
host_email: '',
|
customer_email: '',
|
||||||
admin_email: '',
|
admin_email: '',
|
||||||
require_password: true,
|
require_password: true,
|
||||||
password: '',
|
password: '',
|
||||||
@@ -198,10 +198,10 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
newErrors.event_name = t('validation.eventNameRequired');
|
newErrors.event_name = t('validation.eventNameRequired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.host_email) {
|
if (!formData.customer_email) {
|
||||||
newErrors.host_email = t('validation.hostEmailRequired');
|
newErrors.customer_email = t('validation.hostEmailRequired');
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
||||||
newErrors.host_email = t('validation.invalidEmailFormat');
|
newErrors.customer_email = t('validation.invalidEmailFormat');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.admin_email) {
|
if (!formData.admin_email) {
|
||||||
@@ -245,7 +245,8 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
event_type: formData.event_type,
|
event_type: formData.event_type,
|
||||||
event_name: formData.event_name,
|
event_name: formData.event_name,
|
||||||
event_date: formData.event_date,
|
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,
|
admin_email: formData.admin_email,
|
||||||
require_password: formData.require_password,
|
require_password: formData.require_password,
|
||||||
password: formData.require_password ? formData.password : undefined,
|
password: formData.require_password ? formData.password : undefined,
|
||||||
@@ -388,17 +389,17 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.contactInformation')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.contactInformation')}</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Host Email */}
|
{/* Customer Email */}
|
||||||
<div>
|
<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')}
|
{t('events.hostEmail')}
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="host_email"
|
id="customer_email"
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.host_email}
|
value={formData.customer_email}
|
||||||
onChange={handleInputChange('host_email')}
|
onChange={handleInputChange('customer_email')}
|
||||||
error={errors.host_email}
|
error={errors.customer_email}
|
||||||
placeholder={t('events.hostEmailPlaceholder')}
|
placeholder={t('events.hostEmailPlaceholder')}
|
||||||
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ interface FormData {
|
|||||||
event_type: string;
|
event_type: string;
|
||||||
event_name: string;
|
event_name: string;
|
||||||
event_date: string;
|
event_date: string;
|
||||||
host_name: string;
|
customer_name: string;
|
||||||
host_email: string;
|
customer_email: string;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
require_password: boolean;
|
require_password: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -86,8 +86,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
event_type: 'wedding',
|
event_type: 'wedding',
|
||||||
event_name: '',
|
event_name: '',
|
||||||
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
|
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
|
||||||
host_name: '',
|
customer_name: '',
|
||||||
host_email: '',
|
customer_email: '',
|
||||||
admin_email: '',
|
admin_email: '',
|
||||||
require_password: true,
|
require_password: true,
|
||||||
password: '',
|
password: '',
|
||||||
@@ -184,14 +184,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
newErrors.event_date = t('validation.eventDateRequired');
|
newErrors.event_date = t('validation.eventDateRequired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.host_name) {
|
if (!formData.customer_name) {
|
||||||
newErrors.host_name = t('validation.hostNameRequired');
|
newErrors.customer_name = t('validation.hostNameRequired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.host_email) {
|
if (!formData.customer_email) {
|
||||||
newErrors.host_email = t('validation.hostEmailRequired');
|
newErrors.customer_email = t('validation.hostEmailRequired');
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
||||||
newErrors.host_email = t('validation.invalidEmailFormat');
|
newErrors.customer_email = t('validation.invalidEmailFormat');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.admin_email) {
|
if (!formData.admin_email) {
|
||||||
@@ -236,8 +236,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
event_type: formData.event_type,
|
event_type: formData.event_type,
|
||||||
event_name: formData.event_name,
|
event_name: formData.event_name,
|
||||||
event_date: formData.event_date,
|
event_date: formData.event_date,
|
||||||
host_name: formData.host_name,
|
customer_name: formData.customer_name,
|
||||||
host_email: formData.host_email,
|
customer_email: formData.customer_email,
|
||||||
admin_email: formData.admin_email,
|
admin_email: formData.admin_email,
|
||||||
require_password: formData.require_password,
|
require_password: formData.require_password,
|
||||||
password: formData.require_password ? formData.password : undefined,
|
password: formData.require_password ? formData.password : undefined,
|
||||||
@@ -472,9 +472,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
label={t('events.hostName')}
|
label={t('events.hostName')}
|
||||||
placeholder={t('events.hostNamePlaceholder')}
|
placeholder={t('events.hostNamePlaceholder')}
|
||||||
value={formData.host_name}
|
value={formData.customer_name}
|
||||||
onChange={handleInputChange('host_name')}
|
onChange={handleInputChange('customer_name')}
|
||||||
error={errors.host_name}
|
error={errors.customer_name}
|
||||||
leftIcon={<Calendar className="w-5 h-5" />}
|
leftIcon={<Calendar className="w-5 h-5" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -482,9 +482,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
type="email"
|
type="email"
|
||||||
label={t('events.hostEmail')}
|
label={t('events.hostEmail')}
|
||||||
placeholder={t('events.hostEmailPlaceholder')}
|
placeholder={t('events.hostEmailPlaceholder')}
|
||||||
value={formData.host_email}
|
value={formData.customer_email}
|
||||||
onChange={handleInputChange('host_email')}
|
onChange={handleInputChange('customer_email')}
|
||||||
error={errors.host_email}
|
error={errors.customer_email}
|
||||||
leftIcon={<Mail className="w-5 h-5" />}
|
leftIcon={<Mail className="w-5 h-5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
allow_user_uploads: boolean;
|
allow_user_uploads: boolean;
|
||||||
upload_category_id: number | null;
|
upload_category_id: number | null;
|
||||||
hero_photo_id: number | null;
|
hero_photo_id: number | null;
|
||||||
host_name: string;
|
customer_name: string;
|
||||||
source_mode: 'managed' | 'reference';
|
source_mode: 'managed' | 'reference';
|
||||||
external_path: string;
|
external_path: string;
|
||||||
require_password: boolean;
|
require_password: boolean;
|
||||||
@@ -138,7 +138,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
allow_user_uploads: false,
|
allow_user_uploads: false,
|
||||||
upload_category_id: null,
|
upload_category_id: null,
|
||||||
hero_photo_id: null,
|
hero_photo_id: null,
|
||||||
host_name: '',
|
customer_name: '',
|
||||||
source_mode: 'managed',
|
source_mode: 'managed',
|
||||||
external_path: '',
|
external_path: '',
|
||||||
require_password: true,
|
require_password: true,
|
||||||
@@ -282,7 +282,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
allow_user_uploads: event.allow_user_uploads || false,
|
allow_user_uploads: event.allow_user_uploads || false,
|
||||||
upload_category_id: event.upload_category_id || null,
|
upload_category_id: event.upload_category_id || null,
|
||||||
hero_photo_id: event.hero_photo_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',
|
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
||||||
external_path: event.external_path || '',
|
external_path: event.external_path || '',
|
||||||
require_password: normalizeRequirePassword(event.require_password),
|
require_password: normalizeRequirePassword(event.require_password),
|
||||||
@@ -389,8 +389,8 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
updateData.external_path = editForm.source_mode === 'reference'
|
updateData.external_path = editForm.source_mode === 'reference'
|
||||||
? externalPathToSave
|
? externalPathToSave
|
||||||
: null;
|
: null;
|
||||||
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
if (editForm.customer_name !== undefined && editForm.customer_name !== null) {
|
||||||
updateData.host_name = editForm.host_name;
|
updateData.customer_name = editForm.customer_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editForm.new_password) {
|
if (editForm.new_password) {
|
||||||
@@ -665,8 +665,8 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={editForm.host_name}
|
value={editForm.customer_name}
|
||||||
onChange={(e) => setEditForm(prev => ({ ...prev, host_name: e.target.value }))}
|
onChange={(e) => setEditForm(prev => ({ ...prev, customer_name: e.target.value }))}
|
||||||
placeholder={t('events.hostNamePlaceholder')}
|
placeholder={t('events.hostNamePlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -881,14 +881,14 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
|
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<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>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export const EventsListPage: React.FC = () => {
|
|||||||
events = events.filter(e =>
|
events = events.filter(e =>
|
||||||
e.event_name.toLowerCase().includes(term) ||
|
e.event_name.toLowerCase().includes(term) ||
|
||||||
e.event_type.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">
|
<td className="px-6 py-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
|
<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">
|
<div className="mt-1">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Save,
|
Save,
|
||||||
Database,
|
Database,
|
||||||
Globe,
|
Globe,
|
||||||
Key,
|
Key,
|
||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Activity
|
Activity,
|
||||||
|
Mail,
|
||||||
|
User
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
@@ -19,9 +21,12 @@ import { CategoryManager } from '../../components/admin/CategoryManager';
|
|||||||
import { WordFilterManager } from '../../components/admin/WordFilterManager';
|
import { WordFilterManager } from '../../components/admin/WordFilterManager';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
|
import { adminService } from '../../services/admin.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAdminAuth } from '../../contexts';
|
||||||
|
|
||||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||||
|
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||||
|
|
||||||
const toBoolean = (value: unknown, defaultValue = false): boolean => {
|
const toBoolean = (value: unknown, defaultValue = false): boolean => {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
@@ -56,6 +61,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
|
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { updateUserProfile } = useAdminAuth();
|
||||||
|
|
||||||
// Fetch settings
|
// Fetch settings
|
||||||
const { data: settings, isLoading } = useQuery({
|
const { data: settings, isLoading } = useQuery({
|
||||||
@@ -63,6 +69,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
queryFn: () => settingsService.getAllSettings(),
|
queryFn: () => settingsService.getAllSettings(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: adminProfile, isLoading: adminProfileLoading } = useQuery({
|
||||||
|
queryKey: ['admin-profile'],
|
||||||
|
queryFn: () => adminService.getAdminProfile(),
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch storage info
|
// Fetch storage info
|
||||||
const { data: storageInfo } = useQuery({
|
const { data: storageInfo } = useQuery({
|
||||||
queryKey: ['admin-storage-info'],
|
queryKey: ['admin-storage-info'],
|
||||||
@@ -83,11 +94,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
site_url: '',
|
site_url: '',
|
||||||
default_expiration_days: 30,
|
default_expiration_days: 30,
|
||||||
max_file_size_mb: 50,
|
max_file_size_mb: 50,
|
||||||
|
max_files_per_upload: 500,
|
||||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||||
enable_watermark: false,
|
enable_watermark: false,
|
||||||
enable_analytics: true,
|
enable_analytics: true,
|
||||||
enable_registration: false,
|
enable_registration: false,
|
||||||
maintenance_mode: false,
|
maintenance_mode: false,
|
||||||
|
short_gallery_urls: false,
|
||||||
default_language: 'en',
|
default_language: 'en',
|
||||||
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||||
});
|
});
|
||||||
@@ -117,6 +130,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
|
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
|
||||||
const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>('');
|
const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>('');
|
||||||
const [overrideDirty, setOverrideDirty] = useState(false);
|
const [overrideDirty, setOverrideDirty] = useState(false);
|
||||||
|
const [accountForm, setAccountForm] = useState({
|
||||||
|
username: '',
|
||||||
|
email: ''
|
||||||
|
});
|
||||||
|
const [accountErrors, setAccountErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (settings) {
|
if (settings) {
|
||||||
@@ -130,11 +148,16 @@ export const SettingsPage: React.FC = () => {
|
|||||||
site_url: settings.general_site_url || '',
|
site_url: settings.general_site_url || '',
|
||||||
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
||||||
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
|
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',
|
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||||
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
||||||
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, 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',
|
default_language: settings.general_default_language || 'en',
|
||||||
date_format: settings.general_date_format
|
date_format: settings.general_date_format
|
||||||
? (typeof settings.general_date_format === 'string'
|
? (typeof settings.general_date_format === 'string'
|
||||||
@@ -165,6 +188,15 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [settings, i18n]);
|
}, [settings, i18n]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (adminProfile) {
|
||||||
|
setAccountForm({
|
||||||
|
username: adminProfile.username || '',
|
||||||
|
email: adminProfile.email || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [adminProfile]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!settings || overrideDirty) {
|
if (!settings || overrideDirty) {
|
||||||
return;
|
return;
|
||||||
@@ -285,6 +317,83 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateAdminProfileMutation = useMutation({
|
||||||
|
mutationFn: (payload: { username: string; email: string }) => adminService.updateAdminProfile(payload),
|
||||||
|
onSuccess: (updatedUser) => {
|
||||||
|
toast.success(t('settings.general.accountSaveSuccess'));
|
||||||
|
setAccountErrors({});
|
||||||
|
setAccountForm({
|
||||||
|
username: updatedUser.username,
|
||||||
|
email: updatedUser.email
|
||||||
|
});
|
||||||
|
updateUserProfile(updatedUser);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-profile'] });
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
if (error.response?.data?.errors) {
|
||||||
|
const fieldErrors: Record<string, string> = {};
|
||||||
|
for (const err of error.response.data.errors) {
|
||||||
|
if (err.path === 'username') {
|
||||||
|
fieldErrors.username = err.msg;
|
||||||
|
}
|
||||||
|
if (err.path === 'email') {
|
||||||
|
fieldErrors.email = err.msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAccountErrors(fieldErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
toast.error(error.response.data.error);
|
||||||
|
} else {
|
||||||
|
toast.error(t('toast.saveError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAccountChange = (field: 'username' | 'email') => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
setAccountForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
if (accountErrors[field]) {
|
||||||
|
setAccountErrors((prev) => ({ ...prev, [field]: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (updateAdminProfileMutation.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedUsername = accountForm.username.trim();
|
||||||
|
const trimmedEmail = accountForm.email.trim();
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!trimmedUsername) {
|
||||||
|
errors.username = t('settings.general.accountUsernameRequired');
|
||||||
|
} else if (trimmedUsername.length < 3) {
|
||||||
|
errors.username = t('settings.general.accountUsernameLength');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedEmail) {
|
||||||
|
errors.email = t('settings.general.accountEmailRequired');
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
|
||||||
|
errors.email = t('settings.general.accountEmailInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setAccountErrors(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAdminProfileMutation.mutate({
|
||||||
|
username: trimmedUsername,
|
||||||
|
email: trimmedEmail
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const saveSoftLimitMutation = useMutation({
|
const saveSoftLimitMutation = useMutation({
|
||||||
mutationFn: async (limitBytes: number | null) => {
|
mutationFn: async (limitBytes: number | null) => {
|
||||||
return settingsService.updateSettings({
|
return settingsService.updateSettings({
|
||||||
@@ -466,6 +575,64 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{/* General Settings Tab */}
|
{/* General Settings Tab */}
|
||||||
{activeTab === 'general' && (
|
{activeTab === 'general' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.accountSection')}</h2>
|
||||||
|
{adminProfileLoading ? (
|
||||||
|
<div className="py-8 flex justify-center">
|
||||||
|
<Loading size="md" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-4" onSubmit={handleAccountSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="admin-account-username" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.general.accountUsername')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="admin-account-username"
|
||||||
|
type="text"
|
||||||
|
value={accountForm.username}
|
||||||
|
onChange={handleAccountChange('username')}
|
||||||
|
placeholder="admin"
|
||||||
|
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
|
||||||
|
error={accountErrors.username}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.general.accountUsernameHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="admin-account-email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.general.accountEmail')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="admin-account-email"
|
||||||
|
type="email"
|
||||||
|
value={accountForm.email}
|
||||||
|
onChange={handleAccountChange('email')}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||||
|
error={accountErrors.email}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.general.accountEmailHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<Save className="w-5 h-5" />}
|
||||||
|
isLoading={updateAdminProfileMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('settings.general.accountSaveButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
|
||||||
|
|
||||||
@@ -486,7 +653,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
{t('settings.general.defaultExpiration')}
|
{t('settings.general.defaultExpiration')}
|
||||||
@@ -511,6 +678,29 @@ export const SettingsPage: React.FC = () => {
|
|||||||
max="500"
|
max="500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -573,6 +763,21 @@ export const SettingsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
|
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ export interface Activity {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminProfile {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
mustChangePassword?: boolean;
|
||||||
|
last_login?: string | null;
|
||||||
|
last_login_ip?: string | null;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnalyticsData {
|
export interface AnalyticsData {
|
||||||
chartData: Array<{
|
chartData: Array<{
|
||||||
date: string;
|
date: string;
|
||||||
@@ -130,5 +141,15 @@ export const adminService = {
|
|||||||
// Change password
|
// Change password
|
||||||
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
|
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
|
||||||
await api.post('/admin/auth/change-password', data);
|
await api.post('/admin/auth/change-password', data);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAdminProfile(): Promise<AdminProfile> {
|
||||||
|
const response = await api.get<AdminProfile>('/admin/auth/profile');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateAdminProfile(data: { username: string; email: string }): Promise<AdminProfile> {
|
||||||
|
const response = await api.put<{ user: AdminProfile }>('/admin/auth/profile', data);
|
||||||
|
return response.data.user;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,16 +2,27 @@ import { api } from '../config/api';
|
|||||||
import type { Event } from '../types';
|
import type { Event } from '../types';
|
||||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
const normalizeEvent = (event: Event): Event => ({
|
const normalizeEvent = (event: Event): Event => {
|
||||||
...event,
|
const legacyHostName = (event as any)?.host_name;
|
||||||
require_password: normalizeRequirePassword((event as any)?.require_password, true),
|
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 {
|
interface CreateEventData {
|
||||||
event_type: string;
|
event_type: string;
|
||||||
event_name: string;
|
event_name: string;
|
||||||
event_date: string;
|
event_date: string;
|
||||||
host_email: string;
|
customer_name?: string;
|
||||||
|
customer_email: string;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
require_password?: boolean;
|
require_password?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -33,7 +44,8 @@ interface CreateEventData {
|
|||||||
interface UpdateEventData {
|
interface UpdateEventData {
|
||||||
event_name?: string;
|
event_name?: string;
|
||||||
event_date?: string;
|
event_date?: string;
|
||||||
host_email?: string;
|
customer_name?: string;
|
||||||
|
customer_email?: string;
|
||||||
admin_email?: string;
|
admin_email?: string;
|
||||||
require_password?: boolean;
|
require_password?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from '../config/api';
|
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';
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
export const galleryService = {
|
export const galleryService = {
|
||||||
@@ -119,4 +119,9 @@ export const galleryService = {
|
|||||||
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
||||||
return response.data;
|
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_type: string;
|
||||||
event_name: string;
|
event_name: string;
|
||||||
event_date: string;
|
event_date: string;
|
||||||
host_name?: string;
|
customer_name?: string;
|
||||||
host_email: string;
|
customer_email: string;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
welcome_message?: string;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
@@ -111,6 +111,17 @@ export interface GalleryStats {
|
|||||||
unique_visitors: number;
|
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
|
// Auth types
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
Generated
+6
-5
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "wedding-photo-sharing",
|
"name": "picpeak",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -558,7 +558,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz",
|
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz",
|
||||||
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==",
|
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
@@ -1499,9 +1500,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar-fs": {
|
"node_modules/tar-fs": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chownr": "^1.1.1",
|
"chownr": "^1.1.1",
|
||||||
|
|||||||
@@ -10,5 +10,10 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"puppeteer": "^24.17.0",
|
"puppeteer": "^24.17.0",
|
||||||
"@playwright/test": "^1.48.2"
|
"@playwright/test": "^1.48.2"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"prebuild-install": {
|
||||||
|
"tar-fs": "2.1.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
use: {
|
use: {
|
||||||
baseURL: 'http://localhost:3000',
|
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||||
headless: true,
|
headless: true,
|
||||||
viewport: { width: 1280, height: 800 },
|
viewport: { width: 1280, height: 800 },
|
||||||
ignoreHTTPSErrors: true,
|
ignoreHTTPSErrors: true,
|
||||||
@@ -15,4 +15,3 @@ export default defineConfig({
|
|||||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# PicPeak Unified Setup Script
|
# PicPeak Unified Setup Script
|
||||||
# Version: 2.0.0
|
# Version: 2.1.0
|
||||||
# Description: Universal installer for PicPeak with Docker and Native options
|
# Description: Universal installer for PicPeak with Docker and Native options
|
||||||
# Supports: Ubuntu, Debian, Fedora, RHEL/CentOS, Raspberry Pi OS
|
# Supports: Ubuntu, Debian, Fedora, RHEL/CentOS, Raspberry Pi OS
|
||||||
################################################################################
|
################################################################################
|
||||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
|||||||
IFS=$'\n\t'
|
IFS=$'\n\t'
|
||||||
|
|
||||||
# Script configuration
|
# Script configuration
|
||||||
readonly SCRIPT_VERSION="2.0.0"
|
readonly SCRIPT_VERSION="2.1.0"
|
||||||
readonly APP_NAME="PicPeak"
|
readonly APP_NAME="PicPeak"
|
||||||
readonly REPO_URL="https://github.com/the-luap/picpeak.git"
|
readonly REPO_URL="https://github.com/the-luap/picpeak.git"
|
||||||
readonly NODE_VERSION="20"
|
readonly NODE_VERSION="20"
|
||||||
@@ -64,17 +64,19 @@ FORCE_ADMIN_PASSWORD_RESET=false
|
|||||||
# Run a command as the application user, even if sudo is not available
|
# Run a command as the application user, even if sudo is not available
|
||||||
run_as_user() {
|
run_as_user() {
|
||||||
local cmd="$*"
|
local cmd="$*"
|
||||||
|
local current_dir_escaped
|
||||||
|
current_dir_escaped=$(printf '%q' "$(pwd)")
|
||||||
if [[ "$(id -u)" -ne 0 ]]; then
|
if [[ "$(id -u)" -ne 0 ]]; then
|
||||||
# Already non-root; just run
|
# Already non-root; preserve working directory
|
||||||
bash -lc "$cmd"
|
bash -lc "cd $current_dir_escaped && $cmd"
|
||||||
return $?
|
return $?
|
||||||
fi
|
fi
|
||||||
if command_exists sudo; then
|
if command_exists sudo; then
|
||||||
sudo -H -u "$NATIVE_APP_USER" bash -lc "$cmd"
|
sudo -H -u "$NATIVE_APP_USER" bash -lc "cd $current_dir_escaped && $cmd"
|
||||||
elif command_exists runuser; then
|
elif command_exists runuser; then
|
||||||
runuser -u "$NATIVE_APP_USER" -- bash -lc "$cmd"
|
runuser -u "$NATIVE_APP_USER" -- bash -lc "cd $current_dir_escaped && $cmd"
|
||||||
else
|
else
|
||||||
su -s /bin/bash - "$NATIVE_APP_USER" -c "$cmd"
|
su -s /bin/bash - "$NATIVE_APP_USER" -c "cd $current_dir_escaped && $cmd"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +393,29 @@ setup_docker_installation() {
|
|||||||
if [[ -d "$app_dir/.git" ]]; then
|
if [[ -d "$app_dir/.git" ]]; then
|
||||||
cd "$app_dir"
|
cd "$app_dir"
|
||||||
git pull
|
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
|
else
|
||||||
git clone "$REPO_URL" "$app_dir"
|
git clone "$REPO_URL" "$app_dir"
|
||||||
fi
|
fi
|
||||||
@@ -629,7 +654,14 @@ setup_native_installation() {
|
|||||||
apt)
|
apt)
|
||||||
apt-get install -y build-essential python3
|
apt-get install -y build-essential python3
|
||||||
;;
|
;;
|
||||||
dnf|yum)
|
dnf)
|
||||||
|
if ! $PACKAGE_MANAGER install -y @development-tools; then
|
||||||
|
log_warn "dnf @development-tools group install failed, retrying with legacy groupinstall syntax..."
|
||||||
|
$PACKAGE_MANAGER groupinstall -y "Development Tools"
|
||||||
|
fi
|
||||||
|
$PACKAGE_MANAGER install -y python3
|
||||||
|
;;
|
||||||
|
yum)
|
||||||
$PACKAGE_MANAGER groupinstall -y "Development Tools"
|
$PACKAGE_MANAGER groupinstall -y "Development Tools"
|
||||||
$PACKAGE_MANAGER install -y python3
|
$PACKAGE_MANAGER install -y python3
|
||||||
;;
|
;;
|
||||||
@@ -953,15 +985,17 @@ configure_email() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
print_success_message() {
|
print_success_message() {
|
||||||
local app_dir port
|
local app_dir port manual_reset_hint
|
||||||
|
|
||||||
if [[ "$INSTALL_METHOD" == "docker" ]]; then
|
if [[ "$INSTALL_METHOD" == "docker" ]]; then
|
||||||
app_dir="$DOCKER_APP_DIR"
|
app_dir="$DOCKER_APP_DIR"
|
||||||
[[ -n "${SUDO_USER:-}" ]] && app_dir="/home/$SUDO_USER/picpeak"
|
[[ -n "${SUDO_USER:-}" ]] && app_dir="/home/$SUDO_USER/picpeak"
|
||||||
port="${CUSTOM_PORT:-$DEFAULT_PORT}"
|
port="${CUSTOM_PORT:-$DEFAULT_PORT}"
|
||||||
|
manual_reset_hint="cd $(printf %q "$app_dir") && docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"
|
||||||
else
|
else
|
||||||
app_dir="$NATIVE_APP_DIR"
|
app_dir="$NATIVE_APP_DIR"
|
||||||
port="${CUSTOM_PORT:-$DEFAULT_PORT}"
|
port="${CUSTOM_PORT:-$DEFAULT_PORT}"
|
||||||
|
manual_reset_hint="cd $(printf %q "${NATIVE_APP_DIR}/app/backend") && sudo -H -u $(printf %q "$NATIVE_APP_USER") node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_header "🎉 Installation Complete!"
|
print_header "🎉 Installation Complete!"
|
||||||
@@ -1006,7 +1040,7 @@ print_success_message() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
||||||
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run node scripts/reset-admin-password.js manually)${NC}"
|
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run '${manual_reset_hint}')${NC}"
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||||
|
|
||||||
|
test('admin can update account email via settings page', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('Account settings UI is validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEmail = `admin+playwright-${Date.now()}@example.com`;
|
||||||
|
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
|
||||||
|
await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
await page.goto('/admin/settings');
|
||||||
|
const emailInput = page.getByLabel(/Admin (Email|E-Mail)/i);
|
||||||
|
const usernameInput = page.getByLabel(/Admin (Username|Benutzername)/i);
|
||||||
|
|
||||||
|
await expect(emailInput).toBeVisible();
|
||||||
|
const originalEmail = await emailInput.inputValue();
|
||||||
|
const originalUsername = await usernameInput.inputValue();
|
||||||
|
|
||||||
|
const saveButton = page.getByRole('button', { name: /(Save account details|Kontodaten speichern)/i });
|
||||||
|
|
||||||
|
const revertChanges = async () => {
|
||||||
|
await emailInput.fill(originalEmail);
|
||||||
|
await usernameInput.fill(originalUsername);
|
||||||
|
await saveButton.click();
|
||||||
|
await expect(emailInput).toHaveValue(originalEmail, { timeout: 10000 });
|
||||||
|
await expect(page.locator('.Toastify__toast').filter({ hasText: /(Account details updated|Kontodaten aktualisiert)/i })).toBeVisible({ timeout: 10000 });
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await emailInput.fill(newEmail);
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
await expect(emailInput).toHaveValue(newEmail, { timeout: 10000 });
|
||||||
|
await expect(page.locator('.Toastify__toast').filter({ hasText: /(Account details updated|Kontodaten aktualisiert)/i })).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(page.getByText(newEmail, { exact: false })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await revertChanges();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -29,9 +29,9 @@ test('admin can create event via UI', async ({ page }) => {
|
|||||||
await expect(page.getByRole('heading', { name: /^Create$/i })).toBeVisible({ timeout: 10000 });
|
await expect(page.getByRole('heading', { name: /^Create$/i })).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
await page.getByLabel(/Event Name/i).fill(eventName);
|
await page.getByLabel(/Event Name/i).fill(eventName);
|
||||||
await page.getByLabel(/Host Name/i).fill('Host User');
|
await page.getByLabel(/Customer Name/i).fill('Host User');
|
||||||
await page.getByLabel(/Event Date/i).fill('2025-12-31');
|
await page.getByLabel(/Event Date/i).fill('2025-12-31');
|
||||||
await page.getByLabel(/Host Email/i).fill(hostEmail);
|
await page.getByLabel(/Customer Email/i).fill(hostEmail);
|
||||||
await page.getByLabel(/Admin Email/i).fill(ADMIN_EMAIL);
|
await page.getByLabel(/Admin Email/i).fill(ADMIN_EMAIL);
|
||||||
await page.getByLabel(/Gallery Password/i).fill('UiPlay123!');
|
await page.getByLabel(/Gallery Password/i).fill('UiPlay123!');
|
||||||
await page.getByLabel(/Confirm Password/i).fill('UiPlay123!');
|
await page.getByLabel(/Confirm Password/i).fill('UiPlay123!');
|
||||||
|
|||||||
+138
-24
@@ -6,23 +6,32 @@ const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
|||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
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 api = page.request;
|
||||||
const loginResponse = await api.post('/api/auth/admin/login', {
|
let token = adminToken;
|
||||||
data: {
|
|
||||||
username: ADMIN_EMAIL,
|
if (!token) {
|
||||||
password: ADMIN_PASSWORD,
|
const loginResponse = await api.post('/api/auth/admin/login', {
|
||||||
},
|
data: {
|
||||||
});
|
username: ADMIN_EMAIL,
|
||||||
expect(loginResponse.ok()).toBeTruthy();
|
password: ADMIN_PASSWORD,
|
||||||
const { token } = await loginResponse.json();
|
},
|
||||||
expect(token).toBeTruthy();
|
});
|
||||||
|
expect(loginResponse.ok()).toBeTruthy();
|
||||||
|
const loginData = await loginResponse.json();
|
||||||
|
token = loginData.token;
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
const eventName = `Playwright Smoke ${Date.now()}`;
|
const eventName = `Playwright Smoke ${Date.now()}`;
|
||||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||||
.toISOString()
|
.toISOString()
|
||||||
.slice(0, 10);
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new Error('Failed to acquire admin token');
|
||||||
|
}
|
||||||
|
|
||||||
const eventResponse = await api.post('/api/admin/events', {
|
const eventResponse = await api.post('/api/admin/events', {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
@@ -32,6 +41,8 @@ async function createEventWithPhotos(page: Page) {
|
|||||||
event_type: 'wedding',
|
event_type: 'wedding',
|
||||||
event_name: eventName,
|
event_name: eventName,
|
||||||
event_date: eventDate,
|
event_date: eventDate,
|
||||||
|
customer_name: 'Playwright Host',
|
||||||
|
customer_email: 'host@example.com',
|
||||||
host_name: 'Playwright Host',
|
host_name: 'Playwright Host',
|
||||||
host_email: 'host@example.com',
|
host_email: 'host@example.com',
|
||||||
admin_email: ADMIN_EMAIL,
|
admin_email: ADMIN_EMAIL,
|
||||||
@@ -43,7 +54,17 @@ async function createEventWithPhotos(page: Page) {
|
|||||||
watermark_downloads: false,
|
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 event = await eventResponse.json();
|
||||||
|
|
||||||
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
|
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
|
||||||
@@ -67,11 +88,83 @@ async function createEventWithPhotos(page: Page) {
|
|||||||
event,
|
event,
|
||||||
shareLink: event.share_link,
|
shareLink: event.share_link,
|
||||||
slug: event.slug,
|
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 }) => {
|
test('admin login and gallery viewing smoke test', async ({ page }) => {
|
||||||
const { shareLink } = await createEventWithPhotos(page);
|
const { shareLink, adminToken } = await createEventWithPhotos(page);
|
||||||
|
|
||||||
// Admin UI login
|
// Admin UI login
|
||||||
await page.goto('/admin/login');
|
await page.goto('/admin/login');
|
||||||
@@ -83,18 +176,39 @@ test('admin login and gallery viewing smoke test', async ({ page }) => {
|
|||||||
}
|
}
|
||||||
await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 });
|
await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
// Visit gallery share link and authenticate
|
let resetToken = adminToken;
|
||||||
await page.goto(shareLink);
|
try {
|
||||||
const passwordField = page.getByPlaceholder(/gallery password/i);
|
// Verify long-form share link works
|
||||||
await passwordField.fill(GALLERY_PASSWORD);
|
const tiles = await openGalleryShareLink(page, shareLink);
|
||||||
await page.getByRole('button', { name: /View Gallery/i }).click();
|
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();
|
||||||
|
|
||||||
// Wait for photos grid to appear
|
// Enable short gallery URLs
|
||||||
const tiles = page.locator('.relative.group');
|
await updateShortGallerySetting(page, adminToken, true);
|
||||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
|
||||||
|
|
||||||
// Open lightbox to ensure media renders
|
const settingsResponse = await page.request.get('/api/admin/settings', {
|
||||||
await tiles.first().hover();
|
headers: {
|
||||||
await tiles.first().getByRole('button', { name: /View full size/i }).click();
|
Authorization: `Bearer ${adminToken}`,
|
||||||
await expect(page.getByRole('button', { name: /Close/i })).toBeVisible();
|
},
|
||||||
|
});
|
||||||
|
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 */
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1';
|
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1';
|
||||||
|
|
||||||
async function createExternalGallery(page) {
|
async function createExternalGallery(page) {
|
||||||
|
const externalRoot = path.join(process.cwd(), 'storage', 'external-media', 'picsum-demo', 'individual');
|
||||||
|
if (!fs.existsSync(externalRoot)) {
|
||||||
|
fs.mkdirSync(externalRoot, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleImages = ['img1.png', 'img2.png'];
|
||||||
|
for (const imageName of sampleImages) {
|
||||||
|
const source = path.join(process.cwd(), 'test-assets', imageName);
|
||||||
|
const target = path.join(externalRoot, imageName);
|
||||||
|
if (!fs.existsSync(target)) {
|
||||||
|
fs.copyFileSync(source, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loginResponse = await page.request.post('/api/auth/admin/login', {
|
const loginResponse = await page.request.post('/api/auth/admin/login', {
|
||||||
data: {
|
data: {
|
||||||
username: ADMIN_EMAIL,
|
username: ADMIN_EMAIL,
|
||||||
@@ -30,8 +46,8 @@ async function createExternalGallery(page) {
|
|||||||
event_type: 'wedding',
|
event_type: 'wedding',
|
||||||
event_name: eventName,
|
event_name: eventName,
|
||||||
event_date: eventDate,
|
event_date: eventDate,
|
||||||
host_name: 'External Host',
|
customer_name: 'External Host',
|
||||||
host_email: 'host@example.com',
|
customer_email: 'host@example.com',
|
||||||
admin_email: ADMIN_EMAIL,
|
admin_email: ADMIN_EMAIL,
|
||||||
password: GALLERY_PASSWORD,
|
password: GALLERY_PASSWORD,
|
||||||
expiration_days: 30,
|
expiration_days: 30,
|
||||||
@@ -72,7 +88,10 @@ async function createExternalGallery(page) {
|
|||||||
failOnStatusCode: false,
|
failOnStatusCode: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(importResponse.ok()).toBeTruthy();
|
if (!importResponse.ok()) {
|
||||||
|
const bodyText = await importResponse.text();
|
||||||
|
throw new Error(`Failed to import external media: ${importResponse.status()} ${bodyText}`);
|
||||||
|
}
|
||||||
const importBody = await importResponse.json();
|
const importBody = await importResponse.json();
|
||||||
expect(importBody.imported).toBeGreaterThan(0);
|
expect(importBody.imported).toBeGreaterThan(0);
|
||||||
|
|
||||||
@@ -113,9 +132,13 @@ test.describe('External media gallery behavior', () => {
|
|||||||
await page.waitForLoadState('domcontentloaded');
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
const passwordField = page.getByPlaceholder(/gallery password/i).first();
|
const passwordField = page.getByPlaceholder(/gallery password/i).first();
|
||||||
await expect(passwordField).toBeVisible();
|
if (await passwordField.count()) {
|
||||||
await passwordField.fill(GALLERY_PASSWORD);
|
await passwordField.fill(GALLERY_PASSWORD);
|
||||||
await page.getByRole('button', { name: /View Gallery/i }).click();
|
const viewButton = page.getByRole('button', { name: /View Gallery/i });
|
||||||
|
if (await viewButton.count()) {
|
||||||
|
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tiles = page.locator('.relative.group');
|
const tiles = page.locator('.relative.group');
|
||||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ async function createGalleryWithModeratedComments(page: Page): Promise<GallerySe
|
|||||||
event_type: 'wedding',
|
event_type: 'wedding',
|
||||||
event_name: eventName,
|
event_name: eventName,
|
||||||
event_date: eventDate,
|
event_date: eventDate,
|
||||||
host_name: 'Playwright Host',
|
customer_name: 'Playwright Host',
|
||||||
host_email: 'host@example.com',
|
customer_email: 'host@example.com',
|
||||||
admin_email: ADMIN_EMAIL,
|
admin_email: ADMIN_EMAIL,
|
||||||
password: GALLERY_PASSWORD,
|
password: GALLERY_PASSWORD,
|
||||||
expiration_days: 30,
|
expiration_days: 30,
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ async function ensureGalleryWithPhotos(page) {
|
|||||||
event_type: 'wedding',
|
event_type: 'wedding',
|
||||||
event_name: eventName,
|
event_name: eventName,
|
||||||
event_date: eventDate,
|
event_date: eventDate,
|
||||||
host_name: 'Playwright Host',
|
customer_name: 'Playwright Host',
|
||||||
host_email: 'host@example.com',
|
customer_email: 'host@example.com',
|
||||||
admin_email: ADMIN_EMAIL,
|
admin_email: ADMIN_EMAIL,
|
||||||
password: GALLERY_PASSWORD,
|
password: GALLERY_PASSWORD,
|
||||||
expiration_days: 90,
|
expiration_days: 90,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||||
|
|
||||||
|
test('clearing old notifications removes read entries', async ({ request }) => {
|
||||||
|
const loginResponse = await request.post('/api/auth/admin/login', {
|
||||||
|
data: {
|
||||||
|
username: ADMIN_EMAIL,
|
||||||
|
password: ADMIN_PASSWORD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(loginResponse.ok()).toBeTruthy();
|
||||||
|
const { token } = await loginResponse.json();
|
||||||
|
|
||||||
|
const authHeaders = {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventName = `Notification Clear ${Date.now()}`;
|
||||||
|
const eventDate = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const createEventResponse = await request.post('/api/admin/events', {
|
||||||
|
headers: authHeaders,
|
||||||
|
data: {
|
||||||
|
event_type: 'wedding',
|
||||||
|
event_name: eventName,
|
||||||
|
event_date: eventDate,
|
||||||
|
customer_name: 'Notification Test',
|
||||||
|
customer_email: 'notify@example.com',
|
||||||
|
admin_email: ADMIN_EMAIL,
|
||||||
|
password: 'NotifyClearPass!1',
|
||||||
|
expiration_days: 30,
|
||||||
|
allow_user_uploads: false,
|
||||||
|
allow_downloads: true,
|
||||||
|
disable_right_click: false,
|
||||||
|
watermark_downloads: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(createEventResponse.ok()).toBeTruthy();
|
||||||
|
const createdEvent = await createEventResponse.json();
|
||||||
|
const eventId = createdEvent.id;
|
||||||
|
|
||||||
|
const collectedNotifications = async () => {
|
||||||
|
const notificationsResponse = await request.get('/api/admin/notifications', {
|
||||||
|
headers: authHeaders,
|
||||||
|
params: { includeRead: true, limit: 200 },
|
||||||
|
});
|
||||||
|
expect(notificationsResponse.ok()).toBeTruthy();
|
||||||
|
return notificationsResponse.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
let notificationsPayload = await collectedNotifications();
|
||||||
|
const start = Date.now();
|
||||||
|
while (notificationsPayload.notifications.length === 0 && Date.now() - start < 5000) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
notificationsPayload = await collectedNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetEventNotifications = notificationsPayload.notifications.filter(
|
||||||
|
(notification: any) => notification.eventId === eventId
|
||||||
|
);
|
||||||
|
expect(targetEventNotifications.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const markReadResponse = await request.put('/api/admin/notifications/read-all', {
|
||||||
|
headers: authHeaders,
|
||||||
|
});
|
||||||
|
expect(markReadResponse.ok()).toBeTruthy();
|
||||||
|
|
||||||
|
const postMarkPayload = await collectedNotifications();
|
||||||
|
const postMarkEventNotifications = postMarkPayload.notifications.filter(
|
||||||
|
(notification: any) => notification.eventId === eventId
|
||||||
|
);
|
||||||
|
const readNotificationIds = postMarkEventNotifications
|
||||||
|
.filter((notification: any) => notification.isRead)
|
||||||
|
.map((notification: any) => notification.id);
|
||||||
|
expect(readNotificationIds.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const clearResponse = await request.delete('/api/admin/notifications/clear-old', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(clearResponse.ok()).toBeTruthy();
|
||||||
|
const clearPayload = await clearResponse.json();
|
||||||
|
expect(clearPayload.deletedCount).toBeGreaterThanOrEqual(0);
|
||||||
|
|
||||||
|
const afterClearPayload = await collectedNotifications();
|
||||||
|
expect(Array.isArray(afterClearPayload.notifications)).toBe(true);
|
||||||
|
const remainingIds = new Set(afterClearPayload.notifications.map((notification: any) => notification.id));
|
||||||
|
readNotificationIds.forEach((id) => {
|
||||||
|
expect(remainingIds.has(id)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user