Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07759a0e40 | |||
| 31fd64c83c | |||
| 775c5159ea | |||
| 8f297e25c4 | |||
| ccb65b892b | |||
| 52f8f1f738 | |||
| e731e7b47c | |||
| 2bccb1a439 | |||
| df10fc677e | |||
| 8c690155bf | |||
| 1b1e4f715d | |||
| 68eb9ba552 | |||
| 7040865154 | |||
| 013be18d98 | |||
| 3c2a79a31a | |||
| f20472ca26 | |||
| 87f4526220 | |||
| d42a11680f | |||
| 38dd74b893 | |||
| fc1bf53412 | |||
| 5d6c061f1c |
+5
-5
@@ -7,9 +7,9 @@ This guide covers multiple deployment options for PicPeak, from simple local set
|
||||
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
||||
chmod +x setup.sh && \
|
||||
sudo ./setup.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
This automated script handles everything including:
|
||||
@@ -424,10 +424,10 @@ If you lose your admin credentials after the first login, you'll need to manuall
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./setup.sh --native --force-admin-password-reset
|
||||
sudo ./picpeak-setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
sudo ./setup.sh --docker --force-admin-password-reset
|
||||
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
|
||||
```
|
||||
|
||||
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||
|
||||
@@ -85,6 +85,8 @@ Note on Docker file permissions (PUID/PGID)
|
||||
|
||||
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
|
||||
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
|
||||
- 📚 [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints
|
||||
- 🛠️ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints
|
||||
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
|
||||
- 📜 [**License**](LICENSE) - MIT License
|
||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||
|
||||
+14
-14
@@ -8,9 +8,9 @@ This guide provides easy installation instructions for PicPeak on Linux servers
|
||||
|
||||
```bash
|
||||
# Download and run the unified setup script
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
||||
chmod +x setup.sh && \
|
||||
sudo ./setup.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will automatically detect your environment and recommend the best installation method.
|
||||
@@ -21,7 +21,7 @@ The script will automatically detect your environment and recommend the best ins
|
||||
Best for: Most users, easy updates, isolated environment
|
||||
|
||||
```bash
|
||||
sudo ./setup.sh --docker
|
||||
sudo ./picpeak-setup.sh --docker
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
@@ -38,7 +38,7 @@ sudo ./setup.sh --docker
|
||||
Best for: Resource-constrained systems, Raspberry Pi, direct control
|
||||
|
||||
```bash
|
||||
sudo ./setup.sh --native
|
||||
sudo ./picpeak-setup.sh --native
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
@@ -73,7 +73,7 @@ sudo ./setup.sh --native
|
||||
|
||||
### Interactive Mode (Default)
|
||||
```bash
|
||||
sudo ./setup.sh
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
The script will prompt you to choose:
|
||||
@@ -87,7 +87,7 @@ The script will prompt you to choose:
|
||||
|
||||
#### Docker with full configuration:
|
||||
```bash
|
||||
sudo ./setup.sh --docker --unattended \
|
||||
sudo ./picpeak-setup.sh --docker --unattended \
|
||||
--domain photos.example.com \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123 \
|
||||
@@ -100,7 +100,7 @@ sudo ./setup.sh --docker --unattended \
|
||||
|
||||
#### Native with minimal configuration:
|
||||
```bash
|
||||
sudo ./setup.sh --native --unattended \
|
||||
sudo ./picpeak-setup.sh --native --unattended \
|
||||
--email admin@example.com \
|
||||
--admin-password SecurePass123
|
||||
```
|
||||
@@ -293,7 +293,7 @@ sudo systemctl restart picpeak-backend picpeak-workers
|
||||
|
||||
# Update PicPeak
|
||||
# (reruns migrations to pick up schema fixes for native installs)
|
||||
sudo ./setup.sh --update
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
## ⚙️ Configuration
|
||||
@@ -385,14 +385,14 @@ docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Native
|
||||
sudo ./setup.sh --update
|
||||
sudo ./picpeak-setup.sh --update
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
```bash
|
||||
# Will prompt for confirmation and data removal options
|
||||
sudo ./setup.sh --uninstall
|
||||
sudo ./picpeak-setup.sh --uninstall
|
||||
```
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
@@ -508,13 +508,13 @@ sudo systemctl restart picpeak-backend
|
||||
### Home/Office Network
|
||||
```bash
|
||||
# Simple local setup without domain
|
||||
sudo ./setup.sh --native --email admin@local.com
|
||||
sudo ./picpeak-setup.sh --native --email admin@local.com
|
||||
```
|
||||
|
||||
### Public Website with HTTPS
|
||||
```bash
|
||||
# Full production setup
|
||||
sudo ./setup.sh --docker \
|
||||
sudo ./picpeak-setup.sh --docker \
|
||||
--domain photos.company.com \
|
||||
--email admin@company.com \
|
||||
--enable-ssl
|
||||
@@ -523,7 +523,7 @@ sudo ./setup.sh --docker \
|
||||
### Raspberry Pi Setup
|
||||
```bash
|
||||
# Optimized for ARM devices
|
||||
sudo ./setup.sh --native \
|
||||
sudo ./picpeak-setup.sh --native \
|
||||
--port 8080 \
|
||||
--email pi@local.com
|
||||
```
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
const path = require('path');
|
||||
const mockPath = path;
|
||||
|
||||
jest.mock('../../src/services/externalMediaService', () => ({
|
||||
resolveExternalPath: jest.fn((event, relPath) => mockPath.join('/mock/external', event.external_path || '', relPath || '')),
|
||||
}));
|
||||
|
||||
const { resolveExternalPath } = require('../../src/services/externalMediaService');
|
||||
const { resolvePhotoFilePath } = require('../../src/services/photoResolver');
|
||||
|
||||
describe('resolvePhotoFilePath', () => {
|
||||
const backendRoot = path.resolve(__dirname, '../../');
|
||||
const originalStoragePath = process.env.STORAGE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.STORAGE_PATH = path.join(backendRoot, 'storage');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (typeof originalStoragePath === 'string') {
|
||||
process.env.STORAGE_PATH = originalStoragePath;
|
||||
} else {
|
||||
delete process.env.STORAGE_PATH;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns absolute path for managed photos with legacy slug paths', () => {
|
||||
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||
const photo = { path: 'wedding-party/hero.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||
});
|
||||
|
||||
it('normalizes prefixed managed paths without duplicating segments', () => {
|
||||
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||
const photo = { path: 'events/active/wedding-party/hero.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||
});
|
||||
|
||||
it('delegates external photos to external media resolver', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { source_origin: 'external', external_relpath: 'individual/look-01.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'individual/look-01.jpg');
|
||||
expect(result).toBe(path.join('/mock/external', 'picsum-demo', 'individual', 'look-01.jpg'));
|
||||
});
|
||||
|
||||
it('deduplicates folder names when event external path already ends with segment', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo/individual' };
|
||||
const photo = { source_origin: 'external', external_relpath: 'individual/look-02.jpg' };
|
||||
|
||||
const result = resolvePhotoFilePath(event, photo);
|
||||
|
||||
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'look-02.jpg');
|
||||
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
|
||||
});
|
||||
|
||||
it('throws when external photo is missing relative path data', () => {
|
||||
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||
const photo = { source_origin: 'external' };
|
||||
|
||||
expect(() => resolvePhotoFilePath(event, photo)).toThrow('Missing external_relpath for external photo');
|
||||
});
|
||||
});
|
||||
@@ -1831,8 +1831,8 @@
|
||||
}
|
||||
},
|
||||
"nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"nodemon": {
|
||||
@@ -2086,8 +2086,8 @@
|
||||
"version": "4.0.1"
|
||||
},
|
||||
"tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"overridden": false
|
||||
},
|
||||
"tunnel-agent": {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
exports.up = async function (knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
|
||||
if (!hasColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.boolean('require_password').notNullable().defaultTo(true);
|
||||
});
|
||||
await knex('events').update({ require_password: true });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
|
||||
if (hasColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('require_password');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
const settingKey = 'general_max_files_per_upload';
|
||||
|
||||
const existing = await knex('app_settings')
|
||||
.where({ setting_key: settingKey })
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
// Normalize existing value into allowed bounds
|
||||
let parsedValue;
|
||||
try {
|
||||
parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null;
|
||||
} catch {
|
||||
parsedValue = existing.setting_value;
|
||||
}
|
||||
|
||||
const numeric = Number(parsedValue);
|
||||
let normalized = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
if (Number.isFinite(numeric) && numeric >= 1) {
|
||||
normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric));
|
||||
}
|
||||
|
||||
if (normalized !== numeric) {
|
||||
await knex('app_settings')
|
||||
.where({ setting_key: settingKey })
|
||||
.update({
|
||||
setting_value: JSON.stringify(normalized),
|
||||
updated_at: new Date()
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await knex('app_settings').insert({
|
||||
setting_key: settingKey,
|
||||
setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD),
|
||||
setting_type: 'general',
|
||||
updated_at: new Date()
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
await knex('app_settings')
|
||||
.where({ setting_key: 'general_max_files_per_upload' })
|
||||
.del();
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => {
|
||||
table.string('customer_name');
|
||||
});
|
||||
|
||||
await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => {
|
||||
table.string('customer_email');
|
||||
});
|
||||
|
||||
// Backfill new columns from legacy host_* fields
|
||||
const client = knex?.client?.config?.client;
|
||||
|
||||
if (client === 'pg') {
|
||||
await knex.raw(`
|
||||
UPDATE events
|
||||
SET customer_name = COALESCE(customer_name, host_name),
|
||||
customer_email = COALESCE(customer_email, host_email)
|
||||
`);
|
||||
} else {
|
||||
// SQLite fallback
|
||||
await knex('events').update({
|
||||
customer_name: knex.raw('COALESCE(customer_name, host_name)'),
|
||||
customer_email: knex.raw('COALESCE(customer_email, host_email)')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name');
|
||||
if (hasCustomerName) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('customer_name');
|
||||
});
|
||||
}
|
||||
|
||||
const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email');
|
||||
if (hasCustomerEmail) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('customer_email');
|
||||
});
|
||||
}
|
||||
};
|
||||
Generated
+29
-47
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.13",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "7.0.7",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -1034,6 +1034,7 @@
|
||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -3626,6 +3627,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4189,6 +4191,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
@@ -5154,29 +5157,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -5304,6 +5284,7 @@
|
||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -6346,6 +6327,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -8355,9 +8337,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
|
||||
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
|
||||
"version": "7.0.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
|
||||
"integrity": "sha512-jGOaRznodf62TVzdyhKt/f1Q/c3kYynk8629sgJHpRzGZj01ezbgMMWJSAjHADcwTKxco3B68/R+KHJY2T5BaA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -9008,24 +8990,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -10246,6 +10210,24 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.13",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -39,7 +39,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "7.0.7",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -55,5 +55,10 @@
|
||||
"mock-fs": "^5.5.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"overrides": {
|
||||
"prebuild-install": {
|
||||
"tar-fs": "2.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const path = require('path');
|
||||
const knex = require('knex');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const logger = require('../utils/logger');
|
||||
const { extractShareToken } = require('../utils/shareLinkUtils');
|
||||
|
||||
// Ensure SQLite directory exists when using file-based DB (native installs)
|
||||
try {
|
||||
@@ -63,12 +64,16 @@ async function initializeDatabase() {
|
||||
table.string('event_type').notNullable();
|
||||
table.string('event_name').notNullable();
|
||||
table.date('event_date').notNullable();
|
||||
table.string('customer_name');
|
||||
table.string('customer_email');
|
||||
table.string('host_email').notNullable();
|
||||
table.string('host_name');
|
||||
table.string('admin_email').notNullable();
|
||||
table.string('password_hash').notNullable();
|
||||
table.text('welcome_message');
|
||||
table.text('color_theme');
|
||||
table.string('share_link').unique().notNullable();
|
||||
table.string('share_token').unique();
|
||||
table.datetime('created_at').defaultTo(db.fn.now());
|
||||
table.datetime('expires_at').notNullable();
|
||||
table.boolean('is_active').defaultTo(true);
|
||||
@@ -82,6 +87,7 @@ async function initializeDatabase() {
|
||||
table.boolean('watermark_downloads').defaultTo(false);
|
||||
table.text('watermark_text');
|
||||
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||
table.boolean('require_password').defaultTo(true);
|
||||
});
|
||||
} else {
|
||||
// Check if color_theme needs to be updated to TEXT type
|
||||
@@ -98,12 +104,16 @@ async function initializeDatabase() {
|
||||
event_type TEXT NOT NULL,
|
||||
event_name TEXT NOT NULL,
|
||||
event_date DATE NOT NULL,
|
||||
customer_name TEXT,
|
||||
customer_email TEXT,
|
||||
host_name TEXT,
|
||||
host_email TEXT NOT NULL,
|
||||
admin_email TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
welcome_message TEXT,
|
||||
color_theme TEXT,
|
||||
share_link TEXT UNIQUE NOT NULL,
|
||||
share_token TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at DATETIME NOT NULL,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
@@ -116,7 +126,8 @@ async function initializeDatabase() {
|
||||
disable_right_click BOOLEAN DEFAULT 0,
|
||||
watermark_downloads BOOLEAN DEFAULT 0,
|
||||
watermark_text TEXT,
|
||||
hero_photo_id INTEGER
|
||||
hero_photo_id INTEGER,
|
||||
require_password BOOLEAN DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -138,6 +149,8 @@ async function initializeDatabase() {
|
||||
return 'watermark_text';
|
||||
case 'hero_photo_id':
|
||||
return 'hero_photo_id';
|
||||
case 'require_password':
|
||||
return 'COALESCE(require_password, 1) as require_password';
|
||||
default:
|
||||
return col;
|
||||
}
|
||||
@@ -153,6 +166,37 @@ async function initializeDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
const hasShareTokenColumn = await db.schema.hasColumn('events', 'share_token');
|
||||
if (!hasShareTokenColumn) {
|
||||
await db.schema.table('events', (table) => {
|
||||
table.string('share_token').unique();
|
||||
});
|
||||
}
|
||||
|
||||
const hasHostNameColumn = await db.schema.hasColumn('events', 'host_name');
|
||||
if (!hasHostNameColumn) {
|
||||
await db.schema.table('events', (table) => {
|
||||
table.string('host_name');
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const eventsWithoutToken = await db('events')
|
||||
.whereNull('share_token')
|
||||
.select('id', 'share_link');
|
||||
|
||||
for (const event of eventsWithoutToken) {
|
||||
const token = extractShareToken(event.share_link);
|
||||
if (token) {
|
||||
await db('events')
|
||||
.where({ id: event.id })
|
||||
.update({ share_token: token });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Share token backfill skipped', { error: error.message });
|
||||
}
|
||||
|
||||
// Photo metadata table
|
||||
const hasPhotosTable = await db.schema.hasTable('photos');
|
||||
if (!hasPhotosTable) {
|
||||
|
||||
@@ -2,13 +2,48 @@ const jwt = require('jsonwebtoken');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Middleware to verify gallery access
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
||||
let event;
|
||||
|
||||
if (!token) {
|
||||
if (!requestedSlug) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
userAgent: req.get('User-Agent') || 'unknown',
|
||||
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
@@ -26,10 +61,9 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId);
|
||||
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
||||
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
let event;
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
event = await withRetry(async () => {
|
||||
@@ -62,11 +96,11 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId);
|
||||
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
console.log('[verifyGalleryAccess] Event found:', event.id, event.slug);
|
||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||
req.event = event;
|
||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||
|
||||
@@ -78,10 +112,10 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log('[verifyGalleryAccess] Access granted for event:', event.id);
|
||||
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verifying gallery access:', error);
|
||||
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function photoAuth(req, res, next) {
|
||||
try {
|
||||
// Extract event slug from the path
|
||||
let eventSlug;
|
||||
|
||||
console.log('PhotoAuth middleware - path:', req.path);
|
||||
|
||||
// For thumbnails, we need to parse the filename to get the event info
|
||||
if (req.path.startsWith('/thumb_')) {
|
||||
// For now, we'll rely on JWT token for thumbnail access
|
||||
@@ -80,29 +79,36 @@ async function photoAuth(req, res, next) {
|
||||
// For both thumbnails and photos with admin token, allow access
|
||||
return next();
|
||||
}
|
||||
} catch (err) {
|
||||
// Token invalid, fall through to password check
|
||||
console.error('JWT verification failed:', err.message);
|
||||
} catch (err) {
|
||||
// Token invalid, fall through to password check
|
||||
logger.warn('JWT verification failed in photoAuth', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for password header (legacy support)
|
||||
const password = req.headers['x-gallery-password'];
|
||||
|
||||
if (!password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
||||
if (!eventSlug && !password) {
|
||||
if (!eventSlug && !password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
||||
}
|
||||
|
||||
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
@@ -122,7 +128,7 @@ async function photoAuth(req, res, next) {
|
||||
req.event = event;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Photo auth error:', error);
|
||||
logger.error('Photo auth error', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({ error: 'Authentication error' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,93 @@ const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
||||
const router = express.Router();
|
||||
|
||||
// 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', [
|
||||
adminAuth,
|
||||
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
||||
@@ -96,4 +183,4 @@ router.post('/logout', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
// Only the relevant parts are shown - merge with existing adminEvents.js
|
||||
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
|
||||
// Enhanced event creation with password validation
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail().normalizeEmail(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail().normalizeEmail(),
|
||||
body('password').notEmpty(), // Remove the weak isLength validation
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||
@@ -16,7 +17,7 @@ router.post('/', adminAuth, [
|
||||
body('color_theme').optional().trim(),
|
||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
body('host_name').notEmpty().trim()
|
||||
body('customer_name').notEmpty().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Create event request body:', req.body);
|
||||
@@ -30,8 +31,8 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
@@ -65,9 +66,9 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link
|
||||
// Generate share link based on configured style
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
@@ -88,13 +89,16 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
host_name: customer_name,
|
||||
host_email: customer_email,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLink,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
@@ -121,4 +125,4 @@ router.post('/', adminAuth, [
|
||||
console.error('Error creating event:', error);
|
||||
res.status(500).json({ error: 'Failed to create event' });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,28 +13,129 @@ const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
// formatDate import removed - dates are formatted by email processor
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const logger = require('../utils/logger');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
|
||||
const parseBooleanInput = (value, defaultValue = true) => {
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const getCustomerNameFromPayload = (payload = {}) => {
|
||||
if (typeof payload.customer_name === 'string') {
|
||||
const trimmed = payload.customer_name.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCustomerEmailFromPayload = (payload = {}) => {
|
||||
if (typeof payload.customer_email === 'string') {
|
||||
const trimmed = payload.customer_email.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
logger.debug('Failed to detect customer_email column', { error: error.message });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail().normalizeEmail(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail().normalizeEmail(),
|
||||
body('password').isLength({ min: 6 }),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const input = req.body.require_password;
|
||||
const normalizeBoolean = (val, defaultValue = true) => {
|
||||
if (val === undefined || val === null) return defaultValue;
|
||||
if (typeof val === 'boolean') return val;
|
||||
if (typeof val === 'number') return val !== 0;
|
||||
if (typeof val === 'string') {
|
||||
const normalized = val.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const requirePassword = normalizeBoolean(input, true);
|
||||
if (!requirePassword) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||
body('welcome_message').optional().trim(),
|
||||
body('color_theme').optional().trim(),
|
||||
body('allow_user_uploads').optional().isBoolean().toBoolean(),
|
||||
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
body('host_name').notEmpty().trim(),
|
||||
body('allow_downloads').optional().isBoolean(),
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Create event request body:', req.body);
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.error('Validation errors:', errors.array());
|
||||
@@ -45,8 +146,6 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
admin_email,
|
||||
password,
|
||||
welcome_message = '',
|
||||
@@ -58,6 +157,7 @@ router.post('/', adminAuth, [
|
||||
disable_right_click = false,
|
||||
watermark_downloads = false,
|
||||
watermark_text = null,
|
||||
require_password: requirePasswordInput = true,
|
||||
// Feedback settings
|
||||
feedback_enabled = false,
|
||||
allow_ratings = true,
|
||||
@@ -68,13 +168,25 @@ router.post('/', adminAuth, [
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true
|
||||
} = req.body;
|
||||
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
const customerEmail = getCustomerEmailFromPayload(req.body);
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
if (!customerName || !customerEmail) {
|
||||
return res.status(400).json({ error: 'customer_name and customer_email are required' });
|
||||
}
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
// Debug logging
|
||||
console.log('Download control values:', {
|
||||
logger.debug('Download control values', {
|
||||
allow_downloads,
|
||||
disable_right_click,
|
||||
watermark_downloads,
|
||||
watermark_text,
|
||||
require_password: requirePassword,
|
||||
types: {
|
||||
allow_downloads: typeof allow_downloads,
|
||||
disable_right_click: typeof disable_right_click,
|
||||
@@ -82,18 +194,21 @@ router.post('/', adminAuth, [
|
||||
}
|
||||
});
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
let passwordValidation = null;
|
||||
|
||||
if (requirePassword) {
|
||||
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
@@ -111,12 +226,14 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link
|
||||
// Generate share link respecting configured format
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
// Hash password with configurable rounds (random placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
@@ -141,13 +258,15 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_name,
|
||||
host_email,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLink,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
allow_user_uploads,
|
||||
@@ -155,7 +274,8 @@ router.post('/', adminAuth, [
|
||||
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||
watermark_text
|
||||
watermark_text,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -180,7 +300,7 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_created',
|
||||
{ event_type, expires_at },
|
||||
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
@@ -190,14 +310,16 @@ router.post('/', adminAuth, [
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: host_email,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify({
|
||||
host_name: host_name,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
@@ -211,7 +333,10 @@ router.post('/', adminAuth, [
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
share_link: shareLink,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
@@ -294,7 +419,7 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
created_at: event.created_at ? new Date(event.created_at).toISOString() : null,
|
||||
expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null,
|
||||
archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null
|
||||
}));
|
||||
})).map(mapEventForApi);
|
||||
|
||||
res.json({
|
||||
events: eventsWithCounts,
|
||||
@@ -356,7 +481,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
.where('event_id', id)
|
||||
.countDistinct('ip_address as uniqueVisitors');
|
||||
|
||||
res.json({
|
||||
res.json(mapEventForApi({
|
||||
...event,
|
||||
photo_count: parseInt(photoCount) || 0,
|
||||
total_size: parseInt(totalSize) || 0,
|
||||
@@ -364,7 +489,7 @@ router.get('/:id', adminAuth, async (req, res) => {
|
||||
total_downloads: parseInt(totalDownloads) || 0,
|
||||
unique_visitors: parseInt(uniqueVisitors) || 0,
|
||||
recent_photos: recentPhotos
|
||||
});
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error fetching event:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch event details' });
|
||||
@@ -380,7 +505,8 @@ router.put('/:id', adminAuth, [
|
||||
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||
body('color_theme').optional({ nullable: true }),
|
||||
body('allow_user_uploads').optional().isBoolean(),
|
||||
body('host_name').optional().trim().notEmpty(),
|
||||
body('customer_name').optional().trim().notEmpty(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('upload_category_id').optional().custom((value) => {
|
||||
// Accept null, undefined, or integer values
|
||||
if (value === null || value === undefined) return true;
|
||||
@@ -398,18 +524,77 @@ router.put('/:id', adminAuth, [
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim(),
|
||||
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||
body('external_path').optional({ nullable: true }).isString().trim()
|
||||
body('external_path').optional({ nullable: true }).isString().trim(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2));
|
||||
console.log('Request body:', req.body);
|
||||
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = { ...req.body };
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
|
||||
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
|
||||
const nextName = getCustomerNameFromPayload(updates);
|
||||
if (nextName) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
updates.host_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
|
||||
const nextEmail = getCustomerEmailFromPayload(updates);
|
||||
if (nextEmail) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
updates.host_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
}
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
}
|
||||
|
||||
let newPasswordPlain;
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||
delete updates.password;
|
||||
} else {
|
||||
newPasswordPlain = updates.password;
|
||||
delete updates.password;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
|
||||
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
|
||||
@@ -429,7 +614,7 @@ router.put('/:id', adminAuth, [
|
||||
}
|
||||
|
||||
// Log the update request for debugging
|
||||
console.log('Update event request:', {
|
||||
logger.debug('Update event request', {
|
||||
id,
|
||||
updates,
|
||||
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
|
||||
@@ -444,6 +629,18 @@ router.put('/:id', adminAuth, [
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||
}
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
// Update event
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
@@ -615,10 +812,13 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
|
||||
|
||||
// Queue email notification if requested
|
||||
if (sendEmail) {
|
||||
// For password reset, we'll need to create a template or use a different approach
|
||||
// For now, let's use the gallery_created template with updated password
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_email.split('@')[0],
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
@@ -673,8 +873,13 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
// Dates will be formatted by the email processor based on recipient language
|
||||
|
||||
// Queue the email
|
||||
await queueEmail(id, event.host_email, 'gallery_created', {
|
||||
host_name: event.host_name || event.host_email.split('@')[0],
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
|
||||
await queueEmail(id, recipientEmail, 'gallery_created', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
event_date: event.event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: event.share_link,
|
||||
@@ -689,7 +894,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
|
||||
try {
|
||||
await logActivity('email_resent', {
|
||||
email_type: 'gallery_created',
|
||||
recipient: event.host_email,
|
||||
recipient: recipientEmail,
|
||||
ip_address: req.ip || '0.0.0.0',
|
||||
user_agent: req.get('user-agent') || 'Unknown'
|
||||
}, id, {
|
||||
|
||||
@@ -103,14 +103,51 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
|
||||
// Use database-agnostic date calculation
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
|
||||
const deletedCount = await db('activity_logs')
|
||||
.whereNotNull('read_at')
|
||||
.where('created_at', '<', thirtyDaysAgo)
|
||||
.delete();
|
||||
|
||||
let deletedCount = 0;
|
||||
const client = db?.client?.config?.client;
|
||||
|
||||
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({
|
||||
message: 'Old notifications cleared',
|
||||
message: deletedCount > 0 ? 'Old notifications cleared' : 'No notifications to clear',
|
||||
deletedCount
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -119,4 +156,4 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -8,6 +8,7 @@ const { generateThumbnail, ensureThumbnail } = require('../services/imageProcess
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
@@ -48,7 +49,7 @@ const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB limit per file
|
||||
files: 500, // Maximum 500 files
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
// Add part size limits to prevent incomplete uploads
|
||||
@@ -99,17 +100,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
};
|
||||
|
||||
// Upload photos for an event
|
||||
// Increased limit to 500 files, but recommend chunked uploads for better performance
|
||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
|
||||
upload.array('photos', 500)(req, res, (err) => {
|
||||
// Max file count is configurable via general settings
|
||||
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
||||
let maxFilesPerUpload;
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve max files per upload:', error);
|
||||
return res.status(500).json({ error: 'Unable to determine upload limits' });
|
||||
}
|
||||
|
||||
upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
|
||||
if (err) {
|
||||
console.error('Multer error:', err);
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
|
||||
}
|
||||
if (err.code === 'LIMIT_FILE_COUNT') {
|
||||
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
|
||||
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
|
||||
}
|
||||
return res.status(400).json({ error: `Upload error: ${err.message}` });
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ const {
|
||||
getRawPublicSiteSettings,
|
||||
} = require('../services/publicSiteService');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const router = express.Router();
|
||||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
@@ -472,9 +474,24 @@ router.put('/theme', adminAuth, async (req, res) => {
|
||||
router.put('/general', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = { ...req.body };
|
||||
let uploadLimitTouched = false;
|
||||
|
||||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
|
||||
uploadLimitTouched = true;
|
||||
const rawValue = Number(settings.general_max_files_per_upload);
|
||||
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
|
||||
|
||||
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
|
||||
return res.status(400).json({
|
||||
error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}`
|
||||
});
|
||||
}
|
||||
|
||||
settings.general_max_files_per_upload = normalizedValue;
|
||||
}
|
||||
|
||||
if (publicSiteKeysTouched) {
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
||||
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
||||
@@ -529,6 +546,12 @@ router.put('/general', adminAuth, async (req, res) => {
|
||||
if (publicSiteKeysTouched) {
|
||||
clearPublicSiteCache();
|
||||
}
|
||||
if (uploadLimitTouched) {
|
||||
clearMaxFilesPerUploadCache();
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
|
||||
clearShareLinkSettingsCache();
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
|
||||
@@ -220,7 +220,7 @@ router.post('/logout', async (req, res) => {
|
||||
// Gallery password verification with enhanced security
|
||||
router.post('/gallery/verify', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('password').notEmpty()
|
||||
body('password').optional().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -232,53 +232,69 @@ router.post('/gallery/verify', [
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check gallery-specific lockout
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
|
||||
|
||||
if (requiresPassword) {
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
// Don't reveal if gallery exists
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
if (requiresPassword) {
|
||||
if (!password) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
action: 'login_success'
|
||||
});
|
||||
} else {
|
||||
logger.info('Public gallery access granted without password', { slug, ipAddress });
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
// Successful access
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
// Log successful access
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
|
||||
// Generate session token with additional security info
|
||||
const token = jwt.sign({
|
||||
eventId: event.id,
|
||||
@@ -302,7 +318,8 @@ router.post('/gallery/verify', [
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -372,4 +389,4 @@ router.post('/password-strength', [
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
getAdminTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
} = require('../utils/tokenUtils');
|
||||
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login with enhanced security
|
||||
@@ -162,7 +163,7 @@ router.post('/logout', async (req, res) => {
|
||||
// Gallery password verification with enhanced security
|
||||
router.post('/gallery/verify', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('password').notEmpty()
|
||||
body('password').optional().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -173,55 +174,68 @@ router.post('/gallery/verify', [
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check gallery-specific lockout
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
// Don't reveal if gallery exists
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
if (requiresPassword) {
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
action: 'login_success'
|
||||
});
|
||||
} else {
|
||||
logger.info('Public gallery access granted without password', { slug, ipAddress });
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
// Successful access
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
// Log successful access
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
|
||||
// Generate session token with additional security info
|
||||
|
||||
const token = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
@@ -246,7 +260,8 @@ router.post('/gallery/verify', [
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -270,18 +285,22 @@ router.post('/gallery/share-login', [
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
const event = await db('events')
|
||||
let event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
const resolved = await resolveShareIdentifier(slug);
|
||||
if (resolved?.event) {
|
||||
event = resolved.event;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
let expectedToken = event.share_link;
|
||||
if (expectedToken && expectedToken.includes('/')) {
|
||||
expectedToken = expectedToken.split('/').pop();
|
||||
}
|
||||
const expectedToken = getEventShareToken(event);
|
||||
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
@@ -298,9 +317,11 @@ router.post('/gallery/share-login', [
|
||||
issuer: 'picpeak-auth'
|
||||
});
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
||||
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
|
||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
token: jwtToken,
|
||||
event: {
|
||||
@@ -312,7 +333,8 @@ router.post('/gallery/share-login', [
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
+221
-25
@@ -4,19 +4,107 @@ const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
|
||||
const parseBooleanInput = (value, defaultValue = true) => {
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const getCustomerNameFromPayload = (payload = {}) => {
|
||||
if (typeof payload.customer_name === 'string') {
|
||||
const trimmed = payload.customer_name.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getCustomerEmailFromPayload = (payload = {}) => {
|
||||
if (typeof payload.customer_email === 'string') {
|
||||
const trimmed = payload.customer_email.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const mapEventForApi = (event) => {
|
||||
if (!event || typeof event !== 'object') {
|
||||
return event;
|
||||
}
|
||||
|
||||
const {
|
||||
host_name,
|
||||
host_email,
|
||||
customer_name,
|
||||
customer_email,
|
||||
...rest
|
||||
} = event;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
customer_name: customer_name ?? host_name ?? null,
|
||||
customer_email: customer_email ?? host_email ?? null
|
||||
};
|
||||
};
|
||||
|
||||
let customerColumnCache = null;
|
||||
const hasCustomerContactColumns = async () => {
|
||||
if (customerColumnCache === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
|
||||
if (hasColumn) {
|
||||
customerColumnCache = true;
|
||||
}
|
||||
return hasColumn;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty(),
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail(),
|
||||
body('password').isLength({ min: 6 }),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const requirePassword = parseBooleanInput(req.body.require_password, true);
|
||||
if (!requirePassword) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
@@ -29,13 +117,39 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_email,
|
||||
admin_email,
|
||||
password,
|
||||
require_password: requirePasswordInput = true,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
expiration_days = 30
|
||||
} = req.body;
|
||||
|
||||
const customerEmail = getCustomerEmailFromPayload(req.body);
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
|
||||
if (!customerName || !customerEmail) {
|
||||
return res.status(400).json({ error: 'customer_name and customer_email are required' });
|
||||
}
|
||||
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
if (requirePassword) {
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||
@@ -47,12 +161,14 @@ router.post('/', adminAuth, [
|
||||
counter++;
|
||||
}
|
||||
|
||||
// Generate share link (just slug/token, not full URL)
|
||||
// Generate share link variants (auto-detects short URL preference)
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const shareLink = `${slug}/${shareToken}`;
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
// Hash password (or placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
@@ -70,13 +186,17 @@ router.post('/', adminAuth, [
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
host_email,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLink,
|
||||
expires_at
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -84,21 +204,26 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Queue creation email
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
await queueEmail(eventId, host_email, 'gallery_created', {
|
||||
host_name: host_email.split('@')[0], // Extract name from email
|
||||
await queueEmail(eventId, customerEmail, 'gallery_created', {
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName,
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
});
|
||||
|
||||
|
||||
res.json({
|
||||
id: eventId,
|
||||
slug,
|
||||
share_link: shareLink,
|
||||
expires_at
|
||||
share_link: shareUrl,
|
||||
expires_at,
|
||||
require_password: requirePassword,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -127,27 +252,98 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
event.photo_count = photoCount.count;
|
||||
}
|
||||
|
||||
res.json(events);
|
||||
res.json(events.map(mapEventForApi));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to fetch events' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update event
|
||||
router.put('/:id', adminAuth, async (req, res) => {
|
||||
router.put('/:id', adminAuth, [
|
||||
body('customer_name').optional().trim().notEmpty(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('require_password').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
const updates = { ...req.body };
|
||||
const customerColumnsAvailable = await hasCustomerContactColumns();
|
||||
|
||||
// Don't allow updating certain fields
|
||||
delete updates.id;
|
||||
delete updates.slug;
|
||||
delete updates.created_at;
|
||||
|
||||
// If updating password, hash it
|
||||
if (updates.password) {
|
||||
updates.password_hash = await bcrypt.hash(updates.password, 10);
|
||||
delete updates.password;
|
||||
delete updates.password_confirmation;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
|
||||
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
|
||||
const nextName = getCustomerNameFromPayload(updates);
|
||||
if (nextName) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
updates.host_name = nextName;
|
||||
} else {
|
||||
delete updates.customer_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
|
||||
const nextEmail = getCustomerEmailFromPayload(updates);
|
||||
if (nextEmail) {
|
||||
if (customerColumnsAvailable) {
|
||||
updates.customer_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
updates.host_email = nextEmail;
|
||||
} else {
|
||||
delete updates.customer_email;
|
||||
}
|
||||
}
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
}
|
||||
|
||||
let newPasswordPlain;
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||
delete updates.password;
|
||||
} else {
|
||||
newPasswordPlain = updates.password;
|
||||
delete updates.password;
|
||||
}
|
||||
}
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||
}
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
await db('events').where('id', id).update(updates);
|
||||
|
||||
+146
-56
@@ -1,5 +1,4 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const archiver = require('archiver');
|
||||
@@ -8,28 +7,58 @@ const router = express.Router();
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
|
||||
// Resolve gallery identifier (slug or token) to canonical data
|
||||
router.get('/resolve/:identifier', async (req, res) => {
|
||||
try {
|
||||
const { identifier } = req.params;
|
||||
const result = await resolveShareIdentifier(identifier);
|
||||
|
||||
if (!result) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const { event, matchType, shareToken } = result;
|
||||
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
slug: event.slug,
|
||||
token: shareToken,
|
||||
matchType,
|
||||
share_link: event.share_link,
|
||||
share_path: linkVariants.sharePath,
|
||||
share_url: linkVariants.shareUrl,
|
||||
short_enabled: linkVariants.shortEnabled,
|
||||
requires_password: requiresPassword
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error resolving gallery identifier:', error);
|
||||
res.status(500).json({ error: 'Failed to resolve gallery link' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify share token
|
||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
||||
try {
|
||||
const { slug, token } = req.params;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ share_link: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('id', 'share_link')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.select('id', 'share_link', 'share_token')
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
// Extract token from share link and verify
|
||||
const expectedToken = event.share_link.split('/').pop();
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (token !== expectedToken) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
@@ -48,9 +77,23 @@ router.get('/:slug/info', async (req, res) => {
|
||||
const { token } = req.query;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug: slug })
|
||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link',
|
||||
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text')
|
||||
.where({ slug })
|
||||
.select(
|
||||
'event_name',
|
||||
'event_type',
|
||||
'event_date',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'is_archived',
|
||||
'share_link',
|
||||
'share_token',
|
||||
'allow_downloads',
|
||||
'disable_right_click',
|
||||
'watermark_downloads',
|
||||
'watermark_text',
|
||||
'require_password',
|
||||
'color_theme'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
@@ -64,16 +107,14 @@ router.get('/:slug/info', async (req, res) => {
|
||||
|
||||
// If token provided, verify it matches the share link
|
||||
if (token) {
|
||||
let expectedToken = event.share_link;
|
||||
// Handle both formats: full URL or just token
|
||||
if (event.share_link && event.share_link.includes('/')) {
|
||||
expectedToken = event.share_link.split('/').pop();
|
||||
}
|
||||
if (token !== expectedToken) {
|
||||
const expectedToken = getEventShareToken(event);
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
return res.status(404).json({ error: 'Invalid gallery link' });
|
||||
}
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
@@ -81,11 +122,11 @@ router.get('/:slug/info', async (req, res) => {
|
||||
expires_at: event.expires_at,
|
||||
is_active: event.is_active,
|
||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||
requires_password: true,
|
||||
requires_password: requiresPassword,
|
||||
color_theme: event.color_theme,
|
||||
allow_downloads: event.allow_downloads !== false,
|
||||
disable_right_click: event.disable_right_click === true,
|
||||
watermark_downloads: event.watermark_downloads === true,
|
||||
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||
watermark_text: event.watermark_text
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -99,7 +140,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
// Get filter parameters from query
|
||||
const { filter, guest_id } = req.query;
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
|
||||
// First get all photos
|
||||
let photos = await db('photos')
|
||||
@@ -317,16 +357,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
photo_id: photoId
|
||||
});
|
||||
|
||||
// Photo path should be in storage/events/active directory
|
||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
||||
const storagePath = getStoragePath();
|
||||
let filePath;
|
||||
if (photo.path.startsWith('events/active/')) {
|
||||
// New format: path already includes events/active/ prefix
|
||||
filePath = path.join(storagePath, photo.path);
|
||||
} else {
|
||||
// Legacy format: path is just slug/filename
|
||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Get watermark settings
|
||||
@@ -345,9 +386,24 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file
|
||||
res.download(filePath, photo.filename);
|
||||
res.download(filePath, photo.filename, (downloadError) => {
|
||||
if (downloadError) {
|
||||
logger.error('Error streaming gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: downloadError.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error processing gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
});
|
||||
@@ -390,16 +446,17 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
// Add photos to archive
|
||||
for (const photo of photos) {
|
||||
// Photo path should be in storage/events/active directory
|
||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
||||
const storagePath = getStoragePath();
|
||||
let filePath;
|
||||
if (photo.path.startsWith('events/active/')) {
|
||||
// New format: path already includes events/active/ prefix
|
||||
filePath = path.join(storagePath, photo.path);
|
||||
} else {
|
||||
// Legacy format: path is just slug/filename
|
||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.warn('Skipping photo in bulk download due to unresolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine the file name in the archive
|
||||
@@ -414,11 +471,18 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name: archiveName });
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name: archiveName });
|
||||
} catch (watermarkError) {
|
||||
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: watermarkError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Add original file
|
||||
archive.file(filePath, { name: archiveName });
|
||||
}
|
||||
}
|
||||
@@ -433,6 +497,11 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
action: 'download_all'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating bulk gallery download', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to create download archive' });
|
||||
}
|
||||
});
|
||||
@@ -477,30 +546,47 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||
archive.on('error', (err) => {
|
||||
console.error('Zip error:', err);
|
||||
try { res.status(500).end(); } catch (e) {}
|
||||
logger.error('Zip error generating selected download', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: err.message,
|
||||
});
|
||||
try {
|
||||
res.status(500).end();
|
||||
} catch (_) {
|
||||
// ignore double-send errors
|
||||
}
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const fs = require('fs');
|
||||
// Check watermark settings similar to download-all
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark like download-all
|
||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name });
|
||||
} else {
|
||||
archive.file(filePath, { name });
|
||||
} catch (watermarkError) {
|
||||
logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: watermarkError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
archive.file(filePath, { name });
|
||||
}
|
||||
} catch (e) {
|
||||
// skip missing/inaccessible files
|
||||
} catch (resolveError) {
|
||||
logger.warn('Skipping selected photo due to unresolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,7 +599,11 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
action: 'download_selected'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in download-selected:', error);
|
||||
logger.error('Error in download-selected:', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download selected photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Generate secure token for image access
|
||||
*/
|
||||
@@ -94,11 +91,11 @@ router.get('/:slug/secure/:photoId/:token',
|
||||
const { slug, photoId, token } = req.params; // Move outside try block for error handler access
|
||||
|
||||
try {
|
||||
console.log('Secure image route hit:', {
|
||||
slug: slug,
|
||||
photoId: photoId,
|
||||
logger.debug('Secure image route hit', {
|
||||
slug,
|
||||
photoId,
|
||||
tokenLength: token?.length,
|
||||
headers: req.headers.authorization ? 'present' : 'absent'
|
||||
hasAuthHeader: Boolean(req.headers.authorization),
|
||||
});
|
||||
const { fragment } = req.query;
|
||||
|
||||
@@ -142,7 +139,18 @@ router.get('/:slug/secure/:photoId/:token',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for secure token generation', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Get protection settings for this event
|
||||
const protectionSettings = {
|
||||
@@ -284,7 +292,18 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for secure download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Apply watermark if enabled
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
@@ -426,4 +445,4 @@ async function getSuspiciousActivityStats() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -132,6 +132,12 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? 'Kein Passwort erforderlich'
|
||||
: 'No password required';
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
@@ -546,4 +552,4 @@ module.exports = {
|
||||
queueEmail,
|
||||
stopEmailQueueProcessor,
|
||||
testEmailConnection
|
||||
};
|
||||
};
|
||||
|
||||
@@ -58,11 +58,15 @@ async function queueExpirationWarning(event) {
|
||||
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Determine language based on email domain
|
||||
const emailLang = event.host_email.endsWith('.de') ? 'de' : 'en';
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
const emailLang = recipientEmail && recipientEmail.endsWith('.de') ? 'de' : 'en';
|
||||
|
||||
// Queue email to host
|
||||
await queueEmail(event.id, event.host_email, 'expiration_warning', {
|
||||
host_name: event.host_name || event.host_email.split('@')[0],
|
||||
// Queue email to customer
|
||||
await queueEmail(event.id, recipientEmail, 'expiration_warning', {
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail,
|
||||
host_name: recipientName,
|
||||
event_name: event.event_name,
|
||||
days_remaining: daysRemaining.toString(),
|
||||
expiration_date: await formatDate(event.expires_at, emailLang),
|
||||
@@ -78,9 +82,14 @@ async function handleExpiredEvent(event) {
|
||||
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
|
||||
|
||||
// Queue expiration emails
|
||||
await queueEmail(event.id, event.host_email, 'gallery_expired', {
|
||||
const recipientEmail = event.customer_email || event.host_email;
|
||||
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
|
||||
|
||||
await queueEmail(event.id, recipientEmail, 'gallery_expired', {
|
||||
event_name: event.event_name,
|
||||
admin_email: event.admin_email
|
||||
admin_email: event.admin_email,
|
||||
customer_name: recipientName,
|
||||
customer_email: recipientEmail
|
||||
});
|
||||
|
||||
// Also notify admin
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
|
||||
let cachedRoot = null;
|
||||
|
||||
function resolveDefaultRoot() {
|
||||
const containerDefault = '/external-media';
|
||||
try {
|
||||
if (fsSync.existsSync(containerDefault)) {
|
||||
return containerDefault;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore lookup errors, fallback below
|
||||
}
|
||||
|
||||
const localFallback = path.resolve(__dirname, '../../..', 'storage/external-media');
|
||||
try {
|
||||
if (fsSync.existsSync(localFallback)) {
|
||||
return localFallback;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore and return container default
|
||||
}
|
||||
|
||||
return containerDefault;
|
||||
}
|
||||
|
||||
function getExternalMediaRoot() {
|
||||
return process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
|
||||
if (cachedRoot) {
|
||||
return cachedRoot;
|
||||
}
|
||||
|
||||
const configured = process.env.EXTERNAL_MEDIA_ROOT;
|
||||
if (configured && configured.trim()) {
|
||||
const resolvedConfigured = path.resolve(configured.trim());
|
||||
try {
|
||||
if (fsSync.existsSync(resolvedConfigured)) {
|
||||
cachedRoot = resolvedConfigured;
|
||||
return cachedRoot;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore lookup errors and fall back to defaults
|
||||
}
|
||||
}
|
||||
|
||||
cachedRoot = resolveDefaultRoot();
|
||||
return cachedRoot;
|
||||
}
|
||||
|
||||
function isUnderRoot(p) {
|
||||
@@ -64,4 +107,3 @@ module.exports = {
|
||||
list,
|
||||
resolveExternalPath,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
|
||||
|
||||
const SETTING_KEY = 'general_short_gallery_urls';
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
let cachedSetting = null;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
const parseSettingValue = (rawValue) => {
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'boolean') {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'number') {
|
||||
return rawValue !== 0;
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
return parseSettingValue(parsed);
|
||||
} catch {
|
||||
const normalized = trimmed.toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
|
||||
return true;
|
||||
}
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'object') {
|
||||
try {
|
||||
return parseSettingValue(JSON.parse(JSON.stringify(rawValue)));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getRawSettingValue = async () => {
|
||||
try {
|
||||
const setting = await db('app_settings').where({ setting_key: SETTING_KEY }).first();
|
||||
return setting?.setting_value ?? null;
|
||||
} catch (error) {
|
||||
console.error('Failed to read gallery URL setting:', error.message);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isShortGalleryUrlsEnabled = async () => {
|
||||
if (cachedSetting !== null && Date.now() < cacheExpiresAt) {
|
||||
return cachedSetting;
|
||||
}
|
||||
|
||||
const rawValue = await getRawSettingValue();
|
||||
const parsed = parseSettingValue(rawValue);
|
||||
cachedSetting = parsed === null ? false : Boolean(parsed);
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return cachedSetting;
|
||||
};
|
||||
|
||||
const clearShareLinkSettingsCache = () => {
|
||||
cachedSetting = null;
|
||||
cacheExpiresAt = 0;
|
||||
};
|
||||
|
||||
const buildShareLinkVariants = async ({ slug, shareToken }) => {
|
||||
if (!shareToken) {
|
||||
throw new Error('shareToken is required to build share link variants');
|
||||
}
|
||||
|
||||
const shortEnabled = await isShortGalleryUrlsEnabled();
|
||||
const sharePath = buildSharePath(slug, shareToken, shortEnabled);
|
||||
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||
const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||
|
||||
return {
|
||||
shortEnabled,
|
||||
sharePath,
|
||||
shareUrl,
|
||||
shareLinkToStore: sharePath
|
||||
};
|
||||
};
|
||||
|
||||
const getEventShareToken = (event) => {
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (event.share_token) {
|
||||
return event.share_token;
|
||||
}
|
||||
|
||||
return extractShareToken(event.share_link);
|
||||
};
|
||||
|
||||
const ACTIVE_EVENT_FILTER = {
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
};
|
||||
|
||||
const resolveShareIdentifier = async (identifier) => {
|
||||
if (!identifier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = String(identifier).trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseQuery = db('events')
|
||||
.select(
|
||||
'id',
|
||||
'slug',
|
||||
'share_link',
|
||||
'share_token',
|
||||
'require_password',
|
||||
'event_name',
|
||||
'event_type',
|
||||
'event_date',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'is_archived'
|
||||
)
|
||||
.where(ACTIVE_EVENT_FILTER);
|
||||
|
||||
let event = await baseQuery.clone().where({ slug: trimmed }).first();
|
||||
if (event) {
|
||||
return { event, matchType: 'slug', shareToken: getEventShareToken(event) };
|
||||
}
|
||||
|
||||
event = await baseQuery.clone().where({ share_token: trimmed }).first();
|
||||
if (event) {
|
||||
return { event, matchType: 'token', shareToken: getEventShareToken(event) };
|
||||
}
|
||||
|
||||
event = await baseQuery.clone().where({ share_link: trimmed }).first();
|
||||
if (event) {
|
||||
return { event, matchType: 'link', shareToken: getEventShareToken(event) };
|
||||
}
|
||||
|
||||
event = await baseQuery.clone().where('share_link', 'like', `%/${trimmed}`).first();
|
||||
if (event) {
|
||||
return { event, matchType: 'link_partial', shareToken: getEventShareToken(event) };
|
||||
}
|
||||
|
||||
// As a final fallback, if identifier looks like a token but we did not match via share_token
|
||||
if (isPotentialShareToken(trimmed)) {
|
||||
event = await baseQuery.clone().whereRaw('LOWER(share_token) = ?', [trimmed.toLowerCase()]).first();
|
||||
if (event) {
|
||||
return { event, matchType: 'token_case_insensitive', shareToken: getEventShareToken(event) };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
isShortGalleryUrlsEnabled,
|
||||
clearShareLinkSettingsCache,
|
||||
buildShareLinkVariants,
|
||||
getEventShareToken,
|
||||
resolveShareIdentifier
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
const { db } = require('../database/db');
|
||||
|
||||
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||
const MAX_ALLOWED_FILES_PER_UPLOAD = 2000;
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
const parseSettingValue = (setting) => {
|
||||
if (!setting || setting.setting_value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let rawValue = setting.setting_value;
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
try {
|
||||
rawValue = JSON.parse(rawValue);
|
||||
} catch {
|
||||
// keep original string
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'string') {
|
||||
const trimmed = rawValue.trim();
|
||||
if (trimmed === '') {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
if (typeof rawValue === 'number') {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeLimit = (value) => {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
|
||||
const intValue = Math.floor(value);
|
||||
if (intValue < 1) {
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
if (intValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
|
||||
return MAX_ALLOWED_FILES_PER_UPLOAD;
|
||||
}
|
||||
return intValue;
|
||||
};
|
||||
|
||||
const getMaxFilesPerUpload = async () => {
|
||||
if (Date.now() < cacheExpiresAt) {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
try {
|
||||
const setting = await db('app_settings')
|
||||
.where({ setting_key: 'general_max_files_per_upload' })
|
||||
.first();
|
||||
|
||||
const parsedValue = normalizeLimit(parseSettingValue(setting));
|
||||
cachedValue = parsedValue;
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return parsedValue;
|
||||
} catch (error) {
|
||||
console.error('Failed to read max files per upload setting:', error.message);
|
||||
cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
};
|
||||
|
||||
const clearMaxFilesPerUploadCache = () => {
|
||||
cacheExpiresAt = 0;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMaxFilesPerUpload,
|
||||
clearMaxFilesPerUploadCache,
|
||||
DEFAULT_MAX_FILES_PER_UPLOAD,
|
||||
MAX_ALLOWED_FILES_PER_UPLOAD
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
const SHARE_TOKEN_REGEX = /^[0-9a-fA-F]{32}$/;
|
||||
|
||||
/**
|
||||
* Extracts the share token portion from a stored share link.
|
||||
* Supports full URLs, absolute paths, and legacy slug/token formats.
|
||||
* @param {string|null|undefined} shareLink
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function extractShareToken(shareLink) {
|
||||
if (!shareLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = String(shareLink).trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove protocol + host when a full URL is stored
|
||||
const path = trimmed.replace(/^https?:\/\/[^/]+/i, '');
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = segments[segments.length - 1];
|
||||
return candidate || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the provided identifier looks like a generated share token.
|
||||
* @param {string|null|undefined} identifier
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPotentialShareToken(identifier) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
return SHARE_TOKEN_REGEX.test(String(identifier).trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the gallery share path depending on whether short URLs are enabled.
|
||||
* @param {string} slug
|
||||
* @param {string} shareToken
|
||||
* @param {boolean} useShort
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildSharePath(slug, shareToken, useShort) {
|
||||
if (!shareToken) {
|
||||
throw new Error('shareToken is required to build share path');
|
||||
}
|
||||
if (useShort || !slug) {
|
||||
return `/gallery/${shareToken}`;
|
||||
}
|
||||
return `/gallery/${slug}/${shareToken}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractShareToken,
|
||||
isPotentialShareToken,
|
||||
buildSharePath
|
||||
};
|
||||
@@ -109,7 +109,7 @@ If ADMIN_CREDENTIALS.txt is missing:
|
||||
- File is created in the backend directory root
|
||||
- File might have been deleted for security (as recommended)
|
||||
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
|
||||
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||
- When using the unified `picpeak-setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# PicPeak Admin API Quickstart
|
||||
|
||||
This guide explains how to authenticate against the PicPeak Admin API, use the OpenAPI documentation, and exercise the three automation endpoints (`create event`, `photo upload`, `resend email`) that now ship with machine-readable docs.
|
||||
|
||||
> **Prerequisites**
|
||||
>
|
||||
> - PicPeak backend running (Docker or local `node backend/server.js`)
|
||||
> - An admin account (see `data/ADMIN_CREDENTIALS.txt` for the seeded defaults)
|
||||
> - API base URL (defaults to `http://localhost:3001/api`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Obtain an Admin API Token
|
||||
|
||||
1. Determine whether reCAPTCHA is enabled in **Admin → Settings → Security**. If disabled (the default), you can skip the `recaptchaToken` field shown below.
|
||||
2. Authenticate with your admin username/email and password:
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "http://localhost:3001/api/auth/admin/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "admin",
|
||||
"password": "BoldTiger5872%",
|
||||
"recaptchaToken": ""
|
||||
}' | jq
|
||||
```
|
||||
|
||||
Successful responses look like:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"email": "admin@example.com",
|
||||
"mustChangePassword": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- PicPeak also sets the `admin_token` cookie; however, when scripting you typically pass the token in an `Authorization: Bearer <token>` header.
|
||||
- Tokens expire after 24 hours. Log in again to refresh them.
|
||||
|
||||
---
|
||||
|
||||
## 2. Use the OpenAPI Documentation
|
||||
|
||||
The machine-readable spec lives at `docs/picpeak-admin-api.openapi.yaml`. You can:
|
||||
|
||||
- Preview it interactively with Redocly:
|
||||
|
||||
```bash
|
||||
npx --yes @redocly/cli preview-docs docs/picpeak-admin-api.openapi.yaml
|
||||
```
|
||||
|
||||
- Import it into Postman, Insomnia, or VS Code REST client.
|
||||
- Validate changes as part of CI with:
|
||||
|
||||
```bash
|
||||
npx --yes @apidevtools/swagger-cli@4.0.4 validate docs/picpeak-admin-api.openapi.yaml
|
||||
```
|
||||
|
||||
Keep this file in sync whenever the backend endpoints evolve.
|
||||
|
||||
---
|
||||
|
||||
## 3. Call the Key Admin Endpoints
|
||||
|
||||
Below are minimal `curl` examples that rely on the bearer token captured earlier.
|
||||
|
||||
### 3.1 Create an Event
|
||||
|
||||
```bash
|
||||
API_URL="http://localhost:3001/api"
|
||||
TOKEN="REPLACE_WITH_JWT"
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "$API_URL/admin/events" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event_type": "wedding",
|
||||
"event_name": "Emily & Jordan Celebration",
|
||||
"event_date": "2025-06-07",
|
||||
"customer_name": "Emily Carter",
|
||||
"customer_email": "emily@example.com",
|
||||
"admin_email": "studio@example.com",
|
||||
"require_password": true,
|
||||
"password": "Shutter123",
|
||||
"expiration_days": 45
|
||||
}' | jq
|
||||
```
|
||||
|
||||
### 3.2 Upload Photos to the Event
|
||||
|
||||
```bash
|
||||
EVENT_ID=512
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "$API_URL/admin/events/$EVENT_ID/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "photos=@/path/to/DSC_2031.jpg" \
|
||||
-F "photos=@/path/to/DSC_2032.jpg" \
|
||||
-F "category_id=individual" | jq
|
||||
```
|
||||
|
||||
- Files must be JPEG/PNG/WebP, each ≤ 50 MB.
|
||||
- The per-request file count respects the `general_max_files_per_upload` admin setting (default 500).
|
||||
|
||||
### 3.3 Resend the Gallery Email
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "$API_URL/admin/events/$EVENT_ID/resend-email" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password": "Shutter123"}' | jq
|
||||
```
|
||||
|
||||
Omit `"password"` to send the standard security message instead.
|
||||
|
||||
---
|
||||
|
||||
## 4. Quick Testing Checklist
|
||||
|
||||
- ✅ Login succeeds and returns a token (HTTP 200).
|
||||
- ✅ Creating an event returns `id`, `slug`, and `share_link`.
|
||||
- ✅ Uploading more files than allowed returns HTTP 400 with a helpful message.
|
||||
- ✅ Resending email for a missing event returns HTTP 404.
|
||||
- ✅ `swagger-cli validate` passes after any spec edits.
|
||||
|
||||
Automate these checks using your preferred test harness or CI pipeline to catch regressions early.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migrating From `host_*`
|
||||
|
||||
- Run backend migrations to add the new `customer_name` / `customer_email` columns: `npm --prefix backend run migrate` (or your existing deployment flow). The migration copies legacy data automatically, so upgrades remain seamless.
|
||||
- All admin APIs now require the `customer_*` fields. Older `host_*` payloads are rejected, which makes downstream client issues obvious during testing instead of silently dropping data.
|
||||
- API responses still mirror `customer_*` even if migrations have not run yet (the server falls back to legacy columns until the upgrade is complete), so existing frontends can move over incrementally.
|
||||
- Once every consumer writes and reads the new fields, you can safely plan the removal of the legacy `host_*` columns in a future release.
|
||||
|
||||
---
|
||||
|
||||
Need deeper integration examples or language-specific SDKs? Import the OpenAPI spec into code generators such as `openapi-generator` or `orval` to scaffold API clients quickly.
|
||||
@@ -0,0 +1,584 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: PicPeak Admin API
|
||||
version: 1.1.11
|
||||
summary: High-level administrative endpoints for creating events, uploading photos, and resending gallery access emails.
|
||||
description: |
|
||||
This document describes the core administrative endpoints that power PicPeak automations.
|
||||
It focuses on the three workflows requested by integrators:
|
||||
|
||||
1. Creating events with customer access credentials.
|
||||
2. Uploading photos in bulk to an event gallery.
|
||||
3. Resending the customer-facing gallery email.
|
||||
|
||||
The specification follows the latest [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) best practices
|
||||
and is intended to be kept in sync with backend changes.
|
||||
contact:
|
||||
name: PicPeak Maintainers
|
||||
url: https://github.com/the-luap/picpeak
|
||||
servers:
|
||||
- url: https://api.picpeak.example.com/api
|
||||
description: Example production deployment
|
||||
- url: http://localhost:3001/api
|
||||
description: Local development
|
||||
tags:
|
||||
- name: Admin Events
|
||||
description: Administrative endpoints for managing event galleries.
|
||||
components:
|
||||
securitySchemes:
|
||||
CookieAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: admin_token
|
||||
description: >
|
||||
Session cookie issued by the admin authentication flow. When present, the backend mirrors
|
||||
it into the `Authorization` header automatically.
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: >
|
||||
JSON Web Token created by the admin login endpoint. You can also pass the token explicitly
|
||||
as `Authorization: Bearer <token>` instead of using the admin cookie.
|
||||
parameters:
|
||||
EventId:
|
||||
name: eventId
|
||||
in: path
|
||||
description: Numeric identifier of the event.
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
example: 341
|
||||
schemas:
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: Human readable error message.
|
||||
details:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Additional context (when available).
|
||||
required:
|
||||
- error
|
||||
example:
|
||||
error: Invalid token
|
||||
ValidationErrorItem:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Validation error type reported by express-validator.
|
||||
msg:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
description: Dot-delimited path to the invalid field.
|
||||
value:
|
||||
description: Value that failed validation.
|
||||
location:
|
||||
type: string
|
||||
description: Location of the invalid value (always `body` for these endpoints).
|
||||
required:
|
||||
- msg
|
||||
- path
|
||||
- location
|
||||
example:
|
||||
type: field
|
||||
msg: Event date must be a valid ISO 8601 date
|
||||
path: event_date
|
||||
value: 2025/05/01
|
||||
location: body
|
||||
ValidationErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationErrorItem'
|
||||
required:
|
||||
- errors
|
||||
example:
|
||||
errors:
|
||||
- type: field
|
||||
msg: Customer email must be a valid address
|
||||
path: customer_email
|
||||
value: example@invalid
|
||||
location: body
|
||||
CreateEventRequest:
|
||||
type: object
|
||||
required:
|
||||
- event_type
|
||||
- event_name
|
||||
- event_date
|
||||
- customer_name
|
||||
- customer_email
|
||||
- admin_email
|
||||
properties:
|
||||
event_type:
|
||||
type: string
|
||||
description: Type of event. Controls default theme and copy in the UI.
|
||||
enum: [wedding, birthday, corporate, other]
|
||||
event_name:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Display name for the gallery shown to end customers.
|
||||
event_date:
|
||||
type: string
|
||||
format: date
|
||||
description: Event date (YYYY-MM-DD). Used to calculate the default expiration.
|
||||
customer_name:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Name of the customer receiving gallery access.
|
||||
customer_email:
|
||||
type: string
|
||||
format: email
|
||||
description: Email address of the customer who will receive the gallery link.
|
||||
admin_email:
|
||||
type: string
|
||||
format: email
|
||||
description: Admin contact email included in notification messages.
|
||||
require_password:
|
||||
type: boolean
|
||||
default: true
|
||||
description: When true, the gallery requires `password`; when false a random placeholder is stored.
|
||||
password:
|
||||
type: string
|
||||
minLength: 6
|
||||
description: >
|
||||
Gallery password issued to the customer. Required when `require_password` is `true`.
|
||||
Left unset to auto-generate a placeholder when password protection is disabled.
|
||||
expiration_days:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
default: 30
|
||||
description: Number of days after the event date before the gallery expires.
|
||||
welcome_message:
|
||||
type: string
|
||||
description: Optional welcome message displayed in the gallery.
|
||||
color_theme:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Optional theme identifier or CSS color settings.
|
||||
allow_user_uploads:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Allow gallery guests to upload their own photos.
|
||||
upload_category_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: ID of the default category for user uploads.
|
||||
allow_downloads:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Allow guests to download photos.
|
||||
disable_right_click:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Disable right-click in the gallery view.
|
||||
watermark_downloads:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Enable watermarking on downloaded images.
|
||||
watermark_text:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Custom watermark text when `watermark_downloads` is true.
|
||||
feedback_enabled:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Enable the feedback module for this gallery.
|
||||
allow_ratings:
|
||||
type: boolean
|
||||
default: true
|
||||
allow_likes:
|
||||
type: boolean
|
||||
default: true
|
||||
allow_comments:
|
||||
type: boolean
|
||||
default: true
|
||||
allow_favorites:
|
||||
type: boolean
|
||||
default: true
|
||||
require_name_email:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Require guests to provide name and email when leaving feedback.
|
||||
moderate_comments:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Hold guest comments for moderation.
|
||||
show_feedback_to_guests:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Display aggregated feedback metrics back to guests.
|
||||
example:
|
||||
event_type: wedding
|
||||
event_name: Emily & Jordan Celebration
|
||||
event_date: 2025-06-07
|
||||
customer_name: Emily Carter
|
||||
customer_email: emily@example.com
|
||||
admin_email: studio@example.com
|
||||
require_password: true
|
||||
password: Shutter123
|
||||
expiration_days: 45
|
||||
welcome_message: >
|
||||
We loved capturing your day! Use the password below to view and download your photos.
|
||||
allow_user_uploads: false
|
||||
allow_downloads: true
|
||||
feedback_enabled: true
|
||||
allow_comments: true
|
||||
show_feedback_to_guests: true
|
||||
EventSummary:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: Database identifier of the newly created event.
|
||||
slug:
|
||||
type: string
|
||||
description: Unique slug used to build the gallery URL.
|
||||
event_name:
|
||||
type: string
|
||||
event_type:
|
||||
type: string
|
||||
enum: [wedding, birthday, corporate, other]
|
||||
customer_name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Name of the customer associated with the event.
|
||||
customer_email:
|
||||
type: string
|
||||
format: email
|
||||
nullable: true
|
||||
description: Email address of the customer associated with the event.
|
||||
require_password:
|
||||
type: boolean
|
||||
share_link:
|
||||
type: string
|
||||
description: Absolute or relative URL guests can use to reach the gallery.
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: ISO 8601 timestamp when the gallery expires.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: ISO 8601 timestamp when the event was created.
|
||||
required:
|
||||
- id
|
||||
- slug
|
||||
- event_name
|
||||
- event_type
|
||||
- require_password
|
||||
- share_link
|
||||
- expires_at
|
||||
- created_at
|
||||
example:
|
||||
id: 512
|
||||
slug: wedding-emily-jordan-2025-06-07
|
||||
event_name: Emily & Jordan Celebration
|
||||
event_type: wedding
|
||||
customer_name: Emily Carter
|
||||
customer_email: emily@example.com
|
||||
require_password: true
|
||||
share_link: https://app.picpeak.io/gallery/wedding-emily-jordan-2025-06-07/2f3c8a4d90bb11ef9b2e0242ac120002
|
||||
expires_at: 2025-07-22T00:00:00.000Z
|
||||
created_at: 2025-05-01T14:32:45.000Z
|
||||
UploadPhotosResponse:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
photos:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadedPhotoSummary'
|
||||
description: Metadata for each photo that was persisted successfully.
|
||||
totalFiles:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Total number of files included in the request (valid + invalid).
|
||||
successCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
failureCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadFailure'
|
||||
description: Present when some files failed validation or processing.
|
||||
required:
|
||||
- message
|
||||
- photos
|
||||
- totalFiles
|
||||
- successCount
|
||||
- failureCount
|
||||
example:
|
||||
message: Uploaded 18 of 20 photos. 2 failed.
|
||||
photos:
|
||||
- id: 9821
|
||||
filename: DSC_2031.jpg
|
||||
size: 4812096
|
||||
category_id: 2
|
||||
- id: 9822
|
||||
filename: DSC_2032.jpg
|
||||
size: 5216743
|
||||
category_id: 2
|
||||
totalFiles: 20
|
||||
successCount: 18
|
||||
failureCount: 2
|
||||
errors:
|
||||
- filename: DSC_2020.raw
|
||||
error: Only JPEG, PNG and WebP images are allowed
|
||||
- filename: portrait.png
|
||||
error: File is empty
|
||||
UploadedPhotoSummary:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
filename:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
description: File size in bytes.
|
||||
category_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
required:
|
||||
- id
|
||||
- filename
|
||||
- size
|
||||
example:
|
||||
id: 9821
|
||||
filename: DSC_2031.jpg
|
||||
size: 4812096
|
||||
category_id: 2
|
||||
UploadFailure:
|
||||
type: object
|
||||
properties:
|
||||
filename:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
required:
|
||||
- filename
|
||||
- error
|
||||
example:
|
||||
filename: DSC_2031.gif
|
||||
error: Only JPEG, PNG and WebP images are allowed
|
||||
ResendEmailRequest:
|
||||
type: object
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: >
|
||||
Optional plain-text password to include in the email. When omitted a security notice
|
||||
placeholder is inserted because the stored hash cannot be reversed.
|
||||
example:
|
||||
password: Shutter123
|
||||
ResendEmailResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- success
|
||||
- message
|
||||
example:
|
||||
success: true
|
||||
message: Creation email has been queued for sending
|
||||
paths:
|
||||
/admin/events:
|
||||
post:
|
||||
tags: [Admin Events]
|
||||
operationId: createAdminEvent
|
||||
summary: Create a new event
|
||||
description: >
|
||||
Creates a new event, provisions storage folders, stores the gallery password, and queues
|
||||
the initial gallery email for the customer. Requires admin authentication.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateEventRequest'
|
||||
examples:
|
||||
weddingExample:
|
||||
summary: Wedding with password protection
|
||||
value:
|
||||
event_type: wedding
|
||||
event_name: Emily & Jordan Celebration
|
||||
event_date: 2025-06-07
|
||||
customer_name: Emily Carter
|
||||
customer_email: emily@example.com
|
||||
admin_email: studio@example.com
|
||||
require_password: true
|
||||
password: Shutter123
|
||||
expiration_days: 45
|
||||
welcome_message: >
|
||||
We loved capturing your day! Use the password below to view and download your photos.
|
||||
allow_user_uploads: false
|
||||
allow_downloads: true
|
||||
feedback_enabled: true
|
||||
allow_comments: true
|
||||
show_feedback_to_guests: true
|
||||
responses:
|
||||
'200':
|
||||
description: Event created successfully.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EventSummary'
|
||||
'400':
|
||||
description: Validation failed. At least one field is invalid or missing.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationErrorResponse'
|
||||
'401':
|
||||
description: Authentication required or token invalid.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected server error while creating the event.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/admin/events/{eventId}/upload:
|
||||
post:
|
||||
tags: [Admin Events]
|
||||
operationId: uploadEventPhotos
|
||||
summary: Upload photos to an event gallery
|
||||
description: |
|
||||
Uploads one or more photos to the specified event. Files are validated, moved into the
|
||||
event storage directory, and thumbnails are generated asynchronously.
|
||||
|
||||
The maximum number of files per upload is controlled via the `general_max_files_per_upload`
|
||||
setting (default 500, capped at 2000). Files exceeding 50 MB are rejected.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/EventId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
photos:
|
||||
type: array
|
||||
description: >
|
||||
One or more image files (JPEG, PNG, WebP). Each file must be <= 50 MB.
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
category_id:
|
||||
oneOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
description: >
|
||||
Optional category assignment. Accepts numeric IDs or the string values `collage`
|
||||
and `individual` for backward compatibility.
|
||||
required:
|
||||
- photos
|
||||
encoding:
|
||||
photos:
|
||||
style: form
|
||||
explode: false
|
||||
responses:
|
||||
'200':
|
||||
description: Upload completed. Failed files (if any) are listed in the response.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UploadPhotosResponse'
|
||||
'400':
|
||||
description: Request failed validation (invalid files, too many files, etc.).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Authentication required or token invalid.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: The referenced event does not exist.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected server error while processing uploads.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/admin/events/{eventId}/resend-email:
|
||||
post:
|
||||
tags: [Admin Events]
|
||||
operationId: resendEventEmail
|
||||
summary: Resend the gallery access email to the customer
|
||||
description: >
|
||||
Queues the standard `gallery_created` email for the event's customer. Useful when resending
|
||||
credentials to the customer or communicating an updated password. Requires admin authentication.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/EventId'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResendEmailRequest'
|
||||
example:
|
||||
password: NewSecurePassword!
|
||||
responses:
|
||||
'200':
|
||||
description: Email successfully queued for delivery.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResendEmailResponse'
|
||||
'401':
|
||||
description: Authentication required or token invalid.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Event not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected server error while queuing the email.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.1.7",
|
||||
"version": "1.1.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.1.7",
|
||||
"version": "1.1.13",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.7",
|
||||
"version": "1.1.13",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../../config/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
@@ -13,6 +14,9 @@ interface PhotoUploadProps {
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||
|
||||
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -29,6 +33,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
});
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const maxFilesPerUpload = React.useMemo(() => {
|
||||
const rawValue = settings?.general_max_files_per_upload;
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
|
||||
}, [settings]);
|
||||
|
||||
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const imageFiles = files.filter(file =>
|
||||
@@ -37,13 +57,19 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
// Check total file count with existing files
|
||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||
if (totalFiles > 500) {
|
||||
const allowedNewFiles = 500 - selectedFiles.length;
|
||||
if (totalFiles > maxFilesPerUpload) {
|
||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||
if (allowedNewFiles <= 0) {
|
||||
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
|
||||
toast.error(
|
||||
t('upload.maxFilesReached', { limit: maxFilesPerUpload }) ||
|
||||
`Maximum ${maxFilesPerUpload} files allowed`
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
|
||||
toast.warning(
|
||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
|
||||
);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
return;
|
||||
}
|
||||
@@ -59,8 +85,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
// Validate file count
|
||||
if (selectedFiles.length > 500) {
|
||||
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
|
||||
if (selectedFiles.length > maxFilesPerUpload) {
|
||||
toast.error(
|
||||
t('upload.tooManyFiles', { limit: maxFilesPerUpload }) ||
|
||||
`Maximum ${maxFilesPerUpload} files can be uploaded at once`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +97,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
setUploadProgress(0);
|
||||
|
||||
// For large uploads, chunk the files to prevent memory issues
|
||||
const CHUNK_SIZE = 50; // Upload 50 files at a time
|
||||
const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
||||
@@ -187,7 +216,21 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('upload.fileRequirements')}
|
||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
"text-xs mt-2",
|
||||
remainingSlots === 0 ? "text-red-600" : "text-neutral-500"
|
||||
)}
|
||||
>
|
||||
{remainingSlots === 0
|
||||
? t('upload.limitReached', { limit: maxFilesPerUpload })
|
||||
: t('upload.limitInfo', {
|
||||
selected: selectedFiles.length,
|
||||
limit: maxFilesPerUpload,
|
||||
remaining: remainingSlots,
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import {
|
||||
getActiveGallerySlug,
|
||||
getGalleryToken,
|
||||
inferGallerySlugFromLocation,
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../../utils/galleryAuthStorage';
|
||||
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
@@ -52,7 +58,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
}) => {
|
||||
const unusedProps = {
|
||||
protectFromDownload,
|
||||
slug,
|
||||
photoId,
|
||||
requiresToken,
|
||||
secureUrlTemplate,
|
||||
@@ -76,7 +81,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
let aborted = false;
|
||||
const objectUrls: string[] = [];
|
||||
|
||||
// Determine which token to use based on context
|
||||
if (!src) {
|
||||
@@ -88,37 +94,79 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
|
||||
// Create a new URL with auth header
|
||||
const resolveSlug = (candidateSrc?: string): string | null => {
|
||||
if (slug) {
|
||||
return slug;
|
||||
}
|
||||
const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null;
|
||||
if (fromUrl) {
|
||||
return fromUrl;
|
||||
}
|
||||
return getActiveGallerySlug() || inferGallerySlugFromLocation();
|
||||
};
|
||||
|
||||
const fetchWithAuth = async (rawUrl: string | undefined | null): Promise<string> => {
|
||||
if (!rawUrl) {
|
||||
throw new Error('No URL provided');
|
||||
}
|
||||
|
||||
// Build full URL for the image
|
||||
const fullImageUrl = rawUrl.startsWith('/admin')
|
||||
? buildResourceUrl(`/api${rawUrl}`)
|
||||
: rawUrl.startsWith('/')
|
||||
? buildResourceUrl(rawUrl)
|
||||
: rawUrl;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const slugForRequest = resolveSlug(rawUrl);
|
||||
const token = getGalleryToken(slugForRequest);
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(fullImageUrl, {
|
||||
credentials: 'include',
|
||||
headers: Object.keys(headers).length ? headers : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
objectUrls.push(objectUrl);
|
||||
return objectUrl;
|
||||
};
|
||||
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
// Use the src as-is since it should already be the correct endpoint
|
||||
let imageUrl = src;
|
||||
|
||||
// Build full URL for the image
|
||||
// For API paths that start with /admin, we need to prepend /api
|
||||
const fullImageUrl = imageUrl.startsWith('/admin')
|
||||
? buildResourceUrl(`/api${imageUrl}`)
|
||||
: imageUrl.startsWith('/')
|
||||
? buildResourceUrl(imageUrl)
|
||||
: imageUrl;
|
||||
|
||||
// Fetch authenticated image
|
||||
const response = await fetch(fullImageUrl, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
||||
const primaryUrl = await fetchWithAuth(src);
|
||||
if (!aborted) {
|
||||
setImageSrc(primaryUrl);
|
||||
setError(false);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageSrc(objectUrl);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
// Image loading failed - use fallback
|
||||
setError(true);
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
if (fallbackSrc && fallbackSrc !== src) {
|
||||
try {
|
||||
const fallbackUrl = await fetchWithAuth(fallbackSrc);
|
||||
if (!aborted) {
|
||||
setImageSrc(fallbackUrl);
|
||||
setError(false);
|
||||
}
|
||||
return;
|
||||
} catch (fallbackError) {
|
||||
// Swallow and mark error below
|
||||
}
|
||||
}
|
||||
if (!aborted) {
|
||||
setError(true);
|
||||
setImageSrc('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -127,11 +175,11 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
aborted = true;
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [src, fallbackSrc, useWatermark, isGallery]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [src, fallbackSrc, slug]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AdminAuthContextType {
|
||||
error: string | null;
|
||||
mustChangePassword: boolean;
|
||||
updatePasswordChanged: () => void;
|
||||
updateUserProfile: (updates: Partial<AdminUser>) => void;
|
||||
}
|
||||
|
||||
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 (
|
||||
<AdminAuthContext.Provider
|
||||
value={{
|
||||
@@ -115,6 +127,7 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
||||
error,
|
||||
mustChangePassword,
|
||||
updatePasswordChanged,
|
||||
updateUserProfile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api } from '../config/api';
|
||||
import { authService, galleryService } from '../services';
|
||||
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
import {
|
||||
clearActiveGallerySlug,
|
||||
clearGalleryToken,
|
||||
@@ -18,12 +20,24 @@ interface GalleryEvent {
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
require_password?: boolean;
|
||||
}
|
||||
|
||||
const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent | null => {
|
||||
if (!incoming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
require_password: normalizeRequirePassword(incoming.require_password, true),
|
||||
};
|
||||
};
|
||||
|
||||
interface GalleryAuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
event: GalleryEvent | null;
|
||||
login: (slug: string, password: string, recaptchaToken?: string | null) => Promise<void>;
|
||||
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
@@ -48,48 +62,133 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get current gallery slug from URL
|
||||
const getCurrentGallerySlug = () => {
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
||||
return pathParts[2];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const [routeError, setRouteError] = useState<string | null>(null);
|
||||
const location = useLocation();
|
||||
const [routeInfo, setRouteInfo] = useState<{ slug: string | null; token?: string; identifier: string | null; ready: boolean }>({
|
||||
slug: null,
|
||||
token: undefined,
|
||||
identifier: null,
|
||||
ready: false,
|
||||
});
|
||||
const lastResolvedIdentifier = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOldGalleryAuth();
|
||||
}, []);
|
||||
|
||||
const slugAtMount = getCurrentGallerySlug();
|
||||
if (slugAtMount) {
|
||||
setActiveGallerySlug(slugAtMount);
|
||||
} else {
|
||||
clearActiveGallerySlug();
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const initialise = async () => {
|
||||
const currentSlug = getCurrentGallerySlug();
|
||||
const parseRoute = async () => {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (!currentSlug) {
|
||||
setIsLoading(false);
|
||||
if (segments[0] !== 'gallery') {
|
||||
if (!cancelled) {
|
||||
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||
setRouteError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveGallerySlug(currentSlug);
|
||||
const identifier = segments[1] || null;
|
||||
const tokenSegment = segments[2];
|
||||
|
||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||
if (storedEvent) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedEvent);
|
||||
if (parsed && parsed.id) {
|
||||
setEvent(parsed);
|
||||
}
|
||||
} catch (err) {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
if (!identifier) {
|
||||
if (!cancelled) {
|
||||
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const looksLikeToken = /^[0-9a-fA-F]{32}$/.test(identifier) && !tokenSegment;
|
||||
|
||||
if (looksLikeToken) {
|
||||
if (lastResolvedIdentifier.current === identifier) {
|
||||
setRouteInfo(prev => ({
|
||||
slug: prev.slug,
|
||||
token: prev.token,
|
||||
identifier,
|
||||
ready: true,
|
||||
}));
|
||||
setRouteError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await galleryService.resolveIdentifier(identifier);
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = identifier;
|
||||
setRouteInfo({
|
||||
slug: resolved.slug,
|
||||
token: resolved.token,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(null);
|
||||
} catch (err: any) {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = identifier;
|
||||
setRouteInfo({
|
||||
slug: null,
|
||||
token: undefined,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(err?.response?.data?.error || 'Unable to resolve gallery link');
|
||||
}
|
||||
} else {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setRouteInfo({
|
||||
slug: identifier,
|
||||
token: tokenSegment,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(null);
|
||||
}
|
||||
};
|
||||
|
||||
setRouteInfo(prev => ({ ...prev, ready: false }));
|
||||
parseRoute();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeInfo.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!routeInfo.slug) {
|
||||
clearActiveGallerySlug();
|
||||
setIsAuthenticated(false);
|
||||
setEvent(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSlug = routeInfo.slug;
|
||||
setActiveGallerySlug(currentSlug);
|
||||
|
||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||
if (storedEvent) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedEvent);
|
||||
if (parsed && parsed.id) {
|
||||
const normalizedStored = normalizeEvent(parsed);
|
||||
setEvent(normalizedStored);
|
||||
if (normalizedStored) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
}
|
||||
}
|
||||
|
||||
const initialise = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
|
||||
@@ -101,29 +200,30 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
setIsAuthenticated(true);
|
||||
|
||||
if (!storedEvent) {
|
||||
// Fetch gallery details to hydrate context
|
||||
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
||||
if (galleryData?.event) {
|
||||
setEvent(galleryData.event);
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(galleryData.event));
|
||||
const normalizedEvent = normalizeEvent(galleryData.event);
|
||||
setEvent(normalizedEvent);
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If no active session, check for share token in URL
|
||||
const parts = window.location.pathname.split('/');
|
||||
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
|
||||
|
||||
if (urlToken) {
|
||||
const verify = await galleryService.verifyToken(currentSlug, urlToken);
|
||||
if (routeInfo.token) {
|
||||
const verify = await galleryService.verifyToken(currentSlug, routeInfo.token);
|
||||
if (verify?.valid) {
|
||||
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
||||
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
|
||||
if (response?.event) {
|
||||
setEvent(response.event);
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
setIsAuthenticated(true);
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
if (response.token) {
|
||||
storeGalleryToken(currentSlug, response.token);
|
||||
}
|
||||
@@ -133,41 +233,47 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
}
|
||||
}
|
||||
|
||||
// No valid session found
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
setEvent(null);
|
||||
clearGalleryToken(currentSlug);
|
||||
} catch (error) {
|
||||
} catch (initialiseError: any) {
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
setEvent(null);
|
||||
clearGalleryToken(currentSlug);
|
||||
if (initialiseError?.response?.data?.error) {
|
||||
setError(initialiseError.response.data.error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initialise();
|
||||
|
||||
return () => {
|
||||
clearActiveGallerySlug();
|
||||
};
|
||||
}, []);
|
||||
}, [routeInfo]);
|
||||
|
||||
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
|
||||
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
|
||||
try {
|
||||
setRouteError(null);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||
setEvent(response.event);
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
setIsAuthenticated(true);
|
||||
if (response.token) {
|
||||
storeGalleryToken(slug, response.token);
|
||||
}
|
||||
setActiveGallerySlug(slug);
|
||||
|
||||
// Store event data for quick reloads (non-sensitive)
|
||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Invalid password');
|
||||
throw err;
|
||||
@@ -177,7 +283,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const currentSlug = getCurrentGallerySlug();
|
||||
const currentSlug = routeInfo.slug;
|
||||
if (currentSlug) {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
clearGalleryToken(currentSlug);
|
||||
@@ -196,7 +302,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
login,
|
||||
logout,
|
||||
isLoading,
|
||||
error,
|
||||
error: routeError ?? error,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,12 +2,18 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { galleryService } from '../services';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useGalleryInfo = (slug: string, token?: string) => {
|
||||
export const useGalleryInfo = (slug?: string, token?: string, enabled: boolean = true) => {
|
||||
return useQuery({
|
||||
queryKey: ['gallery-info', slug, token],
|
||||
queryFn: () => galleryService.getGalleryInfo(slug, token),
|
||||
queryFn: () => {
|
||||
if (!slug) {
|
||||
throw new Error('Gallery slug is required');
|
||||
}
|
||||
return galleryService.getGalleryInfo(slug, token);
|
||||
},
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: Boolean(slug) && enabled,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"noCategory": "Keine Kategorie",
|
||||
"eventSpecific": "(Veranstaltungsspezifisch)",
|
||||
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
|
||||
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei)",
|
||||
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
|
||||
"selectedFiles": "Ausgewählte Dateien",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"uploadComplete": "Upload abgeschlossen!",
|
||||
@@ -59,9 +59,11 @@
|
||||
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
|
||||
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
|
||||
"importFromSelectedFolder": "Ausgewählten Ordner importieren",
|
||||
"maxFilesReached": "Maximal 500 Dateien erlaubt",
|
||||
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
|
||||
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden",
|
||||
"maxFilesReached": "Maximal {{limit}} Dateien erlaubt",
|
||||
"someFilesSkipped": "Nur {{allowed}} weitere Dateien erlaubt (Limit {{limit}})",
|
||||
"tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden",
|
||||
"limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)",
|
||||
"limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)",
|
||||
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..."
|
||||
},
|
||||
"navigation": {
|
||||
@@ -496,6 +498,8 @@
|
||||
"expiresIn": "Galerie läuft in {{count}} Tag ab",
|
||||
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
|
||||
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.",
|
||||
"publicGalleryTitle": "Diese Galerie ist öffentlich zugänglich",
|
||||
"publicGallerySubtitle": "Fotos werden geladen...",
|
||||
"viewGallery": "Galerie anzeigen",
|
||||
"downloadAll": "Alle herunterladen",
|
||||
"downloading": "Lade {{count}} Foto herunter...",
|
||||
@@ -564,8 +568,8 @@
|
||||
"eventName": "Veranstaltungsname",
|
||||
"eventType": "Veranstaltungstyp",
|
||||
"eventDate": "Veranstaltungsdatum",
|
||||
"hostEmail": "Gastgeber-E-Mail",
|
||||
"hostName": "Name des Gastgebers",
|
||||
"hostEmail": "E-Mail des Kunden",
|
||||
"hostName": "Name des Kunden",
|
||||
"hostNamePlaceholder": "Max Mustermann",
|
||||
"adminEmail": "Admin-E-Mail",
|
||||
"expirationDate": "Ablaufdatum",
|
||||
@@ -587,8 +591,8 @@
|
||||
"eventExpired": "Diese Veranstaltung ist abgelaufen",
|
||||
"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.",
|
||||
"warningEmailsSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
||||
"warningEmailsHaveBeenSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
||||
"warningEmailsSent": "Warn-E-Mails wurden an den Kunden gesendet.",
|
||||
"warningEmailsHaveBeenSent": "Warn-E-Mails wurden an den Kunden gesendet.",
|
||||
"extendSevenDays": "Um 7 Tage verlängern",
|
||||
"overview": "Übersicht",
|
||||
"photos": "Fotos",
|
||||
@@ -607,6 +611,7 @@
|
||||
"created": "Erstellt",
|
||||
"expires": "Läuft ab",
|
||||
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
|
||||
"shareWithGuestsPublic": "Teilen Sie diesen Link mit Gästen. Für diese Galerie ist kein Passwort erforderlich.",
|
||||
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
|
||||
"resendCreationEmail": "Erstellungs-E-Mail erneut senden",
|
||||
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
|
||||
@@ -628,14 +633,18 @@
|
||||
"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.",
|
||||
"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",
|
||||
"securityAccess": "Sicherheit & Zugriff",
|
||||
"galleryPassword": "Galerie-Passwort",
|
||||
"requirePasswordToggle": "Galerie mit Passwort schützen",
|
||||
"requirePasswordToggleHelp": "Deaktivieren Sie diese Option, wenn die Galerie ohne Passwort geteilt werden soll. Jeder mit dem Link kann die Fotos ansehen.",
|
||||
"publicGalleryWarning": "Öffentliche Galerien sind für jeden mit dem Link zugänglich. Aktivieren Sie gegebenenfalls Wasserzeichen und behalten Sie die Aktivität im Blick.",
|
||||
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
|
||||
"passwordPlaceholder": "Sicheres Passwort eingeben",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"showPasswords": "Passwörter anzeigen",
|
||||
"newPasswordLabel": "Neues Galerie-Passwort",
|
||||
"gallerySettings": "Galerie-Einstellungen",
|
||||
"colorTheme": "Farbthema",
|
||||
"galleryExpiration": "Galerie-Ablauf",
|
||||
@@ -679,7 +688,7 @@
|
||||
"eventNamePlaceholder": "z.B. Max & Maria's Hochzeit",
|
||||
"welcomeMessageOptional": "Willkommensnachricht (Optional)",
|
||||
"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",
|
||||
"securityAndAccess": "Sicherheit & Zugriff",
|
||||
"accessAndSecurity": "Zugriff & Sicherheit",
|
||||
@@ -735,6 +744,9 @@
|
||||
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
|
||||
"eventsSelected": "{{count}} Veranstaltung ausgewählt",
|
||||
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
||||
"publicAccess": "Öffentlicher Zugriff",
|
||||
"passwordProtected": "Passwortgeschützt",
|
||||
"newPasswordRequired": "Bitte legen Sie vor dem Aktivieren des Passwortschutzes ein Passwort fest.",
|
||||
"viewDetails": "Details anzeigen",
|
||||
"archiveEventAction": "Veranstaltung archivieren",
|
||||
"downloadArchiveAction": "Archiv herunterladen",
|
||||
@@ -763,12 +775,16 @@
|
||||
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
|
||||
"maxFileSize": "Max. Dateigröße (MB)",
|
||||
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
|
||||
"maxFilesPerUpload": "Max. Dateien pro Upload",
|
||||
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
|
||||
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||
"featureToggles": "Funktionsschalter",
|
||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
||||
"enableAnalytics": "Analytics-Tracking aktivieren",
|
||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
||||
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
|
||||
"enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.",
|
||||
"maintenanceMode": "Wartungsmodus aktivieren",
|
||||
"language": "Sprache",
|
||||
"defaultLanguage": "Standardsprache",
|
||||
@@ -781,7 +797,18 @@
|
||||
"saveGeneralSettings": "Allgemeine Einstellungen speichern",
|
||||
"dateTimeFormat": "Datums- & Zeitformat",
|
||||
"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": {
|
||||
"tabLabel": "Öffentliche Seite",
|
||||
@@ -853,7 +880,6 @@
|
||||
"security": {
|
||||
"title": "Sicherheit",
|
||||
"passwordSettings": "Passworteinstellungen",
|
||||
"requirePassword": "Passwort für alle Galerien erforderlich",
|
||||
"minPasswordLength": "Minimale Passwortlänge",
|
||||
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
||||
"passwordComplexity": "Passwort-Komplexität",
|
||||
@@ -1341,8 +1367,8 @@
|
||||
},
|
||||
"validation": {
|
||||
"eventNameRequired": "Veranstaltungsname ist erforderlich",
|
||||
"hostEmailRequired": "Gastgeber-E-Mail ist erforderlich",
|
||||
"hostNameRequired": "Der Name des Gastgebers ist erforderlich",
|
||||
"hostEmailRequired": "Die E-Mail des Kunden ist erforderlich",
|
||||
"hostNameRequired": "Der Name des Kunden ist erforderlich",
|
||||
"adminEmailRequired": "Admin-E-Mail ist erforderlich",
|
||||
"invalidEmailFormat": "Ungültiges E-Mail-Format",
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"noCategory": "No category",
|
||||
"eventSpecific": "(Event specific)",
|
||||
"clickToUpload": "Click to upload or drag and drop",
|
||||
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file)",
|
||||
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)",
|
||||
"selectedFiles": "Selected files",
|
||||
"uploading": "Uploading...",
|
||||
"uploadComplete": "Upload complete!",
|
||||
@@ -59,9 +59,11 @@
|
||||
"externalImportInfo": "All pictures from the selected folder will be imported.",
|
||||
"selectExternalFolder": "Select external folder under /external-media",
|
||||
"importFromSelectedFolder": "Import from selected folder",
|
||||
"maxFilesReached": "Maximum 500 files allowed",
|
||||
"someFilesSkipped": "Some files were skipped (500 file limit)",
|
||||
"tooManyFiles": "Maximum 500 files can be uploaded at once",
|
||||
"maxFilesReached": "Maximum {{limit}} files allowed",
|
||||
"someFilesSkipped": "Only {{allowed}} more files can be added (limit {{limit}})",
|
||||
"tooManyFiles": "Maximum {{limit}} files can be uploaded at once",
|
||||
"limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)",
|
||||
"limitReached": "Upload limit reached ({{limit}} files per batch)",
|
||||
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..."
|
||||
},
|
||||
"navigation": {
|
||||
@@ -161,6 +163,8 @@
|
||||
"expiresIn": "Gallery expires in {{count}} day",
|
||||
"expiresIn_plural": "Gallery expires in {{count}} days",
|
||||
"downloadBefore": "Download your photos before they're no longer available.",
|
||||
"publicGalleryTitle": "This gallery is publicly accessible",
|
||||
"publicGallerySubtitle": "Loading the photos now...",
|
||||
"viewGallery": "View Gallery",
|
||||
"downloadAll": "Download All",
|
||||
"downloading": "Downloading {{count}} photo...",
|
||||
@@ -223,7 +227,7 @@
|
||||
"eventNamePlaceholder": "e.g., John & Jane's Wedding",
|
||||
"welcomeMessageOptional": "Welcome Message (Optional)",
|
||||
"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",
|
||||
"securityAndAccess": "Security & Access",
|
||||
"accessAndSecurity": "Access & Security",
|
||||
@@ -249,8 +253,8 @@
|
||||
"eventName": "Event Name",
|
||||
"eventType": "Event Type",
|
||||
"eventDate": "Event Date",
|
||||
"hostEmail": "Host Email",
|
||||
"hostName": "Host Name",
|
||||
"hostEmail": "Customer Email",
|
||||
"hostName": "Customer Name",
|
||||
"hostNamePlaceholder": "John Smith",
|
||||
"adminEmail": "Admin Email",
|
||||
"adminNotificationEmail": "Admin Notification Email",
|
||||
@@ -273,7 +277,7 @@
|
||||
"eventExpired": "This event has expired",
|
||||
"eventExpiresIn": "This event expires in {{days}} days",
|
||||
"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",
|
||||
"photos": "Photos",
|
||||
"categories": "Categories",
|
||||
@@ -290,6 +294,7 @@
|
||||
"created": "Created",
|
||||
"expires": "Expires",
|
||||
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
|
||||
"shareWithGuestsPublic": "Share this link with guests. No password is required for this gallery.",
|
||||
"resetGalleryPassword": "Reset Gallery Password",
|
||||
"resendCreationEmail": "Resend Creation Email",
|
||||
"creationEmailResent": "Creation email has been queued for sending",
|
||||
@@ -312,13 +317,17 @@
|
||||
"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.",
|
||||
"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",
|
||||
"securityAccess": "Security & Access",
|
||||
"galleryPassword": "Gallery Password",
|
||||
"requirePasswordToggle": "Require password for this gallery",
|
||||
"requirePasswordToggleHelp": "Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.",
|
||||
"publicGalleryWarning": "Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.",
|
||||
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"showPasswords": "Show passwords",
|
||||
"newPasswordLabel": "New Gallery Password",
|
||||
"gallerySettings": "Gallery Settings",
|
||||
"themeAndStyle": "Theme & Style",
|
||||
"colorTheme": "Color Theme",
|
||||
@@ -373,6 +382,9 @@
|
||||
"eventsSelected_plural": "{{count}} events selected",
|
||||
"clear": "Clear",
|
||||
"archiveSelected": "Archive Selected",
|
||||
"publicAccess": "Public access",
|
||||
"passwordProtected": "Password protected",
|
||||
"newPasswordRequired": "Please set a password before enabling protection.",
|
||||
"event": "Event",
|
||||
"type": "Type",
|
||||
"date": "Date",
|
||||
@@ -396,13 +408,13 @@
|
||||
"tryAgain": "Try Again",
|
||||
"eventExpiredMessage": "This event has expired",
|
||||
"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",
|
||||
"overview": "Overview",
|
||||
"eventInformation": "Event Information",
|
||||
"welcomeMessageLabel": "Welcome Message",
|
||||
"noWelcomeMessageSet": "No welcome message set",
|
||||
"hostEmail": "Host Email",
|
||||
"hostEmail": "Customer Email",
|
||||
"adminEmail": "Admin Email",
|
||||
"createdOn": "Created",
|
||||
"expires": "Expires",
|
||||
@@ -443,12 +455,16 @@
|
||||
"defaultExpirationHelp": "How long galleries remain active by default",
|
||||
"maxFileSize": "Max File Size (MB)",
|
||||
"maxFileSizeHelp": "Maximum size per uploaded photo",
|
||||
"maxFilesPerUpload": "Max Files per Upload",
|
||||
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
|
||||
"allowedFileTypes": "Allowed File Types",
|
||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||
"featureToggles": "Feature Toggles",
|
||||
"enableWatermark": "Enable watermark on photos",
|
||||
"enableAnalytics": "Enable analytics tracking",
|
||||
"enableRegistration": "Allow self-registration for admins",
|
||||
"enableShortGalleryUrls": "Use short gallery URLs",
|
||||
"enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.",
|
||||
"maintenanceMode": "Enable maintenance mode",
|
||||
"language": "Language",
|
||||
"defaultLanguage": "Default Language",
|
||||
@@ -461,7 +477,18 @@
|
||||
"saveGeneralSettings": "Save General Settings",
|
||||
"dateTimeFormat": "Date & Time 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": {
|
||||
"tabLabel": "Public Site",
|
||||
@@ -533,7 +560,6 @@
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"passwordSettings": "Password Settings",
|
||||
"requirePassword": "Require password for all galleries",
|
||||
"minPasswordLength": "Minimum Password Length",
|
||||
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
||||
"passwordComplexity": "Password Complexity",
|
||||
@@ -946,8 +972,8 @@
|
||||
},
|
||||
"validation": {
|
||||
"eventNameRequired": "Event name is required",
|
||||
"hostEmailRequired": "Host email is required",
|
||||
"hostNameRequired": "Host name is required",
|
||||
"hostEmailRequired": "Customer email is required",
|
||||
"hostNameRequired": "Customer name is required",
|
||||
"adminEmailRequired": "Admin email is required",
|
||||
"invalidEmailFormat": "Invalid email format",
|
||||
"passwordRequired": "Password is required",
|
||||
|
||||
@@ -11,12 +11,14 @@ import { useGalleryAuth, useTheme } from '../contexts';
|
||||
import { useGalleryInfo } from '../hooks/useGallery';
|
||||
import { GalleryView } from '../components/gallery';
|
||||
import { analyticsService } from '../services/analytics.service';
|
||||
import { galleryService } from '../services';
|
||||
import { api } from '../config/api';
|
||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||
import { buildResourceUrl } from '../utils/url';
|
||||
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
export const GalleryPage: React.FC = () => {
|
||||
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||
const { slug: rawSlug, token: rawToken } = useParams<{ slug: string; token?: string }>();
|
||||
const { isAuthenticated, login, event } = useGalleryAuth();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
@@ -25,9 +27,83 @@ export const GalleryPage: React.FC = () => {
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
|
||||
const [resolvedSlug, setResolvedSlug] = useState<string | null>(() => {
|
||||
if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) {
|
||||
return null;
|
||||
}
|
||||
return rawSlug || null;
|
||||
});
|
||||
const [resolvedToken, setResolvedToken] = useState<string | undefined>(rawToken);
|
||||
const [isResolvingIdentifier, setIsResolvingIdentifier] = useState<boolean>(() =>
|
||||
Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug))
|
||||
);
|
||||
const [identifierError, setIdentifierError] = useState<string | null>(null);
|
||||
const lastResolvedIdentifier = React.useRef<string | null>(null);
|
||||
|
||||
// Fetch gallery info (public data)
|
||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const looksLikeToken = Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug));
|
||||
|
||||
if (!rawSlug) {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setResolvedSlug(null);
|
||||
setResolvedToken(rawToken);
|
||||
setIsResolvingIdentifier(false);
|
||||
setIdentifierError(null);
|
||||
} else if (!looksLikeToken) {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setResolvedSlug(rawSlug);
|
||||
setResolvedToken(rawToken);
|
||||
setIsResolvingIdentifier(false);
|
||||
setIdentifierError(null);
|
||||
} else if (lastResolvedIdentifier.current !== rawSlug) {
|
||||
setIsResolvingIdentifier(true);
|
||||
setIdentifierError(null);
|
||||
|
||||
galleryService.resolveIdentifier(rawSlug)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = rawSlug;
|
||||
setResolvedSlug(data.slug);
|
||||
setResolvedToken(data.token);
|
||||
setIdentifierError(null);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = rawSlug;
|
||||
setResolvedSlug(null);
|
||||
setResolvedToken(undefined);
|
||||
const message = error?.response?.data?.error || 'Unable to resolve gallery link';
|
||||
setIdentifierError(message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsResolvingIdentifier(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setIsResolvingIdentifier(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [rawSlug, rawToken]);
|
||||
|
||||
const canFetchGalleryInfo = Boolean(resolvedSlug) && !isResolvingIdentifier;
|
||||
const {
|
||||
data: galleryInfo,
|
||||
isLoading: isLoadingInfoQuery,
|
||||
error: infoError
|
||||
} = useGalleryInfo(canFetchGalleryInfo ? resolvedSlug ?? undefined : undefined, resolvedToken, canFetchGalleryInfo);
|
||||
const isLoadingInfo = isLoadingInfoQuery || isResolvingIdentifier;
|
||||
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setAutoLoginAttempted(false);
|
||||
}, [resolvedSlug]);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
@@ -87,6 +163,30 @@ export const GalleryPage: React.FC = () => {
|
||||
}
|
||||
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!resolvedSlug || isResolvingIdentifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
|
||||
setAutoLoginAttempted(true);
|
||||
setIsLoggingIn(true);
|
||||
login(resolvedSlug, '')
|
||||
.then(() => {
|
||||
setLoginError(null);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
const message = error?.response?.data?.error;
|
||||
if (message) {
|
||||
setLoginError(message);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoggingIn(false);
|
||||
});
|
||||
}
|
||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = galleryInfo
|
||||
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
||||
@@ -96,7 +196,7 @@ export const GalleryPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent any bubbling
|
||||
|
||||
if (!password.trim()) {
|
||||
if (requiresPassword && !password.trim()) {
|
||||
setLoginError(t('auth.pleaseEnterPassword'));
|
||||
return;
|
||||
}
|
||||
@@ -104,13 +204,19 @@ export const GalleryPage: React.FC = () => {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
await login(slug!, password, recaptchaToken);
|
||||
if (!resolvedSlug) {
|
||||
setLoginError(t('errors.galleryNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken);
|
||||
|
||||
// Track successful password entry
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
success: true
|
||||
});
|
||||
if (requiresPassword) {
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: resolvedSlug,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
const errorMessage = error.response?.data?.error || 'Invalid password';
|
||||
@@ -128,11 +234,13 @@ export const GalleryPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// Track failed password entry
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
success: false,
|
||||
statusCode
|
||||
});
|
||||
if (requiresPassword) {
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: resolvedSlug ?? rawSlug ?? 'unknown',
|
||||
success: false,
|
||||
statusCode
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the password field to allow retry
|
||||
// Do not clear the password
|
||||
@@ -152,6 +260,59 @@ export const GalleryPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (identifierError && !resolvedSlug && !isResolvingIdentifier) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{t('errors.galleryNotFound')}
|
||||
</h2>
|
||||
<p className="text-neutral-600">
|
||||
{identifierError}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-8 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-neutral-500">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (infoError) {
|
||||
// Check if it's an archived gallery error
|
||||
@@ -269,9 +430,11 @@ export const GalleryPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const gallerySlugForView = resolvedSlug ?? rawSlug ?? '';
|
||||
|
||||
// Show gallery view if authenticated
|
||||
if (isAuthenticated && event) {
|
||||
return <GalleryView slug={slug!} event={event} />;
|
||||
return <GalleryView slug={gallerySlugForView} event={event} />;
|
||||
}
|
||||
|
||||
// Show login form
|
||||
@@ -311,43 +474,61 @@ export const GalleryPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Card */}
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
label={t('auth.password')}
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={loginError || undefined}
|
||||
autoFocus
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
|
||||
<ReCaptcha
|
||||
onChange={setRecaptchaToken}
|
||||
onExpired={() => setRecaptchaToken(null)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full text-sm sm:text-base"
|
||||
isLoading={isLoggingIn}
|
||||
disabled={isLoggingIn}
|
||||
>
|
||||
{t('gallery.viewGallery')}
|
||||
</Button>
|
||||
</form>
|
||||
{requiresPassword ? (
|
||||
<>
|
||||
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
label={t('auth.password')}
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={loginError || undefined}
|
||||
autoFocus
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
|
||||
<ReCaptcha
|
||||
onChange={setRecaptchaToken}
|
||||
onExpired={() => setRecaptchaToken(null)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full text-sm sm:text-base"
|
||||
isLoading={isLoggingIn}
|
||||
disabled={isLoggingIn}
|
||||
>
|
||||
{t('gallery.viewGallery')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
|
||||
{t('auth.passwordHint')}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
|
||||
{t('auth.passwordHint')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center space-y-3">
|
||||
<h2 className="text-base sm:text-lg lg:text-xl font-semibold">
|
||||
{t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')}
|
||||
</p>
|
||||
<div className="flex justify-center py-4">
|
||||
<Loading size="sm" text={t('gallery.loading')} />
|
||||
</div>
|
||||
{loginError && (
|
||||
<p className="text-xs text-red-600">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -376,4 +557,4 @@ export const GalleryPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -68,6 +68,7 @@ export const AdminLoginPage: React.FC = () => {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
toast.dismiss();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
|
||||
@@ -25,8 +25,9 @@ interface FormData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_email: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
require_password: boolean;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
welcome_message: string;
|
||||
@@ -121,8 +122,9 @@ export const CreateEventPage: React.FC = () => {
|
||||
event_type: 'wedding',
|
||||
event_name: '',
|
||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
host_email: '',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
require_password: true,
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
welcome_message: '',
|
||||
@@ -196,10 +198,10 @@ export const CreateEventPage: React.FC = () => {
|
||||
newErrors.event_name = t('validation.eventNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_email) {
|
||||
newErrors.host_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
|
||||
newErrors.host_email = t('validation.invalidEmailFormat');
|
||||
if (!formData.customer_email) {
|
||||
newErrors.customer_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
||||
newErrors.customer_email = t('validation.invalidEmailFormat');
|
||||
}
|
||||
|
||||
if (!formData.admin_email) {
|
||||
@@ -208,17 +210,18 @@ export const CreateEventPage: React.FC = () => {
|
||||
newErrors.admin_email = t('validation.invalidEmailFormat');
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t('validation.passwordMinLength');
|
||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||
// Prevent simple numeric passwords like "123456"
|
||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
if (formData.require_password) {
|
||||
if (!formData.password) {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t('validation.passwordMinLength');
|
||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
||||
@@ -238,19 +241,23 @@ export const CreateEventPage: React.FC = () => {
|
||||
|
||||
const selectedTheme = COLOR_THEMES.find(t => t.value === formData.color_theme);
|
||||
|
||||
createMutation.mutate({
|
||||
const payload = {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
host_email: formData.host_email,
|
||||
customer_name: formData.customer_email.split('@')[0],
|
||||
customer_email: formData.customer_email,
|
||||
admin_email: formData.admin_email,
|
||||
password: formData.password,
|
||||
require_password: formData.require_password,
|
||||
password: formData.require_password ? formData.password : undefined,
|
||||
welcome_message: formData.welcome_message || '',
|
||||
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
|
||||
expiration_days: formData.expires_in_days,
|
||||
allow_user_uploads: formData.allow_user_uploads,
|
||||
upload_category_id: formData.upload_category_id,
|
||||
});
|
||||
};
|
||||
|
||||
createMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof FormData) => (
|
||||
@@ -382,17 +389,17 @@ export const CreateEventPage: React.FC = () => {
|
||||
<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">
|
||||
{/* Host Email */}
|
||||
{/* Customer Email */}
|
||||
<div>
|
||||
<label htmlFor="host_email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
<label htmlFor="customer_email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.hostEmail')}
|
||||
</label>
|
||||
<Input
|
||||
id="host_email"
|
||||
id="customer_email"
|
||||
type="email"
|
||||
value={formData.host_email}
|
||||
onChange={handleInputChange('host_email')}
|
||||
error={errors.host_email}
|
||||
value={formData.customer_email}
|
||||
onChange={handleInputChange('customer_email')}
|
||||
error={errors.customer_email}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
@@ -426,81 +433,114 @@ export const CreateEventPage: React.FC = () => {
|
||||
<Card padding="md" className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.galleryPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
placeholder={t('events.enterPassword')}
|
||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
style={{ top: errors.password ? '0' : '0' }}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
checked={formData.require_password}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
require_password: checked,
|
||||
password: checked ? prev.password : '',
|
||||
confirm_password: checked ? prev.confirm_password : ''
|
||||
}));
|
||||
if (!checked) {
|
||||
setErrors(prev => ({ ...prev, password: '', confirm_password: '' }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Generator */}
|
||||
<div className="mt-2">
|
||||
<PasswordGenerator
|
||||
eventName={formData.event_name}
|
||||
eventDate={formData.event_date}
|
||||
eventType={formData.event_type}
|
||||
onPasswordGenerated={handlePasswordGenerated}
|
||||
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.confirm_password}
|
||||
onChange={handleInputChange('confirm_password')}
|
||||
error={errors.confirm_password}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
style={{ top: errors.confirm_password ? '0' : '0' }}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
{!formData.require_password && (
|
||||
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.require_password && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.galleryPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
placeholder={t('events.enterPassword')}
|
||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
style={{ top: errors.password ? '0' : '0' }}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<PasswordGenerator
|
||||
eventName={formData.event_name}
|
||||
eventDate={formData.event_date}
|
||||
eventType={formData.event_type}
|
||||
onPasswordGenerated={handlePasswordGenerated}
|
||||
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.confirm_password}
|
||||
onChange={handleInputChange('confirm_password')}
|
||||
error={errors.confirm_password}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
style={{ top: errors.confirm_password ? '0' : '0' }}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -645,4 +685,4 @@ export const CreateEventPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
CreateEventPage.displayName = 'CreateEventPage';
|
||||
CreateEventPage.displayName = 'CreateEventPage';
|
||||
|
||||
@@ -27,9 +27,10 @@ interface FormData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_name: string;
|
||||
host_email: string;
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
require_password: boolean;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
welcome_message: string;
|
||||
@@ -85,9 +86,10 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
event_type: 'wedding',
|
||||
event_name: '',
|
||||
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
|
||||
host_name: '',
|
||||
host_email: '',
|
||||
customer_name: '',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
require_password: true,
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
welcome_message: '',
|
||||
@@ -182,14 +184,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
newErrors.event_date = t('validation.eventDateRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_name) {
|
||||
newErrors.host_name = t('validation.hostNameRequired');
|
||||
if (!formData.customer_name) {
|
||||
newErrors.customer_name = t('validation.hostNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_email) {
|
||||
newErrors.host_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
|
||||
newErrors.host_email = t('validation.invalidEmailFormat');
|
||||
if (!formData.customer_email) {
|
||||
newErrors.customer_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
||||
newErrors.customer_email = t('validation.invalidEmailFormat');
|
||||
}
|
||||
|
||||
if (!formData.admin_email) {
|
||||
@@ -198,17 +200,19 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
newErrors.admin_email = t('validation.invalidEmailFormat');
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t('validation.passwordMinLength');
|
||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||
// Prevent simple numeric passwords like "123456"
|
||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
if (formData.require_password) {
|
||||
if (!formData.password) {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t('validation.passwordMinLength');
|
||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||
// Prevent simple numeric passwords like "123456"
|
||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
||||
@@ -232,10 +236,11 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
host_name: formData.host_name,
|
||||
host_email: formData.host_email,
|
||||
customer_name: formData.customer_name,
|
||||
customer_email: formData.customer_email,
|
||||
admin_email: formData.admin_email,
|
||||
password: formData.password,
|
||||
require_password: formData.require_password,
|
||||
password: formData.require_password ? formData.password : undefined,
|
||||
welcome_message: formData.welcome_message || '',
|
||||
color_theme: JSON.stringify(formData.theme_config),
|
||||
expiration_days: formData.expires_in_days,
|
||||
@@ -467,9 +472,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
<Input
|
||||
label={t('events.hostName')}
|
||||
placeholder={t('events.hostNamePlaceholder')}
|
||||
value={formData.host_name}
|
||||
onChange={handleInputChange('host_name')}
|
||||
error={errors.host_name}
|
||||
value={formData.customer_name}
|
||||
onChange={handleInputChange('customer_name')}
|
||||
error={errors.customer_name}
|
||||
leftIcon={<Calendar className="w-5 h-5" />}
|
||||
/>
|
||||
|
||||
@@ -477,9 +482,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
type="email"
|
||||
label={t('events.hostEmail')}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
value={formData.host_email}
|
||||
onChange={handleInputChange('host_email')}
|
||||
error={errors.host_email}
|
||||
value={formData.customer_email}
|
||||
onChange={handleInputChange('customer_email')}
|
||||
error={errors.customer_email}
|
||||
leftIcon={<Mail className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
@@ -495,51 +500,89 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
checked={formData.require_password}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
require_password: checked,
|
||||
password: checked ? prev.password : '',
|
||||
confirm_password: checked ? prev.confirm_password : '',
|
||||
}));
|
||||
if (!checked) {
|
||||
setErrors(prev => ({ ...prev, password: undefined, confirm_password: undefined }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{t('events.requirePasswordToggle')}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!formData.require_password && (
|
||||
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.require_password && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label={t('events.galleryPassword')}
|
||||
placeholder={t('events.passwordPlaceholder')}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="p-1"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Password Generator */}
|
||||
<div className="mt-2">
|
||||
<PasswordGenerator
|
||||
eventName={formData.event_name}
|
||||
eventDate={formData.event_date}
|
||||
eventType={formData.event_type}
|
||||
onPasswordGenerated={handlePasswordGenerated}
|
||||
passwordComplexity="moderate"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label={t('events.galleryPassword')}
|
||||
placeholder={t('events.passwordPlaceholder')}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||
label={t('events.confirmPassword')}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
value={formData.confirm_password}
|
||||
onChange={handleInputChange('confirm_password')}
|
||||
error={errors.confirm_password}
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="p-1"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Password Generator */}
|
||||
<div className="mt-2">
|
||||
<PasswordGenerator
|
||||
eventName={formData.event_name}
|
||||
eventDate={formData.event_date}
|
||||
eventType={formData.event_type}
|
||||
onPasswordGenerated={handlePasswordGenerated}
|
||||
passwordComplexity="moderate"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label={t('events.confirmPassword')}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
value={formData.confirm_password}
|
||||
onChange={handleInputChange('confirm_password')}
|
||||
error={errors.confirm_password}
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
Image,
|
||||
Key,
|
||||
Mail,
|
||||
MessageSquare
|
||||
MessageSquare,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -27,6 +30,7 @@ import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { externalMediaService } from '../../services/externalMedia.service';
|
||||
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
|
||||
@@ -118,9 +122,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_user_uploads: boolean;
|
||||
upload_category_id: number | null;
|
||||
hero_photo_id: number | null;
|
||||
host_name: string;
|
||||
customer_name: string;
|
||||
source_mode: 'managed' | 'reference';
|
||||
external_path: string;
|
||||
require_password: boolean;
|
||||
new_password: string;
|
||||
confirm_new_password: string;
|
||||
};
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -131,9 +138,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_user_uploads: false,
|
||||
upload_category_id: null,
|
||||
hero_photo_id: null,
|
||||
host_name: '',
|
||||
customer_name: '',
|
||||
source_mode: 'managed',
|
||||
external_path: '',
|
||||
require_password: true,
|
||||
new_password: '',
|
||||
confirm_new_password: '',
|
||||
});
|
||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||
feedback_enabled: false,
|
||||
@@ -156,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [importing, setImporting] = useState<boolean>(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
|
||||
@@ -271,10 +282,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_user_uploads: event.allow_user_uploads || false,
|
||||
upload_category_id: event.upload_category_id || null,
|
||||
hero_photo_id: event.hero_photo_id || null,
|
||||
host_name: event.host_name || '',
|
||||
customer_name: event.customer_name || '',
|
||||
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
||||
external_path: event.external_path || '',
|
||||
require_password: normalizeRequirePassword(event.require_password),
|
||||
new_password: '',
|
||||
confirm_new_password: '',
|
||||
});
|
||||
|
||||
setShowNewPassword(false);
|
||||
|
||||
// Set feedback settings if available
|
||||
if (eventFeedbackSettings) {
|
||||
@@ -324,6 +340,26 @@ export const EventDetailsPage: React.FC = () => {
|
||||
|
||||
const externalPathToSave = editForm.external_path?.trim() || '';
|
||||
|
||||
const currentRequirePassword = normalizeRequirePassword(event.require_password);
|
||||
const requirePasswordChanged = editForm.require_password !== currentRequirePassword;
|
||||
|
||||
if (editForm.require_password) {
|
||||
if (requirePasswordChanged && !editForm.new_password) {
|
||||
toast.error(t('events.newPasswordRequired', 'Please set a password before enabling protection.'));
|
||||
return;
|
||||
}
|
||||
if (editForm.new_password) {
|
||||
if (editForm.new_password.length < 6) {
|
||||
toast.error(t('validation.passwordMinLength'));
|
||||
return;
|
||||
}
|
||||
if (editForm.new_password !== editForm.confirm_new_password) {
|
||||
toast.error(t('validation.passwordsDoNotMatch'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editForm.source_mode === 'reference' && !externalPathToSave) {
|
||||
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
|
||||
return;
|
||||
@@ -333,6 +369,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const updateData: any = {
|
||||
expires_at: editForm.expires_at,
|
||||
allow_user_uploads: editForm.allow_user_uploads,
|
||||
require_password: editForm.require_password,
|
||||
};
|
||||
|
||||
// Only include fields that have defined values
|
||||
@@ -352,8 +389,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
updateData.external_path = editForm.source_mode === 'reference'
|
||||
? externalPathToSave
|
||||
: null;
|
||||
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
||||
updateData.host_name = editForm.host_name;
|
||||
if (editForm.customer_name !== undefined && editForm.customer_name !== null) {
|
||||
updateData.customer_name = editForm.customer_name;
|
||||
}
|
||||
|
||||
if (editForm.new_password) {
|
||||
updateData.password = editForm.new_password;
|
||||
}
|
||||
|
||||
// Remove any keys with undefined values
|
||||
@@ -437,6 +478,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
{format(parseISO(event.event_date), 'PPP')}
|
||||
</span>
|
||||
<span className="capitalize">{event.event_type}</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
isGalleryPublic(event.require_password)
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-neutral-100 text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
||||
</span>
|
||||
{event.is_archived ? (
|
||||
<span className="text-neutral-500 flex items-center">
|
||||
<Archive className="w-4 h-4 mr-1" />
|
||||
@@ -615,8 +665,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={editForm.host_name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, host_name: e.target.value }))}
|
||||
value={editForm.customer_name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, customer_name: e.target.value }))}
|
||||
placeholder={t('events.hostNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@@ -641,6 +691,84 @@ export const EventDetailsPage: React.FC = () => {
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
checked={editForm.require_password}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setEditForm(prev => ({
|
||||
...prev,
|
||||
require_password: checked,
|
||||
new_password: checked ? prev.new_password : '',
|
||||
confirm_new_password: checked ? prev.confirm_new_password : '',
|
||||
}));
|
||||
if (!checked) {
|
||||
setShowNewPassword(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!editForm.require_password && (
|
||||
<div className="mt-2 rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editForm.require_password && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.newPasswordLabel', 'New gallery password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={editForm.new_password}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, new_password: e.target.value }))}
|
||||
placeholder={t('events.enterPassword')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.confirmPassword')}
|
||||
</label>
|
||||
<Input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={editForm.confirm_new_password}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, confirm_new_password: e.target.value }))}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.sourceMode', 'Source Mode')}
|
||||
@@ -753,14 +881,14 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{event.host_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
||||
{event.customer_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">{event.host_email}</dd>
|
||||
<dd className="mt-1 text-sm text-neutral-900">{event.customer_email}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -848,7 +976,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600 mt-2">
|
||||
{t('events.shareWithGuests')}
|
||||
{isGalleryPublic(event.require_password)
|
||||
? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.')
|
||||
: t('events.shareWithGuests')}
|
||||
</p>
|
||||
|
||||
{!event.is_archived && (
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../compone
|
||||
import { BulkArchiveModal } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { isGalleryPublic } from '../../utils/accessControl';
|
||||
import type { Event } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -158,7 +159,7 @@ export const EventsListPage: React.FC = () => {
|
||||
events = events.filter(e =>
|
||||
e.event_name.toLowerCase().includes(term) ||
|
||||
e.event_type.toLowerCase().includes(term) ||
|
||||
e.host_email.toLowerCase().includes(term)
|
||||
(e.customer_email || '').toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -427,7 +428,18 @@ export const EventsListPage: React.FC = () => {
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
|
||||
<p className="text-xs text-neutral-500">{event.host_email}</p>
|
||||
<p className="text-xs text-neutral-500">{event.customer_email}</p>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||
isGalleryPublic(event.require_password)
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-neutral-100 text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Save,
|
||||
import {
|
||||
Save,
|
||||
Database,
|
||||
Globe,
|
||||
Key,
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
HardDrive,
|
||||
Activity
|
||||
Activity,
|
||||
Mail,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -19,9 +21,12 @@ import { CategoryManager } from '../../components/admin/CategoryManager';
|
||||
import { WordFilterManager } from '../../components/admin/WordFilterManager';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
|
||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||
|
||||
const toBoolean = (value: unknown, defaultValue = false): boolean => {
|
||||
if (value === undefined || value === null) {
|
||||
@@ -56,6 +61,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
|
||||
const queryClient = useQueryClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { updateUserProfile } = useAdminAuth();
|
||||
|
||||
// Fetch settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
@@ -63,6 +69,11 @@ export const SettingsPage: React.FC = () => {
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const { data: adminProfile, isLoading: adminProfileLoading } = useQuery({
|
||||
queryKey: ['admin-profile'],
|
||||
queryFn: () => adminService.getAdminProfile(),
|
||||
});
|
||||
|
||||
// Fetch storage info
|
||||
const { data: storageInfo } = useQuery({
|
||||
queryKey: ['admin-storage-info'],
|
||||
@@ -83,11 +94,13 @@ export const SettingsPage: React.FC = () => {
|
||||
site_url: '',
|
||||
default_expiration_days: 30,
|
||||
max_file_size_mb: 50,
|
||||
max_files_per_upload: 500,
|
||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||
enable_watermark: false,
|
||||
enable_analytics: true,
|
||||
enable_registration: false,
|
||||
maintenance_mode: false,
|
||||
short_gallery_urls: false,
|
||||
default_language: 'en',
|
||||
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||
});
|
||||
@@ -117,6 +130,11 @@ export const SettingsPage: React.FC = () => {
|
||||
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
|
||||
const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>('');
|
||||
const [overrideDirty, setOverrideDirty] = useState(false);
|
||||
const [accountForm, setAccountForm] = useState({
|
||||
username: '',
|
||||
email: ''
|
||||
});
|
||||
const [accountErrors, setAccountErrors] = useState<Record<string, string>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings) {
|
||||
@@ -130,11 +148,16 @@ export const SettingsPage: React.FC = () => {
|
||||
site_url: settings.general_site_url || '',
|
||||
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
||||
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
|
||||
max_files_per_upload: Math.min(
|
||||
MAX_FILES_PER_UPLOAD_LIMIT,
|
||||
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||
),
|
||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
||||
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
|
||||
default_language: settings.general_default_language || 'en',
|
||||
date_format: settings.general_date_format
|
||||
? (typeof settings.general_date_format === 'string'
|
||||
@@ -165,6 +188,15 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [settings, i18n]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (adminProfile) {
|
||||
setAccountForm({
|
||||
username: adminProfile.username || '',
|
||||
email: adminProfile.email || ''
|
||||
});
|
||||
}
|
||||
}, [adminProfile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!settings || overrideDirty) {
|
||||
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({
|
||||
mutationFn: async (limitBytes: number | null) => {
|
||||
return settingsService.updateSettings({
|
||||
@@ -466,6 +575,64 @@ export const SettingsPage: React.FC = () => {
|
||||
{/* General Settings Tab */}
|
||||
{activeTab === 'general' && (
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.defaultExpiration')}
|
||||
@@ -511,6 +678,29 @@ export const SettingsPage: React.FC = () => {
|
||||
max="500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.maxFilesPerUpload')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={generalSettings.max_files_per_upload}
|
||||
onChange={(e) => {
|
||||
const parsed = parseInt(e.target.value, 10);
|
||||
setGeneralSettings(prev => ({
|
||||
...prev,
|
||||
max_files_per_upload: Number.isFinite(parsed)
|
||||
? Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, parsed))
|
||||
: prev.max_files_per_upload
|
||||
}));
|
||||
}}
|
||||
min="1"
|
||||
max={MAX_FILES_PER_UPLOAD_LIMIT}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -573,6 +763,21 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={generalSettings.short_gallery_urls}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableShortGalleryUrls')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 ml-6 mt-1">
|
||||
{t('settings.general.enableShortGalleryUrlsHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -47,6 +47,17 @@ export interface Activity {
|
||||
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 {
|
||||
chartData: Array<{
|
||||
date: string;
|
||||
@@ -130,5 +141,15 @@ export const adminService = {
|
||||
// Change password
|
||||
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { api } from '../config/api';
|
||||
import type { LoginResponse, GalleryAuthResponse } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
|
||||
...response,
|
||||
event: response.event
|
||||
? {
|
||||
...response.event,
|
||||
require_password: normalizeRequirePassword((response.event as any)?.require_password, true),
|
||||
}
|
||||
: response.event,
|
||||
});
|
||||
|
||||
export const authService = {
|
||||
// Admin authentication
|
||||
@@ -24,7 +35,7 @@ export const authService = {
|
||||
},
|
||||
|
||||
// Gallery authentication
|
||||
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
||||
async verifyGalleryPassword(slug: string, password?: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
||||
const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
|
||||
slug,
|
||||
password,
|
||||
@@ -32,7 +43,7 @@ export const authService = {
|
||||
});
|
||||
|
||||
// Token is now handled by GalleryAuthContext with slug-specific storage
|
||||
return response.data;
|
||||
return normalizeGalleryResponse(response.data);
|
||||
},
|
||||
|
||||
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
|
||||
@@ -40,7 +51,7 @@ export const authService = {
|
||||
slug,
|
||||
token,
|
||||
});
|
||||
return response.data;
|
||||
return normalizeGalleryResponse(response.data);
|
||||
},
|
||||
|
||||
async galleryLogout(slug?: string | null) {
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { api } from '../config/api';
|
||||
import type { Event } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
const normalizeEvent = (event: Event): Event => {
|
||||
const legacyHostName = (event as any)?.host_name;
|
||||
const legacyHostEmail = (event as any)?.host_email;
|
||||
|
||||
const customerName = event.customer_name ?? legacyHostName ?? undefined;
|
||||
const customerEmail = event.customer_email ?? legacyHostEmail ?? '';
|
||||
|
||||
return {
|
||||
...event,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: normalizeRequirePassword((event as any)?.require_password, true),
|
||||
};
|
||||
};
|
||||
|
||||
interface CreateEventData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_email: string;
|
||||
customer_name?: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
password: string;
|
||||
require_password?: boolean;
|
||||
password?: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expiration_days: number;
|
||||
@@ -26,8 +44,10 @@ interface CreateEventData {
|
||||
interface UpdateEventData {
|
||||
event_name?: string;
|
||||
event_date?: string;
|
||||
host_email?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
admin_email?: string;
|
||||
require_password?: boolean;
|
||||
password?: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
@@ -64,19 +84,25 @@ export const eventsService = {
|
||||
}
|
||||
|
||||
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
||||
return response.data;
|
||||
const data: any = response.data;
|
||||
if (Array.isArray(data?.events)) {
|
||||
data.events = data.events.map((event: Event) => normalizeEvent(event));
|
||||
} else if (Array.isArray(data)) {
|
||||
return data.map((event: Event) => normalizeEvent(event)) as any;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
// Get single event details (admin)
|
||||
async getEvent(id: number): Promise<Event> {
|
||||
const response = await api.get<Event>(`/admin/events/${id}`);
|
||||
return response.data;
|
||||
return normalizeEvent(response.data as Event);
|
||||
},
|
||||
|
||||
// Create new event (admin)
|
||||
async createEvent(data: CreateEventData): Promise<Event> {
|
||||
const response = await api.post<Event>('/admin/events', data);
|
||||
return response.data;
|
||||
return normalizeEvent(response.data as Event);
|
||||
},
|
||||
|
||||
// Update event (admin)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../config/api';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
export const galleryService = {
|
||||
// Verify share token
|
||||
@@ -12,7 +13,11 @@ export const galleryService = {
|
||||
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
|
||||
const params = token ? { token } : {};
|
||||
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params });
|
||||
return response.data;
|
||||
const data = response.data;
|
||||
return {
|
||||
...data,
|
||||
requires_password: normalizeRequirePassword((data as any)?.requires_password, true),
|
||||
};
|
||||
},
|
||||
|
||||
// Get gallery photos (requires auth)
|
||||
@@ -29,7 +34,17 @@ export const galleryService = {
|
||||
}
|
||||
}
|
||||
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
|
||||
return response.data;
|
||||
const data = response.data;
|
||||
const normalizedEvent = data?.event
|
||||
? {
|
||||
...data.event,
|
||||
require_password: normalizeRequirePassword((data.event as any)?.require_password, true),
|
||||
}
|
||||
: data.event;
|
||||
return {
|
||||
...data,
|
||||
event: normalizedEvent,
|
||||
};
|
||||
},
|
||||
|
||||
// Download single photo
|
||||
@@ -104,4 +119,9 @@ export const galleryService = {
|
||||
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async resolveIdentifier(identifier: string): Promise<ResolvedGalleryIdentifier> {
|
||||
const response = await api.get<ResolvedGalleryIdentifier>(`/gallery/resolve/${identifier}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ export interface Event {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_name?: string;
|
||||
host_email: string;
|
||||
customer_name?: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
@@ -17,6 +17,7 @@ export interface Event {
|
||||
is_archived: boolean;
|
||||
archive_path?: string;
|
||||
archived_at?: string;
|
||||
require_password?: boolean;
|
||||
photo_count?: number;
|
||||
total_size?: number;
|
||||
recent_photos?: Array<{
|
||||
@@ -92,6 +93,7 @@ export interface GalleryData {
|
||||
disable_right_click?: boolean;
|
||||
watermark_downloads?: boolean;
|
||||
watermark_text?: string;
|
||||
require_password?: boolean;
|
||||
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
image_quality?: number;
|
||||
use_canvas_rendering?: boolean;
|
||||
@@ -109,6 +111,17 @@ export interface GalleryStats {
|
||||
unique_visitors: number;
|
||||
}
|
||||
|
||||
export interface ResolvedGalleryIdentifier {
|
||||
slug: string;
|
||||
token: string;
|
||||
matchType: string;
|
||||
share_link: string;
|
||||
share_path: string;
|
||||
share_url: string;
|
||||
short_enabled: boolean;
|
||||
requires_password: boolean;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
@@ -134,6 +147,7 @@ export interface GalleryAuthResponse {
|
||||
expires_at: string;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
require_password?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const normalizeRequirePassword = (value: unknown, defaultValue = true): boolean => {
|
||||
if (value === undefined || value === null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value !== 0;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'false' || normalized === '0' || normalized === 'no' || normalized === 'off') {
|
||||
return false;
|
||||
}
|
||||
if (normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
export const isGalleryPublic = (value: unknown, defaultValue = true): boolean => {
|
||||
return !normalizeRequirePassword(value, defaultValue);
|
||||
};
|
||||
@@ -24,4 +24,19 @@ export const cleanupOldGalleryAuth = () => {
|
||||
sessionStorage.removeItem('gallery_event');
|
||||
sessionStorage.removeItem('gallery_token');
|
||||
sessionStorage.removeItem('gallery_active_slug');
|
||||
|
||||
// Remove slug-specific session storage entries as well
|
||||
try {
|
||||
const sessionKeysToRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i += 1) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key && (key.startsWith('gallery_event_') || key.startsWith('gallery_token_'))) {
|
||||
sessionKeysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
|
||||
} catch {
|
||||
// Session storage may be unavailable; ignore cleanup failures
|
||||
}
|
||||
};
|
||||
|
||||
Generated
+6
-5
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "wedding-photo-sharing",
|
||||
"name": "picpeak",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -558,7 +558,8 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz",
|
||||
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
@@ -1499,9 +1500,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"devDependencies": {
|
||||
"puppeteer": "^24.17.0",
|
||||
"@playwright/test": "^1.48.2"
|
||||
},
|
||||
"overrides": {
|
||||
"prebuild-install": {
|
||||
"tar-fs": "2.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 800 },
|
||||
ignoreHTTPSErrors: true,
|
||||
@@ -15,4 +15,3 @@ export default defineConfig({
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
################################################################################
|
||||
# PicPeak Unified Setup Script
|
||||
# Version: 2.0.0
|
||||
# Version: 2.1.0
|
||||
# Description: Universal installer for PicPeak with Docker and Native options
|
||||
# Supports: Ubuntu, Debian, Fedora, RHEL/CentOS, Raspberry Pi OS
|
||||
################################################################################
|
||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# Script configuration
|
||||
readonly SCRIPT_VERSION="2.0.0"
|
||||
readonly SCRIPT_VERSION="2.1.0"
|
||||
readonly APP_NAME="PicPeak"
|
||||
readonly REPO_URL="https://github.com/the-luap/picpeak.git"
|
||||
readonly NODE_VERSION="20"
|
||||
@@ -64,17 +64,19 @@ FORCE_ADMIN_PASSWORD_RESET=false
|
||||
# Run a command as the application user, even if sudo is not available
|
||||
run_as_user() {
|
||||
local cmd="$*"
|
||||
local current_dir_escaped
|
||||
current_dir_escaped=$(printf '%q' "$(pwd)")
|
||||
if [[ "$(id -u)" -ne 0 ]]; then
|
||||
# Already non-root; just run
|
||||
bash -lc "$cmd"
|
||||
# Already non-root; preserve working directory
|
||||
bash -lc "cd $current_dir_escaped && $cmd"
|
||||
return $?
|
||||
fi
|
||||
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
|
||||
runuser -u "$NATIVE_APP_USER" -- bash -lc "$cmd"
|
||||
runuser -u "$NATIVE_APP_USER" -- bash -lc "cd $current_dir_escaped && $cmd"
|
||||
else
|
||||
su -s /bin/bash - "$NATIVE_APP_USER" -c "$cmd"
|
||||
su -s /bin/bash - "$NATIVE_APP_USER" -c "cd $current_dir_escaped && $cmd"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -391,6 +393,29 @@ setup_docker_installation() {
|
||||
if [[ -d "$app_dir/.git" ]]; then
|
||||
cd "$app_dir"
|
||||
git pull
|
||||
elif [[ -d "$app_dir" ]]; then
|
||||
if [[ -z "$(ls -A "$app_dir" 2>/dev/null)" ]]; then
|
||||
log_warn "Existing directory $app_dir is empty but not a git repository; recreating it..."
|
||||
rm -rf "$app_dir"
|
||||
git clone "$REPO_URL" "$app_dir"
|
||||
else
|
||||
log_warn "Directory $app_dir already exists and is not a git repository."
|
||||
if [[ "$UNATTENDED" == "true" ]]; then
|
||||
local backup_dir="${app_dir}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||
log_warn "Unattended mode: backing up directory to $backup_dir and cloning a fresh copy."
|
||||
mv "$app_dir" "$backup_dir"
|
||||
git clone "$REPO_URL" "$app_dir"
|
||||
else
|
||||
if confirm "Replace existing directory $app_dir with a fresh clone? This will move the current contents to a backup folder." "y"; then
|
||||
local backup_dir="${app_dir}.backup-$(date +%Y%m%d-%H%M%S)"
|
||||
mv "$app_dir" "$backup_dir"
|
||||
log_step "Existing directory moved to $backup_dir"
|
||||
git clone "$REPO_URL" "$app_dir"
|
||||
else
|
||||
die "Installation aborted because $app_dir already exists and is not a PicPeak git repository."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
git clone "$REPO_URL" "$app_dir"
|
||||
fi
|
||||
@@ -629,7 +654,14 @@ setup_native_installation() {
|
||||
apt)
|
||||
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 install -y python3
|
||||
;;
|
||||
@@ -953,15 +985,17 @@ configure_email() {
|
||||
}
|
||||
|
||||
print_success_message() {
|
||||
local app_dir port
|
||||
local app_dir port manual_reset_hint
|
||||
|
||||
if [[ "$INSTALL_METHOD" == "docker" ]]; then
|
||||
app_dir="$DOCKER_APP_DIR"
|
||||
[[ -n "${SUDO_USER:-}" ]] && app_dir="/home/$SUDO_USER/picpeak"
|
||||
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
|
||||
app_dir="$NATIVE_APP_DIR"
|
||||
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
|
||||
|
||||
print_header "🎉 Installation Complete!"
|
||||
@@ -1006,7 +1040,7 @@ print_success_message() {
|
||||
fi
|
||||
else
|
||||
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
|
||||
echo
|
||||
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
|
||||
test('admin can update account email via settings page', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip('Account settings UI is validated on desktop viewport');
|
||||
}
|
||||
|
||||
const newEmail = `admin+playwright-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto('/admin/login');
|
||||
await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
|
||||
await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
|
||||
await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
|
||||
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await page.goto('/admin/settings');
|
||||
const emailInput = page.getByLabel(/Admin (Email|E-Mail)/i);
|
||||
const usernameInput = page.getByLabel(/Admin (Username|Benutzername)/i);
|
||||
|
||||
await expect(emailInput).toBeVisible();
|
||||
const originalEmail = await emailInput.inputValue();
|
||||
const originalUsername = await usernameInput.inputValue();
|
||||
|
||||
const saveButton = page.getByRole('button', { name: /(Save account details|Kontodaten speichern)/i });
|
||||
|
||||
const revertChanges = async () => {
|
||||
await emailInput.fill(originalEmail);
|
||||
await usernameInput.fill(originalUsername);
|
||||
await saveButton.click();
|
||||
await expect(emailInput).toHaveValue(originalEmail, { timeout: 10000 });
|
||||
await expect(page.locator('.Toastify__toast').filter({ hasText: /(Account details updated|Kontodaten aktualisiert)/i })).toBeVisible({ timeout: 10000 });
|
||||
};
|
||||
|
||||
try {
|
||||
await emailInput.fill(newEmail);
|
||||
await saveButton.click();
|
||||
|
||||
await expect(emailInput).toHaveValue(newEmail, { timeout: 10000 });
|
||||
await expect(page.locator('.Toastify__toast').filter({ hasText: /(Account details updated|Kontodaten aktualisiert)/i })).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(newEmail, { exact: false })).toBeVisible();
|
||||
} finally {
|
||||
await revertChanges();
|
||||
}
|
||||
});
|
||||
@@ -29,9 +29,9 @@ test('admin can create event via UI', async ({ page }) => {
|
||||
await expect(page.getByRole('heading', { name: /^Create$/i })).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await 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(/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(/Gallery Password/i).fill('UiPlay123!');
|
||||
await page.getByLabel(/Confirm Password/i).fill('UiPlay123!');
|
||||
|
||||
+138
-24
@@ -6,23 +6,32 @@ const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
async function createEventWithPhotos(page: Page) {
|
||||
async function createEventWithPhotos(page: Page, adminToken?: string, attempt = 1) {
|
||||
const api = page.request;
|
||||
const loginResponse = await api.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const { token } = await loginResponse.json();
|
||||
expect(token).toBeTruthy();
|
||||
let token = adminToken;
|
||||
|
||||
if (!token) {
|
||||
const loginResponse = await api.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const loginData = await loginResponse.json();
|
||||
token = loginData.token;
|
||||
expect(token).toBeTruthy();
|
||||
}
|
||||
|
||||
const eventName = `Playwright Smoke ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Failed to acquire admin token');
|
||||
}
|
||||
|
||||
const eventResponse = await api.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
@@ -32,6 +41,8 @@ async function createEventWithPhotos(page: Page) {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: 'host@example.com',
|
||||
host_name: 'Playwright Host',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
@@ -43,7 +54,17 @@ async function createEventWithPhotos(page: Page) {
|
||||
watermark_downloads: false,
|
||||
},
|
||||
});
|
||||
expect(eventResponse.ok()).toBeTruthy();
|
||||
if (!eventResponse.ok()) {
|
||||
const message = await eventResponse.text();
|
||||
if (
|
||||
attempt < 3 &&
|
||||
/UNIQUE constraint failed: events\.slug/i.test(message || '')
|
||||
) {
|
||||
await page.waitForTimeout(150);
|
||||
return createEventWithPhotos(page, token, attempt + 1);
|
||||
}
|
||||
throw new Error(`Event creation failed: ${eventResponse.status()} ${message}`);
|
||||
}
|
||||
const event = await eventResponse.json();
|
||||
|
||||
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
|
||||
@@ -67,11 +88,83 @@ async function createEventWithPhotos(page: Page) {
|
||||
event,
|
||||
shareLink: event.share_link,
|
||||
slug: event.slug,
|
||||
adminToken: token,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateShortGallerySetting(page: Page, adminToken: string, enabled: boolean) {
|
||||
const response = await page.request.put('/api/admin/settings/general', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
general_short_gallery_urls: enabled,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openGalleryShareLink(page: Page, shareLink: string) {
|
||||
await page.context().clearCookies();
|
||||
await page.goto(shareLink);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
try {
|
||||
await page.getByText(/Enter Gallery Password/i).first().waitFor({ timeout: 5000 });
|
||||
} catch {
|
||||
// No password prompt shown (public gallery)
|
||||
}
|
||||
|
||||
let passwordEntered = false;
|
||||
const passwordTextbox = page.getByRole('textbox', { name: /password/i }).first();
|
||||
if (await passwordTextbox.count()) {
|
||||
await passwordTextbox.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
}
|
||||
|
||||
const galleryPasswordField = page.getByPlaceholder(/gallery password/i);
|
||||
if (!passwordEntered && await galleryPasswordField.count()) {
|
||||
await galleryPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
} else if (!passwordEntered) {
|
||||
const genericPasswordField = page.getByPlaceholder(/password/i).first();
|
||||
if (await genericPasswordField.count()) {
|
||||
await genericPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
} else {
|
||||
const labelledPasswordField = page.getByLabel(/password/i).first();
|
||||
if (await labelledPasswordField.count()) {
|
||||
await labelledPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!passwordEntered) {
|
||||
const fallbackPasswordField = page.locator('input').first();
|
||||
if (await fallbackPasswordField.count()) {
|
||||
await fallbackPasswordField.fill(GALLERY_PASSWORD);
|
||||
passwordEntered = true;
|
||||
}
|
||||
}
|
||||
|
||||
const viewButton = page.getByRole('button', { name: /View Gallery/i });
|
||||
if (await viewButton.count()) {
|
||||
try {
|
||||
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
|
||||
} catch {
|
||||
// Already navigated into gallery view.
|
||||
}
|
||||
}
|
||||
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
return tiles;
|
||||
}
|
||||
|
||||
test('admin login and gallery viewing smoke test', async ({ page }) => {
|
||||
const { shareLink } = await createEventWithPhotos(page);
|
||||
const { shareLink, adminToken } = await createEventWithPhotos(page);
|
||||
|
||||
// Admin UI login
|
||||
await page.goto('/admin/login');
|
||||
@@ -83,18 +176,39 @@ test('admin login and gallery viewing smoke test', async ({ page }) => {
|
||||
}
|
||||
await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Visit gallery share link and authenticate
|
||||
await page.goto(shareLink);
|
||||
const passwordField = page.getByPlaceholder(/gallery password/i);
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
await page.getByRole('button', { name: /View Gallery/i }).click();
|
||||
let resetToken = adminToken;
|
||||
try {
|
||||
// Verify long-form share link works
|
||||
const tiles = await openGalleryShareLink(page, shareLink);
|
||||
await tiles.first().hover();
|
||||
await tiles.first().getByRole('button', { name: /View full size/i }).click();
|
||||
await expect(page.getByRole('button', { name: /Close/i })).toBeVisible();
|
||||
await page.getByRole('button', { name: /Close/i }).click();
|
||||
|
||||
// Wait for photos grid to appear
|
||||
const tiles = page.locator('.relative.group');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
// Enable short gallery URLs
|
||||
await updateShortGallerySetting(page, adminToken, true);
|
||||
|
||||
// Open lightbox to ensure media renders
|
||||
await tiles.first().hover();
|
||||
await tiles.first().getByRole('button', { name: /View full size/i }).click();
|
||||
await expect(page.getByRole('button', { name: /Close/i })).toBeVisible();
|
||||
const settingsResponse = await page.request.get('/api/admin/settings', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
});
|
||||
expect(settingsResponse.ok()).toBeTruthy();
|
||||
const adminSettings = await settingsResponse.json();
|
||||
expect(adminSettings.general_short_gallery_urls === true || adminSettings.general_short_gallery_urls === 'true').toBeTruthy();
|
||||
|
||||
const { shareLink: shortShareLink, event: shortEvent } = await createEventWithPhotos(page, adminToken);
|
||||
expect(shortShareLink).toMatch(/\/gallery\/[0-9a-fA-F]{32}$/);
|
||||
expect(shortShareLink).not.toContain(shortEvent.slug);
|
||||
|
||||
// Verify short share link works
|
||||
await openGalleryShareLink(page, shortShareLink);
|
||||
|
||||
// Legacy share link should still work after enabling short URLs
|
||||
await openGalleryShareLink(page, shareLink);
|
||||
} finally {
|
||||
await updateShortGallerySetting(page, resetToken, false).catch(() => {
|
||||
/* noop */
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
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_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1';
|
||||
|
||||
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', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
@@ -30,8 +46,8 @@ async function createExternalGallery(page) {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
host_name: 'External Host',
|
||||
host_email: 'host@example.com',
|
||||
customer_name: 'External Host',
|
||||
customer_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
@@ -72,7 +88,10 @@ async function createExternalGallery(page) {
|
||||
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();
|
||||
expect(importBody.imported).toBeGreaterThan(0);
|
||||
|
||||
@@ -113,9 +132,13 @@ test.describe('External media gallery behavior', () => {
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
const passwordField = page.getByPlaceholder(/gallery password/i).first();
|
||||
await expect(passwordField).toBeVisible();
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
await page.getByRole('button', { name: /View Gallery/i }).click();
|
||||
if (await passwordField.count()) {
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
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');
|
||||
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
@@ -43,8 +43,8 @@ async function createGalleryWithModeratedComments(page: Page): Promise<GallerySe
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
host_name: 'Playwright Host',
|
||||
host_email: 'host@example.com',
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 30,
|
||||
|
||||
@@ -32,8 +32,8 @@ async function ensureGalleryWithPhotos(page) {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
host_name: 'Playwright Host',
|
||||
host_email: 'host@example.com',
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 90,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
|
||||
test('clearing old notifications removes read entries', async ({ request }) => {
|
||||
const loginResponse = await request.post('/api/auth/admin/login', {
|
||||
data: {
|
||||
username: ADMIN_EMAIL,
|
||||
password: ADMIN_PASSWORD,
|
||||
},
|
||||
});
|
||||
expect(loginResponse.ok()).toBeTruthy();
|
||||
const { token } = await loginResponse.json();
|
||||
|
||||
const authHeaders = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const eventName = `Notification Clear ${Date.now()}`;
|
||||
const eventDate = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const createEventResponse = await request.post('/api/admin/events', {
|
||||
headers: authHeaders,
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Notification Test',
|
||||
customer_email: 'notify@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: 'NotifyClearPass!1',
|
||||
expiration_days: 30,
|
||||
allow_user_uploads: false,
|
||||
allow_downloads: true,
|
||||
disable_right_click: false,
|
||||
watermark_downloads: false,
|
||||
},
|
||||
});
|
||||
expect(createEventResponse.ok()).toBeTruthy();
|
||||
const createdEvent = await createEventResponse.json();
|
||||
const eventId = createdEvent.id;
|
||||
|
||||
const collectedNotifications = async () => {
|
||||
const notificationsResponse = await request.get('/api/admin/notifications', {
|
||||
headers: authHeaders,
|
||||
params: { includeRead: true, limit: 200 },
|
||||
});
|
||||
expect(notificationsResponse.ok()).toBeTruthy();
|
||||
return notificationsResponse.json();
|
||||
};
|
||||
|
||||
let notificationsPayload = await collectedNotifications();
|
||||
const start = Date.now();
|
||||
while (notificationsPayload.notifications.length === 0 && Date.now() - start < 5000) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
notificationsPayload = await collectedNotifications();
|
||||
}
|
||||
|
||||
const targetEventNotifications = notificationsPayload.notifications.filter(
|
||||
(notification: any) => notification.eventId === eventId
|
||||
);
|
||||
expect(targetEventNotifications.length).toBeGreaterThan(0);
|
||||
|
||||
const markReadResponse = await request.put('/api/admin/notifications/read-all', {
|
||||
headers: authHeaders,
|
||||
});
|
||||
expect(markReadResponse.ok()).toBeTruthy();
|
||||
|
||||
const postMarkPayload = await collectedNotifications();
|
||||
const postMarkEventNotifications = postMarkPayload.notifications.filter(
|
||||
(notification: any) => notification.eventId === eventId
|
||||
);
|
||||
const readNotificationIds = postMarkEventNotifications
|
||||
.filter((notification: any) => notification.isRead)
|
||||
.map((notification: any) => notification.id);
|
||||
expect(readNotificationIds.length).toBeGreaterThan(0);
|
||||
|
||||
const clearResponse = await request.delete('/api/admin/notifications/clear-old', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(clearResponse.ok()).toBeTruthy();
|
||||
const clearPayload = await clearResponse.json();
|
||||
expect(clearPayload.deletedCount).toBeGreaterThanOrEqual(0);
|
||||
|
||||
const afterClearPayload = await collectedNotifications();
|
||||
expect(Array.isArray(afterClearPayload.notifications)).toBe(true);
|
||||
const remainingIds = new Set(afterClearPayload.notifications.map((notification: any) => notification.id));
|
||||
readNotificationIds.forEach((id) => {
|
||||
expect(remainingIds.has(id)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user