Compare commits

...

19 Commits

Author SHA1 Message Date
Gitea Actions Bot 07759a0e40 chore: bump version to 1.1.13 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-15 05:29:19 +00:00
Paul Nothaft 31fd64c83c Add short gallery URL toggle and token support (#38)
Test and Lint / backend-test (push) Successful in 1m55s
Test and Lint / frontend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
2025-10-15 07:21:09 +02:00
Paul Nothaft 775c5159ea Add customer contact fields and admin API docs (refs #41) 2025-10-14 18:29:21 +02:00
Paul Nothaft 8f297e25c4 Make photo upload limit configurable via admin settings (#40) 2025-10-14 16:27:44 +02:00
Paul Nothaft ccb65b892b Rename setup script and bump installer version (#39) 2025-10-14 15:48:55 +02:00
Paul Nothaft 52f8f1f738 Upgrade nodemailer to 7.0.7 (GHSA-mm7p-fcc7-pg87)
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 21:11:38 +02:00
Paul Nothaft e731e7b47c Address tar-fs CVE-2025-59343
Test and Lint / backend-test (push) Successful in 1m21s
Test and Lint / frontend-test (push) Has been cancelled
2025-10-13 21:09:52 +02:00
Paul Nothaft 2bccb1a439 Handle pre-existing docker app dir (#32)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:59:50 +02:00
Paul Nothaft df10fc677e Send gallery image requests with bearer token fallback (#31)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:29:07 +02:00
Gitea Actions Bot 8c690155bf chore: bump frontend version to 1.1.12 2025-10-13 18:19:57 +00:00
Paul Nothaft 1b1e4f715d Rename event owner fields to customer (#37)
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 20:09:28 +02:00
Paul Nothaft 68eb9ba552 Clarify event owner labeling in UI (#37)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m52s
2025-10-13 20:02:52 +02:00
Paul Nothaft 7040865154 Fix admin password reset guidance in setup.sh (#34)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-10-13 19:58:07 +02:00
Paul Nothaft 013be18d98 fix: clear notifications via API (#35)
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 17:41:06 +02:00
Paul Nothaft 3c2a79a31a feat: allow admin email updates in UI (#36) 2025-10-13 17:21:03 +02:00
Gitea Actions Bot f20472ca26 chore: bump version to 1.1.11 (backend + frontend) 2025-10-12 19:23:19 +00:00
Gitea Actions Bot 87f4526220 chore: bump version to 1.1.10 (backend + frontend) 2025-10-12 19:18:37 +00:00
Gitea Actions Bot d42a11680f chore: bump version to 1.1.9 (backend + frontend) 2025-10-06 13:18:43 +00:00
Gitea Actions Bot 38dd74b893 chore: bump version to 1.1.8 (backend + frontend) 2025-10-03 05:19:52 +00:00
56 changed files with 2930 additions and 403 deletions
+5 -5
View File
@@ -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.
+2
View File
@@ -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
View File
@@ -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
``` ```
+4 -4
View File
@@ -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');
});
}
};
+29 -47
View File
@@ -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",
+7 -2
View File
@@ -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"
}
} }
} }
+40
View File
@@ -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) {
+88 -1
View File
@@ -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;
+14 -10
View File
@@ -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' });
} }
}); });
+135 -30
View File
@@ -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, {
+44 -7
View File
@@ -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;
+15 -6
View File
@@ -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}` });
} }
+23
View File
@@ -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({
+11 -6
View File
@@ -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
View File
@@ -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) {
+36 -9
View File
@@ -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' });
} }
} }
+15 -6
View File
@@ -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
+181
View File
@@ -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
};
+87
View File
@@ -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
};
+63
View File
@@ -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
};
+1 -1
View File
@@ -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
+147
View File
@@ -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 ≤ 50MB.
- 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.
+584
View File
@@ -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'
+2 -2
View File
@@ -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 -1
View File
@@ -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",
+51 -8
View File
@@ -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}
+128 -48
View File
@@ -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}
+8 -2
View File
@@ -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,
}); });
}; };
+30 -13
View File
@@ -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",
+31 -14
View File
@@ -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",
+143 -10
View File
@@ -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;
+14 -13
View File
@@ -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>
+2 -2
View File
@@ -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 ${
+209 -4
View File
@@ -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>
+21
View File
@@ -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;
} }
}; };
+18 -6
View File
@@ -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;
+6 -1
View File
@@ -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;
},
}; };
+13 -2
View File
@@ -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;
+6 -5
View File
@@ -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",
+5
View File
@@ -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"
}
} }
} }
+1 -2
View File
@@ -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'] } },
], ],
}); });
+44 -10
View File
@@ -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}"
+47
View File
@@ -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();
}
});
+2 -2
View File
@@ -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
View File
@@ -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 */
});
}
}); });
+29 -6
View File
@@ -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 });
+2 -2
View File
@@ -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,
+2 -2
View File
@@ -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,
+93
View File
@@ -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);
});
});