Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f29e9db99d | |||
| d2e97567a9 | |||
| 69538b86ea | |||
| b76e45cb54 | |||
| 5b5e431b08 | |||
| 07759a0e40 | |||
| 31fd64c83c | |||
| 775c5159ea | |||
| 8f297e25c4 | |||
| ccb65b892b | |||
| 52f8f1f738 | |||
| e731e7b47c | |||
| 2bccb1a439 | |||
| df10fc677e | |||
| 8c690155bf | |||
| 1b1e4f715d | |||
| 68eb9ba552 | |||
| 7040865154 | |||
| 013be18d98 | |||
| 3c2a79a31a | |||
| f20472ca26 | |||
| 87f4526220 | |||
| d42a11680f | |||
| 38dd74b893 | |||
| fc1bf53412 | |||
| 5d6c061f1c | |||
| 45e835a51a | |||
| afc00090cf | |||
| 59750dea15 | |||
| 2fe32e9a69 | |||
| 5f8c8c5508 | |||
| fb739f221d | |||
| b5399aaa9b |
+19
-6
@@ -7,9 +7,9 @@ This guide covers multiple deployment options for PicPeak, from simple local set
|
||||
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \
|
||||
chmod +x setup.sh && \
|
||||
sudo ./setup.sh
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
This automated script handles everything including:
|
||||
@@ -219,14 +219,17 @@ Update `.env` with:
|
||||
- **URL Configuration** (for backend CORS):
|
||||
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
|
||||
- Example (Docker): `http://localhost:3000`
|
||||
|
||||
Notes:
|
||||
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
|
||||
- Always include the scheme (`http://` or `https://`).
|
||||
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
|
||||
|
||||
#### Authentication Security
|
||||
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
|
||||
|
||||
#### External Database Example
|
||||
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
|
||||
|
||||
@@ -420,7 +423,17 @@ Upon first login, the system will **automatically redirect** you to change your
|
||||
|
||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference.
|
||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||
|
||||
```bash
|
||||
# Native reinstall example
|
||||
sudo ./picpeak-setup.sh --native --force-admin-password-reset
|
||||
|
||||
# Docker reinstall example
|
||||
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.
|
||||
|
||||
#### Configuring Admin Email
|
||||
|
||||
|
||||
@@ -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,184 @@
|
||||
const fs = require('fs');
|
||||
const fsPromises = fs.promises;
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
describe('Admin settings logo upload flow', () => {
|
||||
let tmpDir;
|
||||
let router;
|
||||
let app;
|
||||
let settingsStore;
|
||||
|
||||
const resetModules = () => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
resetModules();
|
||||
|
||||
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
|
||||
process.env.STORAGE_PATH = tmpDir;
|
||||
|
||||
settingsStore = new Map();
|
||||
|
||||
const buildQuery = (table) => {
|
||||
const filters = [];
|
||||
const applyFilters = (rows) => {
|
||||
if (filters.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((row) =>
|
||||
filters.every(({ column, value }) => row[column] === value)
|
||||
);
|
||||
};
|
||||
|
||||
const makeRow = (row) => ({ ...row });
|
||||
|
||||
return {
|
||||
where(column, value) {
|
||||
filters.push({ column, value });
|
||||
return this;
|
||||
},
|
||||
first() {
|
||||
if (table === 'app_settings') {
|
||||
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
|
||||
return Promise.resolve(rows[0]);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
},
|
||||
select() {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
sum() {
|
||||
return Promise.resolve({ total: 0 });
|
||||
},
|
||||
join() {
|
||||
return this;
|
||||
},
|
||||
groupBy() {
|
||||
return this;
|
||||
},
|
||||
orderBy() {
|
||||
return this;
|
||||
},
|
||||
limit() {
|
||||
return this;
|
||||
},
|
||||
insert(payload) {
|
||||
const rows = Array.isArray(payload) ? payload : [payload];
|
||||
const upsert = (row, overrides = {}) => {
|
||||
if (table === 'app_settings') {
|
||||
const key = row.setting_key;
|
||||
const existing = settingsStore.get(key) || {};
|
||||
settingsStore.set(key, { ...existing, ...row, ...overrides });
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
return {
|
||||
onConflict() {
|
||||
return {
|
||||
merge(overrides) {
|
||||
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const dbMock = jest.fn((table) => buildQuery(table));
|
||||
dbMock.raw = jest.fn();
|
||||
dbMock.transaction = async (handler) => handler({
|
||||
commit: async () => {},
|
||||
rollback: async () => {}
|
||||
});
|
||||
|
||||
jest.doMock('../src/database/db', () => ({
|
||||
db: dbMock,
|
||||
logActivity: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/auth', () => ({
|
||||
adminAuth: (req, res, next) => {
|
||||
req.admin = { id: 1, username: 'tester' };
|
||||
next();
|
||||
}
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/publicSiteService', () => ({
|
||||
clearPublicSiteCache: jest.fn(),
|
||||
getDefaultPublicSitePayload: jest.fn(),
|
||||
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
|
||||
}));
|
||||
|
||||
jest.doMock('../src/services/rateLimitService', () => ({
|
||||
clearSettingsCache: jest.fn()
|
||||
}));
|
||||
|
||||
jest.doMock('../src/middleware/maintenance', () => ({
|
||||
maintenanceMiddleware: (req, res, next) => next(),
|
||||
clearMaintenanceCache: jest.fn()
|
||||
}));
|
||||
|
||||
router = require('../src/routes/adminSettings');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/settings', router);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetModules();
|
||||
if (tmpDir) {
|
||||
await fsPromises.rm(tmpDir, { recursive: true, force: true });
|
||||
tmpDir = null;
|
||||
}
|
||||
delete process.env.STORAGE_PATH;
|
||||
});
|
||||
|
||||
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
|
||||
const fileBuffer = Buffer.from('fake image data');
|
||||
|
||||
const uploadResponse = await request(app)
|
||||
.post('/api/admin/settings/logo')
|
||||
.attach('logo', fileBuffer, 'logo.png');
|
||||
|
||||
expect(uploadResponse.status).toBe(200);
|
||||
expect(uploadResponse.body).toHaveProperty('logoUrl');
|
||||
const logoUrl = uploadResponse.body.logoUrl;
|
||||
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
|
||||
|
||||
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
|
||||
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
|
||||
|
||||
await request(app)
|
||||
.put('/api/admin/settings/branding')
|
||||
.send({
|
||||
company_name: 'Test Co',
|
||||
company_tagline: 'Tagline',
|
||||
support_email: 'test@example.com',
|
||||
footer_text: 'Footer',
|
||||
watermark_enabled: false,
|
||||
watermark_position: 'bottom-right',
|
||||
watermark_opacity: 0.5,
|
||||
watermark_size: 'medium',
|
||||
favicon_url: null,
|
||||
logo_url: '',
|
||||
watermark_logo_url: null,
|
||||
logo_size: 'medium',
|
||||
logo_max_height: 120,
|
||||
logo_position: 'left',
|
||||
logo_display_header: true,
|
||||
logo_display_hero: false,
|
||||
logo_display_mode: 'default'
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
await expect(fsPromises.access(storedPath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -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
+31
-54
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.15",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -5154,29 +5154,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -5620,13 +5597,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-validator": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
|
||||
"integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==",
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
|
||||
"integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"validator": "~13.12.0"
|
||||
"validator": "~13.15.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
@@ -8355,9 +8332,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
|
||||
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
|
||||
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -9008,24 +8985,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -10246,6 +10205,24 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
@@ -10624,9 +10601,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.12.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
|
||||
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
|
||||
"version": "13.15.20",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
|
||||
"integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.3",
|
||||
"version": "1.1.15",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -39,7 +39,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -55,5 +55,10 @@
|
||||
"mock-fs": "^5.5.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"overrides": {
|
||||
"prebuild-install": {
|
||||
"tar-fs": "2.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const fsp = fs.promises;
|
||||
|
||||
async function pathExists(location) {
|
||||
try {
|
||||
await fsp.access(location);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFile(source, destination) {
|
||||
await fsp.mkdir(path.dirname(destination), { recursive: true });
|
||||
try {
|
||||
await fsp.rename(source, destination);
|
||||
} catch (error) {
|
||||
if (error.code === 'EXDEV') {
|
||||
await fsp.copyFile(source, destination);
|
||||
await fsp.unlink(source);
|
||||
} else if (error.code === 'EEXIST') {
|
||||
console.warn(`Destination already exists, leaving original in place: ${destination}`);
|
||||
return;
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrate() {
|
||||
const backendRoot = path.resolve(__dirname, '..');
|
||||
const defaultStorage = path.resolve(backendRoot, '../storage');
|
||||
const targetStorage = path.resolve(process.env.STORAGE_PATH || defaultStorage);
|
||||
const legacyUploadsRoot = path.resolve(backendRoot, 'storage/uploads');
|
||||
const targetUploadsRoot = path.join(targetStorage, 'uploads');
|
||||
|
||||
if (legacyUploadsRoot === targetUploadsRoot) {
|
||||
console.log('Legacy uploads directory already matches target STORAGE_PATH. Nothing to migrate.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(legacyUploadsRoot)) {
|
||||
console.log(`Legacy uploads directory not found at ${legacyUploadsRoot}. Nothing to migrate.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const categories = ['logos', 'favicons'];
|
||||
let migratedCounter = 0;
|
||||
|
||||
for (const category of categories) {
|
||||
const legacyDir = path.join(legacyUploadsRoot, category);
|
||||
if (!fs.existsSync(legacyDir)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetDir = path.join(targetUploadsRoot, category);
|
||||
await fsp.mkdir(targetDir, { recursive: true });
|
||||
|
||||
const entries = await fsp.readdir(legacyDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePath = path.join(legacyDir, entry.name);
|
||||
const destinationPath = path.join(targetDir, entry.name);
|
||||
|
||||
if (await pathExists(destinationPath)) {
|
||||
console.warn(`Skipping ${sourcePath} because ${destinationPath} already exists.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await moveFile(sourcePath, destinationPath);
|
||||
migratedCounter += 1;
|
||||
}
|
||||
|
||||
const remaining = await fsp.readdir(legacyDir);
|
||||
if (remaining.length === 0) {
|
||||
await fsp.rm(legacyDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (migratedCounter === 0) {
|
||||
console.log('No legacy logo or favicon files needed migration.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Migrated ${migratedCounter} files into ${targetUploadsRoot}.`);
|
||||
console.log('If the database still references legacy absolute paths, they will be cleaned up automatically on the next upload.');
|
||||
}
|
||||
|
||||
migrate().catch((error) => {
|
||||
console.error('Migration failed:', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const readline = require('readline');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
const args = process.argv.slice(2);
|
||||
const hasFlag = (flag) => args.includes(flag);
|
||||
const getOption = (name) => {
|
||||
const index = args.indexOf(`--${name}`);
|
||||
if (index !== -1 && index + 1 < args.length) {
|
||||
return args[index + 1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
|
||||
const credentialsFileArg = getOption('credentials-file');
|
||||
const resolvedCredentialsFile = credentialsFileArg
|
||||
? path.resolve(process.cwd(), credentialsFileArg)
|
||||
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||
|
||||
const rl = force ? null : readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
async function question(prompt) {
|
||||
async function ask(prompt) {
|
||||
if (force) {
|
||||
return 'yes';
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, resolve);
|
||||
});
|
||||
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
|
||||
|
||||
console.log('Found admin user:', admin.username);
|
||||
console.log('Email:', admin.email);
|
||||
console.log('\nThis will reset the password for this admin account.');
|
||||
|
||||
const confirm = await question('\nDo you want to continue? (yes/no): ');
|
||||
|
||||
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
|
||||
console.log('\n❌ Password reset cancelled.');
|
||||
process.exit(0);
|
||||
if (!force) {
|
||||
console.log('\nThis will reset the password for this admin account.');
|
||||
}
|
||||
|
||||
const confirm = await ask('\nDo you want to continue? (yes/no): ');
|
||||
|
||||
if (!force) {
|
||||
const normalized = confirm.trim().toLowerCase();
|
||||
if (normalized !== 'yes' && normalized !== 'y') {
|
||||
console.log('\n❌ Password reset cancelled.');
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new password
|
||||
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
|
||||
});
|
||||
|
||||
// Save to file
|
||||
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||
const credentialsDir = path.dirname(resolvedCredentialsFile);
|
||||
await fs.mkdir(credentialsDir, { recursive: true });
|
||||
|
||||
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
|
||||
const resetInfo = `
|
||||
========================================
|
||||
PicPeak Admin Password Reset
|
||||
PicPeak Admin Credentials
|
||||
========================================
|
||||
|
||||
Password has been reset for admin account:
|
||||
Your admin account has been reset with these credentials:
|
||||
|
||||
Username: admin
|
||||
New Password: ${newPassword}
|
||||
Username: ${admin.username}
|
||||
Email: ${admin.email}
|
||||
Password: ${newPassword}
|
||||
|
||||
IMPORTANT:
|
||||
1. You MUST change this password on next login
|
||||
IMPORTANT SECURITY NOTES:
|
||||
1. You MUST change this password after first login
|
||||
2. This file contains sensitive information
|
||||
3. Delete this file after noting the password
|
||||
|
||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
||||
Login URL: ${adminUrl}
|
||||
|
||||
Reset performed on: ${new Date().toISOString()}
|
||||
========================================
|
||||
`;
|
||||
|
||||
await fs.writeFile(resetInfoPath, resetInfo, 'utf8');
|
||||
await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
|
||||
|
||||
console.log('\n✅ Password reset successful!\n');
|
||||
console.log('========================================');
|
||||
console.log('New Credentials:');
|
||||
console.log('========================================');
|
||||
console.log('Username: admin');
|
||||
console.log(`Username: ${admin.username}`);
|
||||
console.log(`Email: ${admin.email}`);
|
||||
console.log(`Password: ${newPassword}`);
|
||||
console.log('\n⚠️ IMPORTANT:');
|
||||
console.log('1. You will be required to change this password on next login');
|
||||
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt');
|
||||
console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
|
||||
console.log('3. Delete the file after noting the password');
|
||||
console.log('========================================\n');
|
||||
|
||||
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
|
||||
console.error('❌ Error resetting password:', error.message);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
rl.close();
|
||||
if (rl) {
|
||||
rl.close();
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the reset
|
||||
resetAdminPassword();
|
||||
resetAdminPassword();
|
||||
|
||||
@@ -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,12 +18,17 @@ const {
|
||||
getRawPublicSiteSettings,
|
||||
} = require('../services/publicSiteService');
|
||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||
const router = express.Router();
|
||||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Configure multer for logo uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../storage/uploads/logos');
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/logos');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
@@ -53,7 +58,7 @@ const upload = multer({
|
||||
// Configure multer for favicon uploads
|
||||
const faviconStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '../../storage/uploads/favicons');
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/favicons');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
@@ -228,7 +233,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
|
||||
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
||||
// Delete the file from filesystem
|
||||
const faviconPath = path.join(__dirname, '..', '..', 'storage', currentFaviconUrl.replace('/uploads/', ''));
|
||||
const relativePath = currentFaviconUrl.replace(/^\//, '');
|
||||
const faviconPath = path.join(getStoragePath(), relativePath);
|
||||
try {
|
||||
await fs.unlink(faviconPath);
|
||||
console.log('Deleted favicon file:', faviconPath);
|
||||
@@ -258,7 +264,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
||||
|
||||
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
||||
// Delete the file from filesystem
|
||||
const logoPath = path.join(__dirname, '..', '..', 'storage', currentLogoUrl.replace('/uploads/', ''));
|
||||
const relativePath = currentLogoUrl.replace(/^\//, '');
|
||||
const logoPath = path.join(getStoragePath(), relativePath);
|
||||
try {
|
||||
await fs.unlink(logoPath);
|
||||
console.log('Deleted logo file:', logoPath);
|
||||
@@ -468,9 +475,24 @@ router.put('/theme', adminAuth, async (req, res) => {
|
||||
router.put('/general', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const settings = { ...req.body };
|
||||
let uploadLimitTouched = false;
|
||||
|
||||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
|
||||
uploadLimitTouched = true;
|
||||
const rawValue = Number(settings.general_max_files_per_upload);
|
||||
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
|
||||
|
||||
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
|
||||
return res.status(400).json({
|
||||
error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}`
|
||||
});
|
||||
}
|
||||
|
||||
settings.general_max_files_per_upload = normalizedValue;
|
||||
}
|
||||
|
||||
if (publicSiteKeysTouched) {
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
||||
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
||||
@@ -525,6 +547,12 @@ router.put('/general', adminAuth, async (req, res) => {
|
||||
if (publicSiteKeysTouched) {
|
||||
clearPublicSiteCache();
|
||||
}
|
||||
if (uploadLimitTouched) {
|
||||
clearMaxFilesPerUploadCache();
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
|
||||
clearShareLinkSettingsCache();
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
@@ -563,6 +591,8 @@ router.put('/security', adminAuth, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
resetSecurityConfigCache();
|
||||
|
||||
// Log activity
|
||||
await db('activity_logs').insert({
|
||||
activity_type: 'security_settings_updated',
|
||||
@@ -643,7 +673,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
for (const archive of archives) {
|
||||
if (archive.archive_path) {
|
||||
try {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const storagePath = getStoragePath();
|
||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||
const stats = await fs.stat(fullArchivePath);
|
||||
archiveStorage += stats.size;
|
||||
@@ -654,7 +684,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const storagePath = getStoragePath();
|
||||
|
||||
let diskStats = null;
|
||||
let rawDiskTotal = null;
|
||||
|
||||
@@ -12,13 +12,14 @@ const {
|
||||
checkSuspiciousActivity,
|
||||
getGenericAuthError
|
||||
} = require('../utils/authSecurity');
|
||||
const {
|
||||
const {
|
||||
validatePasswordInContext,
|
||||
getBcryptRounds,
|
||||
logPasswordValidationFailure
|
||||
} = require('../utils/passwordValidation');
|
||||
const { endSession } = require('../middleware/sessionTimeout');
|
||||
const logger = require('../utils/logger');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login with enhanced security
|
||||
@@ -33,7 +34,7 @@ router.post('/admin/login', [
|
||||
}
|
||||
|
||||
const { username, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check account lockout first
|
||||
@@ -175,7 +176,7 @@ router.post('/admin/change-password', [
|
||||
logger.info('Admin password changed', {
|
||||
userId: adminId,
|
||||
username: admin.username,
|
||||
ip: req.ip
|
||||
ip: ipAddress
|
||||
});
|
||||
|
||||
res.json({
|
||||
@@ -220,7 +221,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);
|
||||
@@ -229,56 +230,72 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// 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}`, ipAddress);
|
||||
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 +319,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 +390,4 @@ router.post('/password-strength', [
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -22,6 +22,8 @@ const {
|
||||
getAdminTokenFromRequest,
|
||||
getGalleryTokenFromRequest,
|
||||
} = require('../utils/tokenUtils');
|
||||
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login with enhanced security
|
||||
@@ -36,7 +38,7 @@ router.post('/admin/login', [
|
||||
}
|
||||
|
||||
const { username, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check account lockout first
|
||||
@@ -162,7 +164,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);
|
||||
@@ -171,57 +173,70 @@ router.post('/gallery/verify', [
|
||||
}
|
||||
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// 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}`, ipAddress);
|
||||
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 +261,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) {
|
||||
@@ -267,21 +283,25 @@ router.post('/gallery/share-login', [
|
||||
}
|
||||
|
||||
const { slug, token } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const ipAddress = getClientIp(req);
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
const event = await db('events')
|
||||
let event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
const resolved = await resolveShareIdentifier(slug);
|
||||
if (resolved?.event) {
|
||||
event = resolved.event;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
let expectedToken = event.share_link;
|
||||
if (expectedToken && expectedToken.includes('/')) {
|
||||
expectedToken = expectedToken.split('/').pop();
|
||||
}
|
||||
const expectedToken = getEventShareToken(event);
|
||||
|
||||
if (!expectedToken || token !== expectedToken) {
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
@@ -298,9 +318,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 +334,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
|
||||
};
|
||||
@@ -7,10 +7,140 @@ const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('./dbCompat');
|
||||
const logger = require('./logger');
|
||||
|
||||
// Configuration constants
|
||||
const MAX_LOGIN_ATTEMPTS = 5;
|
||||
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
|
||||
const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes window for counting attempts
|
||||
const DEFAULT_SECURITY_CONFIG = Object.freeze({
|
||||
maxAttempts: 5,
|
||||
lockoutDurationMs: 30 * 60 * 1000, // 30 minutes
|
||||
attemptWindowMs: 15 * 60 * 1000 // 15 minutes
|
||||
});
|
||||
|
||||
const SECURITY_CONFIG_CACHE_MS = 60 * 1000; // 1 minute cache
|
||||
let cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
|
||||
let cachedConfigFetchedAt = 0;
|
||||
|
||||
function parseStoredValue(rawValue) {
|
||||
if (rawValue === undefined || rawValue === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof rawValue !== 'string') {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawValue);
|
||||
} catch (error) {
|
||||
logger.warn(`Unable to parse stored security setting value "${rawValue}", using raw string.`);
|
||||
return rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(name, value, fallback, options = {}) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const numericValue = Number(value);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
logger.warn(`Invalid numeric value for ${name}: ${value}. Falling back to default (${fallback}).`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let adjustedValue = Math.floor(numericValue);
|
||||
|
||||
if (options.min !== undefined && adjustedValue < options.min) {
|
||||
logger.warn(`Value for ${name} below minimum (${options.min}). Clamping to minimum.`);
|
||||
adjustedValue = options.min;
|
||||
}
|
||||
|
||||
if (options.max !== undefined && adjustedValue > options.max) {
|
||||
logger.warn(`Value for ${name} exceeds maximum (${options.max}). Clamping to maximum.`);
|
||||
adjustedValue = options.max;
|
||||
}
|
||||
|
||||
if (adjustedValue <= 0) {
|
||||
logger.warn(`Value for ${name} must be positive. Falling back to default (${fallback}).`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return adjustedValue;
|
||||
}
|
||||
|
||||
async function loadSecurityConfigFromSettings() {
|
||||
const rows = await db('app_settings').whereIn('setting_key', [
|
||||
'security_max_login_attempts',
|
||||
'security_lockout_duration_minutes',
|
||||
'security_attempt_window_minutes'
|
||||
]);
|
||||
|
||||
const config = { ...DEFAULT_SECURITY_CONFIG };
|
||||
|
||||
rows.forEach(row => {
|
||||
const value = parseStoredValue(row.setting_value);
|
||||
|
||||
switch (row.setting_key) {
|
||||
case 'security_max_login_attempts': {
|
||||
config.maxAttempts = normalizePositiveInteger(
|
||||
'security_max_login_attempts',
|
||||
value,
|
||||
DEFAULT_SECURITY_CONFIG.maxAttempts,
|
||||
{ min: 1, max: 50 }
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'security_lockout_duration_minutes': {
|
||||
const minutes = normalizePositiveInteger(
|
||||
'security_lockout_duration_minutes',
|
||||
value,
|
||||
DEFAULT_SECURITY_CONFIG.lockoutDurationMs / (60 * 1000),
|
||||
{ min: 1, max: 24 * 60 }
|
||||
);
|
||||
config.lockoutDurationMs = minutes * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
case 'security_attempt_window_minutes': {
|
||||
const minutes = normalizePositiveInteger(
|
||||
'security_attempt_window_minutes',
|
||||
value,
|
||||
DEFAULT_SECURITY_CONFIG.attemptWindowMs / (60 * 1000),
|
||||
{ min: 1, max: 24 * 60 }
|
||||
);
|
||||
config.attemptWindowMs = minutes * 60 * 1000;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function getSecurityConfig(options = {}) {
|
||||
const now = Date.now();
|
||||
const forceRefresh = options.forceRefresh === true;
|
||||
|
||||
if (!forceRefresh && cachedSecurityConfig && (now - cachedConfigFetchedAt) < SECURITY_CONFIG_CACHE_MS) {
|
||||
return cachedSecurityConfig;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await loadSecurityConfigFromSettings();
|
||||
cachedSecurityConfig = config;
|
||||
cachedConfigFetchedAt = now;
|
||||
return cachedSecurityConfig;
|
||||
} catch (error) {
|
||||
logger.error('Error loading security configuration:', error);
|
||||
cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
|
||||
cachedConfigFetchedAt = now;
|
||||
return cachedSecurityConfig;
|
||||
}
|
||||
}
|
||||
|
||||
function resetSecurityConfigCache() {
|
||||
cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
|
||||
cachedConfigFetchedAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track failed login attempt
|
||||
@@ -59,6 +189,8 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
||||
if (!tableExists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { attemptWindowMs } = await getSecurityConfig();
|
||||
|
||||
await db('login_attempts').insert({
|
||||
identifier,
|
||||
@@ -69,7 +201,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
||||
});
|
||||
|
||||
// Clear old failed attempts for this user
|
||||
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
|
||||
const cutoffTime = new Date(Date.now() - attemptWindowMs);
|
||||
await db('login_attempts')
|
||||
.where('identifier', identifier)
|
||||
.where('success', formatBoolean(false))
|
||||
@@ -83,30 +215,39 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
|
||||
/**
|
||||
* Check if account is locked due to too many failed attempts
|
||||
* @param {string} identifier - Username or email
|
||||
* @param {string} [ipAddress] - Optional IP address scope
|
||||
* @returns {Promise<{isLocked: boolean, remainingTime?: number}>}
|
||||
*/
|
||||
async function checkAccountLockout(identifier) {
|
||||
async function checkAccountLockout(identifier, ipAddress) {
|
||||
try {
|
||||
// Check if table exists first
|
||||
const tableExists = await db.schema.hasTable('login_attempts');
|
||||
if (!tableExists) {
|
||||
return { isLocked: false };
|
||||
}
|
||||
|
||||
const { attemptWindowMs, maxAttempts, lockoutDurationMs } = await getSecurityConfig();
|
||||
|
||||
const recentWindow = new Date(Date.now() - ATTEMPT_WINDOW);
|
||||
const recentWindow = new Date(Date.now() - attemptWindowMs);
|
||||
|
||||
// Get recent failed attempts
|
||||
const failedAttempts = await db('login_attempts')
|
||||
const failedAttemptsQuery = db('login_attempts')
|
||||
.where('identifier', identifier)
|
||||
.where('success', formatBoolean(false))
|
||||
.where('attempt_time', '>=', recentWindow.toISOString())
|
||||
.orderBy('attempt_time', 'desc')
|
||||
.limit(MAX_LOGIN_ATTEMPTS);
|
||||
.where('attempt_time', '>=', recentWindow.toISOString());
|
||||
|
||||
if (failedAttempts.length >= MAX_LOGIN_ATTEMPTS) {
|
||||
if (ipAddress) {
|
||||
failedAttemptsQuery.andWhere('ip_address', ipAddress);
|
||||
}
|
||||
|
||||
const failedAttempts = await failedAttemptsQuery
|
||||
.orderBy('attempt_time', 'desc')
|
||||
.limit(maxAttempts);
|
||||
|
||||
if (failedAttempts.length >= maxAttempts) {
|
||||
// Check if still within lockout period
|
||||
const oldestAttempt = failedAttempts[failedAttempts.length - 1];
|
||||
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + LOCKOUT_DURATION;
|
||||
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + lockoutDurationMs;
|
||||
const now = Date.now();
|
||||
|
||||
if (now < lockoutEnd) {
|
||||
@@ -216,6 +357,6 @@ module.exports = {
|
||||
checkSuspiciousActivity,
|
||||
getGenericAuthError,
|
||||
initializeCleanupJob,
|
||||
MAX_LOGIN_ATTEMPTS,
|
||||
LOCKOUT_DURATION
|
||||
};
|
||||
getSecurityConfig,
|
||||
resetSecurityConfigCache
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Resolve the originating client IP address, accounting for reverse proxies.
|
||||
* Returns the first entry from X-Forwarded-For when available, otherwise falls back
|
||||
* to Express/Node connection properties.
|
||||
* @param {import('express').Request} req
|
||||
* @returns {string}
|
||||
*/
|
||||
function getClientIp(req) {
|
||||
if (!req) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const forwardedFor = req.headers['x-forwarded-for'];
|
||||
|
||||
if (typeof forwardedFor === 'string' && forwardedFor.length > 0) {
|
||||
const [firstIp] = forwardedFor.split(',').map(part => part.trim()).filter(Boolean);
|
||||
if (firstIp) {
|
||||
return firstIp;
|
||||
}
|
||||
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
|
||||
const [firstIp] = forwardedFor;
|
||||
if (firstIp) {
|
||||
return firstIp.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
req.ip ||
|
||||
req.connection?.remoteAddress ||
|
||||
req.socket?.remoteAddress ||
|
||||
req.connection?.socket?.remoteAddress ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { getClientIp };
|
||||
@@ -0,0 +1,63 @@
|
||||
const SHARE_TOKEN_REGEX = /^[0-9a-fA-F]{32}$/;
|
||||
|
||||
/**
|
||||
* Extracts the share token portion from a stored share link.
|
||||
* Supports full URLs, absolute paths, and legacy slug/token formats.
|
||||
* @param {string|null|undefined} shareLink
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function extractShareToken(shareLink) {
|
||||
if (!shareLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const trimmed = String(shareLink).trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove protocol + host when a full URL is stored
|
||||
const path = trimmed.replace(/^https?:\/\/[^/]+/i, '');
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidate = segments[segments.length - 1];
|
||||
return candidate || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the provided identifier looks like a generated share token.
|
||||
* @param {string|null|undefined} identifier
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPotentialShareToken(identifier) {
|
||||
if (!identifier) {
|
||||
return false;
|
||||
}
|
||||
return SHARE_TOKEN_REGEX.test(String(identifier).trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the gallery share path depending on whether short URLs are enabled.
|
||||
* @param {string} slug
|
||||
* @param {string} shareToken
|
||||
* @param {boolean} useShort
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildSharePath(slug, shareToken, useShort) {
|
||||
if (!shareToken) {
|
||||
throw new Error('shareToken is required to build share path');
|
||||
}
|
||||
if (useShort || !slug) {
|
||||
return `/gallery/${shareToken}`;
|
||||
}
|
||||
return `/gallery/${slug}/${shareToken}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
extractShareToken,
|
||||
isPotentialShareToken,
|
||||
buildSharePath
|
||||
};
|
||||
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
|
||||
- Check the console output from when you ran migrations
|
||||
- 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 `picpeak-setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||
|
||||
## Best Practices
|
||||
|
||||
@@ -161,4 +163,4 @@ If upgrading from the old system with hardcoded `admin123`:
|
||||
- [ ] Stored new password in password manager
|
||||
- [ ] Tested login with new password
|
||||
- [ ] Set up additional admin accounts if needed
|
||||
- [ ] Configured password policies for organization
|
||||
- [ ] Configured password policies for organization
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
# PicPeak Admin API Quickstart
|
||||
|
||||
This guide explains how to authenticate against the PicPeak Admin API, use the OpenAPI documentation, and exercise the three automation endpoints (`create event`, `photo upload`, `resend email`) that now ship with machine-readable docs.
|
||||
|
||||
> **Prerequisites**
|
||||
>
|
||||
> - PicPeak backend running (Docker or local `node backend/server.js`)
|
||||
> - An admin account (see `data/ADMIN_CREDENTIALS.txt` for the seeded defaults)
|
||||
> - API base URL (defaults to `http://localhost:3001/api`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Obtain an Admin API Token
|
||||
|
||||
1. Determine whether reCAPTCHA is enabled in **Admin → Settings → Security**. If disabled (the default), you can skip the `recaptchaToken` field shown below.
|
||||
2. Authenticate with your admin username/email and password:
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "http://localhost:3001/api/auth/admin/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"username": "admin",
|
||||
"password": "BoldTiger5872%",
|
||||
"recaptchaToken": ""
|
||||
}' | jq
|
||||
```
|
||||
|
||||
Successful responses look like:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"email": "admin@example.com",
|
||||
"mustChangePassword": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- PicPeak also sets the `admin_token` cookie; however, when scripting you typically pass the token in an `Authorization: Bearer <token>` header.
|
||||
- Tokens expire after 24 hours. Log in again to refresh them.
|
||||
|
||||
---
|
||||
|
||||
## 2. Use the OpenAPI Documentation
|
||||
|
||||
The machine-readable spec lives at `docs/picpeak-admin-api.openapi.yaml`. You can:
|
||||
|
||||
- Preview it interactively with Redocly:
|
||||
|
||||
```bash
|
||||
npx --yes @redocly/cli preview-docs docs/picpeak-admin-api.openapi.yaml
|
||||
```
|
||||
|
||||
- Import it into Postman, Insomnia, or VS Code REST client.
|
||||
- Validate changes as part of CI with:
|
||||
|
||||
```bash
|
||||
npx --yes @apidevtools/swagger-cli@4.0.4 validate docs/picpeak-admin-api.openapi.yaml
|
||||
```
|
||||
|
||||
Keep this file in sync whenever the backend endpoints evolve.
|
||||
|
||||
---
|
||||
|
||||
## 3. Call the Key Admin Endpoints
|
||||
|
||||
Below are minimal `curl` examples that rely on the bearer token captured earlier.
|
||||
|
||||
### 3.1 Create an Event
|
||||
|
||||
```bash
|
||||
API_URL="http://localhost:3001/api"
|
||||
TOKEN="REPLACE_WITH_JWT"
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "$API_URL/admin/events" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"event_type": "wedding",
|
||||
"event_name": "Emily & Jordan Celebration",
|
||||
"event_date": "2025-06-07",
|
||||
"customer_name": "Emily Carter",
|
||||
"customer_email": "emily@example.com",
|
||||
"admin_email": "studio@example.com",
|
||||
"require_password": true,
|
||||
"password": "Shutter123",
|
||||
"expiration_days": 45
|
||||
}' | jq
|
||||
```
|
||||
|
||||
### 3.2 Upload Photos to the Event
|
||||
|
||||
```bash
|
||||
EVENT_ID=512
|
||||
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "$API_URL/admin/events/$EVENT_ID/upload" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-F "photos=@/path/to/DSC_2031.jpg" \
|
||||
-F "photos=@/path/to/DSC_2032.jpg" \
|
||||
-F "category_id=individual" | jq
|
||||
```
|
||||
|
||||
- Files must be JPEG/PNG/WebP, each ≤ 50 MB.
|
||||
- The per-request file count respects the `general_max_files_per_upload` admin setting (default 500).
|
||||
|
||||
### 3.3 Resend the Gallery Email
|
||||
|
||||
```bash
|
||||
curl --fail --silent --show-error \
|
||||
-X POST "$API_URL/admin/events/$EVENT_ID/resend-email" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password": "Shutter123"}' | jq
|
||||
```
|
||||
|
||||
Omit `"password"` to send the standard security message instead.
|
||||
|
||||
---
|
||||
|
||||
## 4. Quick Testing Checklist
|
||||
|
||||
- ✅ Login succeeds and returns a token (HTTP 200).
|
||||
- ✅ Creating an event returns `id`, `slug`, and `share_link`.
|
||||
- ✅ Uploading more files than allowed returns HTTP 400 with a helpful message.
|
||||
- ✅ Resending email for a missing event returns HTTP 404.
|
||||
- ✅ `swagger-cli validate` passes after any spec edits.
|
||||
|
||||
Automate these checks using your preferred test harness or CI pipeline to catch regressions early.
|
||||
|
||||
---
|
||||
|
||||
## 5. Migrating From `host_*`
|
||||
|
||||
- Run backend migrations to add the new `customer_name` / `customer_email` columns: `npm --prefix backend run migrate` (or your existing deployment flow). The migration copies legacy data automatically, so upgrades remain seamless.
|
||||
- All admin APIs now require the `customer_*` fields. Older `host_*` payloads are rejected, which makes downstream client issues obvious during testing instead of silently dropping data.
|
||||
- API responses still mirror `customer_*` even if migrations have not run yet (the server falls back to legacy columns until the upgrade is complete), so existing frontends can move over incrementally.
|
||||
- Once every consumer writes and reads the new fields, you can safely plan the removal of the legacy `host_*` columns in a future release.
|
||||
|
||||
---
|
||||
|
||||
Need deeper integration examples or language-specific SDKs? Import the OpenAPI spec into code generators such as `openapi-generator` or `orval` to scaffold API clients quickly.
|
||||
@@ -0,0 +1,584 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: PicPeak Admin API
|
||||
version: 1.1.11
|
||||
summary: High-level administrative endpoints for creating events, uploading photos, and resending gallery access emails.
|
||||
description: |
|
||||
This document describes the core administrative endpoints that power PicPeak automations.
|
||||
It focuses on the three workflows requested by integrators:
|
||||
|
||||
1. Creating events with customer access credentials.
|
||||
2. Uploading photos in bulk to an event gallery.
|
||||
3. Resending the customer-facing gallery email.
|
||||
|
||||
The specification follows the latest [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) best practices
|
||||
and is intended to be kept in sync with backend changes.
|
||||
contact:
|
||||
name: PicPeak Maintainers
|
||||
url: https://github.com/the-luap/picpeak
|
||||
servers:
|
||||
- url: https://api.picpeak.example.com/api
|
||||
description: Example production deployment
|
||||
- url: http://localhost:3001/api
|
||||
description: Local development
|
||||
tags:
|
||||
- name: Admin Events
|
||||
description: Administrative endpoints for managing event galleries.
|
||||
components:
|
||||
securitySchemes:
|
||||
CookieAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: admin_token
|
||||
description: >
|
||||
Session cookie issued by the admin authentication flow. When present, the backend mirrors
|
||||
it into the `Authorization` header automatically.
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: >
|
||||
JSON Web Token created by the admin login endpoint. You can also pass the token explicitly
|
||||
as `Authorization: Bearer <token>` instead of using the admin cookie.
|
||||
parameters:
|
||||
EventId:
|
||||
name: eventId
|
||||
in: path
|
||||
description: Numeric identifier of the event.
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
example: 341
|
||||
schemas:
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: Human readable error message.
|
||||
details:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Additional context (when available).
|
||||
required:
|
||||
- error
|
||||
example:
|
||||
error: Invalid token
|
||||
ValidationErrorItem:
|
||||
type: object
|
||||
properties:
|
||||
type:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Validation error type reported by express-validator.
|
||||
msg:
|
||||
type: string
|
||||
path:
|
||||
type: string
|
||||
description: Dot-delimited path to the invalid field.
|
||||
value:
|
||||
description: Value that failed validation.
|
||||
location:
|
||||
type: string
|
||||
description: Location of the invalid value (always `body` for these endpoints).
|
||||
required:
|
||||
- msg
|
||||
- path
|
||||
- location
|
||||
example:
|
||||
type: field
|
||||
msg: Event date must be a valid ISO 8601 date
|
||||
path: event_date
|
||||
value: 2025/05/01
|
||||
location: body
|
||||
ValidationErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ValidationErrorItem'
|
||||
required:
|
||||
- errors
|
||||
example:
|
||||
errors:
|
||||
- type: field
|
||||
msg: Customer email must be a valid address
|
||||
path: customer_email
|
||||
value: example@invalid
|
||||
location: body
|
||||
CreateEventRequest:
|
||||
type: object
|
||||
required:
|
||||
- event_type
|
||||
- event_name
|
||||
- event_date
|
||||
- customer_name
|
||||
- customer_email
|
||||
- admin_email
|
||||
properties:
|
||||
event_type:
|
||||
type: string
|
||||
description: Type of event. Controls default theme and copy in the UI.
|
||||
enum: [wedding, birthday, corporate, other]
|
||||
event_name:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Display name for the gallery shown to end customers.
|
||||
event_date:
|
||||
type: string
|
||||
format: date
|
||||
description: Event date (YYYY-MM-DD). Used to calculate the default expiration.
|
||||
customer_name:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: Name of the customer receiving gallery access.
|
||||
customer_email:
|
||||
type: string
|
||||
format: email
|
||||
description: Email address of the customer who will receive the gallery link.
|
||||
admin_email:
|
||||
type: string
|
||||
format: email
|
||||
description: Admin contact email included in notification messages.
|
||||
require_password:
|
||||
type: boolean
|
||||
default: true
|
||||
description: When true, the gallery requires `password`; when false a random placeholder is stored.
|
||||
password:
|
||||
type: string
|
||||
minLength: 6
|
||||
description: >
|
||||
Gallery password issued to the customer. Required when `require_password` is `true`.
|
||||
Left unset to auto-generate a placeholder when password protection is disabled.
|
||||
expiration_days:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 365
|
||||
default: 30
|
||||
description: Number of days after the event date before the gallery expires.
|
||||
welcome_message:
|
||||
type: string
|
||||
description: Optional welcome message displayed in the gallery.
|
||||
color_theme:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Optional theme identifier or CSS color settings.
|
||||
allow_user_uploads:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Allow gallery guests to upload their own photos.
|
||||
upload_category_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: ID of the default category for user uploads.
|
||||
allow_downloads:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Allow guests to download photos.
|
||||
disable_right_click:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Disable right-click in the gallery view.
|
||||
watermark_downloads:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Enable watermarking on downloaded images.
|
||||
watermark_text:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Custom watermark text when `watermark_downloads` is true.
|
||||
feedback_enabled:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Enable the feedback module for this gallery.
|
||||
allow_ratings:
|
||||
type: boolean
|
||||
default: true
|
||||
allow_likes:
|
||||
type: boolean
|
||||
default: true
|
||||
allow_comments:
|
||||
type: boolean
|
||||
default: true
|
||||
allow_favorites:
|
||||
type: boolean
|
||||
default: true
|
||||
require_name_email:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Require guests to provide name and email when leaving feedback.
|
||||
moderate_comments:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Hold guest comments for moderation.
|
||||
show_feedback_to_guests:
|
||||
type: boolean
|
||||
default: true
|
||||
description: Display aggregated feedback metrics back to guests.
|
||||
example:
|
||||
event_type: wedding
|
||||
event_name: Emily & Jordan Celebration
|
||||
event_date: 2025-06-07
|
||||
customer_name: Emily Carter
|
||||
customer_email: emily@example.com
|
||||
admin_email: studio@example.com
|
||||
require_password: true
|
||||
password: Shutter123
|
||||
expiration_days: 45
|
||||
welcome_message: >
|
||||
We loved capturing your day! Use the password below to view and download your photos.
|
||||
allow_user_uploads: false
|
||||
allow_downloads: true
|
||||
feedback_enabled: true
|
||||
allow_comments: true
|
||||
show_feedback_to_guests: true
|
||||
EventSummary:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
description: Database identifier of the newly created event.
|
||||
slug:
|
||||
type: string
|
||||
description: Unique slug used to build the gallery URL.
|
||||
event_name:
|
||||
type: string
|
||||
event_type:
|
||||
type: string
|
||||
enum: [wedding, birthday, corporate, other]
|
||||
customer_name:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Name of the customer associated with the event.
|
||||
customer_email:
|
||||
type: string
|
||||
format: email
|
||||
nullable: true
|
||||
description: Email address of the customer associated with the event.
|
||||
require_password:
|
||||
type: boolean
|
||||
share_link:
|
||||
type: string
|
||||
description: Absolute or relative URL guests can use to reach the gallery.
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: ISO 8601 timestamp when the gallery expires.
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
description: ISO 8601 timestamp when the event was created.
|
||||
required:
|
||||
- id
|
||||
- slug
|
||||
- event_name
|
||||
- event_type
|
||||
- require_password
|
||||
- share_link
|
||||
- expires_at
|
||||
- created_at
|
||||
example:
|
||||
id: 512
|
||||
slug: wedding-emily-jordan-2025-06-07
|
||||
event_name: Emily & Jordan Celebration
|
||||
event_type: wedding
|
||||
customer_name: Emily Carter
|
||||
customer_email: emily@example.com
|
||||
require_password: true
|
||||
share_link: https://app.picpeak.io/gallery/wedding-emily-jordan-2025-06-07/2f3c8a4d90bb11ef9b2e0242ac120002
|
||||
expires_at: 2025-07-22T00:00:00.000Z
|
||||
created_at: 2025-05-01T14:32:45.000Z
|
||||
UploadPhotosResponse:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
photos:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadedPhotoSummary'
|
||||
description: Metadata for each photo that was persisted successfully.
|
||||
totalFiles:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Total number of files included in the request (valid + invalid).
|
||||
successCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
failureCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/UploadFailure'
|
||||
description: Present when some files failed validation or processing.
|
||||
required:
|
||||
- message
|
||||
- photos
|
||||
- totalFiles
|
||||
- successCount
|
||||
- failureCount
|
||||
example:
|
||||
message: Uploaded 18 of 20 photos. 2 failed.
|
||||
photos:
|
||||
- id: 9821
|
||||
filename: DSC_2031.jpg
|
||||
size: 4812096
|
||||
category_id: 2
|
||||
- id: 9822
|
||||
filename: DSC_2032.jpg
|
||||
size: 5216743
|
||||
category_id: 2
|
||||
totalFiles: 20
|
||||
successCount: 18
|
||||
failureCount: 2
|
||||
errors:
|
||||
- filename: DSC_2020.raw
|
||||
error: Only JPEG, PNG and WebP images are allowed
|
||||
- filename: portrait.png
|
||||
error: File is empty
|
||||
UploadedPhotoSummary:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
filename:
|
||||
type: string
|
||||
size:
|
||||
type: integer
|
||||
description: File size in bytes.
|
||||
category_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
required:
|
||||
- id
|
||||
- filename
|
||||
- size
|
||||
example:
|
||||
id: 9821
|
||||
filename: DSC_2031.jpg
|
||||
size: 4812096
|
||||
category_id: 2
|
||||
UploadFailure:
|
||||
type: object
|
||||
properties:
|
||||
filename:
|
||||
type: string
|
||||
error:
|
||||
type: string
|
||||
required:
|
||||
- filename
|
||||
- error
|
||||
example:
|
||||
filename: DSC_2031.gif
|
||||
error: Only JPEG, PNG and WebP images are allowed
|
||||
ResendEmailRequest:
|
||||
type: object
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
minLength: 1
|
||||
description: >
|
||||
Optional plain-text password to include in the email. When omitted a security notice
|
||||
placeholder is inserted because the stored hash cannot be reversed.
|
||||
example:
|
||||
password: Shutter123
|
||||
ResendEmailResponse:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- success
|
||||
- message
|
||||
example:
|
||||
success: true
|
||||
message: Creation email has been queued for sending
|
||||
paths:
|
||||
/admin/events:
|
||||
post:
|
||||
tags: [Admin Events]
|
||||
operationId: createAdminEvent
|
||||
summary: Create a new event
|
||||
description: >
|
||||
Creates a new event, provisions storage folders, stores the gallery password, and queues
|
||||
the initial gallery email for the customer. Requires admin authentication.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
- BearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateEventRequest'
|
||||
examples:
|
||||
weddingExample:
|
||||
summary: Wedding with password protection
|
||||
value:
|
||||
event_type: wedding
|
||||
event_name: Emily & Jordan Celebration
|
||||
event_date: 2025-06-07
|
||||
customer_name: Emily Carter
|
||||
customer_email: emily@example.com
|
||||
admin_email: studio@example.com
|
||||
require_password: true
|
||||
password: Shutter123
|
||||
expiration_days: 45
|
||||
welcome_message: >
|
||||
We loved capturing your day! Use the password below to view and download your photos.
|
||||
allow_user_uploads: false
|
||||
allow_downloads: true
|
||||
feedback_enabled: true
|
||||
allow_comments: true
|
||||
show_feedback_to_guests: true
|
||||
responses:
|
||||
'200':
|
||||
description: Event created successfully.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EventSummary'
|
||||
'400':
|
||||
description: Validation failed. At least one field is invalid or missing.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationErrorResponse'
|
||||
'401':
|
||||
description: Authentication required or token invalid.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected server error while creating the event.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/admin/events/{eventId}/upload:
|
||||
post:
|
||||
tags: [Admin Events]
|
||||
operationId: uploadEventPhotos
|
||||
summary: Upload photos to an event gallery
|
||||
description: |
|
||||
Uploads one or more photos to the specified event. Files are validated, moved into the
|
||||
event storage directory, and thumbnails are generated asynchronously.
|
||||
|
||||
The maximum number of files per upload is controlled via the `general_max_files_per_upload`
|
||||
setting (default 500, capped at 2000). Files exceeding 50 MB are rejected.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/EventId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
photos:
|
||||
type: array
|
||||
description: >
|
||||
One or more image files (JPEG, PNG, WebP). Each file must be <= 50 MB.
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
category_id:
|
||||
oneOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
description: >
|
||||
Optional category assignment. Accepts numeric IDs or the string values `collage`
|
||||
and `individual` for backward compatibility.
|
||||
required:
|
||||
- photos
|
||||
encoding:
|
||||
photos:
|
||||
style: form
|
||||
explode: false
|
||||
responses:
|
||||
'200':
|
||||
description: Upload completed. Failed files (if any) are listed in the response.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UploadPhotosResponse'
|
||||
'400':
|
||||
description: Request failed validation (invalid files, too many files, etc.).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Authentication required or token invalid.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: The referenced event does not exist.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected server error while processing uploads.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
/admin/events/{eventId}/resend-email:
|
||||
post:
|
||||
tags: [Admin Events]
|
||||
operationId: resendEventEmail
|
||||
summary: Resend the gallery access email to the customer
|
||||
description: >
|
||||
Queues the standard `gallery_created` email for the event's customer. Useful when resending
|
||||
credentials to the customer or communicating an updated password. Requires admin authentication.
|
||||
security:
|
||||
- CookieAuth: []
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/EventId'
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResendEmailRequest'
|
||||
example:
|
||||
password: NewSecurePassword!
|
||||
responses:
|
||||
'200':
|
||||
description: Email successfully queued for delivery.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ResendEmailResponse'
|
||||
'401':
|
||||
description: Authentication required or token invalid.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'404':
|
||||
description: Event not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'500':
|
||||
description: Unexpected server error while queuing the email.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
@@ -19,5 +19,28 @@ export default tseslint.config([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'no-useless-escape': 'off',
|
||||
'no-case-declarations': 'off',
|
||||
'prefer-const': 'off',
|
||||
'no-control-regex': 'off',
|
||||
'no-useless-catch': 'off',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-debugger': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+1122
-406
File diff suppressed because it is too large
Load Diff
+10
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.15",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:check": "tsc -b && vite build",
|
||||
"build": "node ./scripts/build.js",
|
||||
"build:check": "tsc -b && node ./scripts/build.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
@@ -49,8 +49,9 @@
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitejs/plugin-react": "^4.5.3",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
@@ -59,6 +60,10 @@
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.34.1",
|
||||
"vite": "^7.1.6"
|
||||
"vite": "^7.1.12",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "^4.45.1"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
import { execSync } from 'node:child_process';
|
||||
import { resolve, join } from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import https from 'node:https';
|
||||
|
||||
const TARGET_NODE_VERSION = '20.19.1';
|
||||
const env = { ...process.env, ROLLUP_USE_NODE_JS: 'true' };
|
||||
const viteBin = resolve(process.cwd(), 'node_modules', 'vite', 'bin', 'vite.js');
|
||||
|
||||
async function ensureNodeBinary(version) {
|
||||
const platformMap = {
|
||||
linux: 'linux',
|
||||
darwin: 'darwin',
|
||||
win32: 'win'
|
||||
};
|
||||
const archMap = {
|
||||
x64: 'x64',
|
||||
arm64: 'arm64'
|
||||
};
|
||||
|
||||
const platform = platformMap[process.platform];
|
||||
const arch = archMap[process.arch];
|
||||
|
||||
if (!platform || !arch) {
|
||||
throw new Error(`Unsupported platform/architecture combination: ${process.platform} ${process.arch}`);
|
||||
}
|
||||
|
||||
if (platform === 'win') {
|
||||
throw new Error('Automatic Node.js download is not supported on Windows runners. Please upgrade Node.js to >=20.19 manually.');
|
||||
}
|
||||
|
||||
const cacheDir = join(process.cwd(), 'node_modules', '.cache', `node-v${version}-${platform}-${arch}`);
|
||||
const nodeBinary = join(cacheDir, `node-v${version}-${platform}-${arch}`, 'bin', 'node');
|
||||
|
||||
try {
|
||||
await fs.access(nodeBinary);
|
||||
return nodeBinary;
|
||||
} catch {
|
||||
// continue with download
|
||||
}
|
||||
|
||||
await fs.mkdir(cacheDir, { recursive: true });
|
||||
const archiveExt = platform === 'win' ? 'zip' : 'tar.xz';
|
||||
const archiveName = `node-v${version}-${platform}-${arch}.${archiveExt}`;
|
||||
const archivePath = join(cacheDir, archiveName);
|
||||
const downloadUrl = `https://nodejs.org/dist/v${version}/${archiveName}`;
|
||||
|
||||
await downloadFile(downloadUrl, archivePath);
|
||||
|
||||
if (archiveExt === 'tar.xz') {
|
||||
execSync(`tar -xf "${archivePath}" -C "${cacheDir}"`, { stdio: 'inherit' });
|
||||
} else {
|
||||
throw new Error('ZIP extraction not implemented. Please upgrade Node.js manually.');
|
||||
}
|
||||
|
||||
await fs.rm(archivePath, { force: true });
|
||||
return nodeBinary;
|
||||
}
|
||||
|
||||
async function downloadFile(url, destination) {
|
||||
await new Promise((resolvePromise, rejectPromise) => {
|
||||
const fileStream = createWriteStream(destination);
|
||||
https.get(url, (response) => {
|
||||
if (response.statusCode && response.statusCode >= 400) {
|
||||
rejectPromise(new Error(`Failed to download ${url}: HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
pipeline(response, fileStream).then(resolvePromise).catch(rejectPromise);
|
||||
}).on('error', rejectPromise);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Node.js ${process.version} detected; forcing Rollup's JavaScript fallback for compatibility.`);
|
||||
|
||||
if (!process.env.USE_DOWNLOADED_NODE) {
|
||||
const [major] = process.versions.node.split('.').map(Number);
|
||||
if (major < 20) {
|
||||
const nodeBinary = await ensureNodeBinary(TARGET_NODE_VERSION);
|
||||
const childEnv = { ...env, USE_DOWNLOADED_NODE: '1' };
|
||||
execSync(`"${nodeBinary}" "${viteBin}" build`, { stdio: 'inherit', env: childEnv });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
execSync(`node "${viteBin}" build`, { stdio: 'inherit', env });
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -25,7 +25,7 @@ export const MaintenanceMode: React.FC = () => {
|
||||
try {
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Return empty object if settings can't be fetched
|
||||
return {};
|
||||
}
|
||||
@@ -110,4 +110,4 @@ export const MaintenanceMode: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
|
||||
if (isMounted) {
|
||||
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setHasAdminSession(false);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setImageSrc(null);
|
||||
|
||||
// Make authenticated request to get the image
|
||||
const response = await api.get(src, {
|
||||
@@ -31,11 +33,11 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
|
||||
if (!cancelled) {
|
||||
// Create object URL from blob
|
||||
const imageUrl = URL.createObjectURL(response.data);
|
||||
setImageSrc(imageUrl);
|
||||
objectUrl = URL.createObjectURL(response.data);
|
||||
setImageSrc(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// Image loading failed - handled by error state
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
@@ -51,8 +53,8 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
// Cleanup function
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (imageSrc) {
|
||||
URL.revokeObjectURL(imageSrc);
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
@@ -74,4 +76,4 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
}
|
||||
|
||||
return <img src={imageSrc || ''} alt={alt} {...props} />;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,7 +62,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
await photosService.deletePhoto(eventId, photo.id);
|
||||
toast.success('Photo deleted successfully');
|
||||
onPhotosDeleted();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to delete photo');
|
||||
setDeletingPhotos(prev => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -90,7 +90,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
onPhotosDeleted();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to delete photos');
|
||||
setDeletingPhotos(new Set());
|
||||
} finally {
|
||||
@@ -103,7 +103,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
try {
|
||||
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
|
||||
toast.success('Download started');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to download photo');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../../config/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
@@ -13,6 +14,9 @@ interface PhotoUploadProps {
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||
|
||||
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -29,6 +33,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
});
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const maxFilesPerUpload = React.useMemo(() => {
|
||||
const rawValue = settings?.general_max_files_per_upload;
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
|
||||
}, [settings]);
|
||||
|
||||
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const imageFiles = files.filter(file =>
|
||||
@@ -37,13 +57,19 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
// Check total file count with existing files
|
||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||
if (totalFiles > 500) {
|
||||
const allowedNewFiles = 500 - selectedFiles.length;
|
||||
if (totalFiles > maxFilesPerUpload) {
|
||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||
if (allowedNewFiles <= 0) {
|
||||
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
|
||||
toast.error(
|
||||
t('upload.maxFilesReached', { limit: maxFilesPerUpload }) ||
|
||||
`Maximum ${maxFilesPerUpload} files allowed`
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
|
||||
toast.warning(
|
||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
|
||||
);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
return;
|
||||
}
|
||||
@@ -59,8 +85,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
// Validate file count
|
||||
if (selectedFiles.length > 500) {
|
||||
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
|
||||
if (selectedFiles.length > maxFilesPerUpload) {
|
||||
toast.error(
|
||||
t('upload.tooManyFiles', { limit: maxFilesPerUpload }) ||
|
||||
`Maximum ${maxFilesPerUpload} files can be uploaded at once`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +97,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
setUploadProgress(0);
|
||||
|
||||
// For large uploads, chunk the files to prevent memory issues
|
||||
const CHUNK_SIZE = 50; // Upload 50 files at a time
|
||||
const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
||||
@@ -187,7 +216,21 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('upload.fileRequirements')}
|
||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
"text-xs mt-2",
|
||||
remainingSlots === 0 ? "text-red-600" : "text-neutral-500"
|
||||
)}
|
||||
>
|
||||
{remainingSlots === 0
|
||||
? t('upload.limitReached', { limit: maxFilesPerUpload })
|
||||
: t('upload.limitInfo', {
|
||||
selected: selectedFiles.length,
|
||||
limit: maxFilesPerUpload,
|
||||
remaining: remainingSlots,
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import {
|
||||
getActiveGallerySlug,
|
||||
getGalleryToken,
|
||||
inferGallerySlugFromLocation,
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../../utils/galleryAuthStorage';
|
||||
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
@@ -52,7 +58,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
}) => {
|
||||
const unusedProps = {
|
||||
protectFromDownload,
|
||||
slug,
|
||||
photoId,
|
||||
requiresToken,
|
||||
secureUrlTemplate,
|
||||
@@ -76,7 +81,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
let aborted = false;
|
||||
const objectUrls: string[] = [];
|
||||
|
||||
// Determine which token to use based on context
|
||||
if (!src) {
|
||||
@@ -88,37 +94,79 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
|
||||
// Create a new URL with auth header
|
||||
const resolveSlug = (candidateSrc?: string): string | null => {
|
||||
if (slug) {
|
||||
return slug;
|
||||
}
|
||||
const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null;
|
||||
if (fromUrl) {
|
||||
return fromUrl;
|
||||
}
|
||||
return getActiveGallerySlug() || inferGallerySlugFromLocation();
|
||||
};
|
||||
|
||||
const fetchWithAuth = async (rawUrl: string | undefined | null): Promise<string> => {
|
||||
if (!rawUrl) {
|
||||
throw new Error('No URL provided');
|
||||
}
|
||||
|
||||
// Build full URL for the image
|
||||
const fullImageUrl = rawUrl.startsWith('/admin')
|
||||
? buildResourceUrl(`/api${rawUrl}`)
|
||||
: rawUrl.startsWith('/')
|
||||
? buildResourceUrl(rawUrl)
|
||||
: rawUrl;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const slugForRequest = resolveSlug(rawUrl);
|
||||
const token = getGalleryToken(slugForRequest);
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(fullImageUrl, {
|
||||
credentials: 'include',
|
||||
headers: Object.keys(headers).length ? headers : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
objectUrls.push(objectUrl);
|
||||
return objectUrl;
|
||||
};
|
||||
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
// Use the src as-is since it should already be the correct endpoint
|
||||
let imageUrl = src;
|
||||
|
||||
// Build full URL for the image
|
||||
// For API paths that start with /admin, we need to prepend /api
|
||||
const fullImageUrl = imageUrl.startsWith('/admin')
|
||||
? buildResourceUrl(`/api${imageUrl}`)
|
||||
: imageUrl.startsWith('/')
|
||||
? buildResourceUrl(imageUrl)
|
||||
: imageUrl;
|
||||
|
||||
// Fetch authenticated image
|
||||
const response = await fetch(fullImageUrl, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
||||
const primaryUrl = await fetchWithAuth(src);
|
||||
if (!aborted) {
|
||||
setImageSrc(primaryUrl);
|
||||
setError(false);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageSrc(objectUrl);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
// Image loading failed - use fallback
|
||||
setError(true);
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
if (fallbackSrc && fallbackSrc !== src) {
|
||||
try {
|
||||
const fallbackUrl = await fetchWithAuth(fallbackSrc);
|
||||
if (!aborted) {
|
||||
setImageSrc(fallbackUrl);
|
||||
setError(false);
|
||||
}
|
||||
return;
|
||||
} catch (fallbackError) {
|
||||
// Swallow and mark error below
|
||||
}
|
||||
}
|
||||
if (!aborted) {
|
||||
setError(true);
|
||||
setImageSrc('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -127,11 +175,11 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
aborted = true;
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [src, fallbackSrc, useWatermark, isGallery]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [src, fallbackSrc, slug]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -209,7 +209,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Invalid theme format - use default
|
||||
// Fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
|
||||
@@ -12,7 +12,7 @@ interface GridPhotoProps {
|
||||
photo: Photo;
|
||||
isSelected: boolean;
|
||||
isSelectionMode: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onClick: () => void;
|
||||
onDownload: (e: React.MouseEvent) => void;
|
||||
onToggleSelect: () => void;
|
||||
animationType?: string;
|
||||
@@ -57,6 +57,79 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
liked = false,
|
||||
onLikeSuccess
|
||||
}) => {
|
||||
const [overlayVisible, setOverlayVisible] = React.useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
|
||||
const overlayTimeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(hover: none) and (pointer: coarse)');
|
||||
const updateTouchState = () => {
|
||||
const hasNavigator = typeof navigator !== 'undefined';
|
||||
setIsTouchDevice(
|
||||
mediaQuery.matches ||
|
||||
('ontouchstart' in window) ||
|
||||
(hasNavigator && navigator.maxTouchPoints > 0)
|
||||
);
|
||||
};
|
||||
|
||||
updateTouchState();
|
||||
|
||||
const listener = (event: MediaQueryListEvent) => {
|
||||
setIsTouchDevice(event.matches);
|
||||
};
|
||||
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
} else if (mediaQuery.addListener) {
|
||||
mediaQuery.addListener(listener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mediaQuery.removeEventListener) {
|
||||
mediaQuery.removeEventListener('change', listener);
|
||||
} else if (mediaQuery.removeListener) {
|
||||
mediaQuery.removeListener(listener);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hideOverlay = React.useCallback(() => {
|
||||
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(overlayTimeoutRef.current);
|
||||
}
|
||||
overlayTimeoutRef.current = null;
|
||||
setOverlayVisible(false);
|
||||
}, []);
|
||||
|
||||
const showOverlayTemporarily = React.useCallback(() => {
|
||||
setOverlayVisible(true);
|
||||
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(overlayTimeoutRef.current);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
overlayTimeoutRef.current = window.setTimeout(() => {
|
||||
overlayTimeoutRef.current = null;
|
||||
setOverlayVisible(false);
|
||||
}, 2500);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(overlayTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSelectionMode) {
|
||||
hideOverlay();
|
||||
}
|
||||
}, [isSelectionMode, hideOverlay]);
|
||||
|
||||
// handled by parent layout; kept here for type completeness but not used
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
@@ -73,11 +146,34 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
const commentCount = photo.comment_count ?? 0;
|
||||
const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions);
|
||||
|
||||
const overlayVisibilityClass = overlayVisible
|
||||
? 'opacity-100 md:opacity-100'
|
||||
: 'opacity-0 md:opacity-0';
|
||||
|
||||
const checkboxVisibilityClass =
|
||||
isSelected || isSelectionMode || overlayVisible
|
||||
? 'opacity-100 md:opacity-100'
|
||||
: 'opacity-0 md:opacity-0';
|
||||
|
||||
const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isTouchDevice && !overlayVisible && !isSelectionMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showOverlayTemporarily();
|
||||
return;
|
||||
}
|
||||
|
||||
onClick();
|
||||
if (isTouchDevice) {
|
||||
hideOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative group cursor-pointer aspect-square ${animationClass}`}
|
||||
onClick={onClick}
|
||||
onClick={handlePhotoClick}
|
||||
style={{
|
||||
opacity: !inView && animationType === 'fade' ? 0 : 1
|
||||
}}
|
||||
@@ -108,14 +204,15 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||
<div className={`absolute inset-0 bg-black/40 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2 ${overlayVisibilityClass} md:group-hover:opacity-100`}>
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
onClick();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
@@ -124,7 +221,11 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
{allowDownloads && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={onDownload}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload(e);
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
@@ -133,7 +234,11 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
{showFeedbackActions && onQuickComment && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onQuickComment();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Comment on photo"
|
||||
title="Comment"
|
||||
>
|
||||
@@ -148,6 +253,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
e.stopPropagation();
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||
onRequireIdentity('like', photo.id);
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
// Optimistic UI: mark as liked immediately
|
||||
@@ -163,6 +269,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Like photo"
|
||||
aria-pressed={liked}
|
||||
@@ -182,9 +289,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
role="checkbox"
|
||||
aria-checked={isSelected}
|
||||
data-testid={`gallery-photo-checkbox-${photo.id}`}
|
||||
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
className={`absolute top-2 right-2 z-20 transition-opacity ${checkboxVisibilityClass} md:group-hover:opacity-100`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||
|
||||
@@ -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",
|
||||
@@ -866,7 +892,11 @@
|
||||
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
||||
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
||||
"maxLoginAttempts": "Max. Anmeldeversuche",
|
||||
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche vor Sperrung",
|
||||
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche pro IP vor Sperrung",
|
||||
"attemptWindowMinutes": "Versuchsfenster (Minuten)",
|
||||
"attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden",
|
||||
"lockoutDurationMinutes": "Sperrdauer (Minuten)",
|
||||
"lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben",
|
||||
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
|
||||
"recaptchaSettings": "reCAPTCHA-Einstellungen",
|
||||
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
|
||||
@@ -1341,8 +1371,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",
|
||||
@@ -546,7 +572,11 @@
|
||||
"sessionTimeout": "Session Timeout (minutes)",
|
||||
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
||||
"maxLoginAttempts": "Max Login Attempts",
|
||||
"maxLoginAttemptsHelp": "Maximum failed login attempts before lockout",
|
||||
"maxLoginAttemptsHelp": "Maximum failed login attempts per IP before lockout",
|
||||
"attemptWindowMinutes": "Attempt Window (minutes)",
|
||||
"attemptWindowMinutesHelp": "How long to look back when counting failed login attempts",
|
||||
"lockoutDurationMinutes": "Lockout Duration (minutes)",
|
||||
"lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures",
|
||||
"enable2FA": "Enable two-factor authentication for admins",
|
||||
"recaptchaSettings": "reCAPTCHA Settings",
|
||||
"enableRecaptcha": "Enable reCAPTCHA for login forms",
|
||||
@@ -946,8 +976,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,23 +94,26 @@ 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' }
|
||||
});
|
||||
|
||||
// Security settings state
|
||||
const [securitySettings, setSecuritySettings] = useState({
|
||||
require_password: true,
|
||||
password_min_length: 8,
|
||||
password_complexity: 'moderate',
|
||||
enable_2fa: false,
|
||||
session_timeout_minutes: 60,
|
||||
max_login_attempts: 5,
|
||||
attempt_window_minutes: 15,
|
||||
lockout_duration_minutes: 30,
|
||||
enable_recaptcha: false,
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: ''
|
||||
@@ -118,6 +132,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) {
|
||||
@@ -131,11 +150,16 @@ export const SettingsPage: React.FC = () => {
|
||||
site_url: settings.general_site_url || '',
|
||||
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
||||
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
|
||||
max_files_per_upload: Math.min(
|
||||
MAX_FILES_PER_UPLOAD_LIMIT,
|
||||
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||
),
|
||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
||||
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
|
||||
default_language: settings.general_default_language || 'en',
|
||||
date_format: settings.general_date_format
|
||||
? (typeof settings.general_date_format === 'string'
|
||||
@@ -146,12 +170,13 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
// Extract security settings
|
||||
setSecuritySettings({
|
||||
require_password: toBoolean(settings.security_require_password, true),
|
||||
password_min_length: toNumber(settings.security_password_min_length, 8),
|
||||
password_complexity: settings.security_password_complexity ?? 'moderate',
|
||||
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
||||
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
|
||||
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
|
||||
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
|
||||
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
|
||||
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||
@@ -167,6 +192,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;
|
||||
@@ -287,6 +321,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({
|
||||
@@ -468,6 +579,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>
|
||||
|
||||
@@ -488,7 +657,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.defaultExpiration')}
|
||||
@@ -513,6 +682,29 @@ export const SettingsPage: React.FC = () => {
|
||||
max="500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.maxFilesPerUpload')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={generalSettings.max_files_per_upload}
|
||||
onChange={(e) => {
|
||||
const parsed = parseInt(e.target.value, 10);
|
||||
setGeneralSettings(prev => ({
|
||||
...prev,
|
||||
max_files_per_upload: Number.isFinite(parsed)
|
||||
? Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, parsed))
|
||||
: prev.max_files_per_upload
|
||||
}));
|
||||
}}
|
||||
min="1"
|
||||
max={MAX_FILES_PER_UPLOAD_LIMIT}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -575,6 +767,21 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={generalSettings.short_gallery_urls}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableShortGalleryUrls')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 ml-6 mt-1">
|
||||
{t('settings.general.enableShortGalleryUrlsHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1105,16 +1312,6 @@ export const SettingsPage: React.FC = () => {
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={securitySettings.require_password}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, require_password: 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.security.requirePassword')}</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.minPasswordLength')}
|
||||
@@ -1153,7 +1350,7 @@ export const SettingsPage: React.FC = () => {
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.sessionTimeout')}
|
||||
@@ -1161,11 +1358,41 @@ export const SettingsPage: React.FC = () => {
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.session_timeout_minutes}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value) || 60 }))}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value, 10) || 60 }))}
|
||||
min="5"
|
||||
max="1440"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.attemptWindowMinutes')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.attempt_window_minutes}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, attempt_window_minutes: parseInt(e.target.value, 10) || 15 }))}
|
||||
min="1"
|
||||
max="1440"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{t('settings.security.attemptWindowMinutesHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.lockoutDurationMinutes')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.lockout_duration_minutes}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, lockout_duration_minutes: parseInt(e.target.value, 10) || 30 }))}
|
||||
min="1"
|
||||
max="1440"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{t('settings.security.lockoutDurationMinutesHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.maxLoginAttempts')}
|
||||
@@ -1173,10 +1400,13 @@ export const SettingsPage: React.FC = () => {
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.max_login_attempts}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value) || 5 }))}
|
||||
min="3"
|
||||
max="10"
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value, 10) || 5 }))}
|
||||
min="1"
|
||||
max="50"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{t('settings.security.maxLoginAttemptsHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
Vendored
+1
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vitest" />
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
/// <reference types="vitest" />
|
||||
// @ts-nocheck
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import type { UserConfig as VitestUserConfig } from 'vitest/config'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
const config: VitestUserConfig = {
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
@@ -28,5 +32,7 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(config as any)
|
||||
|
||||
Generated
+6
-5
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "wedding-photo-sharing",
|
||||
"name": "picpeak",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -558,7 +558,8 @@
|
||||
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz",
|
||||
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
@@ -1499,9 +1500,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
"devDependencies": {
|
||||
"puppeteer": "^24.17.0",
|
||||
"@playwright/test": "^1.48.2"
|
||||
},
|
||||
"overrides": {
|
||||
"prebuild-install": {
|
||||
"tar-fs": "2.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
timeout: 60_000,
|
||||
retries: 0,
|
||||
use: {
|
||||
baseURL: 'http://localhost:3000',
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 800 },
|
||||
ignoreHTTPSErrors: true,
|
||||
@@ -15,4 +15,3 @@ export default defineConfig({
|
||||
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
################################################################################
|
||||
# PicPeak Unified Setup Script
|
||||
# Version: 2.0.0
|
||||
# Version: 2.1.0
|
||||
# Description: Universal installer for PicPeak with Docker and Native options
|
||||
# Supports: Ubuntu, Debian, Fedora, RHEL/CentOS, Raspberry Pi OS
|
||||
################################################################################
|
||||
@@ -11,7 +11,7 @@ set -euo pipefail
|
||||
IFS=$'\n\t'
|
||||
|
||||
# Script configuration
|
||||
readonly SCRIPT_VERSION="2.0.0"
|
||||
readonly SCRIPT_VERSION="2.1.0"
|
||||
readonly APP_NAME="PicPeak"
|
||||
readonly REPO_URL="https://github.com/the-luap/picpeak.git"
|
||||
readonly NODE_VERSION="20"
|
||||
@@ -55,6 +55,7 @@ CUSTOM_PORT=""
|
||||
UNATTENDED=false
|
||||
UPDATE_MODE=false
|
||||
UNINSTALL_MODE=false
|
||||
FORCE_ADMIN_PASSWORD_RESET=false
|
||||
|
||||
################################################################################
|
||||
# Helper Functions
|
||||
@@ -63,17 +64,19 @@ UNINSTALL_MODE=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
|
||||
}
|
||||
|
||||
@@ -390,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
|
||||
@@ -487,6 +513,15 @@ EOF
|
||||
# Run database migrations
|
||||
log_step "Running database migrations..."
|
||||
docker compose exec -T backend npm run migrate
|
||||
|
||||
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
|
||||
log_step "Resetting admin credentials..."
|
||||
if docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt; then
|
||||
docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
|
||||
else
|
||||
log_warn "Automatic admin password reset failed; run reset-admin-password.js inside the backend container."
|
||||
fi
|
||||
fi
|
||||
|
||||
log_success "Docker installation completed!"
|
||||
}
|
||||
@@ -619,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
|
||||
;;
|
||||
@@ -737,6 +779,13 @@ EOF
|
||||
log_step "Initializing database..."
|
||||
cd "$NATIVE_APP_DIR/app/backend"
|
||||
run_as_user "npm run migrate"
|
||||
|
||||
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
|
||||
log_step "Resetting admin credentials..."
|
||||
if ! run_as_user "node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"; then
|
||||
log_warn "Automatic admin password reset failed; please run reset-admin-password.js manually."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create systemd services
|
||||
create_systemd_services
|
||||
@@ -936,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!"
|
||||
@@ -989,7 +1040,7 @@ print_success_message() {
|
||||
fi
|
||||
else
|
||||
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
||||
echo -e "Password: ${YELLOW}(credentials file not found)${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}"
|
||||
@@ -1256,6 +1307,10 @@ parse_arguments() {
|
||||
SMTP_PASS="$2"
|
||||
shift 2
|
||||
;;
|
||||
--force-admin-password-reset)
|
||||
FORCE_ADMIN_PASSWORD_RESET=true
|
||||
shift
|
||||
;;
|
||||
--enable-ssl)
|
||||
ENABLE_SSL=true
|
||||
shift
|
||||
@@ -1301,6 +1356,7 @@ Options:
|
||||
--smtp-port PORT SMTP server port
|
||||
--smtp-user USER SMTP username
|
||||
--smtp-pass PASS SMTP password
|
||||
--force-admin-password-reset Regenerate admin credentials after setup
|
||||
--enable-ssl Enable HTTPS with Let's Encrypt
|
||||
--port PORT Custom port (native only)
|
||||
--update Update existing installation
|
||||
@@ -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