Compare commits

...

31 Commits

Author SHA1 Message Date
paul 69538b86ea Fix mobile overlay and deps per #43
Test and Lint / backend-test (pull_request) Successful in 1m24s
Test and Lint / frontend-test (pull_request) Successful in 1m59s
continuous-integration/drone/pr Build is passing
2025-10-29 12:19:43 +01:00
Gitea Actions Bot b76e45cb54 chore: bump version to 1.1.14 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-20 12:41:34 +00:00
Paul Nothaft 5b5e431b08 Implement per-IP gallery lockouts and UI controls (#42)
Test and Lint / backend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m50s
2025-10-20 14:35:23 +02:00
Gitea Actions Bot 07759a0e40 chore: bump version to 1.1.13 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-15 05:29:19 +00:00
Paul Nothaft 31fd64c83c Add short gallery URL toggle and token support (#38)
Test and Lint / backend-test (push) Successful in 1m55s
Test and Lint / frontend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
2025-10-15 07:21:09 +02:00
Paul Nothaft 775c5159ea Add customer contact fields and admin API docs (refs #41) 2025-10-14 18:29:21 +02:00
Paul Nothaft 8f297e25c4 Make photo upload limit configurable via admin settings (#40) 2025-10-14 16:27:44 +02:00
Paul Nothaft ccb65b892b Rename setup script and bump installer version (#39) 2025-10-14 15:48:55 +02:00
Paul Nothaft 52f8f1f738 Upgrade nodemailer to 7.0.7 (GHSA-mm7p-fcc7-pg87)
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 21:11:38 +02:00
Paul Nothaft e731e7b47c Address tar-fs CVE-2025-59343
Test and Lint / backend-test (push) Successful in 1m21s
Test and Lint / frontend-test (push) Has been cancelled
2025-10-13 21:09:52 +02:00
Paul Nothaft 2bccb1a439 Handle pre-existing docker app dir (#32)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:59:50 +02:00
Paul Nothaft df10fc677e Send gallery image requests with bearer token fallback (#31)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:29:07 +02:00
Gitea Actions Bot 8c690155bf chore: bump frontend version to 1.1.12 2025-10-13 18:19:57 +00:00
Paul Nothaft 1b1e4f715d Rename event owner fields to customer (#37)
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 20:09:28 +02:00
Paul Nothaft 68eb9ba552 Clarify event owner labeling in UI (#37)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m52s
2025-10-13 20:02:52 +02:00
Paul Nothaft 7040865154 Fix admin password reset guidance in setup.sh (#34)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-10-13 19:58:07 +02:00
Paul Nothaft 013be18d98 fix: clear notifications via API (#35)
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 17:41:06 +02:00
Paul Nothaft 3c2a79a31a feat: allow admin email updates in UI (#36) 2025-10-13 17:21:03 +02:00
Gitea Actions Bot f20472ca26 chore: bump version to 1.1.11 (backend + frontend) 2025-10-12 19:23:19 +00:00
Gitea Actions Bot 87f4526220 chore: bump version to 1.1.10 (backend + frontend) 2025-10-12 19:18:37 +00:00
Gitea Actions Bot d42a11680f chore: bump version to 1.1.9 (backend + frontend) 2025-10-06 13:18:43 +00:00
Gitea Actions Bot 38dd74b893 chore: bump version to 1.1.8 (backend + frontend) 2025-10-03 05:19:52 +00:00
paul fc1bf53412 fix: harden gallery downloads and per-gallery auth
Test and Lint / backend-test (push) Successful in 1m57s
Test and Lint / frontend-test (push) Successful in 1m57s
2025-10-01 16:00:37 +02:00
paul 5d6c061f1c feat: support per-gallery password toggle 2025-10-01 16:00:37 +02:00
Gitea Actions Bot 45e835a51a chore: bump frontend version to 1.1.7 2025-09-27 06:14:25 +00:00
Gitea Actions Bot afc00090cf chore: bump frontend version to 1.1.6 2025-09-27 06:11:45 +00:00
paul 59750dea15 Enforce mandatory gallery passwords in UI
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m51s
2025-09-27 08:05:59 +02:00
Gitea Actions Bot 2fe32e9a69 chore: bump backend version to 1.1.5 2025-09-27 05:59:32 +00:00
paul 5f8c8c5508 Fix branding asset storage path
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-09-26 17:26:39 +02:00
Gitea Actions Bot fb739f221d chore: bump version to 1.1.4 (backend + frontend) 2025-09-24 15:39:34 +00:00
paul b5399aaa9b Add installer flag to regenerate admin credentials
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 1m54s
2025-09-24 17:33:54 +02:00
82 changed files with 6229 additions and 1291 deletions
+19 -6
View File
@@ -7,9 +7,9 @@ This guide covers multiple deployment options for PicPeak, from simple local set
For the easiest installation without Docker or complex configurations, use our **unified setup script**: For the easiest installation without Docker or complex configurations, use our **unified setup script**:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \ curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x setup.sh && \ chmod +x picpeak-setup.sh && \
sudo ./setup.sh sudo ./picpeak-setup.sh
``` ```
This automated script handles everything including: This automated script handles everything including:
@@ -219,14 +219,17 @@ Update `.env` with:
- **URL Configuration** (for backend CORS): - **URL Configuration** (for backend CORS):
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash) - `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
- Example (Docker): `http://localhost:3000` - Example (Docker): `http://localhost:3000`
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash) - `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000` - Example (Docker): `http://localhost:3000`
Notes: Notes:
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`). - Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
- Always include the scheme (`http://` or `https://`). - Always include the scheme (`http://` or `https://`).
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500. - 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 #### 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: 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. 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 #### Configuring Admin Email
+2
View File
@@ -85,6 +85,8 @@ Note on Docker file permissions (PUID/PGID)
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions - 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode - Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
- 📚 [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints
- 🛠️ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute - 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
- 📜 [**License**](LICENSE) - MIT License - 📜 [**License**](LICENSE) - MIT License
- 🔒 [**Security**](SECURITY.md) - Security policies - 🔒 [**Security**](SECURITY.md) - Security policies
+14 -14
View File
@@ -8,9 +8,9 @@ This guide provides easy installation instructions for PicPeak on Linux servers
```bash ```bash
# Download and run the unified setup script # Download and run the unified setup script
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/setup.sh -o setup.sh && \ curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x setup.sh && \ chmod +x picpeak-setup.sh && \
sudo ./setup.sh sudo ./picpeak-setup.sh
``` ```
The script will automatically detect your environment and recommend the best installation method. The script will automatically detect your environment and recommend the best installation method.
@@ -21,7 +21,7 @@ The script will automatically detect your environment and recommend the best ins
Best for: Most users, easy updates, isolated environment Best for: Most users, easy updates, isolated environment
```bash ```bash
sudo ./setup.sh --docker sudo ./picpeak-setup.sh --docker
``` ```
**Pros:** **Pros:**
@@ -38,7 +38,7 @@ sudo ./setup.sh --docker
Best for: Resource-constrained systems, Raspberry Pi, direct control Best for: Resource-constrained systems, Raspberry Pi, direct control
```bash ```bash
sudo ./setup.sh --native sudo ./picpeak-setup.sh --native
``` ```
**Pros:** **Pros:**
@@ -73,7 +73,7 @@ sudo ./setup.sh --native
### Interactive Mode (Default) ### Interactive Mode (Default)
```bash ```bash
sudo ./setup.sh sudo ./picpeak-setup.sh
``` ```
The script will prompt you to choose: The script will prompt you to choose:
@@ -87,7 +87,7 @@ The script will prompt you to choose:
#### Docker with full configuration: #### Docker with full configuration:
```bash ```bash
sudo ./setup.sh --docker --unattended \ sudo ./picpeak-setup.sh --docker --unattended \
--domain photos.example.com \ --domain photos.example.com \
--email admin@example.com \ --email admin@example.com \
--admin-password SecurePass123 \ --admin-password SecurePass123 \
@@ -100,7 +100,7 @@ sudo ./setup.sh --docker --unattended \
#### Native with minimal configuration: #### Native with minimal configuration:
```bash ```bash
sudo ./setup.sh --native --unattended \ sudo ./picpeak-setup.sh --native --unattended \
--email admin@example.com \ --email admin@example.com \
--admin-password SecurePass123 --admin-password SecurePass123
``` ```
@@ -293,7 +293,7 @@ sudo systemctl restart picpeak-backend picpeak-workers
# Update PicPeak # Update PicPeak
# (reruns migrations to pick up schema fixes for native installs) # (reruns migrations to pick up schema fixes for native installs)
sudo ./setup.sh --update sudo ./picpeak-setup.sh --update
``` ```
## ⚙️ Configuration ## ⚙️ Configuration
@@ -385,14 +385,14 @@ docker compose pull
docker compose up -d docker compose up -d
# Native # Native
sudo ./setup.sh --update sudo ./picpeak-setup.sh --update
``` ```
### Uninstall ### Uninstall
```bash ```bash
# Will prompt for confirmation and data removal options # Will prompt for confirmation and data removal options
sudo ./setup.sh --uninstall sudo ./picpeak-setup.sh --uninstall
``` ```
## 🐛 Troubleshooting ## 🐛 Troubleshooting
@@ -508,13 +508,13 @@ sudo systemctl restart picpeak-backend
### Home/Office Network ### Home/Office Network
```bash ```bash
# Simple local setup without domain # Simple local setup without domain
sudo ./setup.sh --native --email admin@local.com sudo ./picpeak-setup.sh --native --email admin@local.com
``` ```
### Public Website with HTTPS ### Public Website with HTTPS
```bash ```bash
# Full production setup # Full production setup
sudo ./setup.sh --docker \ sudo ./picpeak-setup.sh --docker \
--domain photos.company.com \ --domain photos.company.com \
--email admin@company.com \ --email admin@company.com \
--enable-ssl --enable-ssl
@@ -523,7 +523,7 @@ sudo ./setup.sh --docker \
### Raspberry Pi Setup ### Raspberry Pi Setup
```bash ```bash
# Optimized for ARM devices # Optimized for ARM devices
sudo ./setup.sh --native \ sudo ./picpeak-setup.sh --native \
--port 8080 \ --port 8080 \
--email pi@local.com --email pi@local.com
``` ```
@@ -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');
});
});
+4 -4
View File
@@ -1831,8 +1831,8 @@
} }
}, },
"nodemailer": { "nodemailer": {
"version": "6.10.1", "version": "7.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
"overridden": false "overridden": false
}, },
"nodemon": { "nodemon": {
@@ -2086,8 +2086,8 @@
"version": "4.0.1" "version": "4.0.1"
}, },
"tar-fs": { "tar-fs": {
"version": "2.1.3", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"overridden": false "overridden": false
}, },
"tunnel-agent": { "tunnel-agent": {
@@ -0,0 +1,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');
});
}
};
+31 -54
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.1.3", "version": "1.1.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.1.3", "version": "1.1.14",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0", "@aws-sdk/lib-storage": "^3.850.0",
@@ -35,7 +35,7 @@
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"multer": "^2.0.2", "multer": "^2.0.2",
"node-cron": "^3.0.2", "node-cron": "^3.0.2",
"nodemailer": "7.0.5", "nodemailer": "^7.0.10",
"pg": "^8.16.3", "pg": "^8.16.3",
"react-i18next": "^15.6.0", "react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
@@ -5154,29 +5154,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/encoding": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
"license": "MIT",
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.2"
}
},
"node_modules/encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/end-of-stream": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -5620,13 +5597,13 @@
} }
}, },
"node_modules/express-validator": { "node_modules/express-validator": {
"version": "7.2.1", "version": "7.3.0",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
"integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", "integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lodash": "^4.17.21", "lodash": "^4.17.21",
"validator": "~13.12.0" "validator": "~13.15.15"
}, },
"engines": { "engines": {
"node": ">= 8.0.0" "node": ">= 8.0.0"
@@ -8355,9 +8332,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "7.0.5", "version": "7.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==", "integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -9008,24 +8985,6 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/prebuild-install/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/prebuild-install/node_modules/tar-fs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -10246,6 +10205,24 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-fs/node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/tar-stream": { "node_modules/tar-stream": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
@@ -10624,9 +10601,9 @@
} }
}, },
"node_modules/validator": { "node_modules/validator": {
"version": "13.12.0", "version": "13.15.20",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", "integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.10"
+7 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.1.3", "version": "1.1.14",
"description": "Backend for PicPeak event photo sharing platform", "description": "Backend for PicPeak event photo sharing platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
@@ -39,7 +39,7 @@
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"multer": "^2.0.2", "multer": "^2.0.2",
"node-cron": "^3.0.2", "node-cron": "^3.0.2",
"nodemailer": "7.0.5", "nodemailer": "^7.0.10",
"pg": "^8.16.3", "pg": "^8.16.3",
"react-i18next": "^15.6.0", "react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0", "sanitize-html": "^2.17.0",
@@ -55,5 +55,10 @@
"mock-fs": "^5.5.0", "mock-fs": "^5.5.0",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",
"supertest": "^6.3.3" "supertest": "^6.3.3"
},
"overrides": {
"prebuild-install": {
"tar-fs": "2.1.4"
}
} }
} }
+102
View File
@@ -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;
});
+54 -22
View File
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const readline = require('readline'); 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, input: process.stdin,
output: process.stdout output: process.stdout
}); });
async function question(prompt) { async function ask(prompt) {
if (force) {
return 'yes';
}
return new Promise((resolve) => { return new Promise((resolve) => {
rl.question(prompt, resolve); rl.question(prompt, resolve);
}); });
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
console.log('Found admin user:', admin.username); console.log('Found admin user:', admin.username);
console.log('Email:', admin.email); console.log('Email:', admin.email);
console.log('\nThis will reset the password for this admin account.'); if (!force) {
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') { const confirm = await ask('\nDo you want to continue? (yes/no): ');
console.log('\n❌ Password reset cancelled.');
process.exit(0); if (!force) {
const normalized = confirm.trim().toLowerCase();
if (normalized !== 'yes' && normalized !== 'y') {
console.log('\n❌ Password reset cancelled.');
process.exit(0);
}
} }
// Generate new password // Generate new password
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
}); });
// Save to file // 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 = ` 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 Username: ${admin.username}
New Password: ${newPassword} Email: ${admin.email}
Password: ${newPassword}
IMPORTANT: IMPORTANT SECURITY NOTES:
1. You MUST change this password on next login 1. You MUST change this password after first login
2. This file contains sensitive information 2. This file contains sensitive information
3. Delete this file after noting the password 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()} 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('\n✅ Password reset successful!\n');
console.log('========================================'); console.log('========================================');
console.log('New Credentials:'); console.log('New Credentials:');
console.log('========================================'); console.log('========================================');
console.log('Username: admin'); console.log(`Username: ${admin.username}`);
console.log(`Email: ${admin.email}`);
console.log(`Password: ${newPassword}`); console.log(`Password: ${newPassword}`);
console.log('\n⚠️ IMPORTANT:'); console.log('\n⚠️ IMPORTANT:');
console.log('1. You will be required to change this password on next login'); 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('3. Delete the file after noting the password');
console.log('========================================\n'); console.log('========================================\n');
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
console.error('❌ Error resetting password:', error.message); console.error('❌ Error resetting password:', error.message);
process.exit(1); process.exit(1);
} finally { } finally {
rl.close(); if (rl) {
rl.close();
}
process.exit(0); process.exit(0);
} }
} }
// Run the reset // Run the reset
resetAdminPassword(); resetAdminPassword();
+45 -1
View File
@@ -3,6 +3,7 @@ const path = require('path');
const knex = require('knex'); const knex = require('knex');
const knexConfig = require('../../knexfile'); const knexConfig = require('../../knexfile');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { extractShareToken } = require('../utils/shareLinkUtils');
// Ensure SQLite directory exists when using file-based DB (native installs) // Ensure SQLite directory exists when using file-based DB (native installs)
try { try {
@@ -63,12 +64,16 @@ async function initializeDatabase() {
table.string('event_type').notNullable(); table.string('event_type').notNullable();
table.string('event_name').notNullable(); table.string('event_name').notNullable();
table.date('event_date').notNullable(); table.date('event_date').notNullable();
table.string('customer_name');
table.string('customer_email');
table.string('host_email').notNullable(); table.string('host_email').notNullable();
table.string('host_name');
table.string('admin_email').notNullable(); table.string('admin_email').notNullable();
table.string('password_hash').notNullable(); table.string('password_hash').notNullable();
table.text('welcome_message'); table.text('welcome_message');
table.text('color_theme'); table.text('color_theme');
table.string('share_link').unique().notNullable(); table.string('share_link').unique().notNullable();
table.string('share_token').unique();
table.datetime('created_at').defaultTo(db.fn.now()); table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('expires_at').notNullable(); table.datetime('expires_at').notNullable();
table.boolean('is_active').defaultTo(true); table.boolean('is_active').defaultTo(true);
@@ -82,6 +87,7 @@ async function initializeDatabase() {
table.boolean('watermark_downloads').defaultTo(false); table.boolean('watermark_downloads').defaultTo(false);
table.text('watermark_text'); table.text('watermark_text');
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL'); table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
table.boolean('require_password').defaultTo(true);
}); });
} else { } else {
// Check if color_theme needs to be updated to TEXT type // Check if color_theme needs to be updated to TEXT type
@@ -98,12 +104,16 @@ async function initializeDatabase() {
event_type TEXT NOT NULL, event_type TEXT NOT NULL,
event_name TEXT NOT NULL, event_name TEXT NOT NULL,
event_date DATE NOT NULL, event_date DATE NOT NULL,
customer_name TEXT,
customer_email TEXT,
host_name TEXT,
host_email TEXT NOT NULL, host_email TEXT NOT NULL,
admin_email TEXT NOT NULL, admin_email TEXT NOT NULL,
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
welcome_message TEXT, welcome_message TEXT,
color_theme TEXT, color_theme TEXT,
share_link TEXT UNIQUE NOT NULL, share_link TEXT UNIQUE NOT NULL,
share_token TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL, expires_at DATETIME NOT NULL,
is_active BOOLEAN DEFAULT 1, is_active BOOLEAN DEFAULT 1,
@@ -116,7 +126,8 @@ async function initializeDatabase() {
disable_right_click BOOLEAN DEFAULT 0, disable_right_click BOOLEAN DEFAULT 0,
watermark_downloads BOOLEAN DEFAULT 0, watermark_downloads BOOLEAN DEFAULT 0,
watermark_text TEXT, 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'; return 'watermark_text';
case 'hero_photo_id': case 'hero_photo_id':
return 'hero_photo_id'; return 'hero_photo_id';
case 'require_password':
return 'COALESCE(require_password, 1) as require_password';
default: default:
return col; 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 // Photo metadata table
const hasPhotosTable = await db.schema.hasTable('photos'); const hasPhotosTable = await db.schema.hasTable('photos');
if (!hasPhotosTable) { if (!hasPhotosTable) {
+40 -6
View File
@@ -2,13 +2,48 @@ const jwt = require('jsonwebtoken');
const { db, withRetry } = require('../database/db'); const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
// Middleware to verify gallery access // Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) { async function verifyGalleryAccess(req, res, next) {
try { try {
const requestedSlug = req.params.slug || req.requestedSlug; const requestedSlug = req.params.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, requestedSlug); const token = getGalleryTokenFromRequest(req, requestedSlug);
let event;
if (!token) { 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' }); return res.status(401).json({ error: 'No token provided' });
} }
@@ -26,10 +61,9 @@ async function verifyGalleryAccess(req, res, next) {
throw error; 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 // If we have a slug in the URL params or from pre-middleware, verify it matches
let event;
if (requestedSlug) { if (requestedSlug) {
// Verify by slug and ensure it matches the token's event // Verify by slug and ensure it matches the token's event
event = await withRetry(async () => { event = await withRetry(async () => {
@@ -62,11 +96,11 @@ async function verifyGalleryAccess(req, res, next) {
} }
if (!event) { 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' }); 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.event = event;
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`; req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
@@ -78,10 +112,10 @@ async function verifyGalleryAccess(req, res, next) {
timestamp: Date.now() timestamp: Date.now()
}; };
console.log('[verifyGalleryAccess] Access granted for event:', event.id); logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
next(); next();
} catch (error) { } 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' }); res.status(401).json({ error: 'Invalid token' });
} }
} }
+18 -12
View File
@@ -3,14 +3,13 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
async function photoAuth(req, res, next) { async function photoAuth(req, res, next) {
try { try {
// Extract event slug from the path // Extract event slug from the path
let eventSlug; let eventSlug;
console.log('PhotoAuth middleware - path:', req.path);
// For thumbnails, we need to parse the filename to get the event info // For thumbnails, we need to parse the filename to get the event info
if (req.path.startsWith('/thumb_')) { if (req.path.startsWith('/thumb_')) {
// For now, we'll rely on JWT token for thumbnail access // 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 // For both thumbnails and photos with admin token, allow access
return next(); return next();
} }
} catch (err) { } catch (err) {
// Token invalid, fall through to password check // Token invalid, fall through to password check
console.error('JWT verification failed:', err.message); logger.warn('JWT verification failed in photoAuth', { error: err.message });
} }
} }
// Check for password header (legacy support) // Check for password header (legacy support)
const password = req.headers['x-gallery-password']; 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 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' }); return res.status(401).json({ error: 'Authentication required for thumbnails' });
} }
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first(); const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) { if (!event) {
return res.status(404).json({ error: 'Gallery not found' }); 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) { if (password) {
const validPassword = await bcrypt.compare(password, event.password_hash); const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) { if (!validPassword) {
@@ -122,7 +128,7 @@ async function photoAuth(req, res, next) {
req.event = event; req.event = event;
next(); next();
} catch (error) { } 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' }); res.status(500).json({ error: 'Authentication error' });
} }
} }
+88 -1
View File
@@ -8,6 +8,93 @@ const { validatePasswordStrength } = require('../utils/passwordGenerator');
const router = express.Router(); const router = express.Router();
// Change password // Change password
router.get('/profile', adminAuth, async (req, res) => {
try {
const admin = await db('admin_users')
.where('id', req.admin.id)
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
.first();
if (!admin) {
return res.status(404).json({ error: 'Admin user not found' });
}
res.json(admin);
} catch (error) {
console.error('Admin profile fetch error:', error);
res.status(500).json({ error: 'Failed to fetch admin profile' });
}
});
router.put('/profile', [
adminAuth,
body('username')
.trim()
.isLength({ min: 3, max: 50 })
.withMessage('Username must be between 3 and 50 characters'),
body('email')
.trim()
.isEmail()
.withMessage('A valid email address is required')
.normalizeEmail()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const username = req.body.username.trim();
const email = req.body.email.trim().toLowerCase();
const adminId = req.admin.id;
const existingUsername = await db('admin_users')
.where('username', username)
.whereNot('id', adminId)
.first();
if (existingUsername) {
return res.status(409).json({ error: 'Username is already in use' });
}
const existingEmail = await db('admin_users')
.where('email', email)
.whereNot('id', adminId)
.first();
if (existingEmail) {
return res.status(409).json({ error: 'Email address is already in use' });
}
await db('admin_users')
.where('id', adminId)
.update({
username,
email,
updated_at: new Date()
});
await logActivity('admin_profile_updated',
{ username, email },
null,
{ type: 'admin', id: adminId, name: req.admin.username }
);
const updatedAdmin = await db('admin_users')
.where('id', adminId)
.select('id', 'username', 'email', 'must_change_password as mustChangePassword')
.first();
res.json({
message: 'Admin profile updated successfully',
user: updatedAdmin
});
} catch (error) {
console.error('Admin profile update error:', error);
res.status(500).json({ error: 'Failed to update admin profile' });
}
});
router.post('/change-password', [ router.post('/change-password', [
adminAuth, adminAuth,
body('currentPassword').notEmpty().withMessage('Current password is required'), body('currentPassword').notEmpty().withMessage('Current password is required'),
@@ -96,4 +183,4 @@ router.post('/logout', adminAuth, async (req, res) => {
} }
}); });
module.exports = router; module.exports = router;
+14 -10
View File
@@ -2,13 +2,14 @@
// Only the relevant parts are shown - merge with existing adminEvents.js // Only the relevant parts are shown - merge with existing adminEvents.js
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('../services/shareLinkService');
// Enhanced event creation with password validation // Enhanced event creation with password validation
router.post('/', adminAuth, [ router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(), body('event_name').notEmpty().trim(),
body('event_date').isDate(), body('event_date').isDate(),
body('host_email').isEmail().normalizeEmail(), body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail().normalizeEmail(), body('admin_email').isEmail().normalizeEmail(),
body('password').notEmpty(), // Remove the weak isLength validation body('password').notEmpty(), // Remove the weak isLength validation
body('expiration_days').isInt({ min: 1, max: 365 }).optional(), body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
@@ -16,7 +17,7 @@ router.post('/', adminAuth, [
body('color_theme').optional().trim(), body('color_theme').optional().trim(),
body('allow_user_uploads').optional().isBoolean().toBoolean(), body('allow_user_uploads').optional().isBoolean().toBoolean(),
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('host_name').notEmpty().trim() body('customer_name').notEmpty().trim()
], async (req, res) => { ], async (req, res) => {
try { try {
console.log('Create event request body:', req.body); console.log('Create event request body:', req.body);
@@ -30,8 +31,8 @@ router.post('/', adminAuth, [
event_type, event_type,
event_name, event_name,
event_date, event_date,
host_name, customer_name,
host_email, customer_email,
admin_email, admin_email,
password, password,
welcome_message = '', welcome_message = '',
@@ -65,9 +66,9 @@ router.post('/', adminAuth, [
counter++; counter++;
} }
// Generate share link // Generate share link based on configured style
const shareToken = crypto.randomBytes(16).toString('hex'); const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`; const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds // Hash password with configurable rounds
const password_hash = await bcrypt.hash(password, getBcryptRounds()); const password_hash = await bcrypt.hash(password, getBcryptRounds());
@@ -88,13 +89,16 @@ router.post('/', adminAuth, [
event_type, event_type,
event_name, event_name,
event_date, event_date,
host_name, customer_name,
host_email, customer_email,
host_name: customer_name,
host_email: customer_email,
admin_email, admin_email,
password_hash, password_hash,
welcome_message, welcome_message,
color_theme, color_theme,
share_link: shareLink, share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(), expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
allow_user_uploads, allow_user_uploads,
@@ -121,4 +125,4 @@ router.post('/', adminAuth, [
console.error('Error creating event:', error); console.error('Error creating event:', error);
res.status(500).json({ error: 'Failed to create event' }); res.status(500).json({ error: 'Failed to create event' });
} }
}); });
+253 -48
View File
@@ -13,28 +13,129 @@ const { queueEmail } = require('../services/emailProcessor');
const { escapeLikePattern } = require('../utils/sqlSecurity'); const { escapeLikePattern } = require('../utils/sqlSecurity');
// formatDate import removed - dates are formatted by email processor // formatDate import removed - dates are formatted by email processor
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { 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 // Create new event
router.post('/', adminAuth, [ router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(), body('event_name').notEmpty().trim(),
body('event_date').isDate(), body('event_date').isDate(),
body('host_email').isEmail().normalizeEmail(), body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail().normalizeEmail(), body('admin_email').isEmail().normalizeEmail(),
body('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('expiration_days').isInt({ min: 1, max: 365 }).optional(),
body('welcome_message').optional().trim(), body('welcome_message').optional().trim(),
body('color_theme').optional().trim(), body('color_theme').optional().trim(),
body('allow_user_uploads').optional().isBoolean().toBoolean(), body('allow_user_uploads').optional().isBoolean().toBoolean(),
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('host_name').notEmpty().trim(),
body('allow_downloads').optional().isBoolean(), body('allow_downloads').optional().isBoolean(),
body('disable_right_click').optional().isBoolean(), body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim() body('watermark_text').optional().trim()
], async (req, res) => { ], async (req, res) => {
try { try {
console.log('Create event request body:', req.body); logger.debug('Create event request body', { body: req.body });
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
console.error('Validation errors:', errors.array()); console.error('Validation errors:', errors.array());
@@ -45,8 +146,6 @@ router.post('/', adminAuth, [
event_type, event_type,
event_name, event_name,
event_date, event_date,
host_name,
host_email,
admin_email, admin_email,
password, password,
welcome_message = '', welcome_message = '',
@@ -58,6 +157,7 @@ router.post('/', adminAuth, [
disable_right_click = false, disable_right_click = false,
watermark_downloads = false, watermark_downloads = false,
watermark_text = null, watermark_text = null,
require_password: requirePasswordInput = true,
// Feedback settings // Feedback settings
feedback_enabled = false, feedback_enabled = false,
allow_ratings = true, allow_ratings = true,
@@ -68,13 +168,25 @@ router.post('/', adminAuth, [
moderate_comments = true, moderate_comments = true,
show_feedback_to_guests = true show_feedback_to_guests = true
} = req.body; } = req.body;
const customerName = getCustomerNameFromPayload(req.body);
const customerEmail = getCustomerEmailFromPayload(req.body);
const customerColumnsAvailable = await hasCustomerContactColumns();
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
}
const requirePassword = parseBooleanInput(requirePasswordInput, true);
// Debug logging // Debug logging
console.log('Download control values:', { logger.debug('Download control values', {
allow_downloads, allow_downloads,
disable_right_click, disable_right_click,
watermark_downloads, watermark_downloads,
watermark_text, watermark_text,
require_password: requirePassword,
types: { types: {
allow_downloads: typeof allow_downloads, allow_downloads: typeof allow_downloads,
disable_right_click: typeof disable_right_click, disable_right_click: typeof disable_right_click,
@@ -82,18 +194,21 @@ router.post('/', adminAuth, [
} }
}); });
// Validate password strength let passwordValidation = null;
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name 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
}); });
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 // Generate unique slug
@@ -111,12 +226,14 @@ router.post('/', adminAuth, [
counter++; counter++;
} }
// Generate share link // Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex'); const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`; const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds // Hash password with configurable rounds (random placeholder when not required)
const password_hash = await bcrypt.hash(password, getBcryptRounds()); 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) // Calculate expiration date (days after event date)
// Parse YYYY-MM-DD format as local date to avoid timezone issues // Parse YYYY-MM-DD format as local date to avoid timezone issues
@@ -141,13 +258,15 @@ router.post('/', adminAuth, [
event_type, event_type,
event_name, event_name,
event_date, event_date,
host_name, ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
host_email, host_name: customerName,
host_email: customerEmail,
admin_email, admin_email,
password_hash, password_hash,
welcome_message, welcome_message,
color_theme, color_theme,
share_link: shareLink, share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(), expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
allow_user_uploads, allow_user_uploads,
@@ -155,7 +274,8 @@ router.post('/', adminAuth, [
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text watermark_text,
require_password: formatBoolean(requirePassword)
}).returning('id'); }).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -180,7 +300,7 @@ router.post('/', adminAuth, [
// Log activity // Log activity
await logActivity('event_created', await logActivity('event_created',
{ event_type, expires_at }, { event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId, eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username } { type: 'admin', id: req.admin.id, name: req.admin.username }
); );
@@ -190,14 +310,16 @@ router.post('/', adminAuth, [
await db('email_queue').insert({ await db('email_queue').insert({
event_id: eventId, event_id: eventId,
recipient_email: host_email, recipient_email: customerEmail,
email_type: 'gallery_created', email_type: 'gallery_created',
email_data: JSON.stringify({ email_data: JSON.stringify({
host_name: host_name, customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name, event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareLink, gallery_link: shareUrl,
gallery_password: password, gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || '' welcome_message: welcome_message || ''
}), }),
@@ -211,7 +333,10 @@ router.post('/', adminAuth, [
slug, slug,
event_name, event_name,
event_type, event_type,
share_link: shareLink, customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
share_link: shareUrl,
expires_at: expires_at.toISOString(), expires_at: expires_at.toISOString(),
created_at: new Date().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, created_at: event.created_at ? new Date(event.created_at).toISOString() : null,
expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null, expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null,
archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null
})); })).map(mapEventForApi);
res.json({ res.json({
events: eventsWithCounts, events: eventsWithCounts,
@@ -356,7 +481,7 @@ router.get('/:id', adminAuth, async (req, res) => {
.where('event_id', id) .where('event_id', id)
.countDistinct('ip_address as uniqueVisitors'); .countDistinct('ip_address as uniqueVisitors');
res.json({ res.json(mapEventForApi({
...event, ...event,
photo_count: parseInt(photoCount) || 0, photo_count: parseInt(photoCount) || 0,
total_size: parseInt(totalSize) || 0, total_size: parseInt(totalSize) || 0,
@@ -364,7 +489,7 @@ router.get('/:id', adminAuth, async (req, res) => {
total_downloads: parseInt(totalDownloads) || 0, total_downloads: parseInt(totalDownloads) || 0,
unique_visitors: parseInt(uniqueVisitors) || 0, unique_visitors: parseInt(uniqueVisitors) || 0,
recent_photos: recentPhotos recent_photos: recentPhotos
}); }));
} catch (error) { } catch (error) {
console.error('Error fetching event:', error); console.error('Error fetching event:', error);
res.status(500).json({ error: 'Failed to fetch event details' }); res.status(500).json({ error: 'Failed to fetch event details' });
@@ -380,7 +505,8 @@ router.put('/:id', adminAuth, [
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(), body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }), body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(), body('allow_user_uploads').optional().isBoolean(),
body('host_name').optional().trim().notEmpty(), body('customer_name').optional().trim().notEmpty(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('upload_category_id').optional().custom((value) => { body('upload_category_id').optional().custom((value) => {
// Accept null, undefined, or integer values // Accept null, undefined, or integer values
if (value === null || value === undefined) return true; if (value === null || value === undefined) return true;
@@ -398,18 +524,77 @@ router.put('/:id', adminAuth, [
body('watermark_downloads').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(), body('watermark_text').optional().trim(),
body('source_mode').optional().isIn(['managed', 'reference']), 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) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2)); logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
console.log('Request body:', req.body);
return res.status(400).json({ errors: errors.array() }); return res.status(400).json({ errors: errors.array() });
} }
const { id } = req.params; const { id } = req.params;
const updates = { ...req.body }; const updates = { ...req.body };
const customerColumnsAvailable = await hasCustomerContactColumns();
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')) { if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed'; updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
@@ -429,7 +614,7 @@ router.put('/:id', adminAuth, [
} }
// Log the update request for debugging // Log the update request for debugging
console.log('Update event request:', { logger.debug('Update event request', {
id, id,
updates, updates,
color_theme_length: updates.color_theme ? updates.color_theme.length : 0, 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' }); 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 // Update event
await db('events') await db('events')
.where('id', id) .where('id', id)
@@ -615,10 +812,13 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
// Queue email notification if requested // Queue email notification if requested
if (sendEmail) { if (sendEmail) {
// For password reset, we'll need to create a template or use a different approach const recipientEmail = event.customer_email || event.host_email;
// For now, let's use the gallery_created template with updated password const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(id, event.host_email, 'gallery_created', {
host_name: event.host_email.split('@')[0], await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name, event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link, gallery_link: event.share_link,
@@ -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 // Dates will be formatted by the email processor based on recipient language
// Queue the email // Queue the email
await queueEmail(id, event.host_email, 'gallery_created', { const recipientEmail = event.customer_email || event.host_email;
host_name: event.host_name || event.host_email.split('@')[0], const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name, event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link, gallery_link: event.share_link,
@@ -689,7 +894,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
try { try {
await logActivity('email_resent', { await logActivity('email_resent', {
email_type: 'gallery_created', email_type: 'gallery_created',
recipient: event.host_email, recipient: recipientEmail,
ip_address: req.ip || '0.0.0.0', ip_address: req.ip || '0.0.0.0',
user_agent: req.get('user-agent') || 'Unknown' user_agent: req.get('user-agent') || 'Unknown'
}, id, { }, id, {
+44 -7
View File
@@ -103,14 +103,51 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
// Use database-agnostic date calculation // Use database-agnostic date calculation
const thirtyDaysAgo = new Date(); const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const deletedCount = await db('activity_logs') let deletedCount = 0;
.whereNotNull('read_at') const client = db?.client?.config?.client;
.where('created_at', '<', thirtyDaysAgo)
.delete(); if (client === 'pg') {
const primaryResult = await db.raw(
`
WITH deleted AS (
DELETE FROM activity_logs
WHERE read_at IS NOT NULL OR created_at < ?
RETURNING id
)
SELECT COUNT(*)::int AS count FROM deleted
`,
[thirtyDaysAgo.toISOString()]
);
deletedCount = primaryResult.rows?.[0]?.count || 0;
if (deletedCount === 0) {
const fallbackResult = await db.raw(
`
WITH deleted AS (
DELETE FROM activity_logs
RETURNING id
)
SELECT COUNT(*)::int AS count FROM deleted
`
);
deletedCount = fallbackResult.rows?.[0]?.count || 0;
}
} else {
deletedCount = await db('activity_logs')
.where(function () {
this.whereNotNull('read_at')
.orWhere('created_at', '<', thirtyDaysAgo);
})
.delete();
if (deletedCount === 0) {
deletedCount = await db('activity_logs').delete();
}
}
res.json({ res.json({
message: 'Old notifications cleared', message: deletedCount > 0 ? 'Old notifications cleared' : 'No notifications to clear',
deletedCount deletedCount
}); });
} catch (error) { } catch (error) {
@@ -119,4 +156,4 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
} }
}); });
module.exports = router; module.exports = router;
+15 -6
View File
@@ -8,6 +8,7 @@ const { generateThumbnail, ensureThumbnail } = require('../services/imageProcess
const { generatePhotoFilename } = require('../utils/filenameSanitizer'); const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity'); const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation'); const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const router = express.Router(); const router = express.Router();
// Get storage path from environment or default // Get storage path from environment or default
@@ -48,7 +49,7 @@ const upload = multer({
storage: storage, storage: storage,
limits: { limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit per file fileSize: 50 * 1024 * 1024, // 50MB limit per file
files: 500, // Maximum 500 files files: 2000, // Hard safety ceiling; actual limit enforced dynamically
// Set a reasonable field size limit to prevent memory issues // Set a reasonable field size limit to prevent memory issues
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
// Add part size limits to prevent incomplete uploads // Add part size limits to prevent incomplete uploads
@@ -99,17 +100,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
}; };
// Upload photos for an event // Upload photos for an event
// Increased limit to 500 files, but recommend chunked uploads for better performance // Max file count is configurable via general settings
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
upload.array('photos', 500)(req, res, (err) => { let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
} catch (error) {
console.error('Failed to resolve max files per upload:', error);
return res.status(500).json({ error: 'Unable to determine upload limits' });
}
upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
if (err) { if (err) {
console.error('Multer error:', err); console.error('Multer error:', err);
if (err instanceof multer.MulterError) { if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') { if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' }); return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
} }
if (err.code === 'LIMIT_FILE_COUNT') { if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' }); return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
} }
return res.status(400).json({ error: `Upload error: ${err.message}` }); return res.status(400).json({ error: `Upload error: ${err.message}` });
} }
+36 -6
View File
@@ -18,12 +18,17 @@ const {
getRawPublicSiteSettings, getRawPublicSiteSettings,
} = require('../services/publicSiteService'); } = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer'); const { sanitizeCss } = require('../utils/cssSanitizer');
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const router = express.Router(); const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Configure multer for logo uploads // Configure multer for logo uploads
const storage = multer.diskStorage({ const storage = multer.diskStorage({
destination: async (req, file, cb) => { 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 }); await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir); cb(null, uploadDir);
}, },
@@ -53,7 +58,7 @@ const upload = multer({
// Configure multer for favicon uploads // Configure multer for favicon uploads
const faviconStorage = multer.diskStorage({ const faviconStorage = multer.diskStorage({
destination: async (req, file, cb) => { 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 }); await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir); cb(null, uploadDir);
}, },
@@ -228,7 +233,8 @@ router.put('/branding', adminAuth, async (req, res) => {
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) { if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
// Delete the file from filesystem // 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 { try {
await fs.unlink(faviconPath); await fs.unlink(faviconPath);
console.log('Deleted favicon file:', 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/')) { if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
// Delete the file from filesystem // 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 { try {
await fs.unlink(logoPath); await fs.unlink(logoPath);
console.log('Deleted logo file:', 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) => { router.put('/general', adminAuth, async (req, res) => {
try { try {
const settings = { ...req.body }; const settings = { ...req.body };
let uploadLimitTouched = false;
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_')); const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
uploadLimitTouched = true;
const rawValue = Number(settings.general_max_files_per_upload);
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
return res.status(400).json({
error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}`
});
}
settings.general_max_files_per_upload = normalizedValue;
}
if (publicSiteKeysTouched) { if (publicSiteKeysTouched) {
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) { if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || ''); settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
@@ -525,6 +547,12 @@ router.put('/general', adminAuth, async (req, res) => {
if (publicSiteKeysTouched) { if (publicSiteKeysTouched) {
clearPublicSiteCache(); clearPublicSiteCache();
} }
if (uploadLimitTouched) {
clearMaxFilesPerUploadCache();
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache();
}
// Log activity // Log activity
await db('activity_logs').insert({ await db('activity_logs').insert({
@@ -563,6 +591,8 @@ router.put('/security', adminAuth, async (req, res) => {
}); });
} }
resetSecurityConfigCache();
// Log activity // Log activity
await db('activity_logs').insert({ await db('activity_logs').insert({
activity_type: 'security_settings_updated', activity_type: 'security_settings_updated',
@@ -643,7 +673,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
for (const archive of archives) { for (const archive of archives) {
if (archive.archive_path) { if (archive.archive_path) {
try { try {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const storagePath = getStoragePath();
const fullArchivePath = path.join(storagePath, archive.archive_path); const fullArchivePath = path.join(storagePath, archive.archive_path);
const stats = await fs.stat(fullArchivePath); const stats = await fs.stat(fullArchivePath);
archiveStorage += stats.size; 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 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 diskStats = null;
let rawDiskTotal = null; let rawDiskTotal = null;
+59 -41
View File
@@ -12,13 +12,14 @@ const {
checkSuspiciousActivity, checkSuspiciousActivity,
getGenericAuthError getGenericAuthError
} = require('../utils/authSecurity'); } = require('../utils/authSecurity');
const { const {
validatePasswordInContext, validatePasswordInContext,
getBcryptRounds, getBcryptRounds,
logPasswordValidationFailure logPasswordValidationFailure
} = require('../utils/passwordValidation'); } = require('../utils/passwordValidation');
const { endSession } = require('../middleware/sessionTimeout'); const { endSession } = require('../middleware/sessionTimeout');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { getClientIp } = require('../utils/requestIp');
const router = express.Router(); const router = express.Router();
// Admin login with enhanced security // Admin login with enhanced security
@@ -33,7 +34,7 @@ router.post('/admin/login', [
} }
const { username, password, recaptchaToken } = req.body; const { username, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress; const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || ''; const userAgent = req.headers['user-agent'] || '';
// Check account lockout first // Check account lockout first
@@ -175,7 +176,7 @@ router.post('/admin/change-password', [
logger.info('Admin password changed', { logger.info('Admin password changed', {
userId: adminId, userId: adminId,
username: admin.username, username: admin.username,
ip: req.ip ip: ipAddress
}); });
res.json({ res.json({
@@ -220,7 +221,7 @@ router.post('/logout', async (req, res) => {
// Gallery password verification with enhanced security // Gallery password verification with enhanced security
router.post('/gallery/verify', [ router.post('/gallery/verify', [
body('slug').notEmpty().trim(), body('slug').notEmpty().trim(),
body('password').notEmpty() body('password').optional().isString()
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -229,56 +230,72 @@ router.post('/gallery/verify', [
} }
const { slug, password, recaptchaToken } = req.body; const { slug, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress; const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || ''; 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();
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) { if (!event) {
// Don't reveal if gallery exists // Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' }); return res.status(401).json({ error: 'Invalid gallery or password' });
} }
const validPassword = await bcrypt.compare(password, event.password_hash); if (requiresPassword) {
if (!validPassword) { if (!password) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); 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({ await db('access_logs').insert({
event_id: event.id, event_id: event.id,
ip_address: ipAddress, ip_address: ipAddress,
user_agent: userAgent, 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 // Generate session token with additional security info
const token = jwt.sign({ const token = jwt.sign({
eventId: event.id, eventId: event.id,
@@ -302,7 +319,8 @@ router.post('/gallery/verify', [
color_theme: event.color_theme, color_theme: event.color_theme,
expires_at: event.expires_at, expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads, 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) { } catch (error) {
@@ -372,4 +390,4 @@ router.post('/password-strength', [
} }
}); });
module.exports = router; module.exports = router;
+74 -51
View File
@@ -22,6 +22,8 @@ const {
getAdminTokenFromRequest, getAdminTokenFromRequest,
getGalleryTokenFromRequest, getGalleryTokenFromRequest,
} = require('../utils/tokenUtils'); } = require('../utils/tokenUtils');
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
const { getClientIp } = require('../utils/requestIp');
const router = express.Router(); const router = express.Router();
// Admin login with enhanced security // Admin login with enhanced security
@@ -36,7 +38,7 @@ router.post('/admin/login', [
} }
const { username, password, recaptchaToken } = req.body; const { username, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress; const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || ''; const userAgent = req.headers['user-agent'] || '';
// Check account lockout first // Check account lockout first
@@ -162,7 +164,7 @@ router.post('/logout', async (req, res) => {
// Gallery password verification with enhanced security // Gallery password verification with enhanced security
router.post('/gallery/verify', [ router.post('/gallery/verify', [
body('slug').notEmpty().trim(), body('slug').notEmpty().trim(),
body('password').notEmpty() body('password').optional().isString()
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req); const errors = validationResult(req);
@@ -171,57 +173,70 @@ router.post('/gallery/verify', [
} }
const { slug, password, recaptchaToken } = req.body; const { slug, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress; const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || ''; const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
// Check gallery-specific lockout .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`); .first();
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();
if (!event) { if (!event) {
// Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' }); return res.status(401).json({ error: 'Invalid gallery or password' });
} }
const validPassword = await bcrypt.compare(password, event.password_hash); const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); 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({ await db('access_logs').insert({
event_id: event.id, event_id: event.id,
ip_address: ipAddress, ip_address: ipAddress,
user_agent: userAgent, 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({ const token = jwt.sign({
eventId: event.id, eventId: event.id,
eventSlug: event.slug, eventSlug: event.slug,
@@ -246,7 +261,8 @@ router.post('/gallery/verify', [
color_theme: event.color_theme, color_theme: event.color_theme,
expires_at: event.expires_at, expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads, 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) { } catch (error) {
@@ -267,21 +283,25 @@ router.post('/gallery/share-login', [
} }
const { slug, token } = req.body; const { slug, token } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress; const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || ''; const userAgent = req.headers['user-agent'] || '';
const event = await db('events') let event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first(); .first();
if (!event) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) { if (!event) {
return res.status(404).json({ error: 'Gallery not found' }); return res.status(404).json({ error: 'Gallery not found' });
} }
let expectedToken = event.share_link; const expectedToken = getEventShareToken(event);
if (expectedToken && expectedToken.includes('/')) {
expectedToken = expectedToken.split('/').pop();
}
if (!expectedToken || token !== expectedToken) { if (!expectedToken || token !== expectedToken) {
return res.status(401).json({ error: 'Invalid or expired share link' }); return res.status(401).json({ error: 'Invalid or expired share link' });
@@ -298,9 +318,11 @@ router.post('/gallery/share-login', [
issuer: 'picpeak-auth' issuer: 'picpeak-auth'
}); });
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent); await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug); setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({ res.json({
token: jwtToken, token: jwtToken,
event: { event: {
@@ -312,7 +334,8 @@ router.post('/gallery/share-login', [
color_theme: event.color_theme, color_theme: event.color_theme,
expires_at: event.expires_at, expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads, 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) { } catch (error) {
+221 -25
View File
@@ -4,19 +4,107 @@ const bcrypt = require('bcrypt');
const crypto = require('crypto'); const crypto = require('crypto');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { adminAuth } = require('../middleware/auth-enhanced-v2'); const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
const router = express.Router(); const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const parseBooleanInput = (value, defaultValue = true) => {
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 // Create new event
router.post('/', adminAuth, [ router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty(), body('event_name').notEmpty(),
body('event_date').isDate(), body('event_date').isDate(),
body('host_email').isEmail(), body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail(), body('admin_email').isEmail(),
body('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() body('expiration_days').isInt({ min: 1, max: 365 }).optional()
], async (req, res) => { ], async (req, res) => {
try { try {
@@ -29,13 +117,39 @@ router.post('/', adminAuth, [
event_type, event_type,
event_name, event_name,
event_date, event_date,
host_email,
admin_email, admin_email,
password, password,
require_password: requirePasswordInput = true,
welcome_message, welcome_message,
color_theme, color_theme,
expiration_days = 30 expiration_days = 30
} = req.body; } = req.body;
const customerEmail = getCustomerEmailFromPayload(req.body);
const customerName = getCustomerNameFromPayload(req.body);
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
}
const customerColumnsAvailable = await hasCustomerContactColumns();
const requirePassword = parseBooleanInput(requirePasswordInput, true);
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 // Generate unique slug
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`; const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
@@ -47,12 +161,14 @@ router.post('/', adminAuth, [
counter++; counter++;
} }
// Generate share link (just slug/token, not full URL) // Generate share link variants (auto-detects short URL preference)
const shareToken = crypto.randomBytes(16).toString('hex'); const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${slug}/${shareToken}`; const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password // Hash password (or placeholder when not required)
const password_hash = await bcrypt.hash(password, 10); 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) // Calculate expiration date (days after event date)
const expires_at = new Date(event_date); const expires_at = new Date(event_date);
@@ -70,13 +186,17 @@ router.post('/', adminAuth, [
event_type, event_type,
event_name, event_name,
event_date, event_date,
host_email, ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
host_name: customerName,
host_email: customerEmail,
admin_email, admin_email,
password_hash, password_hash,
welcome_message, welcome_message,
color_theme, color_theme,
share_link: shareLink, share_link: shareLinkToStore,
expires_at share_token: shareToken,
expires_at,
require_password: formatBoolean(requirePassword)
}).returning('id'); }).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -84,21 +204,26 @@ router.post('/', adminAuth, [
// Queue creation email // Queue creation email
const { queueEmail } = require('../services/emailProcessor'); const { queueEmail } = require('../services/emailProcessor');
await queueEmail(eventId, host_email, 'gallery_created', { await queueEmail(eventId, customerEmail, 'gallery_created', {
host_name: host_email.split('@')[0], // Extract name from email customer_name: customerName,
customer_email: customerEmail,
host_name: customerName,
event_name, event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareLink, gallery_link: shareUrl,
gallery_password: password, gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || '' welcome_message: welcome_message || ''
}); });
res.json({ res.json({
id: eventId, id: eventId,
slug, slug,
share_link: shareLink, share_link: shareUrl,
expires_at expires_at,
require_password: requirePassword,
customer_name: customerName,
customer_email: customerEmail
}); });
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -127,27 +252,98 @@ router.get('/', adminAuth, async (req, res) => {
event.photo_count = photoCount.count; event.photo_count = photoCount.count;
} }
res.json(events); res.json(events.map(mapEventForApi));
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Failed to fetch events' }); res.status(500).json({ error: 'Failed to fetch events' });
} }
}); });
// Update event // Update event
router.put('/:id', adminAuth, async (req, res) => { router.put('/:id', adminAuth, [
body('customer_name').optional().trim().notEmpty(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('require_password').optional().isBoolean()
], async (req, res) => {
try { try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params; const { id } = req.params;
const updates = req.body; const updates = { ...req.body };
const customerColumnsAvailable = await hasCustomerContactColumns();
// Don't allow updating certain fields // Don't allow updating certain fields
delete updates.id; delete updates.id;
delete updates.slug; delete updates.slug;
delete updates.created_at; delete updates.created_at;
delete updates.password_confirmation;
// If updating password, hash it
if (updates.password) { if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
updates.password_hash = await bcrypt.hash(updates.password, 10); return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
delete updates.password; }
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); await db('events').where('id', id).update(updates);
+146 -56
View File
@@ -1,5 +1,4 @@
const express = require('express'); const express = require('express');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const archiver = require('archiver'); const archiver = require('archiver');
@@ -8,28 +7,58 @@ const router = express.Router();
const watermarkService = require('../services/watermarkService'); const watermarkService = require('../services/watermarkService');
const { verifyGalleryAccess } = require('../middleware/gallery'); const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
// Get storage path from environment or default // Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
// Resolve gallery identifier (slug or token) to canonical data
router.get('/resolve/:identifier', async (req, res) => {
try {
const { identifier } = req.params;
const result = await resolveShareIdentifier(identifier);
if (!result) {
return res.status(404).json({ error: 'Gallery not found' });
}
const { event, matchType, shareToken } = result;
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
slug: event.slug,
token: shareToken,
matchType,
share_link: event.share_link,
share_path: linkVariants.sharePath,
share_url: linkVariants.shareUrl,
short_enabled: linkVariants.shortEnabled,
requires_password: requiresPassword
});
} catch (error) {
logger.error('Error resolving gallery identifier:', error);
res.status(500).json({ error: 'Failed to resolve gallery link' });
}
});
// Verify share token // Verify share token
router.get('/:slug/verify-token/:token', async (req, res) => { router.get('/:slug/verify-token/:token', async (req, res) => {
try { try {
const { slug, token } = req.params; const { slug, token } = req.params;
const event = await db('events') const event = await db('events')
.where({ share_link: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link') .select('id', 'share_link', 'share_token')
.first(); .first();
if (!event) { if (!event) {
return res.status(404).json({ error: 'Gallery not found' }); return res.status(404).json({ error: 'Gallery not found' });
} }
// Extract token from share link and verify const expectedToken = getEventShareToken(event);
const expectedToken = event.share_link.split('/').pop();
if (token !== expectedToken) { if (token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' }); return res.status(404).json({ error: 'Invalid gallery link' });
} }
@@ -48,9 +77,23 @@ router.get('/:slug/info', async (req, res) => {
const { token } = req.query; const { token } = req.query;
const event = await db('events') const event = await db('events')
.where({ slug: slug }) .where({ slug })
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link', .select(
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text') '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(); .first();
if (!event) { if (!event) {
@@ -64,16 +107,14 @@ router.get('/:slug/info', async (req, res) => {
// If token provided, verify it matches the share link // If token provided, verify it matches the share link
if (token) { if (token) {
let expectedToken = event.share_link; const expectedToken = getEventShareToken(event);
// Handle both formats: full URL or just token if (!expectedToken || token !== expectedToken) {
if (event.share_link && event.share_link.includes('/')) {
expectedToken = event.share_link.split('/').pop();
}
if (token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' }); return res.status(404).json({ error: 'Invalid gallery link' });
} }
} }
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({ res.json({
event_name: event.event_name, event_name: event.event_name,
event_type: event.event_type, event_type: event.event_type,
@@ -81,11 +122,11 @@ router.get('/:slug/info', async (req, res) => {
expires_at: event.expires_at, expires_at: event.expires_at,
is_active: event.is_active, is_active: event.is_active,
is_expired: !event.is_active || new Date(event.expires_at) < new Date(), is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
requires_password: true, requires_password: requiresPassword,
color_theme: event.color_theme, color_theme: event.color_theme,
allow_downloads: event.allow_downloads !== false, allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
disable_right_click: event.disable_right_click === true, disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
watermark_downloads: event.watermark_downloads === true, watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
watermark_text: event.watermark_text watermark_text: event.watermark_text
}); });
} catch (error) { } catch (error) {
@@ -99,7 +140,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try { try {
// Get filter parameters from query // Get filter parameters from query
const { filter, guest_id } = req.query; const { filter, guest_id } = req.query;
const feedbackService = require('../services/feedbackService');
// First get all photos // First get all photos
let photos = await db('photos') let photos = await db('photos')
@@ -317,16 +357,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
photo_id: photoId 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; let filePath;
if (photo.path.startsWith('events/active/')) { try {
// New format: path already includes events/active/ prefix filePath = resolvePhotoFilePath(req.event, photo);
filePath = path.join(storagePath, photo.path); } catch (resolveError) {
} else { logger.error('Failed to resolve photo path for download', {
// Legacy format: path is just slug/filename slug: req.params.slug,
filePath = path.join(storagePath, 'events/active', photo.path); photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
} }
// Get watermark settings // Get watermark settings
@@ -345,9 +386,24 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
res.send(watermarkedBuffer); res.send(watermarkedBuffer);
} else { } else {
// Send original file // 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) { } 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' }); 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 // Add photos to archive
for (const photo of photos) { 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; let filePath;
if (photo.path.startsWith('events/active/')) { try {
// New format: path already includes events/active/ prefix filePath = resolvePhotoFilePath(req.event, photo);
filePath = path.join(storagePath, photo.path); } catch (resolveError) {
} else { logger.warn('Skipping photo in bulk download due to unresolved path', {
// Legacy format: path is just slug/filename slug: req.params.slug,
filePath = path.join(storagePath, 'events/active', photo.path); photoId: photo.id,
eventId: req.event.id,
error: resolveError.message,
});
continue;
} }
// Determine the file name in the archive // Determine the file name in the archive
@@ -414,11 +471,18 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
} }
if (watermarkSettings && watermarkSettings.enabled) { if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: archiveName }); 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 { } else {
// Add original file
archive.file(filePath, { name: archiveName }); archive.file(filePath, { name: archiveName });
} }
} }
@@ -433,6 +497,11 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
action: 'download_all' action: 'download_all'
}); });
} catch (error) { } 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' }); 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 } }); const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', (err) => { archive.on('error', (err) => {
console.error('Zip error:', err); logger.error('Zip error generating selected download', {
try { res.status(500).end(); } catch (e) {} slug: req.params.slug,
eventId: req.event?.id,
error: err.message,
});
try {
res.status(500).end();
} catch (_) {
// ignore double-send errors
}
}); });
archive.pipe(res); archive.pipe(res);
const { resolvePhotoFilePath } = require('../services/photoResolver');
const fs = require('fs');
// Check watermark settings similar to download-all // Check watermark settings similar to download-all
const watermarkSettings = await watermarkService.getWatermarkSettings(); const watermarkSettings = await watermarkService.getWatermarkSettings();
for (const photo of photos) { for (const photo of photos) {
try { try {
const filePath = resolvePhotoFilePath(req.event, photo); const filePath = resolvePhotoFilePath(req.event, photo);
if (filePath && fs.existsSync(filePath)) { const name = photo.filename || `photo-${photo.id}.jpg`;
const name = photo.filename || `photo-${photo.id}.jpg`; if (watermarkSettings && watermarkSettings.enabled) {
if (watermarkSettings && watermarkSettings.enabled) { try {
// Apply watermark like download-all
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings); const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name }); archive.append(watermarkedBuffer, { name });
} else { } catch (watermarkError) {
archive.file(filePath, { name }); 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) { } catch (resolveError) {
// skip missing/inaccessible files 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' action: 'download_selected'
}); });
} catch (error) { } 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' }); res.status(500).json({ error: 'Failed to download selected photos' });
} }
}); });
+30 -11
View File
@@ -1,17 +1,14 @@
const express = require('express'); const express = require('express');
const path = require('path');
const { db } = require('../database/db'); const { db } = require('../database/db');
const { verifyGalleryAccess } = require('../middleware/gallery'); const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService'); const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware'); const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const router = express.Router(); 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 * 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 const { slug, photoId, token } = req.params; // Move outside try block for error handler access
try { try {
console.log('Secure image route hit:', { logger.debug('Secure image route hit', {
slug: slug, slug,
photoId: photoId, photoId,
tokenLength: token?.length, tokenLength: token?.length,
headers: req.headers.authorization ? 'present' : 'absent' hasAuthHeader: Boolean(req.headers.authorization),
}); });
const { fragment } = req.query; const { fragment } = req.query;
@@ -142,7 +139,18 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' }); 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 // Get protection settings for this event
const protectionSettings = { const protectionSettings = {
@@ -284,7 +292,18 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' }); 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 // Apply watermark if enabled
const watermarkService = require('../services/watermarkService'); const watermarkService = require('../services/watermarkService');
@@ -426,4 +445,4 @@ async function getSuspiciousActivityStats() {
} }
} }
module.exports = router; module.exports = router;
+7 -1
View File
@@ -132,6 +132,12 @@ async function processTemplate(template, variables, language = 'en') {
? '(Aus Sicherheitsgründen nicht angezeigt)' ? '(Aus Sicherheitsgründen nicht angezeigt)'
: '(Not shown for security reasons)'; : '(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 // Format dates if they exist
if (processedVariables.event_date) { if (processedVariables.event_date) {
@@ -546,4 +552,4 @@ module.exports = {
queueEmail, queueEmail,
stopEmailQueueProcessor, stopEmailQueueProcessor,
testEmailConnection testEmailConnection
}; };
+15 -6
View File
@@ -58,11 +58,15 @@ async function queueExpirationWarning(event) {
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24)); const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
// Determine language based on email domain // Determine language based on email domain
const emailLang = event.host_email.endsWith('.de') ? 'de' : 'en'; const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
const emailLang = recipientEmail && recipientEmail.endsWith('.de') ? 'de' : 'en';
// Queue email to host // Queue email to customer
await queueEmail(event.id, event.host_email, 'expiration_warning', { await queueEmail(event.id, recipientEmail, 'expiration_warning', {
host_name: event.host_name || event.host_email.split('@')[0], customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name, event_name: event.event_name,
days_remaining: daysRemaining.toString(), days_remaining: daysRemaining.toString(),
expiration_date: await formatDate(event.expires_at, emailLang), expiration_date: await formatDate(event.expires_at, emailLang),
@@ -78,9 +82,14 @@ async function handleExpiredEvent(event) {
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) }); await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
// Queue expiration emails // Queue expiration emails
await queueEmail(event.id, event.host_email, 'gallery_expired', { const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(event.id, recipientEmail, 'gallery_expired', {
event_name: event.event_name, event_name: event.event_name,
admin_email: event.admin_email admin_email: event.admin_email,
customer_name: recipientName,
customer_email: recipientEmail
}); });
// Also notify admin // Also notify admin
+44 -2
View File
@@ -1,9 +1,52 @@
const fs = require('fs').promises; const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path'); const path = require('path');
const { safePathJoin } = require('../utils/fileSecurityUtils'); 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() { 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) { function isUnderRoot(p) {
@@ -64,4 +107,3 @@ module.exports = {
list, list,
resolveExternalPath, resolveExternalPath,
}; };
+181
View File
@@ -0,0 +1,181 @@
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
const SETTING_KEY = 'general_short_gallery_urls';
const CACHE_TTL_MS = 60_000;
let cachedSetting = null;
let cacheExpiresAt = 0;
const parseSettingValue = (rawValue) => {
if (rawValue === undefined || rawValue === null) {
return null;
}
if (typeof rawValue === 'boolean') {
return rawValue;
}
if (typeof rawValue === 'number') {
return rawValue !== 0;
}
if (typeof rawValue === 'string') {
const trimmed = rawValue.trim();
if (!trimmed) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
return parseSettingValue(parsed);
} catch {
const normalized = trimmed.toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
return true;
}
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
return false;
}
return null;
}
}
if (typeof rawValue === 'object') {
try {
return parseSettingValue(JSON.parse(JSON.stringify(rawValue)));
} catch {
return null;
}
}
return null;
};
const getRawSettingValue = async () => {
try {
const setting = await db('app_settings').where({ setting_key: SETTING_KEY }).first();
return setting?.setting_value ?? null;
} catch (error) {
console.error('Failed to read gallery URL setting:', error.message);
return null;
}
};
const isShortGalleryUrlsEnabled = async () => {
if (cachedSetting !== null && Date.now() < cacheExpiresAt) {
return cachedSetting;
}
const rawValue = await getRawSettingValue();
const parsed = parseSettingValue(rawValue);
cachedSetting = parsed === null ? false : Boolean(parsed);
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return cachedSetting;
};
const clearShareLinkSettingsCache = () => {
cachedSetting = null;
cacheExpiresAt = 0;
};
const buildShareLinkVariants = async ({ slug, shareToken }) => {
if (!shareToken) {
throw new Error('shareToken is required to build share link variants');
}
const shortEnabled = await isShortGalleryUrlsEnabled();
const sharePath = buildSharePath(slug, shareToken, shortEnabled);
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
return {
shortEnabled,
sharePath,
shareUrl,
shareLinkToStore: sharePath
};
};
const getEventShareToken = (event) => {
if (!event) {
return null;
}
if (event.share_token) {
return event.share_token;
}
return extractShareToken(event.share_link);
};
const ACTIVE_EVENT_FILTER = {
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
};
const resolveShareIdentifier = async (identifier) => {
if (!identifier) {
return null;
}
const trimmed = String(identifier).trim();
if (!trimmed) {
return null;
}
const baseQuery = db('events')
.select(
'id',
'slug',
'share_link',
'share_token',
'require_password',
'event_name',
'event_type',
'event_date',
'expires_at',
'is_active',
'is_archived'
)
.where(ACTIVE_EVENT_FILTER);
let event = await baseQuery.clone().where({ slug: trimmed }).first();
if (event) {
return { event, matchType: 'slug', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where({ share_token: trimmed }).first();
if (event) {
return { event, matchType: 'token', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where({ share_link: trimmed }).first();
if (event) {
return { event, matchType: 'link', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where('share_link', 'like', `%/${trimmed}`).first();
if (event) {
return { event, matchType: 'link_partial', shareToken: getEventShareToken(event) };
}
// As a final fallback, if identifier looks like a token but we did not match via share_token
if (isPotentialShareToken(trimmed)) {
event = await baseQuery.clone().whereRaw('LOWER(share_token) = ?', [trimmed.toLowerCase()]).first();
if (event) {
return { event, matchType: 'token_case_insensitive', shareToken: getEventShareToken(event) };
}
}
return null;
};
module.exports = {
isShortGalleryUrlsEnabled,
clearShareLinkSettingsCache,
buildShareLinkVariants,
getEventShareToken,
resolveShareIdentifier
};
+87
View File
@@ -0,0 +1,87 @@
const { db } = require('../database/db');
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
const MAX_ALLOWED_FILES_PER_UPLOAD = 2000;
const CACHE_TTL_MS = 60_000;
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
let cacheExpiresAt = 0;
const parseSettingValue = (setting) => {
if (!setting || setting.setting_value == null) {
return null;
}
let rawValue = setting.setting_value;
if (typeof rawValue === 'string') {
try {
rawValue = JSON.parse(rawValue);
} catch {
// keep original string
}
}
if (typeof rawValue === 'string') {
const trimmed = rawValue.trim();
if (trimmed === '') {
return null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
if (typeof rawValue === 'number') {
return rawValue;
}
return null;
};
const normalizeLimit = (value) => {
if (!Number.isFinite(value)) {
return DEFAULT_MAX_FILES_PER_UPLOAD;
}
const intValue = Math.floor(value);
if (intValue < 1) {
return DEFAULT_MAX_FILES_PER_UPLOAD;
}
if (intValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
return MAX_ALLOWED_FILES_PER_UPLOAD;
}
return intValue;
};
const getMaxFilesPerUpload = async () => {
if (Date.now() < cacheExpiresAt) {
return cachedValue;
}
try {
const setting = await db('app_settings')
.where({ setting_key: 'general_max_files_per_upload' })
.first();
const parsedValue = normalizeLimit(parseSettingValue(setting));
cachedValue = parsedValue;
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return parsedValue;
} catch (error) {
console.error('Failed to read max files per upload setting:', error.message);
cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return DEFAULT_MAX_FILES_PER_UPLOAD;
}
};
const clearMaxFilesPerUploadCache = () => {
cacheExpiresAt = 0;
};
module.exports = {
getMaxFilesPerUpload,
clearMaxFilesPerUploadCache,
DEFAULT_MAX_FILES_PER_UPLOAD,
MAX_ALLOWED_FILES_PER_UPLOAD
};
+157 -16
View File
@@ -7,10 +7,140 @@ const { db } = require('../database/db');
const { formatBoolean } = require('./dbCompat'); const { formatBoolean } = require('./dbCompat');
const logger = require('./logger'); const logger = require('./logger');
// Configuration constants const DEFAULT_SECURITY_CONFIG = Object.freeze({
const MAX_LOGIN_ATTEMPTS = 5; maxAttempts: 5,
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds lockoutDurationMs: 30 * 60 * 1000, // 30 minutes
const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes window for counting attempts 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 * Track failed login attempt
@@ -59,6 +189,8 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
if (!tableExists) { if (!tableExists) {
return; return;
} }
const { attemptWindowMs } = await getSecurityConfig();
await db('login_attempts').insert({ await db('login_attempts').insert({
identifier, identifier,
@@ -69,7 +201,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
}); });
// Clear old failed attempts for this user // 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') await db('login_attempts')
.where('identifier', identifier) .where('identifier', identifier)
.where('success', formatBoolean(false)) .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 * Check if account is locked due to too many failed attempts
* @param {string} identifier - Username or email * @param {string} identifier - Username or email
* @param {string} [ipAddress] - Optional IP address scope
* @returns {Promise<{isLocked: boolean, remainingTime?: number}>} * @returns {Promise<{isLocked: boolean, remainingTime?: number}>}
*/ */
async function checkAccountLockout(identifier) { async function checkAccountLockout(identifier, ipAddress) {
try { try {
// Check if table exists first // Check if table exists first
const tableExists = await db.schema.hasTable('login_attempts'); const tableExists = await db.schema.hasTable('login_attempts');
if (!tableExists) { if (!tableExists) {
return { isLocked: false }; 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 // Get recent failed attempts
const failedAttempts = await db('login_attempts') const failedAttemptsQuery = db('login_attempts')
.where('identifier', identifier) .where('identifier', identifier)
.where('success', formatBoolean(false)) .where('success', formatBoolean(false))
.where('attempt_time', '>=', recentWindow.toISOString()) .where('attempt_time', '>=', recentWindow.toISOString());
.orderBy('attempt_time', 'desc')
.limit(MAX_LOGIN_ATTEMPTS);
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 // Check if still within lockout period
const oldestAttempt = failedAttempts[failedAttempts.length - 1]; 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(); const now = Date.now();
if (now < lockoutEnd) { if (now < lockoutEnd) {
@@ -216,6 +357,6 @@ module.exports = {
checkSuspiciousActivity, checkSuspiciousActivity,
getGenericAuthError, getGenericAuthError,
initializeCleanupJob, initializeCleanupJob,
MAX_LOGIN_ATTEMPTS, getSecurityConfig,
LOCKOUT_DURATION resetSecurityConfigCache
}; };
+36
View File
@@ -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 };
+63
View File
@@ -0,0 +1,63 @@
const SHARE_TOKEN_REGEX = /^[0-9a-fA-F]{32}$/;
/**
* Extracts the share token portion from a stored share link.
* Supports full URLs, absolute paths, and legacy slug/token formats.
* @param {string|null|undefined} shareLink
* @returns {string|null}
*/
function extractShareToken(shareLink) {
if (!shareLink) {
return null;
}
const trimmed = String(shareLink).trim();
if (!trimmed) {
return null;
}
// Remove protocol + host when a full URL is stored
const path = trimmed.replace(/^https?:\/\/[^/]+/i, '');
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) {
return null;
}
const candidate = segments[segments.length - 1];
return candidate || null;
}
/**
* Returns true if the provided identifier looks like a generated share token.
* @param {string|null|undefined} identifier
* @returns {boolean}
*/
function isPotentialShareToken(identifier) {
if (!identifier) {
return false;
}
return SHARE_TOKEN_REGEX.test(String(identifier).trim());
}
/**
* Builds the gallery share path depending on whether short URLs are enabled.
* @param {string} slug
* @param {string} shareToken
* @param {boolean} useShort
* @returns {string}
*/
function buildSharePath(slug, shareToken, useShort) {
if (!shareToken) {
throw new Error('shareToken is required to build share path');
}
if (useShort || !slug) {
return `/gallery/${shareToken}`;
}
return `/gallery/${slug}/${shareToken}`;
}
module.exports = {
extractShareToken,
isPotentialShareToken,
buildSharePath
};
+3 -1
View File
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
- Check the console output from when you ran migrations - Check the console output from when you ran migrations
- File is created in the backend directory root - File is created in the backend directory root
- File might have been deleted for security (as recommended) - File might have been deleted for security (as recommended)
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
- When using the unified `picpeak-setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
## Best Practices ## Best Practices
@@ -161,4 +163,4 @@ If upgrading from the old system with hardcoded `admin123`:
- [ ] Stored new password in password manager - [ ] Stored new password in password manager
- [ ] Tested login with new password - [ ] Tested login with new password
- [ ] Set up additional admin accounts if needed - [ ] Set up additional admin accounts if needed
- [ ] Configured password policies for organization - [ ] Configured password policies for organization
+147
View File
@@ -0,0 +1,147 @@
# PicPeak Admin API Quickstart
This guide explains how to authenticate against the PicPeak Admin API, use the OpenAPI documentation, and exercise the three automation endpoints (`create event`, `photo upload`, `resend email`) that now ship with machine-readable docs.
> **Prerequisites**
>
> - PicPeak backend running (Docker or local `node backend/server.js`)
> - An admin account (see `data/ADMIN_CREDENTIALS.txt` for the seeded defaults)
> - API base URL (defaults to `http://localhost:3001/api`)
---
## 1. Obtain an Admin API Token
1. Determine whether reCAPTCHA is enabled in **Admin → Settings → Security**. If disabled (the default), you can skip the `recaptchaToken` field shown below.
2. Authenticate with your admin username/email and password:
```bash
curl --fail --silent --show-error \
-X POST "http://localhost:3001/api/auth/admin/login" \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "BoldTiger5872%",
"recaptchaToken": ""
}' | jq
```
Successful responses look like:
```json
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": 1,
"username": "admin",
"email": "admin@example.com",
"mustChangePassword": false
}
}
```
- PicPeak also sets the `admin_token` cookie; however, when scripting you typically pass the token in an `Authorization: Bearer <token>` header.
- Tokens expire after 24 hours. Log in again to refresh them.
---
## 2. Use the OpenAPI Documentation
The machine-readable spec lives at `docs/picpeak-admin-api.openapi.yaml`. You can:
- Preview it interactively with Redocly:
```bash
npx --yes @redocly/cli preview-docs docs/picpeak-admin-api.openapi.yaml
```
- Import it into Postman, Insomnia, or VS Code REST client.
- Validate changes as part of CI with:
```bash
npx --yes @apidevtools/swagger-cli@4.0.4 validate docs/picpeak-admin-api.openapi.yaml
```
Keep this file in sync whenever the backend endpoints evolve.
---
## 3. Call the Key Admin Endpoints
Below are minimal `curl` examples that rely on the bearer token captured earlier.
### 3.1 Create an Event
```bash
API_URL="http://localhost:3001/api"
TOKEN="REPLACE_WITH_JWT"
curl --fail --silent --show-error \
-X POST "$API_URL/admin/events" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"event_type": "wedding",
"event_name": "Emily & Jordan Celebration",
"event_date": "2025-06-07",
"customer_name": "Emily Carter",
"customer_email": "emily@example.com",
"admin_email": "studio@example.com",
"require_password": true,
"password": "Shutter123",
"expiration_days": 45
}' | jq
```
### 3.2 Upload Photos to the Event
```bash
EVENT_ID=512
curl --fail --silent --show-error \
-X POST "$API_URL/admin/events/$EVENT_ID/upload" \
-H "Authorization: Bearer $TOKEN" \
-F "photos=@/path/to/DSC_2031.jpg" \
-F "photos=@/path/to/DSC_2032.jpg" \
-F "category_id=individual" | jq
```
- Files must be JPEG/PNG/WebP, each ≤ 50MB.
- The per-request file count respects the `general_max_files_per_upload` admin setting (default 500).
### 3.3 Resend the Gallery Email
```bash
curl --fail --silent --show-error \
-X POST "$API_URL/admin/events/$EVENT_ID/resend-email" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"password": "Shutter123"}' | jq
```
Omit `"password"` to send the standard security message instead.
---
## 4. Quick Testing Checklist
- ✅ Login succeeds and returns a token (HTTP 200).
- ✅ Creating an event returns `id`, `slug`, and `share_link`.
- ✅ Uploading more files than allowed returns HTTP 400 with a helpful message.
- ✅ Resending email for a missing event returns HTTP 404.
- ✅ `swagger-cli validate` passes after any spec edits.
Automate these checks using your preferred test harness or CI pipeline to catch regressions early.
---
## 5. Migrating From `host_*`
- Run backend migrations to add the new `customer_name` / `customer_email` columns: `npm --prefix backend run migrate` (or your existing deployment flow). The migration copies legacy data automatically, so upgrades remain seamless.
- All admin APIs now require the `customer_*` fields. Older `host_*` payloads are rejected, which makes downstream client issues obvious during testing instead of silently dropping data.
- API responses still mirror `customer_*` even if migrations have not run yet (the server falls back to legacy columns until the upgrade is complete), so existing frontends can move over incrementally.
- Once every consumer writes and reads the new fields, you can safely plan the removal of the legacy `host_*` columns in a future release.
---
Need deeper integration examples or language-specific SDKs? Import the OpenAPI spec into code generators such as `openapi-generator` or `orval` to scaffold API clients quickly.
+584
View File
@@ -0,0 +1,584 @@
openapi: 3.1.0
info:
title: PicPeak Admin API
version: 1.1.11
summary: High-level administrative endpoints for creating events, uploading photos, and resending gallery access emails.
description: |
This document describes the core administrative endpoints that power PicPeak automations.
It focuses on the three workflows requested by integrators:
1. Creating events with customer access credentials.
2. Uploading photos in bulk to an event gallery.
3. Resending the customer-facing gallery email.
The specification follows the latest [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0) best practices
and is intended to be kept in sync with backend changes.
contact:
name: PicPeak Maintainers
url: https://github.com/the-luap/picpeak
servers:
- url: https://api.picpeak.example.com/api
description: Example production deployment
- url: http://localhost:3001/api
description: Local development
tags:
- name: Admin Events
description: Administrative endpoints for managing event galleries.
components:
securitySchemes:
CookieAuth:
type: apiKey
in: cookie
name: admin_token
description: >
Session cookie issued by the admin authentication flow. When present, the backend mirrors
it into the `Authorization` header automatically.
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: >
JSON Web Token created by the admin login endpoint. You can also pass the token explicitly
as `Authorization: Bearer <token>` instead of using the admin cookie.
parameters:
EventId:
name: eventId
in: path
description: Numeric identifier of the event.
required: true
schema:
type: integer
minimum: 1
example: 341
schemas:
ErrorResponse:
type: object
properties:
error:
type: string
description: Human readable error message.
details:
type: string
nullable: true
description: Additional context (when available).
required:
- error
example:
error: Invalid token
ValidationErrorItem:
type: object
properties:
type:
type: string
nullable: true
description: Validation error type reported by express-validator.
msg:
type: string
path:
type: string
description: Dot-delimited path to the invalid field.
value:
description: Value that failed validation.
location:
type: string
description: Location of the invalid value (always `body` for these endpoints).
required:
- msg
- path
- location
example:
type: field
msg: Event date must be a valid ISO 8601 date
path: event_date
value: 2025/05/01
location: body
ValidationErrorResponse:
type: object
properties:
errors:
type: array
items:
$ref: '#/components/schemas/ValidationErrorItem'
required:
- errors
example:
errors:
- type: field
msg: Customer email must be a valid address
path: customer_email
value: example@invalid
location: body
CreateEventRequest:
type: object
required:
- event_type
- event_name
- event_date
- customer_name
- customer_email
- admin_email
properties:
event_type:
type: string
description: Type of event. Controls default theme and copy in the UI.
enum: [wedding, birthday, corporate, other]
event_name:
type: string
minLength: 1
description: Display name for the gallery shown to end customers.
event_date:
type: string
format: date
description: Event date (YYYY-MM-DD). Used to calculate the default expiration.
customer_name:
type: string
minLength: 1
description: Name of the customer receiving gallery access.
customer_email:
type: string
format: email
description: Email address of the customer who will receive the gallery link.
admin_email:
type: string
format: email
description: Admin contact email included in notification messages.
require_password:
type: boolean
default: true
description: When true, the gallery requires `password`; when false a random placeholder is stored.
password:
type: string
minLength: 6
description: >
Gallery password issued to the customer. Required when `require_password` is `true`.
Left unset to auto-generate a placeholder when password protection is disabled.
expiration_days:
type: integer
minimum: 1
maximum: 365
default: 30
description: Number of days after the event date before the gallery expires.
welcome_message:
type: string
description: Optional welcome message displayed in the gallery.
color_theme:
type: string
nullable: true
description: Optional theme identifier or CSS color settings.
allow_user_uploads:
type: boolean
default: false
description: Allow gallery guests to upload their own photos.
upload_category_id:
type: integer
nullable: true
description: ID of the default category for user uploads.
allow_downloads:
type: boolean
default: true
description: Allow guests to download photos.
disable_right_click:
type: boolean
default: false
description: Disable right-click in the gallery view.
watermark_downloads:
type: boolean
default: false
description: Enable watermarking on downloaded images.
watermark_text:
type: string
nullable: true
description: Custom watermark text when `watermark_downloads` is true.
feedback_enabled:
type: boolean
default: false
description: Enable the feedback module for this gallery.
allow_ratings:
type: boolean
default: true
allow_likes:
type: boolean
default: true
allow_comments:
type: boolean
default: true
allow_favorites:
type: boolean
default: true
require_name_email:
type: boolean
default: false
description: Require guests to provide name and email when leaving feedback.
moderate_comments:
type: boolean
default: true
description: Hold guest comments for moderation.
show_feedback_to_guests:
type: boolean
default: true
description: Display aggregated feedback metrics back to guests.
example:
event_type: wedding
event_name: Emily & Jordan Celebration
event_date: 2025-06-07
customer_name: Emily Carter
customer_email: emily@example.com
admin_email: studio@example.com
require_password: true
password: Shutter123
expiration_days: 45
welcome_message: >
We loved capturing your day! Use the password below to view and download your photos.
allow_user_uploads: false
allow_downloads: true
feedback_enabled: true
allow_comments: true
show_feedback_to_guests: true
EventSummary:
type: object
properties:
id:
type: integer
description: Database identifier of the newly created event.
slug:
type: string
description: Unique slug used to build the gallery URL.
event_name:
type: string
event_type:
type: string
enum: [wedding, birthday, corporate, other]
customer_name:
type: string
nullable: true
description: Name of the customer associated with the event.
customer_email:
type: string
format: email
nullable: true
description: Email address of the customer associated with the event.
require_password:
type: boolean
share_link:
type: string
description: Absolute or relative URL guests can use to reach the gallery.
expires_at:
type: string
format: date-time
description: ISO 8601 timestamp when the gallery expires.
created_at:
type: string
format: date-time
description: ISO 8601 timestamp when the event was created.
required:
- id
- slug
- event_name
- event_type
- require_password
- share_link
- expires_at
- created_at
example:
id: 512
slug: wedding-emily-jordan-2025-06-07
event_name: Emily & Jordan Celebration
event_type: wedding
customer_name: Emily Carter
customer_email: emily@example.com
require_password: true
share_link: https://app.picpeak.io/gallery/wedding-emily-jordan-2025-06-07/2f3c8a4d90bb11ef9b2e0242ac120002
expires_at: 2025-07-22T00:00:00.000Z
created_at: 2025-05-01T14:32:45.000Z
UploadPhotosResponse:
type: object
properties:
message:
type: string
photos:
type: array
items:
$ref: '#/components/schemas/UploadedPhotoSummary'
description: Metadata for each photo that was persisted successfully.
totalFiles:
type: integer
minimum: 0
description: Total number of files included in the request (valid + invalid).
successCount:
type: integer
minimum: 0
failureCount:
type: integer
minimum: 0
errors:
type: array
items:
$ref: '#/components/schemas/UploadFailure'
description: Present when some files failed validation or processing.
required:
- message
- photos
- totalFiles
- successCount
- failureCount
example:
message: Uploaded 18 of 20 photos. 2 failed.
photos:
- id: 9821
filename: DSC_2031.jpg
size: 4812096
category_id: 2
- id: 9822
filename: DSC_2032.jpg
size: 5216743
category_id: 2
totalFiles: 20
successCount: 18
failureCount: 2
errors:
- filename: DSC_2020.raw
error: Only JPEG, PNG and WebP images are allowed
- filename: portrait.png
error: File is empty
UploadedPhotoSummary:
type: object
properties:
id:
type: integer
filename:
type: string
size:
type: integer
description: File size in bytes.
category_id:
type: integer
nullable: true
required:
- id
- filename
- size
example:
id: 9821
filename: DSC_2031.jpg
size: 4812096
category_id: 2
UploadFailure:
type: object
properties:
filename:
type: string
error:
type: string
required:
- filename
- error
example:
filename: DSC_2031.gif
error: Only JPEG, PNG and WebP images are allowed
ResendEmailRequest:
type: object
properties:
password:
type: string
minLength: 1
description: >
Optional plain-text password to include in the email. When omitted a security notice
placeholder is inserted because the stored hash cannot be reversed.
example:
password: Shutter123
ResendEmailResponse:
type: object
properties:
success:
type: boolean
message:
type: string
required:
- success
- message
example:
success: true
message: Creation email has been queued for sending
paths:
/admin/events:
post:
tags: [Admin Events]
operationId: createAdminEvent
summary: Create a new event
description: >
Creates a new event, provisions storage folders, stores the gallery password, and queues
the initial gallery email for the customer. Requires admin authentication.
security:
- CookieAuth: []
- BearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateEventRequest'
examples:
weddingExample:
summary: Wedding with password protection
value:
event_type: wedding
event_name: Emily & Jordan Celebration
event_date: 2025-06-07
customer_name: Emily Carter
customer_email: emily@example.com
admin_email: studio@example.com
require_password: true
password: Shutter123
expiration_days: 45
welcome_message: >
We loved capturing your day! Use the password below to view and download your photos.
allow_user_uploads: false
allow_downloads: true
feedback_enabled: true
allow_comments: true
show_feedback_to_guests: true
responses:
'200':
description: Event created successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/EventSummary'
'400':
description: Validation failed. At least one field is invalid or missing.
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationErrorResponse'
'401':
description: Authentication required or token invalid.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: Unexpected server error while creating the event.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/admin/events/{eventId}/upload:
post:
tags: [Admin Events]
operationId: uploadEventPhotos
summary: Upload photos to an event gallery
description: |
Uploads one or more photos to the specified event. Files are validated, moved into the
event storage directory, and thumbnails are generated asynchronously.
The maximum number of files per upload is controlled via the `general_max_files_per_upload`
setting (default 500, capped at 2000). Files exceeding 50 MB are rejected.
security:
- CookieAuth: []
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/EventId'
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
photos:
type: array
description: >
One or more image files (JPEG, PNG, WebP). Each file must be <= 50 MB.
items:
type: string
format: binary
category_id:
oneOf:
- type: integer
- type: string
description: >
Optional category assignment. Accepts numeric IDs or the string values `collage`
and `individual` for backward compatibility.
required:
- photos
encoding:
photos:
style: form
explode: false
responses:
'200':
description: Upload completed. Failed files (if any) are listed in the response.
content:
application/json:
schema:
$ref: '#/components/schemas/UploadPhotosResponse'
'400':
description: Request failed validation (invalid files, too many files, etc.).
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'401':
description: Authentication required or token invalid.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: The referenced event does not exist.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: Unexpected server error while processing uploads.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/admin/events/{eventId}/resend-email:
post:
tags: [Admin Events]
operationId: resendEventEmail
summary: Resend the gallery access email to the customer
description: >
Queues the standard `gallery_created` email for the event's customer. Useful when resending
credentials to the customer or communicating an updated password. Requires admin authentication.
security:
- CookieAuth: []
- BearerAuth: []
parameters:
- $ref: '#/components/parameters/EventId'
requestBody:
required: false
content:
application/json:
schema:
$ref: '#/components/schemas/ResendEmailRequest'
example:
password: NewSecurePassword!
responses:
'200':
description: Email successfully queued for delivery.
content:
application/json:
schema:
$ref: '#/components/schemas/ResendEmailResponse'
'401':
description: Authentication required or token invalid.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Event not found.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: Unexpected server error while queuing the email.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
+23
View File
@@ -19,5 +19,28 @@ export default tseslint.config([
ecmaVersion: 2020, ecmaVersion: 2020,
globals: globals.browser, 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',
},
}, },
]) ])
+1122 -406
View File
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"private": true, "private": true,
"version": "1.1.1", "version": "1.1.14",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "node ./scripts/build.js",
"build:check": "tsc -b && vite build", "build:check": "tsc -b && node ./scripts/build.js",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
@@ -49,8 +49,9 @@
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.3",
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"cross-env": "^10.1.0",
"eslint": "^9.29.0", "eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
@@ -59,6 +60,10 @@
"tailwindcss": "^3.3.0", "tailwindcss": "^3.3.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.34.1", "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"
} }
} }
+93
View File
@@ -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();
+2 -2
View File
@@ -25,7 +25,7 @@ export const MaintenanceMode: React.FC = () => {
try { try {
const response = await api.get('/public/settings'); const response = await api.get('/public/settings');
return response.data; return response.data;
} catch (error) { } catch {
// Return empty object if settings can't be fetched // Return empty object if settings can't be fetched
return {}; return {};
} }
@@ -110,4 +110,4 @@ export const MaintenanceMode: React.FC = () => {
)} )}
</div> </div>
); );
}; };
@@ -31,7 +31,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
if (isMounted) { if (isMounted) {
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin')); setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
} }
} catch (error) { } catch {
if (isMounted) { if (isMounted) {
setHasAdminSession(false); setHasAdminSession(false);
} }
@@ -18,11 +18,13 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
let objectUrl: string | null = null;
const loadImage = async () => { const loadImage = async () => {
try { try {
setLoading(true); setLoading(true);
setError(false); setError(false);
setImageSrc(null);
// Make authenticated request to get the image // Make authenticated request to get the image
const response = await api.get(src, { const response = await api.get(src, {
@@ -31,11 +33,11 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
if (!cancelled) { if (!cancelled) {
// Create object URL from blob // Create object URL from blob
const imageUrl = URL.createObjectURL(response.data); objectUrl = URL.createObjectURL(response.data);
setImageSrc(imageUrl); setImageSrc(objectUrl);
setLoading(false); setLoading(false);
} }
} catch (err: any) { } catch {
// Image loading failed - handled by error state // Image loading failed - handled by error state
if (!cancelled) { if (!cancelled) {
setError(true); setError(true);
@@ -51,8 +53,8 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
// Cleanup function // Cleanup function
return () => { return () => {
cancelled = true; cancelled = true;
if (imageSrc) { if (objectUrl) {
URL.revokeObjectURL(imageSrc); URL.revokeObjectURL(objectUrl);
} }
}; };
}, [src]); }, [src]);
@@ -74,4 +76,4 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
} }
return <img src={imageSrc || ''} alt={alt} {...props} />; return <img src={imageSrc || ''} alt={alt} {...props} />;
}; };
@@ -62,7 +62,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
await photosService.deletePhoto(eventId, photo.id); await photosService.deletePhoto(eventId, photo.id);
toast.success('Photo deleted successfully'); toast.success('Photo deleted successfully');
onPhotosDeleted(); onPhotosDeleted();
} catch (error) { } catch {
toast.error('Failed to delete photo'); toast.error('Failed to delete photo');
setDeletingPhotos(prev => { setDeletingPhotos(prev => {
const newSet = new Set(prev); const newSet = new Set(prev);
@@ -90,7 +90,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
setSelectedPhotos(new Set()); setSelectedPhotos(new Set());
setIsSelectionMode(false); setIsSelectionMode(false);
onPhotosDeleted(); onPhotosDeleted();
} catch (error) { } catch {
toast.error('Failed to delete photos'); toast.error('Failed to delete photos');
setDeletingPhotos(new Set()); setDeletingPhotos(new Set());
} finally { } finally {
@@ -103,7 +103,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
try { try {
await photosService.downloadPhoto(eventId, photo.id, photo.filename); await photosService.downloadPhoto(eventId, photo.id, photo.filename);
toast.success('Download started'); toast.success('Download started');
} catch (error) { } catch {
toast.error('Failed to download photo'); toast.error('Failed to download photo');
} }
}; };
+51 -8
View File
@@ -6,6 +6,7 @@ import { api } from '../../config/api';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service'; import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
interface PhotoUploadProps { interface PhotoUploadProps {
@@ -13,6 +14,9 @@ interface PhotoUploadProps {
onUploadComplete?: () => void; onUploadComplete?: () => void;
} }
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => { export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
@@ -29,6 +33,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
queryFn: () => categoriesService.getEventCategories(eventId), queryFn: () => categoriesService.getEventCategories(eventId),
}); });
const { data: settings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
});
const maxFilesPerUpload = React.useMemo(() => {
const rawValue = settings?.general_max_files_per_upload;
const parsed = Number(rawValue);
if (!Number.isFinite(parsed)) {
return DEFAULT_MAX_FILES_PER_UPLOAD;
}
return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
}, [settings]);
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file => const imageFiles = files.filter(file =>
@@ -37,13 +57,19 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
// Check total file count with existing files // Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length; const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > 500) { if (totalFiles > maxFilesPerUpload) {
const allowedNewFiles = 500 - selectedFiles.length; const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
if (allowedNewFiles <= 0) { if (allowedNewFiles <= 0) {
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed'); toast.error(
t('upload.maxFilesReached', { limit: maxFilesPerUpload }) ||
`Maximum ${maxFilesPerUpload} files allowed`
);
return; return;
} }
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`); toast.warning(
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]); setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return; return;
} }
@@ -59,8 +85,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
if (selectedFiles.length === 0) return; if (selectedFiles.length === 0) return;
// Validate file count // Validate file count
if (selectedFiles.length > 500) { if (selectedFiles.length > maxFilesPerUpload) {
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once'); toast.error(
t('upload.tooManyFiles', { limit: maxFilesPerUpload }) ||
`Maximum ${maxFilesPerUpload} files can be uploaded at once`
);
return; return;
} }
@@ -68,7 +97,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setUploadProgress(0); setUploadProgress(0);
// For large uploads, chunk the files to prevent memory issues // For large uploads, chunk the files to prevent memory issues
const CHUNK_SIZE = 50; // Upload 50 files at a time const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time
const chunks = []; const chunks = [];
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) { for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
@@ -187,7 +216,21 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{t('upload.clickToUpload')} {t('upload.clickToUpload')}
</p> </p>
<p className="text-sm text-neutral-500"> <p className="text-sm text-neutral-500">
{t('upload.fileRequirements')} {t('upload.fileRequirements', { limit: maxFilesPerUpload })}
</p>
<p
className={clsx(
"text-xs mt-2",
remainingSlots === 0 ? "text-red-600" : "text-neutral-500"
)}
>
{remainingSlots === 0
? t('upload.limitReached', { limit: maxFilesPerUpload })
: t('upload.limitInfo', {
selected: selectedFiles.length,
limit: maxFilesPerUpload,
remaining: remainingSlots,
})}
</p> </p>
<input <input
ref={fileInputRef} ref={fileInputRef}
@@ -1,5 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { buildResourceUrl } from '../../utils/url'; import { buildResourceUrl } from '../../utils/url';
import {
getActiveGallerySlug,
getGalleryToken,
inferGallerySlugFromLocation,
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> { interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
src: string; src: string;
@@ -52,7 +58,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
}) => { }) => {
const unusedProps = { const unusedProps = {
protectFromDownload, protectFromDownload,
slug,
photoId, photoId,
requiresToken, requiresToken,
secureUrlTemplate, secureUrlTemplate,
@@ -76,7 +81,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
let objectUrl: string | null = null; let aborted = false;
const objectUrls: string[] = [];
// Determine which token to use based on context // Determine which token to use based on context
if (!src) { if (!src) {
@@ -88,37 +94,79 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
setIsLoading(true); setIsLoading(true);
setError(false); setError(false);
// Create a new URL with auth header const resolveSlug = (candidateSrc?: string): string | null => {
if (slug) {
return slug;
}
const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null;
if (fromUrl) {
return fromUrl;
}
return getActiveGallerySlug() || inferGallerySlugFromLocation();
};
const fetchWithAuth = async (rawUrl: string | undefined | null): Promise<string> => {
if (!rawUrl) {
throw new Error('No URL provided');
}
// Build full URL for the image
const fullImageUrl = rawUrl.startsWith('/admin')
? buildResourceUrl(`/api${rawUrl}`)
: rawUrl.startsWith('/')
? buildResourceUrl(rawUrl)
: rawUrl;
const headers: Record<string, string> = {};
const slugForRequest = resolveSlug(rawUrl);
const token = getGalleryToken(slugForRequest);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(fullImageUrl, {
credentials: 'include',
headers: Object.keys(headers).length ? headers : undefined,
});
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
objectUrls.push(objectUrl);
return objectUrl;
};
const fetchImage = async () => { const fetchImage = async () => {
try { try {
// Use the src as-is since it should already be the correct endpoint const primaryUrl = await fetchWithAuth(src);
let imageUrl = src; if (!aborted) {
setImageSrc(primaryUrl);
// Build full URL for the image setError(false);
// For API paths that start with /admin, we need to prepend /api
const fullImageUrl = imageUrl.startsWith('/admin')
? buildResourceUrl(`/api${imageUrl}`)
: imageUrl.startsWith('/')
? buildResourceUrl(imageUrl)
: imageUrl;
// Fetch authenticated image
const response = await fetch(fullImageUrl, {
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
} }
const blob = await response.blob();
objectUrl = URL.createObjectURL(blob);
setImageSrc(objectUrl);
setIsLoading(false);
} catch (err) { } catch (err) {
// Image loading failed - use fallback setIsLoading(false);
setError(true); if (fallbackSrc && fallbackSrc !== src) {
setImageSrc(fallbackSrc || ''); try {
const fallbackUrl = await fetchWithAuth(fallbackSrc);
if (!aborted) {
setImageSrc(fallbackUrl);
setError(false);
}
return;
} catch (fallbackError) {
// Swallow and mark error below
}
}
if (!aborted) {
setError(true);
setImageSrc('');
}
return;
}
if (!aborted) {
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -127,11 +175,11 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
// Cleanup function // Cleanup function
return () => { return () => {
if (objectUrl) { aborted = true;
URL.revokeObjectURL(objectUrl); objectUrls.forEach((url) => URL.revokeObjectURL(url));
}
}; };
}, [src, fallbackSrc, useWatermark, isGallery]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [src, fallbackSrc, slug]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -209,7 +209,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
} }
} }
} }
} catch (e) { } catch {
// Invalid theme format - use default // Invalid theme format - use default
// Fall back to global theme // Fall back to global theme
if (settingsData.theme_config) { if (settingsData.theme_config) {
@@ -12,7 +12,7 @@ interface GridPhotoProps {
photo: Photo; photo: Photo;
isSelected: boolean; isSelected: boolean;
isSelectionMode: boolean; isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void; onClick: () => void;
onDownload: (e: React.MouseEvent) => void; onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void; onToggleSelect: () => void;
animationType?: string; animationType?: string;
@@ -57,6 +57,79 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
liked = false, liked = false,
onLikeSuccess 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 // handled by parent layout; kept here for type completeness but not used
const { ref, inView } = useInView({ const { ref, inView } = useInView({
triggerOnce: true, triggerOnce: true,
@@ -73,11 +146,34 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
const commentCount = photo.comment_count ?? 0; const commentCount = photo.comment_count ?? 0;
const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions); 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 ( return (
<div <div
ref={ref} ref={ref}
className={`relative group cursor-pointer aspect-square ${animationClass}`} className={`relative group cursor-pointer aspect-square ${animationClass}`}
onClick={onClick} onClick={handlePhotoClick}
style={{ style={{
opacity: !inView && animationType === 'fade' ? 0 : 1 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 && ( {!isSelectionMode && (
<> <>
<button <button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors" className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onClick(e); onClick();
hideOverlay();
}} }}
aria-label="View full size" aria-label="View full size"
> >
@@ -124,7 +221,11 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
{allowDownloads && ( {allowDownloads && (
<button <button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors" 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" aria-label="Download photo"
> >
<Download className="w-5 h-5 text-neutral-800" /> <Download className="w-5 h-5 text-neutral-800" />
@@ -133,7 +234,11 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
{showFeedbackActions && onQuickComment && ( {showFeedbackActions && onQuickComment && (
<button <button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors" 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" aria-label="Comment on photo"
title="Comment" title="Comment"
> >
@@ -148,6 +253,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
e.stopPropagation(); e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) { if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
onRequireIdentity('like', photo.id); onRequireIdentity('like', photo.id);
hideOverlay();
return; return;
} }
// Optimistic UI: mark as liked immediately // Optimistic UI: mark as liked immediately
@@ -163,6 +269,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
console.warn('Like submit failed, keeping optimistic UI', err); console.warn('Like submit failed, keeping optimistic UI', err);
} }
if (onFeedbackChange) onFeedbackChange(); if (onFeedbackChange) onFeedbackChange();
hideOverlay();
}} }}
aria-label="Like photo" aria-label="Like photo"
aria-pressed={liked} aria-pressed={liked}
@@ -182,9 +289,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
role="checkbox" role="checkbox"
aria-checked={isSelected} aria-checked={isSelected}
data-testid={`gallery-photo-checkbox-${photo.id}`} data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${ className={`absolute top-2 right-2 z-20 transition-opacity ${checkboxVisibilityClass} md:group-hover:opacity-100`}
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }} 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`}> <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; error: string | null;
mustChangePassword: boolean; mustChangePassword: boolean;
updatePasswordChanged: () => void; updatePasswordChanged: () => void;
updateUserProfile: (updates: Partial<AdminUser>) => void;
} }
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined); const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
@@ -104,6 +105,17 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
} }
}; };
const updateUserProfile = (updates: Partial<AdminUser>) => {
setUser((prev) => {
if (!prev) {
return prev;
}
const nextUser = { ...prev, ...updates };
sessionStorage.setItem('admin_user', JSON.stringify(nextUser));
return nextUser;
});
};
return ( return (
<AdminAuthContext.Provider <AdminAuthContext.Provider
value={{ value={{
@@ -115,6 +127,7 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
error, error,
mustChangePassword, mustChangePassword,
updatePasswordChanged, updatePasswordChanged,
updateUserProfile,
}} }}
> >
{children} {children}
+158 -52
View File
@@ -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 type { ReactNode } from 'react';
import { useLocation } from 'react-router-dom';
import { api } from '../config/api'; import { api } from '../config/api';
import { authService, galleryService } from '../services'; import { authService, galleryService } from '../services';
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth'; import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
import { normalizeRequirePassword } from '../utils/accessControl';
import { import {
clearActiveGallerySlug, clearActiveGallerySlug,
clearGalleryToken, clearGalleryToken,
@@ -18,12 +20,24 @@ interface GalleryEvent {
welcome_message?: string; welcome_message?: string;
color_theme?: string; color_theme?: string;
expires_at: 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 { interface GalleryAuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
event: GalleryEvent | null; 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; logout: () => void;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@@ -48,48 +62,133 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const [event, setEvent] = useState<GalleryEvent | null>(null); const [event, setEvent] = useState<GalleryEvent | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [routeError, setRouteError] = useState<string | null>(null);
// Get current gallery slug from URL const location = useLocation();
const getCurrentGallerySlug = () => { const [routeInfo, setRouteInfo] = useState<{ slug: string | null; token?: string; identifier: string | null; ready: boolean }>({
const pathParts = window.location.pathname.split('/'); slug: null,
if (pathParts[1] === 'gallery' && pathParts[2]) { token: undefined,
return pathParts[2]; identifier: null,
} ready: false,
return null; });
}; const lastResolvedIdentifier = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
cleanupOldGalleryAuth(); cleanupOldGalleryAuth();
}, []);
const slugAtMount = getCurrentGallerySlug(); useEffect(() => {
if (slugAtMount) { let cancelled = false;
setActiveGallerySlug(slugAtMount);
} else {
clearActiveGallerySlug();
}
const initialise = async () => { const parseRoute = async () => {
const currentSlug = getCurrentGallerySlug(); const segments = location.pathname.split('/').filter(Boolean);
if (!currentSlug) { if (segments[0] !== 'gallery') {
setIsLoading(false); if (!cancelled) {
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
setRouteError(null);
}
return; return;
} }
setActiveGallerySlug(currentSlug); const identifier = segments[1] || null;
const tokenSegment = segments[2];
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`); if (!identifier) {
if (storedEvent) { if (!cancelled) {
try { setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
const parsed = JSON.parse(storedEvent);
if (parsed && parsed.id) {
setEvent(parsed);
}
} catch (err) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
} }
return;
} }
const looksLikeToken = /^[0-9a-fA-F]{32}$/.test(identifier) && !tokenSegment;
if (looksLikeToken) {
if (lastResolvedIdentifier.current === identifier) {
setRouteInfo(prev => ({
slug: prev.slug,
token: prev.token,
identifier,
ready: true,
}));
setRouteError(null);
return;
}
try {
const resolved = await galleryService.resolveIdentifier(identifier);
if (cancelled) return;
lastResolvedIdentifier.current = identifier;
setRouteInfo({
slug: resolved.slug,
token: resolved.token,
identifier,
ready: true,
});
setRouteError(null);
} catch (err: any) {
if (cancelled) return;
lastResolvedIdentifier.current = identifier;
setRouteInfo({
slug: null,
token: undefined,
identifier,
ready: true,
});
setRouteError(err?.response?.data?.error || 'Unable to resolve gallery link');
}
} else {
lastResolvedIdentifier.current = null;
setRouteInfo({
slug: identifier,
token: tokenSegment,
identifier,
ready: true,
});
setRouteError(null);
}
};
setRouteInfo(prev => ({ ...prev, ready: false }));
parseRoute();
return () => {
cancelled = true;
};
}, [location.pathname]);
useEffect(() => {
if (!routeInfo.ready) {
return;
}
if (!routeInfo.slug) {
clearActiveGallerySlug();
setIsAuthenticated(false);
setEvent(null);
setIsLoading(false);
return;
}
const currentSlug = routeInfo.slug;
setActiveGallerySlug(currentSlug);
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
if (storedEvent) {
try {
const parsed = JSON.parse(storedEvent);
if (parsed && parsed.id) {
const normalizedStored = normalizeEvent(parsed);
setEvent(normalizedStored);
if (normalizedStored) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
}
}
} catch {
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
}
}
const initialise = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>( const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
@@ -101,29 +200,30 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setIsAuthenticated(true); setIsAuthenticated(true);
if (!storedEvent) { if (!storedEvent) {
// Fetch gallery details to hydrate context
const galleryData = await galleryService.getGalleryPhotos(currentSlug); const galleryData = await galleryService.getGalleryPhotos(currentSlug);
if (galleryData?.event) { if (galleryData?.event) {
setEvent(galleryData.event); const normalizedEvent = normalizeEvent(galleryData.event);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(galleryData.event)); setEvent(normalizedEvent);
if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
}
} }
} }
return; return;
} }
// If no active session, check for share token in URL if (routeInfo.token) {
const parts = window.location.pathname.split('/'); const verify = await galleryService.verifyToken(currentSlug, routeInfo.token);
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
if (urlToken) {
const verify = await galleryService.verifyToken(currentSlug, urlToken);
if (verify?.valid) { if (verify?.valid) {
const response = await authService.shareLinkLogin(currentSlug, urlToken); const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
if (response?.event) { if (response?.event) {
setEvent(response.event); const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
setIsAuthenticated(true); setIsAuthenticated(true);
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event)); if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
}
if (response.token) { if (response.token) {
storeGalleryToken(currentSlug, response.token); storeGalleryToken(currentSlug, response.token);
} }
@@ -133,41 +233,47 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
} }
} }
// No valid session found
setIsAuthenticated(false); setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null); setEvent(null);
clearGalleryToken(currentSlug); clearGalleryToken(currentSlug);
} catch (error) { } catch (initialiseError: any) {
setIsAuthenticated(false); setIsAuthenticated(false);
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
setEvent(null); setEvent(null);
clearGalleryToken(currentSlug); clearGalleryToken(currentSlug);
if (initialiseError?.response?.data?.error) {
setError(initialiseError.response.data.error);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
initialise(); initialise();
return () => { return () => {
clearActiveGallerySlug(); clearActiveGallerySlug();
}; };
}, []); }, [routeInfo]);
const login = async (slug: string, password: string, recaptchaToken?: string | null) => { const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
try { try {
setRouteError(null);
setError(null); setError(null);
setIsLoading(true); setIsLoading(true);
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken); const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
setEvent(response.event); const normalizedEvent = normalizeEvent(response.event);
setEvent(normalizedEvent);
setIsAuthenticated(true); setIsAuthenticated(true);
if (response.token) { if (response.token) {
storeGalleryToken(slug, response.token); storeGalleryToken(slug, response.token);
} }
setActiveGallerySlug(slug); setActiveGallerySlug(slug);
// Store event data for quick reloads (non-sensitive) if (normalizedEvent) {
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event)); sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
}
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Invalid password'); setError(err.response?.data?.error || 'Invalid password');
throw err; throw err;
@@ -177,7 +283,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
}; };
const logout = () => { const logout = () => {
const currentSlug = getCurrentGallerySlug(); const currentSlug = routeInfo.slug;
if (currentSlug) { if (currentSlug) {
sessionStorage.removeItem(`gallery_event_${currentSlug}`); sessionStorage.removeItem(`gallery_event_${currentSlug}`);
clearGalleryToken(currentSlug); clearGalleryToken(currentSlug);
@@ -196,7 +302,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
login, login,
logout, logout,
isLoading, isLoading,
error, error: routeError ?? error,
}} }}
> >
{children} {children}
+8 -2
View File
@@ -2,12 +2,18 @@ import { useQuery, useMutation } from '@tanstack/react-query';
import { galleryService } from '../services'; import { galleryService } from '../services';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
export const useGalleryInfo = (slug: string, token?: string) => { export const useGalleryInfo = (slug?: string, token?: string, enabled: boolean = true) => {
return useQuery({ return useQuery({
queryKey: ['gallery-info', slug, token], queryKey: ['gallery-info', slug, token],
queryFn: () => galleryService.getGalleryInfo(slug, token), queryFn: () => {
if (!slug) {
throw new Error('Gallery slug is required');
}
return galleryService.getGalleryInfo(slug, token);
},
retry: 1, retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
enabled: Boolean(slug) && enabled,
}); });
}; };
+45 -15
View File
@@ -48,7 +48,7 @@
"noCategory": "Keine Kategorie", "noCategory": "Keine Kategorie",
"eventSpecific": "(Veranstaltungsspezifisch)", "eventSpecific": "(Veranstaltungsspezifisch)",
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop", "clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei)", "fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
"selectedFiles": "Ausgewählte Dateien", "selectedFiles": "Ausgewählte Dateien",
"uploading": "Wird hochgeladen...", "uploading": "Wird hochgeladen...",
"uploadComplete": "Upload abgeschlossen!", "uploadComplete": "Upload abgeschlossen!",
@@ -59,9 +59,11 @@
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.", "externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
"selectExternalFolder": "Externen Ordner unter /external-media auswählen", "selectExternalFolder": "Externen Ordner unter /external-media auswählen",
"importFromSelectedFolder": "Ausgewählten Ordner importieren", "importFromSelectedFolder": "Ausgewählten Ordner importieren",
"maxFilesReached": "Maximal 500 Dateien erlaubt", "maxFilesReached": "Maximal {{limit}} Dateien erlaubt",
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)", "someFilesSkipped": "Nur {{allowed}} weitere Dateien erlaubt (Limit {{limit}})",
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden", "tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden",
"limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)",
"limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)",
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..." "uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..."
}, },
"navigation": { "navigation": {
@@ -496,6 +498,8 @@
"expiresIn": "Galerie läuft in {{count}} Tag ab", "expiresIn": "Galerie läuft in {{count}} Tag ab",
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab", "expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.", "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", "viewGallery": "Galerie anzeigen",
"downloadAll": "Alle herunterladen", "downloadAll": "Alle herunterladen",
"downloading": "Lade {{count}} Foto herunter...", "downloading": "Lade {{count}} Foto herunter...",
@@ -564,8 +568,8 @@
"eventName": "Veranstaltungsname", "eventName": "Veranstaltungsname",
"eventType": "Veranstaltungstyp", "eventType": "Veranstaltungstyp",
"eventDate": "Veranstaltungsdatum", "eventDate": "Veranstaltungsdatum",
"hostEmail": "Gastgeber-E-Mail", "hostEmail": "E-Mail des Kunden",
"hostName": "Name des Gastgebers", "hostName": "Name des Kunden",
"hostNamePlaceholder": "Max Mustermann", "hostNamePlaceholder": "Max Mustermann",
"adminEmail": "Admin-E-Mail", "adminEmail": "Admin-E-Mail",
"expirationDate": "Ablaufdatum", "expirationDate": "Ablaufdatum",
@@ -587,8 +591,8 @@
"eventExpired": "Diese Veranstaltung ist abgelaufen", "eventExpired": "Diese Veranstaltung ist abgelaufen",
"eventExpiresIn": "Diese Veranstaltung läuft in {{days}} Tagen ab", "eventExpiresIn": "Diese Veranstaltung läuft in {{days}} Tagen ab",
"guestsNoAccess": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.", "guestsNoAccess": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
"warningEmailsSent": "Warn-E-Mails wurden an den Gastgeber gesendet.", "warningEmailsSent": "Warn-E-Mails wurden an den Kunden gesendet.",
"warningEmailsHaveBeenSent": "Warn-E-Mails wurden an den Gastgeber gesendet.", "warningEmailsHaveBeenSent": "Warn-E-Mails wurden an den Kunden gesendet.",
"extendSevenDays": "Um 7 Tage verlängern", "extendSevenDays": "Um 7 Tage verlängern",
"overview": "Übersicht", "overview": "Übersicht",
"photos": "Fotos", "photos": "Fotos",
@@ -607,6 +611,7 @@
"created": "Erstellt", "created": "Erstellt",
"expires": "Läuft ab", "expires": "Läuft ab",
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.", "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", "resetGalleryPassword": "Galerie-Passwort zurücksetzen",
"resendCreationEmail": "Erstellungs-E-Mail erneut senden", "resendCreationEmail": "Erstellungs-E-Mail erneut senden",
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt", "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.", "organizeCategoriesInfo": "Organisieren Sie Ihre Fotos in Kategorien. Kategorien helfen Gästen, bestimmte Fototypen zu navigieren und zu finden.",
"categoriesTip": "Tipp: Kategorien sind spezifisch für jede Veranstaltung. Sie können auch globale Kategorien in den Einstellungen erstellen.", "categoriesTip": "Tipp: Kategorien sind spezifisch für jede Veranstaltung. Sie können auch globale Kategorien in den Einstellungen erstellen.",
"contactInformation": "Kontaktinformationen", "contactInformation": "Kontaktinformationen",
"hostEmailHelp": "Erhält Benachrichtigungen zur Galerie-Erstellung und zum Ablauf", "hostEmailHelp": "Der Kunde erhält Benachrichtigungen zur Galerie-Erstellung und zum Ablauf",
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen", "adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
"securityAccess": "Sicherheit & Zugriff", "securityAccess": "Sicherheit & Zugriff",
"galleryPassword": "Galerie-Passwort", "galleryPassword": "Galerie-Passwort",
"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", "passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
"passwordPlaceholder": "Sicheres Passwort eingeben", "passwordPlaceholder": "Sicheres Passwort eingeben",
"confirmPassword": "Passwort bestätigen", "confirmPassword": "Passwort bestätigen",
"showPasswords": "Passwörter anzeigen", "showPasswords": "Passwörter anzeigen",
"newPasswordLabel": "Neues Galerie-Passwort",
"gallerySettings": "Galerie-Einstellungen", "gallerySettings": "Galerie-Einstellungen",
"colorTheme": "Farbthema", "colorTheme": "Farbthema",
"galleryExpiration": "Galerie-Ablauf", "galleryExpiration": "Galerie-Ablauf",
@@ -679,7 +688,7 @@
"eventNamePlaceholder": "z.B. Max & Maria's Hochzeit", "eventNamePlaceholder": "z.B. Max & Maria's Hochzeit",
"welcomeMessageOptional": "Willkommensnachricht (Optional)", "welcomeMessageOptional": "Willkommensnachricht (Optional)",
"welcomeMessagePlaceholder": "Willkommen zu unserem besonderen Tag! Laden Sie diese Erinnerungen gerne herunter und teilen Sie sie...", "welcomeMessagePlaceholder": "Willkommen zu unserem besonderen Tag! Laden Sie diese Erinnerungen gerne herunter und teilen Sie sie...",
"hostEmailPlaceholder": "gastgeber@beispiel.de", "hostEmailPlaceholder": "kunde@beispiel.de",
"adminEmailPlaceholder": "admin@beispiel.de", "adminEmailPlaceholder": "admin@beispiel.de",
"securityAndAccess": "Sicherheit & Zugriff", "securityAndAccess": "Sicherheit & Zugriff",
"accessAndSecurity": "Zugriff & Sicherheit", "accessAndSecurity": "Zugriff & Sicherheit",
@@ -735,6 +744,9 @@
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.", "noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
"eventsSelected": "{{count}} Veranstaltung ausgewählt", "eventsSelected": "{{count}} Veranstaltung ausgewählt",
"eventsSelected_plural": "{{count}} Veranstaltungen 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", "viewDetails": "Details anzeigen",
"archiveEventAction": "Veranstaltung archivieren", "archiveEventAction": "Veranstaltung archivieren",
"downloadArchiveAction": "Archiv herunterladen", "downloadArchiveAction": "Archiv herunterladen",
@@ -763,12 +775,16 @@
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben", "defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
"maxFileSize": "Max. Dateigröße (MB)", "maxFileSize": "Max. Dateigröße (MB)",
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto", "maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
"maxFilesPerUpload": "Max. Dateien pro Upload",
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
"allowedFileTypes": "Erlaubte Dateitypen", "allowedFileTypes": "Erlaubte Dateitypen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen", "allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
"featureToggles": "Funktionsschalter", "featureToggles": "Funktionsschalter",
"enableWatermark": "Wasserzeichen auf Fotos aktivieren", "enableWatermark": "Wasserzeichen auf Fotos aktivieren",
"enableAnalytics": "Analytics-Tracking aktivieren", "enableAnalytics": "Analytics-Tracking aktivieren",
"enableRegistration": "Selbstregistrierung für Admins erlauben", "enableRegistration": "Selbstregistrierung für Admins erlauben",
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
"enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.",
"maintenanceMode": "Wartungsmodus aktivieren", "maintenanceMode": "Wartungsmodus aktivieren",
"language": "Sprache", "language": "Sprache",
"defaultLanguage": "Standardsprache", "defaultLanguage": "Standardsprache",
@@ -781,7 +797,18 @@
"saveGeneralSettings": "Allgemeine Einstellungen speichern", "saveGeneralSettings": "Allgemeine Einstellungen speichern",
"dateTimeFormat": "Datums- & Zeitformat", "dateTimeFormat": "Datums- & Zeitformat",
"dateFormat": "Datumsformat", "dateFormat": "Datumsformat",
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden" "dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden",
"accountSection": "Admin-Konto",
"accountUsername": "Admin-Benutzername",
"accountUsernameHelp": "Wird im Admin-Bereich angezeigt und in Aktivitätsprotokollen verwendet.",
"accountUsernameRequired": "Benutzername ist erforderlich",
"accountUsernameLength": "Benutzername muss mindestens 3 Zeichen lang sein",
"accountEmail": "Admin-E-Mail",
"accountEmailHelp": "Wird für die Anmeldung und für Sicherheitsbenachrichtigungen verwendet.",
"accountEmailRequired": "E-Mail-Adresse ist erforderlich",
"accountEmailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben",
"accountSaveButton": "Kontodaten speichern",
"accountSaveSuccess": "Kontodaten aktualisiert"
}, },
"publicSite": { "publicSite": {
"tabLabel": "Öffentliche Seite", "tabLabel": "Öffentliche Seite",
@@ -853,7 +880,6 @@
"security": { "security": {
"title": "Sicherheit", "title": "Sicherheit",
"passwordSettings": "Passworteinstellungen", "passwordSettings": "Passworteinstellungen",
"requirePassword": "Passwort für alle Galerien erforderlich",
"minPasswordLength": "Minimale Passwortlänge", "minPasswordLength": "Minimale Passwortlänge",
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter", "minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
"passwordComplexity": "Passwort-Komplexität", "passwordComplexity": "Passwort-Komplexität",
@@ -866,7 +892,11 @@
"sessionTimeout": "Sitzungs-Timeout (Minuten)", "sessionTimeout": "Sitzungs-Timeout (Minuten)",
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten", "sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
"maxLoginAttempts": "Max. Anmeldeversuche", "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", "enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
"recaptchaSettings": "reCAPTCHA-Einstellungen", "recaptchaSettings": "reCAPTCHA-Einstellungen",
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren", "enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
@@ -1341,8 +1371,8 @@
}, },
"validation": { "validation": {
"eventNameRequired": "Veranstaltungsname ist erforderlich", "eventNameRequired": "Veranstaltungsname ist erforderlich",
"hostEmailRequired": "Gastgeber-E-Mail ist erforderlich", "hostEmailRequired": "Die E-Mail des Kunden ist erforderlich",
"hostNameRequired": "Der Name des Gastgebers ist erforderlich", "hostNameRequired": "Der Name des Kunden ist erforderlich",
"adminEmailRequired": "Admin-E-Mail ist erforderlich", "adminEmailRequired": "Admin-E-Mail ist erforderlich",
"invalidEmailFormat": "Ungültiges E-Mail-Format", "invalidEmailFormat": "Ungültiges E-Mail-Format",
"passwordRequired": "Passwort ist erforderlich", "passwordRequired": "Passwort ist erforderlich",
+46 -16
View File
@@ -48,7 +48,7 @@
"noCategory": "No category", "noCategory": "No category",
"eventSpecific": "(Event specific)", "eventSpecific": "(Event specific)",
"clickToUpload": "Click to upload or drag and drop", "clickToUpload": "Click to upload or drag and drop",
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file)", "fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)",
"selectedFiles": "Selected files", "selectedFiles": "Selected files",
"uploading": "Uploading...", "uploading": "Uploading...",
"uploadComplete": "Upload complete!", "uploadComplete": "Upload complete!",
@@ -59,9 +59,11 @@
"externalImportInfo": "All pictures from the selected folder will be imported.", "externalImportInfo": "All pictures from the selected folder will be imported.",
"selectExternalFolder": "Select external folder under /external-media", "selectExternalFolder": "Select external folder under /external-media",
"importFromSelectedFolder": "Import from selected folder", "importFromSelectedFolder": "Import from selected folder",
"maxFilesReached": "Maximum 500 files allowed", "maxFilesReached": "Maximum {{limit}} files allowed",
"someFilesSkipped": "Some files were skipped (500 file limit)", "someFilesSkipped": "Only {{allowed}} more files can be added (limit {{limit}})",
"tooManyFiles": "Maximum 500 files can be uploaded at once", "tooManyFiles": "Maximum {{limit}} files can be uploaded at once",
"limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)",
"limitReached": "Upload limit reached ({{limit}} files per batch)",
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..." "uploadingChunks": "Uploading {{count}} files in {{total}} batches..."
}, },
"navigation": { "navigation": {
@@ -161,6 +163,8 @@
"expiresIn": "Gallery expires in {{count}} day", "expiresIn": "Gallery expires in {{count}} day",
"expiresIn_plural": "Gallery expires in {{count}} days", "expiresIn_plural": "Gallery expires in {{count}} days",
"downloadBefore": "Download your photos before they're no longer available.", "downloadBefore": "Download your photos before they're no longer available.",
"publicGalleryTitle": "This gallery is publicly accessible",
"publicGallerySubtitle": "Loading the photos now...",
"viewGallery": "View Gallery", "viewGallery": "View Gallery",
"downloadAll": "Download All", "downloadAll": "Download All",
"downloading": "Downloading {{count}} photo...", "downloading": "Downloading {{count}} photo...",
@@ -223,7 +227,7 @@
"eventNamePlaceholder": "e.g., John & Jane's Wedding", "eventNamePlaceholder": "e.g., John & Jane's Wedding",
"welcomeMessageOptional": "Welcome Message (Optional)", "welcomeMessageOptional": "Welcome Message (Optional)",
"welcomeMessagePlaceholder": "Welcome to our special day! Feel free to download and share these memories...", "welcomeMessagePlaceholder": "Welcome to our special day! Feel free to download and share these memories...",
"hostEmailPlaceholder": "host@example.com", "hostEmailPlaceholder": "customer@example.com",
"adminEmailPlaceholder": "admin@example.com", "adminEmailPlaceholder": "admin@example.com",
"securityAndAccess": "Security & Access", "securityAndAccess": "Security & Access",
"accessAndSecurity": "Access & Security", "accessAndSecurity": "Access & Security",
@@ -249,8 +253,8 @@
"eventName": "Event Name", "eventName": "Event Name",
"eventType": "Event Type", "eventType": "Event Type",
"eventDate": "Event Date", "eventDate": "Event Date",
"hostEmail": "Host Email", "hostEmail": "Customer Email",
"hostName": "Host Name", "hostName": "Customer Name",
"hostNamePlaceholder": "John Smith", "hostNamePlaceholder": "John Smith",
"adminEmail": "Admin Email", "adminEmail": "Admin Email",
"adminNotificationEmail": "Admin Notification Email", "adminNotificationEmail": "Admin Notification Email",
@@ -273,7 +277,7 @@
"eventExpired": "This event has expired", "eventExpired": "This event has expired",
"eventExpiresIn": "This event expires in {{days}} days", "eventExpiresIn": "This event expires in {{days}} days",
"guestsNoAccess": "Guests can no longer access the gallery. Consider archiving this event.", "guestsNoAccess": "Guests can no longer access the gallery. Consider archiving this event.",
"warningEmailsSent": "Warning emails have been sent to the host.", "warningEmailsSent": "Warning emails have been sent to the customer.",
"overview": "Overview", "overview": "Overview",
"photos": "Photos", "photos": "Photos",
"categories": "Categories", "categories": "Categories",
@@ -290,6 +294,7 @@
"created": "Created", "created": "Created",
"expires": "Expires", "expires": "Expires",
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.", "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", "resetGalleryPassword": "Reset Gallery Password",
"resendCreationEmail": "Resend Creation Email", "resendCreationEmail": "Resend Creation Email",
"creationEmailResent": "Creation email has been queued for sending", "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.", "organizeCategoriesInfo": "Organize your photos into categories. Categories help guests navigate and find specific types of photos.",
"categoriesTip": "Tip: Categories are specific to each event. You can also create global categories in Settings.", "categoriesTip": "Tip: Categories are specific to each event. You can also create global categories in Settings.",
"contactInformation": "Contact Information", "contactInformation": "Contact Information",
"hostEmailHelp": "Will receive gallery creation and expiration notifications", "hostEmailHelp": "Customer will receive gallery creation and expiration notifications",
"adminEmailHelp": "Will receive system notifications and archive confirmations", "adminEmailHelp": "Will receive system notifications and archive confirmations",
"securityAccess": "Security & Access", "securityAccess": "Security & Access",
"galleryPassword": "Gallery Password", "galleryPassword": "Gallery Password",
"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", "passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
"confirmPassword": "Confirm Password", "confirmPassword": "Confirm Password",
"showPasswords": "Show passwords", "showPasswords": "Show passwords",
"newPasswordLabel": "New Gallery Password",
"gallerySettings": "Gallery Settings", "gallerySettings": "Gallery Settings",
"themeAndStyle": "Theme & Style", "themeAndStyle": "Theme & Style",
"colorTheme": "Color Theme", "colorTheme": "Color Theme",
@@ -373,6 +382,9 @@
"eventsSelected_plural": "{{count}} events selected", "eventsSelected_plural": "{{count}} events selected",
"clear": "Clear", "clear": "Clear",
"archiveSelected": "Archive Selected", "archiveSelected": "Archive Selected",
"publicAccess": "Public access",
"passwordProtected": "Password protected",
"newPasswordRequired": "Please set a password before enabling protection.",
"event": "Event", "event": "Event",
"type": "Type", "type": "Type",
"date": "Date", "date": "Date",
@@ -396,13 +408,13 @@
"tryAgain": "Try Again", "tryAgain": "Try Again",
"eventExpiredMessage": "This event has expired", "eventExpiredMessage": "This event has expired",
"guestsCannotAccessGallery": "Guests can no longer access the gallery. Consider archiving this event.", "guestsCannotAccessGallery": "Guests can no longer access the gallery. Consider archiving this event.",
"warningEmailsHaveBeenSent": "Warning emails have been sent to the host.", "warningEmailsHaveBeenSent": "Warning emails have been sent to the customer.",
"extendSevenDays": "Extend 7 Days", "extendSevenDays": "Extend 7 Days",
"overview": "Overview", "overview": "Overview",
"eventInformation": "Event Information", "eventInformation": "Event Information",
"welcomeMessageLabel": "Welcome Message", "welcomeMessageLabel": "Welcome Message",
"noWelcomeMessageSet": "No welcome message set", "noWelcomeMessageSet": "No welcome message set",
"hostEmail": "Host Email", "hostEmail": "Customer Email",
"adminEmail": "Admin Email", "adminEmail": "Admin Email",
"createdOn": "Created", "createdOn": "Created",
"expires": "Expires", "expires": "Expires",
@@ -443,12 +455,16 @@
"defaultExpirationHelp": "How long galleries remain active by default", "defaultExpirationHelp": "How long galleries remain active by default",
"maxFileSize": "Max File Size (MB)", "maxFileSize": "Max File Size (MB)",
"maxFileSizeHelp": "Maximum size per uploaded photo", "maxFileSizeHelp": "Maximum size per uploaded photo",
"maxFilesPerUpload": "Max Files per Upload",
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
"allowedFileTypes": "Allowed File Types", "allowedFileTypes": "Allowed File Types",
"allowedFileTypesHelp": "Comma-separated list of file extensions", "allowedFileTypesHelp": "Comma-separated list of file extensions",
"featureToggles": "Feature Toggles", "featureToggles": "Feature Toggles",
"enableWatermark": "Enable watermark on photos", "enableWatermark": "Enable watermark on photos",
"enableAnalytics": "Enable analytics tracking", "enableAnalytics": "Enable analytics tracking",
"enableRegistration": "Allow self-registration for admins", "enableRegistration": "Allow self-registration for admins",
"enableShortGalleryUrls": "Use short gallery URLs",
"enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.",
"maintenanceMode": "Enable maintenance mode", "maintenanceMode": "Enable maintenance mode",
"language": "Language", "language": "Language",
"defaultLanguage": "Default Language", "defaultLanguage": "Default Language",
@@ -461,7 +477,18 @@
"saveGeneralSettings": "Save General Settings", "saveGeneralSettings": "Save General Settings",
"dateTimeFormat": "Date & Time Format", "dateTimeFormat": "Date & Time Format",
"dateFormat": "Date Format", "dateFormat": "Date Format",
"dateFormatHelp": "How dates are displayed in emails and throughout the application" "dateFormatHelp": "How dates are displayed in emails and throughout the application",
"accountSection": "Admin Account",
"accountUsername": "Admin Username",
"accountUsernameHelp": "Displayed in the admin interface and used in activity logs.",
"accountUsernameRequired": "Username is required",
"accountUsernameLength": "Username must be at least 3 characters",
"accountEmail": "Admin Email",
"accountEmailHelp": "Used for login and receiving security notifications.",
"accountEmailRequired": "Email address is required",
"accountEmailInvalid": "Enter a valid email address",
"accountSaveButton": "Save account details",
"accountSaveSuccess": "Account details updated"
}, },
"publicSite": { "publicSite": {
"tabLabel": "Public Site", "tabLabel": "Public Site",
@@ -533,7 +560,6 @@
"security": { "security": {
"title": "Security", "title": "Security",
"passwordSettings": "Password Settings", "passwordSettings": "Password Settings",
"requirePassword": "Require password for all galleries",
"minPasswordLength": "Minimum Password Length", "minPasswordLength": "Minimum Password Length",
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords", "minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
"passwordComplexity": "Password Complexity", "passwordComplexity": "Password Complexity",
@@ -546,7 +572,11 @@
"sessionTimeout": "Session Timeout (minutes)", "sessionTimeout": "Session Timeout (minutes)",
"sessionTimeoutHelp": "Admin session timeout in minutes", "sessionTimeoutHelp": "Admin session timeout in minutes",
"maxLoginAttempts": "Max Login Attempts", "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", "enable2FA": "Enable two-factor authentication for admins",
"recaptchaSettings": "reCAPTCHA Settings", "recaptchaSettings": "reCAPTCHA Settings",
"enableRecaptcha": "Enable reCAPTCHA for login forms", "enableRecaptcha": "Enable reCAPTCHA for login forms",
@@ -946,8 +976,8 @@
}, },
"validation": { "validation": {
"eventNameRequired": "Event name is required", "eventNameRequired": "Event name is required",
"hostEmailRequired": "Host email is required", "hostEmailRequired": "Customer email is required",
"hostNameRequired": "Host name is required", "hostNameRequired": "Customer name is required",
"adminEmailRequired": "Admin email is required", "adminEmailRequired": "Admin email is required",
"invalidEmailFormat": "Invalid email format", "invalidEmailFormat": "Invalid email format",
"passwordRequired": "Password is required", "passwordRequired": "Password is required",
+232 -51
View File
@@ -11,12 +11,14 @@ import { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery'; import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery'; import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service'; import { analyticsService } from '../services/analytics.service';
import { galleryService } from '../services';
import { api } from '../config/api'; import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types'; import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url'; import { buildResourceUrl } from '../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
export const GalleryPage: React.FC = () => { export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>(); const { slug: rawSlug, token: rawToken } = useParams<{ slug: string; token?: string }>();
const { isAuthenticated, login, event } = useGalleryAuth(); const { isAuthenticated, login, event } = useGalleryAuth();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { format } = useLocalizedDate(); const { format } = useLocalizedDate();
@@ -25,9 +27,83 @@ export const GalleryPage: React.FC = () => {
const [isLoggingIn, setIsLoggingIn] = useState(false); const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null); const [loginError, setLoginError] = useState<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null); const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
const [resolvedSlug, setResolvedSlug] = useState<string | null>(() => {
if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) {
return null;
}
return rawSlug || null;
});
const [resolvedToken, setResolvedToken] = useState<string | undefined>(rawToken);
const [isResolvingIdentifier, setIsResolvingIdentifier] = useState<boolean>(() =>
Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug))
);
const [identifierError, setIdentifierError] = useState<string | null>(null);
const lastResolvedIdentifier = React.useRef<string | null>(null);
// Fetch gallery info (public data) React.useEffect(() => {
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token); let cancelled = false;
const looksLikeToken = Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug));
if (!rawSlug) {
lastResolvedIdentifier.current = null;
setResolvedSlug(null);
setResolvedToken(rawToken);
setIsResolvingIdentifier(false);
setIdentifierError(null);
} else if (!looksLikeToken) {
lastResolvedIdentifier.current = null;
setResolvedSlug(rawSlug);
setResolvedToken(rawToken);
setIsResolvingIdentifier(false);
setIdentifierError(null);
} else if (lastResolvedIdentifier.current !== rawSlug) {
setIsResolvingIdentifier(true);
setIdentifierError(null);
galleryService.resolveIdentifier(rawSlug)
.then((data) => {
if (cancelled) return;
lastResolvedIdentifier.current = rawSlug;
setResolvedSlug(data.slug);
setResolvedToken(data.token);
setIdentifierError(null);
})
.catch((error: any) => {
if (cancelled) return;
lastResolvedIdentifier.current = rawSlug;
setResolvedSlug(null);
setResolvedToken(undefined);
const message = error?.response?.data?.error || 'Unable to resolve gallery link';
setIdentifierError(message);
})
.finally(() => {
if (!cancelled) {
setIsResolvingIdentifier(false);
}
});
} else {
setIsResolvingIdentifier(false);
}
return () => {
cancelled = true;
};
}, [rawSlug, rawToken]);
const canFetchGalleryInfo = Boolean(resolvedSlug) && !isResolvingIdentifier;
const {
data: galleryInfo,
isLoading: isLoadingInfoQuery,
error: infoError
} = useGalleryInfo(canFetchGalleryInfo ? resolvedSlug ?? undefined : undefined, resolvedToken, canFetchGalleryInfo);
const isLoadingInfo = isLoadingInfoQuery || isResolvingIdentifier;
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
React.useEffect(() => {
setAutoLoginAttempted(false);
}, [resolvedSlug]);
// Fetch branding settings // Fetch branding settings
const { data: settingsData } = useQuery({ const { data: settingsData } = useQuery({
@@ -87,6 +163,30 @@ export const GalleryPage: React.FC = () => {
} }
}, [galleryInfo, settingsData, isAuthenticated, setTheme]); }, [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 // Calculate days until expiration
const daysUntilExpiration = galleryInfo const daysUntilExpiration = galleryInfo
? differenceInDays(parseISO(galleryInfo.expires_at), new Date()) ? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
@@ -96,7 +196,7 @@ export const GalleryPage: React.FC = () => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); // Prevent any bubbling e.stopPropagation(); // Prevent any bubbling
if (!password.trim()) { if (requiresPassword && !password.trim()) {
setLoginError(t('auth.pleaseEnterPassword')); setLoginError(t('auth.pleaseEnterPassword'));
return; return;
} }
@@ -104,13 +204,19 @@ export const GalleryPage: React.FC = () => {
try { try {
setIsLoggingIn(true); setIsLoggingIn(true);
setLoginError(null); setLoginError(null);
await login(slug!, password, recaptchaToken); if (!resolvedSlug) {
setLoginError(t('errors.galleryNotFound'));
return;
}
await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken);
// Track successful password entry if (requiresPassword) {
analyticsService.trackGalleryEvent('password_entry', { analyticsService.trackGalleryEvent('password_entry', {
gallery: slug, gallery: resolvedSlug,
success: true success: true
}); });
}
} catch (error: any) { } catch (error: any) {
console.error('Login error:', error); console.error('Login error:', error);
const errorMessage = error.response?.data?.error || 'Invalid password'; const errorMessage = error.response?.data?.error || 'Invalid password';
@@ -128,11 +234,13 @@ export const GalleryPage: React.FC = () => {
} }
// Track failed password entry // Track failed password entry
analyticsService.trackGalleryEvent('password_entry', { if (requiresPassword) {
gallery: slug, analyticsService.trackGalleryEvent('password_entry', {
success: false, gallery: resolvedSlug ?? rawSlug ?? 'unknown',
statusCode success: false,
}); statusCode
});
}
// Keep the password field to allow retry // Keep the password field to allow retry
// Do not clear the password // 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 // Show error state
if (infoError) { if (infoError) {
// Check if it's an archived gallery error // 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 // Show gallery view if authenticated
if (isAuthenticated && event) { if (isAuthenticated && event) {
return <GalleryView slug={slug!} event={event} />; return <GalleryView slug={gallerySlugForView} event={event} />;
} }
// Show login form // Show login form
@@ -311,43 +474,61 @@ export const GalleryPage: React.FC = () => {
</div> </div>
)} )}
{/* Login Card */}
<Card> <Card>
<CardContent className="p-4 sm:p-6"> <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> {requiresPassword ? (
<>
<form onSubmit={handleLogin} className="space-y-4"> <h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<Input
type="password" <form onSubmit={handleLogin} className="space-y-4">
label={t('auth.password')} <Input
placeholder={t('auth.passwordPlaceholder')} type="password"
value={password} label={t('auth.password')}
onChange={(e) => setPassword(e.target.value)} placeholder={t('auth.passwordPlaceholder')}
error={loginError || undefined} value={password}
autoFocus onChange={(e) => setPassword(e.target.value)}
className="text-sm sm:text-base" error={loginError || undefined}
/> autoFocus
className="text-sm sm:text-base"
<ReCaptcha />
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)} <ReCaptcha
/> onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
<Button />
type="submit"
variant="primary" <Button
size="lg" type="submit"
className="w-full text-sm sm:text-base" variant="primary"
isLoading={isLoggingIn} size="lg"
disabled={isLoggingIn} className="w-full text-sm sm:text-base"
> isLoading={isLoggingIn}
{t('gallery.viewGallery')} disabled={isLoggingIn}
</Button> >
</form> {t('gallery.viewGallery')}
</Button>
</form>
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6"> <p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')} {t('auth.passwordHint')}
</p> </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> </CardContent>
</Card> </Card>
@@ -376,4 +557,4 @@ export const GalleryPage: React.FC = () => {
</div> </div>
</div> </div>
); );
}; };
@@ -68,6 +68,7 @@ export const AdminLoginPage: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
toast.dismiss();
if (!validateForm()) { if (!validateForm()) {
return; return;
+139 -99
View File
@@ -25,8 +25,9 @@ interface FormData {
event_type: string; event_type: string;
event_name: string; event_name: string;
event_date: string; event_date: string;
host_email: string; customer_email: string;
admin_email: string; admin_email: string;
require_password: boolean;
password: string; password: string;
confirm_password: string; confirm_password: string;
welcome_message: string; welcome_message: string;
@@ -121,8 +122,9 @@ export const CreateEventPage: React.FC = () => {
event_type: 'wedding', event_type: 'wedding',
event_name: '', event_name: '',
event_date: format(new Date(), 'yyyy-MM-dd'), event_date: format(new Date(), 'yyyy-MM-dd'),
host_email: '', customer_email: '',
admin_email: '', admin_email: '',
require_password: true,
password: '', password: '',
confirm_password: '', confirm_password: '',
welcome_message: '', welcome_message: '',
@@ -196,10 +198,10 @@ export const CreateEventPage: React.FC = () => {
newErrors.event_name = t('validation.eventNameRequired'); newErrors.event_name = t('validation.eventNameRequired');
} }
if (!formData.host_email) { if (!formData.customer_email) {
newErrors.host_email = t('validation.hostEmailRequired'); newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) { } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
newErrors.host_email = t('validation.invalidEmailFormat'); newErrors.customer_email = t('validation.invalidEmailFormat');
} }
if (!formData.admin_email) { if (!formData.admin_email) {
@@ -208,17 +210,18 @@ export const CreateEventPage: React.FC = () => {
newErrors.admin_email = t('validation.invalidEmailFormat'); newErrors.admin_email = t('validation.invalidEmailFormat');
} }
if (!formData.password) { if (formData.require_password) {
newErrors.password = t('validation.passwordRequired'); if (!formData.password) {
} else if (formData.password.length < 6) { newErrors.password = t('validation.passwordRequired');
newErrors.password = t('validation.passwordMinLength'); } else if (formData.password.length < 6) {
} else if (/^\d{1,6}$/.test(formData.password)) { newErrors.password = t('validation.passwordMinLength');
// Prevent simple numeric passwords like "123456" } 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"'); 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) { if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch'); newErrors.confirm_password = t('validation.passwordsDoNotMatch');
}
} }
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) { 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); const selectedTheme = COLOR_THEMES.find(t => t.value === formData.color_theme);
createMutation.mutate({ const payload = {
event_type: formData.event_type, event_type: formData.event_type,
event_name: formData.event_name, event_name: formData.event_name,
event_date: formData.event_date, event_date: formData.event_date,
host_email: formData.host_email, customer_name: formData.customer_email.split('@')[0],
customer_email: formData.customer_email,
admin_email: formData.admin_email, admin_email: formData.admin_email,
password: formData.password, require_password: formData.require_password,
password: formData.require_password ? formData.password : undefined,
welcome_message: formData.welcome_message || '', welcome_message: formData.welcome_message || '',
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined, color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
expiration_days: formData.expires_in_days, expiration_days: formData.expires_in_days,
allow_user_uploads: formData.allow_user_uploads, allow_user_uploads: formData.allow_user_uploads,
upload_category_id: formData.upload_category_id, upload_category_id: formData.upload_category_id,
}); };
createMutation.mutate(payload);
}; };
const handleInputChange = (field: keyof FormData) => ( 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> <h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.contactInformation')}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Host Email */} {/* Customer Email */}
<div> <div>
<label htmlFor="host_email" className="block text-sm font-medium text-neutral-700 mb-1"> <label htmlFor="customer_email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.hostEmail')} {t('events.hostEmail')}
</label> </label>
<Input <Input
id="host_email" id="customer_email"
type="email" type="email"
value={formData.host_email} value={formData.customer_email}
onChange={handleInputChange('host_email')} onChange={handleInputChange('customer_email')}
error={errors.host_email} error={errors.customer_email}
placeholder={t('events.hostEmailPlaceholder')} placeholder={t('events.hostEmailPlaceholder')}
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />} leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
/> />
@@ -426,81 +433,114 @@ export const CreateEventPage: React.FC = () => {
<Card padding="md" className="mb-6"> <Card padding="md" className="mb-6">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2> <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"> <div className="space-y-4">
{/* Password */} <label className="flex items-start gap-2">
<div> <input
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1"> type="checkbox"
{t('events.galleryPassword')} className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
</label> checked={formData.require_password}
<div className="relative"> onChange={(e) => {
<Input const checked = e.target.checked;
id="password" setFormData(prev => ({
type={showPassword ? 'text' : 'password'} ...prev,
value={formData.password} require_password: checked,
onChange={handleInputChange('password')} password: checked ? prev.password : '',
error={errors.password} confirm_password: checked ? prev.confirm_password : ''
placeholder={t('events.enterPassword')} }));
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')} if (!checked) {
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} setErrors(prev => ({ ...prev, password: '', confirm_password: '' }));
className="pr-10" }
/> }}
<button />
type="button" <div>
onClick={() => setShowPassword(!showPassword)} <span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
className="absolute inset-y-0 right-0 pr-3 flex items-center" <p className="text-xs text-neutral-500 mt-1">
style={{ top: errors.password ? '0' : '0' }} {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>
{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>
</label>
{/* 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>
{/* Confirm Password */} {!formData.require_password && (
<div> <div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1"> {t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
{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> )}
{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> </div>
</Card> </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_type: string;
event_name: string; event_name: string;
event_date: string; event_date: string;
host_name: string; customer_name: string;
host_email: string; customer_email: string;
admin_email: string; admin_email: string;
require_password: boolean;
password: string; password: string;
confirm_password: string; confirm_password: string;
welcome_message: string; welcome_message: string;
@@ -85,9 +86,10 @@ export const CreateEventPageEnhanced: React.FC = () => {
event_type: 'wedding', event_type: 'wedding',
event_name: '', event_name: '',
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
host_name: '', customer_name: '',
host_email: '', customer_email: '',
admin_email: '', admin_email: '',
require_password: true,
password: '', password: '',
confirm_password: '', confirm_password: '',
welcome_message: '', welcome_message: '',
@@ -182,14 +184,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
newErrors.event_date = t('validation.eventDateRequired'); newErrors.event_date = t('validation.eventDateRequired');
} }
if (!formData.host_name) { if (!formData.customer_name) {
newErrors.host_name = t('validation.hostNameRequired'); newErrors.customer_name = t('validation.hostNameRequired');
} }
if (!formData.host_email) { if (!formData.customer_email) {
newErrors.host_email = t('validation.hostEmailRequired'); newErrors.customer_email = t('validation.hostEmailRequired');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) { } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
newErrors.host_email = t('validation.invalidEmailFormat'); newErrors.customer_email = t('validation.invalidEmailFormat');
} }
if (!formData.admin_email) { if (!formData.admin_email) {
@@ -198,17 +200,19 @@ export const CreateEventPageEnhanced: React.FC = () => {
newErrors.admin_email = t('validation.invalidEmailFormat'); newErrors.admin_email = t('validation.invalidEmailFormat');
} }
if (!formData.password) { if (formData.require_password) {
newErrors.password = t('validation.passwordRequired'); if (!formData.password) {
} else if (formData.password.length < 6) { newErrors.password = t('validation.passwordRequired');
newErrors.password = t('validation.passwordMinLength'); } else if (formData.password.length < 6) {
} else if (/^\d{1,6}$/.test(formData.password)) { newErrors.password = t('validation.passwordMinLength');
// Prevent simple numeric passwords like "123456" } 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"'); // 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) { if (formData.password !== formData.confirm_password) {
newErrors.confirm_password = t('validation.passwordsDoNotMatch'); newErrors.confirm_password = t('validation.passwordsDoNotMatch');
}
} }
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) { 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_type: formData.event_type,
event_name: formData.event_name, event_name: formData.event_name,
event_date: formData.event_date, event_date: formData.event_date,
host_name: formData.host_name, customer_name: formData.customer_name,
host_email: formData.host_email, customer_email: formData.customer_email,
admin_email: formData.admin_email, admin_email: formData.admin_email,
password: formData.password, require_password: formData.require_password,
password: formData.require_password ? formData.password : undefined,
welcome_message: formData.welcome_message || '', welcome_message: formData.welcome_message || '',
color_theme: JSON.stringify(formData.theme_config), color_theme: JSON.stringify(formData.theme_config),
expiration_days: formData.expires_in_days, expiration_days: formData.expires_in_days,
@@ -467,9 +472,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
<Input <Input
label={t('events.hostName')} label={t('events.hostName')}
placeholder={t('events.hostNamePlaceholder')} placeholder={t('events.hostNamePlaceholder')}
value={formData.host_name} value={formData.customer_name}
onChange={handleInputChange('host_name')} onChange={handleInputChange('customer_name')}
error={errors.host_name} error={errors.customer_name}
leftIcon={<Calendar className="w-5 h-5" />} leftIcon={<Calendar className="w-5 h-5" />}
/> />
@@ -477,9 +482,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
type="email" type="email"
label={t('events.hostEmail')} label={t('events.hostEmail')}
placeholder={t('events.hostEmailPlaceholder')} placeholder={t('events.hostEmailPlaceholder')}
value={formData.host_email} value={formData.customer_email}
onChange={handleInputChange('host_email')} onChange={handleInputChange('customer_email')}
error={errors.host_email} error={errors.customer_email}
leftIcon={<Mail className="w-5 h-5" />} leftIcon={<Mail className="w-5 h-5" />}
/> />
</div> </div>
@@ -495,51 +500,89 @@ export const CreateEventPageEnhanced: React.FC = () => {
/> />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="space-y-3">
<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={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 <Input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
label={t('events.galleryPassword')} label={t('events.confirmPassword')}
placeholder={t('events.passwordPlaceholder')} placeholder={t('events.confirmPasswordPlaceholder')}
value={formData.password} value={formData.confirm_password}
onChange={handleInputChange('password')} onChange={handleInputChange('confirm_password')}
error={errors.password} error={errors.confirm_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" />} 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> </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> <div>
<label className="block text-sm font-medium text-neutral-700 mb-2"> <label className="block text-sm font-medium text-neutral-700 mb-2">
+141 -11
View File
@@ -17,7 +17,10 @@ import {
Image, Image,
Key, Key,
Mail, Mail,
MessageSquare MessageSquare,
Lock,
Eye,
EyeOff
} from 'lucide-react'; } from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns'; import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify'; 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 { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
import { archiveService } from '../../services/archive.service'; import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.service'; import { externalMediaService } from '../../services/externalMedia.service';
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.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; allow_user_uploads: boolean;
upload_category_id: number | null; upload_category_id: number | null;
hero_photo_id: number | null; hero_photo_id: number | null;
host_name: string; customer_name: string;
source_mode: 'managed' | 'reference'; source_mode: 'managed' | 'reference';
external_path: string; external_path: string;
require_password: boolean;
new_password: string;
confirm_new_password: string;
}; };
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
@@ -131,9 +138,12 @@ export const EventDetailsPage: React.FC = () => {
allow_user_uploads: false, allow_user_uploads: false,
upload_category_id: null, upload_category_id: null,
hero_photo_id: null, hero_photo_id: null,
host_name: '', customer_name: '',
source_mode: 'managed', source_mode: 'managed',
external_path: '', external_path: '',
require_password: true,
new_password: '',
confirm_new_password: '',
}); });
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({ const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false, feedback_enabled: false,
@@ -156,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
const [importing, setImporting] = useState<boolean>(false); const [importing, setImporting] = useState<boolean>(false);
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null); const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
const [showPasswordReset, setShowPasswordReset] = useState(false); const [showPasswordReset, setShowPasswordReset] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null); const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default'); const [currentPresetName, setCurrentPresetName] = useState<string>('default');
@@ -271,10 +282,15 @@ export const EventDetailsPage: React.FC = () => {
allow_user_uploads: event.allow_user_uploads || false, allow_user_uploads: event.allow_user_uploads || false,
upload_category_id: event.upload_category_id || null, upload_category_id: event.upload_category_id || null,
hero_photo_id: event.hero_photo_id || null, hero_photo_id: event.hero_photo_id || null,
host_name: event.host_name || '', customer_name: event.customer_name || '',
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed', source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
external_path: event.external_path || '', external_path: event.external_path || '',
require_password: normalizeRequirePassword(event.require_password),
new_password: '',
confirm_new_password: '',
}); });
setShowNewPassword(false);
// Set feedback settings if available // Set feedback settings if available
if (eventFeedbackSettings) { if (eventFeedbackSettings) {
@@ -324,6 +340,26 @@ export const EventDetailsPage: React.FC = () => {
const externalPathToSave = editForm.external_path?.trim() || ''; 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) { if (editForm.source_mode === 'reference' && !externalPathToSave) {
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.')); toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
return; return;
@@ -333,6 +369,7 @@ export const EventDetailsPage: React.FC = () => {
const updateData: any = { const updateData: any = {
expires_at: editForm.expires_at, expires_at: editForm.expires_at,
allow_user_uploads: editForm.allow_user_uploads, allow_user_uploads: editForm.allow_user_uploads,
require_password: editForm.require_password,
}; };
// Only include fields that have defined values // Only include fields that have defined values
@@ -352,8 +389,12 @@ export const EventDetailsPage: React.FC = () => {
updateData.external_path = editForm.source_mode === 'reference' updateData.external_path = editForm.source_mode === 'reference'
? externalPathToSave ? externalPathToSave
: null; : null;
if (editForm.host_name !== undefined && editForm.host_name !== null) { if (editForm.customer_name !== undefined && editForm.customer_name !== null) {
updateData.host_name = editForm.host_name; updateData.customer_name = editForm.customer_name;
}
if (editForm.new_password) {
updateData.password = editForm.new_password;
} }
// Remove any keys with undefined values // Remove any keys with undefined values
@@ -437,6 +478,15 @@ export const EventDetailsPage: React.FC = () => {
{format(parseISO(event.event_date), 'PPP')} {format(parseISO(event.event_date), 'PPP')}
</span> </span>
<span className="capitalize">{event.event_type}</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 ? ( {event.is_archived ? (
<span className="text-neutral-500 flex items-center"> <span className="text-neutral-500 flex items-center">
<Archive className="w-4 h-4 mr-1" /> <Archive className="w-4 h-4 mr-1" />
@@ -615,8 +665,8 @@ export const EventDetailsPage: React.FC = () => {
</label> </label>
<Input <Input
type="text" type="text"
value={editForm.host_name} value={editForm.customer_name}
onChange={(e) => setEditForm(prev => ({ ...prev, host_name: e.target.value }))} onChange={(e) => setEditForm(prev => ({ ...prev, customer_name: e.target.value }))}
placeholder={t('events.hostNamePlaceholder')} placeholder={t('events.hostNamePlaceholder')}
/> />
</div> </div>
@@ -641,6 +691,84 @@ export const EventDetailsPage: React.FC = () => {
isEditing={isEditing} 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> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1"> <label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.sourceMode', 'Source Mode')} {t('events.sourceMode', 'Source Mode')}
@@ -753,14 +881,14 @@ export const EventDetailsPage: React.FC = () => {
<div> <div>
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt> <dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
<dd className="mt-1 text-sm text-neutral-900"> <dd className="mt-1 text-sm text-neutral-900">
{event.host_name || <span className="text-neutral-400">{t('common.notSet')}</span>} {event.customer_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
</dd> </dd>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt> <dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt>
<dd className="mt-1 text-sm text-neutral-900">{event.host_email}</dd> <dd className="mt-1 text-sm text-neutral-900">{event.customer_email}</dd>
</div> </div>
<div> <div>
@@ -848,7 +976,9 @@ export const EventDetailsPage: React.FC = () => {
</div> </div>
<p className="text-sm text-neutral-600 mt-2"> <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> </p>
{!event.is_archived && ( {!event.is_archived && (
+14 -2
View File
@@ -22,6 +22,7 @@ import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../compone
import { BulkArchiveModal } from '../../components/admin'; import { BulkArchiveModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
import { isGalleryPublic } from '../../utils/accessControl';
import type { Event } from '../../types'; import type { Event } from '../../types';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -158,7 +159,7 @@ export const EventsListPage: React.FC = () => {
events = events.filter(e => events = events.filter(e =>
e.event_name.toLowerCase().includes(term) || e.event_name.toLowerCase().includes(term) ||
e.event_type.toLowerCase().includes(term) || e.event_type.toLowerCase().includes(term) ||
e.host_email.toLowerCase().includes(term) (e.customer_email || '').toLowerCase().includes(term)
); );
} }
@@ -427,7 +428,18 @@ export const EventsListPage: React.FC = () => {
<td className="px-6 py-4"> <td className="px-6 py-4">
<div> <div>
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p> <p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
<p className="text-xs text-neutral-500">{event.host_email}</p> <p className="text-xs text-neutral-500">{event.customer_email}</p>
<div className="mt-1">
<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> </div>
</td> </td>
<td className="px-6 py-4 text-sm text-neutral-700"> <td className="px-6 py-4 text-sm text-neutral-700">
+251 -21
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Save, Save,
Database, Database,
Globe, Globe,
Key, Key,
@@ -10,7 +10,9 @@ import {
CheckCircle, CheckCircle,
Clock, Clock,
HardDrive, HardDrive,
Activity Activity,
Mail,
User
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -19,9 +21,12 @@ import { CategoryManager } from '../../components/admin/CategoryManager';
import { WordFilterManager } from '../../components/admin/WordFilterManager'; import { WordFilterManager } from '../../components/admin/WordFilterManager';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { adminService } from '../../services/admin.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAdminAuth } from '../../contexts';
const BYTES_PER_GB = 1024 * 1024 * 1024; const BYTES_PER_GB = 1024 * 1024 * 1024;
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
const toBoolean = (value: unknown, defaultValue = false): boolean => { const toBoolean = (value: unknown, defaultValue = false): boolean => {
if (value === undefined || value === null) { if (value === undefined || value === null) {
@@ -56,6 +61,7 @@ export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general'); const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { updateUserProfile } = useAdminAuth();
// Fetch settings // Fetch settings
const { data: settings, isLoading } = useQuery({ const { data: settings, isLoading } = useQuery({
@@ -63,6 +69,11 @@ export const SettingsPage: React.FC = () => {
queryFn: () => settingsService.getAllSettings(), queryFn: () => settingsService.getAllSettings(),
}); });
const { data: adminProfile, isLoading: adminProfileLoading } = useQuery({
queryKey: ['admin-profile'],
queryFn: () => adminService.getAdminProfile(),
});
// Fetch storage info // Fetch storage info
const { data: storageInfo } = useQuery({ const { data: storageInfo } = useQuery({
queryKey: ['admin-storage-info'], queryKey: ['admin-storage-info'],
@@ -83,23 +94,26 @@ export const SettingsPage: React.FC = () => {
site_url: '', site_url: '',
default_expiration_days: 30, default_expiration_days: 30,
max_file_size_mb: 50, max_file_size_mb: 50,
max_files_per_upload: 500,
allowed_file_types: 'jpg,jpeg,png,gif,webp', allowed_file_types: 'jpg,jpeg,png,gif,webp',
enable_watermark: false, enable_watermark: false,
enable_analytics: true, enable_analytics: true,
enable_registration: false, enable_registration: false,
maintenance_mode: false, maintenance_mode: false,
short_gallery_urls: false,
default_language: 'en', default_language: 'en',
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' } date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
}); });
// Security settings state // Security settings state
const [securitySettings, setSecuritySettings] = useState({ const [securitySettings, setSecuritySettings] = useState({
require_password: true,
password_min_length: 8, password_min_length: 8,
password_complexity: 'moderate', password_complexity: 'moderate',
enable_2fa: false, enable_2fa: false,
session_timeout_minutes: 60, session_timeout_minutes: 60,
max_login_attempts: 5, max_login_attempts: 5,
attempt_window_minutes: 15,
lockout_duration_minutes: 30,
enable_recaptcha: false, enable_recaptcha: false,
recaptcha_site_key: '', recaptcha_site_key: '',
recaptcha_secret_key: '' recaptcha_secret_key: ''
@@ -118,6 +132,11 @@ export const SettingsPage: React.FC = () => {
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>(''); const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>(''); const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>('');
const [overrideDirty, setOverrideDirty] = useState(false); const [overrideDirty, setOverrideDirty] = useState(false);
const [accountForm, setAccountForm] = useState({
username: '',
email: ''
});
const [accountErrors, setAccountErrors] = useState<Record<string, string>>({});
React.useEffect(() => { React.useEffect(() => {
if (settings) { if (settings) {
@@ -131,11 +150,16 @@ export const SettingsPage: React.FC = () => {
site_url: settings.general_site_url || '', site_url: settings.general_site_url || '',
default_expiration_days: toNumber(settings.general_default_expiration_days, 30), default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50), max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
max_files_per_upload: Math.min(
MAX_FILES_PER_UPLOAD_LIMIT,
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp', allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
enable_watermark: toBoolean(settings.general_enable_watermark, false), enable_watermark: toBoolean(settings.general_enable_watermark, false),
enable_analytics: toBoolean(settings.general_enable_analytics, true), enable_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false), enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false), maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
default_language: settings.general_default_language || 'en', default_language: settings.general_default_language || 'en',
date_format: settings.general_date_format date_format: settings.general_date_format
? (typeof settings.general_date_format === 'string' ? (typeof settings.general_date_format === 'string'
@@ -146,12 +170,13 @@ export const SettingsPage: React.FC = () => {
// Extract security settings // Extract security settings
setSecuritySettings({ setSecuritySettings({
require_password: toBoolean(settings.security_require_password, true),
password_min_length: toNumber(settings.security_password_min_length, 8), password_min_length: toNumber(settings.security_password_min_length, 8),
password_complexity: settings.security_password_complexity ?? 'moderate', password_complexity: settings.security_password_complexity ?? 'moderate',
enable_2fa: toBoolean(settings.security_enable_2fa, false), enable_2fa: toBoolean(settings.security_enable_2fa, false),
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60), session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
max_login_attempts: toNumber(settings.security_max_login_attempts, 5), 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), enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
recaptcha_site_key: settings.security_recaptcha_site_key ?? '', recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? '' recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
@@ -167,6 +192,15 @@ export const SettingsPage: React.FC = () => {
} }
}, [settings, i18n]); }, [settings, i18n]);
React.useEffect(() => {
if (adminProfile) {
setAccountForm({
username: adminProfile.username || '',
email: adminProfile.email || ''
});
}
}, [adminProfile]);
React.useEffect(() => { React.useEffect(() => {
if (!settings || overrideDirty) { if (!settings || overrideDirty) {
return; return;
@@ -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({ const saveSoftLimitMutation = useMutation({
mutationFn: async (limitBytes: number | null) => { mutationFn: async (limitBytes: number | null) => {
return settingsService.updateSettings({ return settingsService.updateSettings({
@@ -468,6 +579,64 @@ export const SettingsPage: React.FC = () => {
{/* General Settings Tab */} {/* General Settings Tab */}
{activeTab === 'general' && ( {activeTab === 'general' && (
<div className="space-y-6"> <div className="space-y-6">
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.accountSection')}</h2>
{adminProfileLoading ? (
<div className="py-8 flex justify-center">
<Loading size="md" />
</div>
) : (
<form className="space-y-4" onSubmit={handleAccountSubmit}>
<div>
<label htmlFor="admin-account-username" className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.accountUsername')}
</label>
<Input
id="admin-account-username"
type="text"
value={accountForm.username}
onChange={handleAccountChange('username')}
placeholder="admin"
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
error={accountErrors.username}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.accountUsernameHelp')}
</p>
</div>
<div>
<label htmlFor="admin-account-email" className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.accountEmail')}
</label>
<Input
id="admin-account-email"
type="email"
value={accountForm.email}
onChange={handleAccountChange('email')}
placeholder="admin@example.com"
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
error={accountErrors.email}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.accountEmailHelp')}
</p>
</div>
<div className="pt-2">
<Button
type="submit"
variant="primary"
leftIcon={<Save className="w-5 h-5" />}
isLoading={updateAdminProfileMutation.isPending}
>
{t('settings.general.accountSaveButton')}
</Button>
</div>
</form>
)}
</Card>
<Card padding="md"> <Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2> <h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
@@ -488,7 +657,7 @@ export const SettingsPage: React.FC = () => {
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1"> <label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.defaultExpiration')} {t('settings.general.defaultExpiration')}
@@ -513,6 +682,29 @@ export const SettingsPage: React.FC = () => {
max="500" max="500"
/> />
</div> </div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.general.maxFilesPerUpload')}
</label>
<Input
type="number"
value={generalSettings.max_files_per_upload}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
setGeneralSettings(prev => ({
...prev,
max_files_per_upload: Number.isFinite(parsed)
? Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, parsed))
: prev.max_files_per_upload
}));
}}
min="1"
max={MAX_FILES_PER_UPLOAD_LIMIT}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
</p>
</div>
</div> </div>
<div> <div>
@@ -575,6 +767,21 @@ export const SettingsPage: React.FC = () => {
/> />
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span> <span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
</label> </label>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.short_gallery_urls}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableShortGalleryUrls')}</span>
</label>
<p className="text-xs text-neutral-500 ml-6 mt-1">
{t('settings.general.enableShortGalleryUrlsHelp')}
</p>
</div>
</div> </div>
</Card> </Card>
@@ -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> <h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
<div className="space-y-4"> <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> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1"> <label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.minPasswordLength')} {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> <h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
<div className="space-y-4"> <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> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1"> <label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.sessionTimeout')} {t('settings.security.sessionTimeout')}
@@ -1161,11 +1358,41 @@ export const SettingsPage: React.FC = () => {
<Input <Input
type="number" type="number"
value={securitySettings.session_timeout_minutes} 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" min="5"
max="1440" max="1440"
/> />
</div> </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> <div>
<label className="block text-sm font-medium text-neutral-700 mb-1"> <label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.maxLoginAttempts')} {t('settings.security.maxLoginAttempts')}
@@ -1173,10 +1400,13 @@ export const SettingsPage: React.FC = () => {
<Input <Input
type="number" type="number"
value={securitySettings.max_login_attempts} value={securitySettings.max_login_attempts}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value) || 5 }))} onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value, 10) || 5 }))}
min="3" min="1"
max="10" max="50"
/> />
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.maxLoginAttemptsHelp')}
</p>
</div> </div>
</div> </div>
+21
View File
@@ -47,6 +47,17 @@ export interface Activity {
createdAt: string; createdAt: string;
} }
export interface AdminProfile {
id: number;
username: string;
email: string;
mustChangePassword?: boolean;
last_login?: string | null;
last_login_ip?: string | null;
created_at?: string;
updated_at?: string;
}
export interface AnalyticsData { export interface AnalyticsData {
chartData: Array<{ chartData: Array<{
date: string; date: string;
@@ -130,5 +141,15 @@ export const adminService = {
// Change password // Change password
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> { async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
await api.post('/admin/auth/change-password', data); await api.post('/admin/auth/change-password', data);
},
async getAdminProfile(): Promise<AdminProfile> {
const response = await api.get<AdminProfile>('/admin/auth/profile');
return response.data;
},
async updateAdminProfile(data: { username: string; email: string }): Promise<AdminProfile> {
const response = await api.put<{ user: AdminProfile }>('/admin/auth/profile', data);
return response.data.user;
} }
}; };
+14 -3
View File
@@ -1,5 +1,16 @@
import { api } from '../config/api'; import { api } from '../config/api';
import type { LoginResponse, GalleryAuthResponse } from '../types'; 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 = { export const authService = {
// Admin authentication // Admin authentication
@@ -24,7 +35,7 @@ export const authService = {
}, },
// Gallery authentication // 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', { const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
slug, slug,
password, password,
@@ -32,7 +43,7 @@ export const authService = {
}); });
// Token is now handled by GalleryAuthContext with slug-specific storage // 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> { async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
@@ -40,7 +51,7 @@ export const authService = {
slug, slug,
token, token,
}); });
return response.data; return normalizeGalleryResponse(response.data);
}, },
async galleryLogout(slug?: string | null) { async galleryLogout(slug?: string | null) {
+32 -6
View File
@@ -1,13 +1,31 @@
import { api } from '../config/api'; import { api } from '../config/api';
import type { Event } from '../types'; 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 { interface CreateEventData {
event_type: string; event_type: string;
event_name: string; event_name: string;
event_date: string; event_date: string;
host_email: string; customer_name?: string;
customer_email: string;
admin_email: string; admin_email: string;
password: string; require_password?: boolean;
password?: string;
welcome_message?: string; welcome_message?: string;
color_theme?: string; color_theme?: string;
expiration_days: number; expiration_days: number;
@@ -26,8 +44,10 @@ interface CreateEventData {
interface UpdateEventData { interface UpdateEventData {
event_name?: string; event_name?: string;
event_date?: string; event_date?: string;
host_email?: string; customer_name?: string;
customer_email?: string;
admin_email?: string; admin_email?: string;
require_password?: boolean;
password?: string; password?: string;
welcome_message?: string; welcome_message?: string;
color_theme?: string; color_theme?: string;
@@ -64,19 +84,25 @@ export const eventsService = {
} }
const response = await api.get<EventsListResponse>(`/admin/events?${params}`); 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) // Get single event details (admin)
async getEvent(id: number): Promise<Event> { async getEvent(id: number): Promise<Event> {
const response = await api.get<Event>(`/admin/events/${id}`); const response = await api.get<Event>(`/admin/events/${id}`);
return response.data; return normalizeEvent(response.data as Event);
}, },
// Create new event (admin) // Create new event (admin)
async createEvent(data: CreateEventData): Promise<Event> { async createEvent(data: CreateEventData): Promise<Event> {
const response = await api.post<Event>('/admin/events', data); const response = await api.post<Event>('/admin/events', data);
return response.data; return normalizeEvent(response.data as Event);
}, },
// Update event (admin) // Update event (admin)
+23 -3
View File
@@ -1,5 +1,6 @@
import { api } from '../config/api'; import { api } from '../config/api';
import type { GalleryInfo, GalleryData, GalleryStats } from '../types'; import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
export const galleryService = { export const galleryService = {
// Verify share token // Verify share token
@@ -12,7 +13,11 @@ export const galleryService = {
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> { async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
const params = token ? { token } : {}; const params = token ? { token } : {};
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params }); 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) // Get gallery photos (requires auth)
@@ -29,7 +34,17 @@ export const galleryService = {
} }
} }
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params }); 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 // Download single photo
@@ -104,4 +119,9 @@ export const galleryService = {
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`); const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
return response.data; return response.data;
}, },
async resolveIdentifier(identifier: string): Promise<ResolvedGalleryIdentifier> {
const response = await api.get<ResolvedGalleryIdentifier>(`/gallery/resolve/${identifier}`);
return response.data;
},
}; };
+16 -2
View File
@@ -5,8 +5,8 @@ export interface Event {
event_type: string; event_type: string;
event_name: string; event_name: string;
event_date: string; event_date: string;
host_name?: string; customer_name?: string;
host_email: string; customer_email: string;
admin_email: string; admin_email: string;
welcome_message?: string; welcome_message?: string;
color_theme?: string; color_theme?: string;
@@ -17,6 +17,7 @@ export interface Event {
is_archived: boolean; is_archived: boolean;
archive_path?: string; archive_path?: string;
archived_at?: string; archived_at?: string;
require_password?: boolean;
photo_count?: number; photo_count?: number;
total_size?: number; total_size?: number;
recent_photos?: Array<{ recent_photos?: Array<{
@@ -92,6 +93,7 @@ export interface GalleryData {
disable_right_click?: boolean; disable_right_click?: boolean;
watermark_downloads?: boolean; watermark_downloads?: boolean;
watermark_text?: string; watermark_text?: string;
require_password?: boolean;
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum'; protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
image_quality?: number; image_quality?: number;
use_canvas_rendering?: boolean; use_canvas_rendering?: boolean;
@@ -109,6 +111,17 @@ export interface GalleryStats {
unique_visitors: number; unique_visitors: number;
} }
export interface ResolvedGalleryIdentifier {
slug: string;
token: string;
matchType: string;
share_link: string;
share_path: string;
share_url: string;
short_enabled: boolean;
requires_password: boolean;
}
// Auth types // Auth types
export interface AdminUser { export interface AdminUser {
id: number; id: number;
@@ -134,6 +147,7 @@ export interface GalleryAuthResponse {
expires_at: string; expires_at: string;
allow_user_uploads?: boolean; allow_user_uploads?: boolean;
upload_category_id?: number | null; upload_category_id?: number | null;
require_password?: boolean;
}; };
} }
+29
View File
@@ -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);
};
+15
View File
@@ -24,4 +24,19 @@ export const cleanupOldGalleryAuth = () => {
sessionStorage.removeItem('gallery_event'); sessionStorage.removeItem('gallery_event');
sessionStorage.removeItem('gallery_token'); sessionStorage.removeItem('gallery_token');
sessionStorage.removeItem('gallery_active_slug'); 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
}
}; };
+1
View File
@@ -1 +1,2 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vitest" />
+9 -3
View File
@@ -1,8 +1,12 @@
/// <reference types="vitest" />
// @ts-nocheck
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import type { UserConfig as VitestUserConfig } from 'vitest/config'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ const config: VitestUserConfig = {
plugins: [react()], plugins: [react()],
build: { build: {
rollupOptions: { rollupOptions: {
@@ -28,5 +32,7 @@ export default defineConfig({
changeOrigin: true, changeOrigin: true,
}, },
}, },
}, }
}) }
export default defineConfig(config as any)
+6 -5
View File
@@ -1,5 +1,5 @@
{ {
"name": "wedding-photo-sharing", "name": "picpeak",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
@@ -558,7 +558,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1475386.tgz",
"integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==", "integrity": "sha512-RQ809ykTfJ+dgj9bftdeL2vRVxASAuGU+I9LEx9Ij5TXU5HrgAQVmzi72VA+mkzscE12uzlRv5/tWWv9R9J1SA==",
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause",
"peer": true
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
@@ -1499,9 +1500,9 @@
} }
}, },
"node_modules/tar-fs": { "node_modules/tar-fs": {
"version": "2.1.3", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==", "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"chownr": "^1.1.1", "chownr": "^1.1.1",
+5
View File
@@ -10,5 +10,10 @@
"devDependencies": { "devDependencies": {
"puppeteer": "^24.17.0", "puppeteer": "^24.17.0",
"@playwright/test": "^1.48.2" "@playwright/test": "^1.48.2"
},
"overrides": {
"prebuild-install": {
"tar-fs": "2.1.4"
}
} }
} }
+1 -2
View File
@@ -5,7 +5,7 @@ export default defineConfig({
timeout: 60_000, timeout: 60_000,
retries: 0, retries: 0,
use: { use: {
baseURL: 'http://localhost:3000', baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
headless: true, headless: true,
viewport: { width: 1280, height: 800 }, viewport: { width: 1280, height: 800 },
ignoreHTTPSErrors: true, ignoreHTTPSErrors: true,
@@ -15,4 +15,3 @@ export default defineConfig({
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } }, { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
], ],
}); });
+66 -10
View File
@@ -2,7 +2,7 @@
################################################################################ ################################################################################
# PicPeak Unified Setup Script # PicPeak Unified Setup Script
# Version: 2.0.0 # Version: 2.1.0
# Description: Universal installer for PicPeak with Docker and Native options # Description: Universal installer for PicPeak with Docker and Native options
# Supports: Ubuntu, Debian, Fedora, RHEL/CentOS, Raspberry Pi OS # Supports: Ubuntu, Debian, Fedora, RHEL/CentOS, Raspberry Pi OS
################################################################################ ################################################################################
@@ -11,7 +11,7 @@ set -euo pipefail
IFS=$'\n\t' IFS=$'\n\t'
# Script configuration # Script configuration
readonly SCRIPT_VERSION="2.0.0" readonly SCRIPT_VERSION="2.1.0"
readonly APP_NAME="PicPeak" readonly APP_NAME="PicPeak"
readonly REPO_URL="https://github.com/the-luap/picpeak.git" readonly REPO_URL="https://github.com/the-luap/picpeak.git"
readonly NODE_VERSION="20" readonly NODE_VERSION="20"
@@ -55,6 +55,7 @@ CUSTOM_PORT=""
UNATTENDED=false UNATTENDED=false
UPDATE_MODE=false UPDATE_MODE=false
UNINSTALL_MODE=false UNINSTALL_MODE=false
FORCE_ADMIN_PASSWORD_RESET=false
################################################################################ ################################################################################
# Helper Functions # Helper Functions
@@ -63,17 +64,19 @@ UNINSTALL_MODE=false
# Run a command as the application user, even if sudo is not available # Run a command as the application user, even if sudo is not available
run_as_user() { run_as_user() {
local cmd="$*" local cmd="$*"
local current_dir_escaped
current_dir_escaped=$(printf '%q' "$(pwd)")
if [[ "$(id -u)" -ne 0 ]]; then if [[ "$(id -u)" -ne 0 ]]; then
# Already non-root; just run # Already non-root; preserve working directory
bash -lc "$cmd" bash -lc "cd $current_dir_escaped && $cmd"
return $? return $?
fi fi
if command_exists sudo; then if command_exists sudo; then
sudo -H -u "$NATIVE_APP_USER" bash -lc "$cmd" sudo -H -u "$NATIVE_APP_USER" bash -lc "cd $current_dir_escaped && $cmd"
elif command_exists runuser; then elif command_exists runuser; then
runuser -u "$NATIVE_APP_USER" -- bash -lc "$cmd" runuser -u "$NATIVE_APP_USER" -- bash -lc "cd $current_dir_escaped && $cmd"
else else
su -s /bin/bash - "$NATIVE_APP_USER" -c "$cmd" su -s /bin/bash - "$NATIVE_APP_USER" -c "cd $current_dir_escaped && $cmd"
fi fi
} }
@@ -390,6 +393,29 @@ setup_docker_installation() {
if [[ -d "$app_dir/.git" ]]; then if [[ -d "$app_dir/.git" ]]; then
cd "$app_dir" cd "$app_dir"
git pull git pull
elif [[ -d "$app_dir" ]]; then
if [[ -z "$(ls -A "$app_dir" 2>/dev/null)" ]]; then
log_warn "Existing directory $app_dir is empty but not a git repository; recreating it..."
rm -rf "$app_dir"
git clone "$REPO_URL" "$app_dir"
else
log_warn "Directory $app_dir already exists and is not a git repository."
if [[ "$UNATTENDED" == "true" ]]; then
local backup_dir="${app_dir}.backup-$(date +%Y%m%d-%H%M%S)"
log_warn "Unattended mode: backing up directory to $backup_dir and cloning a fresh copy."
mv "$app_dir" "$backup_dir"
git clone "$REPO_URL" "$app_dir"
else
if confirm "Replace existing directory $app_dir with a fresh clone? This will move the current contents to a backup folder." "y"; then
local backup_dir="${app_dir}.backup-$(date +%Y%m%d-%H%M%S)"
mv "$app_dir" "$backup_dir"
log_step "Existing directory moved to $backup_dir"
git clone "$REPO_URL" "$app_dir"
else
die "Installation aborted because $app_dir already exists and is not a PicPeak git repository."
fi
fi
fi
else else
git clone "$REPO_URL" "$app_dir" git clone "$REPO_URL" "$app_dir"
fi fi
@@ -487,6 +513,15 @@ EOF
# Run database migrations # Run database migrations
log_step "Running database migrations..." log_step "Running database migrations..."
docker compose exec -T backend npm run migrate 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!" log_success "Docker installation completed!"
} }
@@ -619,7 +654,14 @@ setup_native_installation() {
apt) apt)
apt-get install -y build-essential python3 apt-get install -y build-essential python3
;; ;;
dnf|yum) dnf)
if ! $PACKAGE_MANAGER install -y @development-tools; then
log_warn "dnf @development-tools group install failed, retrying with legacy groupinstall syntax..."
$PACKAGE_MANAGER groupinstall -y "Development Tools"
fi
$PACKAGE_MANAGER install -y python3
;;
yum)
$PACKAGE_MANAGER groupinstall -y "Development Tools" $PACKAGE_MANAGER groupinstall -y "Development Tools"
$PACKAGE_MANAGER install -y python3 $PACKAGE_MANAGER install -y python3
;; ;;
@@ -737,6 +779,13 @@ EOF
log_step "Initializing database..." log_step "Initializing database..."
cd "$NATIVE_APP_DIR/app/backend" cd "$NATIVE_APP_DIR/app/backend"
run_as_user "npm run migrate" 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
create_systemd_services create_systemd_services
@@ -936,15 +985,17 @@ configure_email() {
} }
print_success_message() { print_success_message() {
local app_dir port local app_dir port manual_reset_hint
if [[ "$INSTALL_METHOD" == "docker" ]]; then if [[ "$INSTALL_METHOD" == "docker" ]]; then
app_dir="$DOCKER_APP_DIR" app_dir="$DOCKER_APP_DIR"
[[ -n "${SUDO_USER:-}" ]] && app_dir="/home/$SUDO_USER/picpeak" [[ -n "${SUDO_USER:-}" ]] && app_dir="/home/$SUDO_USER/picpeak"
port="${CUSTOM_PORT:-$DEFAULT_PORT}" port="${CUSTOM_PORT:-$DEFAULT_PORT}"
manual_reset_hint="cd $(printf %q "$app_dir") && docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"
else else
app_dir="$NATIVE_APP_DIR" app_dir="$NATIVE_APP_DIR"
port="${CUSTOM_PORT:-$DEFAULT_PORT}" port="${CUSTOM_PORT:-$DEFAULT_PORT}"
manual_reset_hint="cd $(printf %q "${NATIVE_APP_DIR}/app/backend") && sudo -H -u $(printf %q "$NATIVE_APP_USER") node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"
fi fi
print_header "🎉 Installation Complete!" print_header "🎉 Installation Complete!"
@@ -989,7 +1040,7 @@ print_success_message() {
fi fi
else else
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}" echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
echo -e "Password: ${YELLOW}(credentials file not found)${NC}" echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run '${manual_reset_hint}')${NC}"
fi fi
echo echo
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}" echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
@@ -1256,6 +1307,10 @@ parse_arguments() {
SMTP_PASS="$2" SMTP_PASS="$2"
shift 2 shift 2
;; ;;
--force-admin-password-reset)
FORCE_ADMIN_PASSWORD_RESET=true
shift
;;
--enable-ssl) --enable-ssl)
ENABLE_SSL=true ENABLE_SSL=true
shift shift
@@ -1301,6 +1356,7 @@ Options:
--smtp-port PORT SMTP server port --smtp-port PORT SMTP server port
--smtp-user USER SMTP username --smtp-user USER SMTP username
--smtp-pass PASS SMTP password --smtp-pass PASS SMTP password
--force-admin-password-reset Regenerate admin credentials after setup
--enable-ssl Enable HTTPS with Let's Encrypt --enable-ssl Enable HTTPS with Let's Encrypt
--port PORT Custom port (native only) --port PORT Custom port (native only)
--update Update existing installation --update Update existing installation
+47
View File
@@ -0,0 +1,47 @@
import { test, expect } from '@playwright/test';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
test('admin can update account email via settings page', async ({ page }, testInfo) => {
if (testInfo.project.name === 'mobile-chrome') {
test.skip('Account settings UI is validated on desktop viewport');
}
const newEmail = `admin+playwright-${Date.now()}@example.com`;
await page.goto('/admin/login');
await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
await page.goto('/admin/settings');
const emailInput = page.getByLabel(/Admin (Email|E-Mail)/i);
const usernameInput = page.getByLabel(/Admin (Username|Benutzername)/i);
await expect(emailInput).toBeVisible();
const originalEmail = await emailInput.inputValue();
const originalUsername = await usernameInput.inputValue();
const saveButton = page.getByRole('button', { name: /(Save account details|Kontodaten speichern)/i });
const revertChanges = async () => {
await emailInput.fill(originalEmail);
await usernameInput.fill(originalUsername);
await saveButton.click();
await expect(emailInput).toHaveValue(originalEmail, { timeout: 10000 });
await expect(page.locator('.Toastify__toast').filter({ hasText: /(Account details updated|Kontodaten aktualisiert)/i })).toBeVisible({ timeout: 10000 });
};
try {
await emailInput.fill(newEmail);
await saveButton.click();
await expect(emailInput).toHaveValue(newEmail, { timeout: 10000 });
await expect(page.locator('.Toastify__toast').filter({ hasText: /(Account details updated|Kontodaten aktualisiert)/i })).toBeVisible({ timeout: 10000 });
await expect(page.getByText(newEmail, { exact: false })).toBeVisible();
} finally {
await revertChanges();
}
});
+2 -2
View File
@@ -29,9 +29,9 @@ test('admin can create event via UI', async ({ page }) => {
await expect(page.getByRole('heading', { name: /^Create$/i })).toBeVisible({ timeout: 10000 }); await expect(page.getByRole('heading', { name: /^Create$/i })).toBeVisible({ timeout: 10000 });
await page.getByLabel(/Event Name/i).fill(eventName); await page.getByLabel(/Event Name/i).fill(eventName);
await page.getByLabel(/Host Name/i).fill('Host User'); await page.getByLabel(/Customer Name/i).fill('Host User');
await page.getByLabel(/Event Date/i).fill('2025-12-31'); await page.getByLabel(/Event Date/i).fill('2025-12-31');
await page.getByLabel(/Host Email/i).fill(hostEmail); await page.getByLabel(/Customer Email/i).fill(hostEmail);
await page.getByLabel(/Admin Email/i).fill(ADMIN_EMAIL); await page.getByLabel(/Admin Email/i).fill(ADMIN_EMAIL);
await page.getByLabel(/Gallery Password/i).fill('UiPlay123!'); await page.getByLabel(/Gallery Password/i).fill('UiPlay123!');
await page.getByLabel(/Confirm Password/i).fill('UiPlay123!'); await page.getByLabel(/Confirm Password/i).fill('UiPlay123!');
+138 -24
View File
@@ -6,23 +6,32 @@ const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!'; const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
async function createEventWithPhotos(page: Page) { async function createEventWithPhotos(page: Page, adminToken?: string, attempt = 1) {
const api = page.request; const api = page.request;
const loginResponse = await api.post('/api/auth/admin/login', { let token = adminToken;
data: {
username: ADMIN_EMAIL, if (!token) {
password: ADMIN_PASSWORD, const loginResponse = await api.post('/api/auth/admin/login', {
}, data: {
}); username: ADMIN_EMAIL,
expect(loginResponse.ok()).toBeTruthy(); password: ADMIN_PASSWORD,
const { token } = await loginResponse.json(); },
expect(token).toBeTruthy(); });
expect(loginResponse.ok()).toBeTruthy();
const loginData = await loginResponse.json();
token = loginData.token;
expect(token).toBeTruthy();
}
const eventName = `Playwright Smoke ${Date.now()}`; const eventName = `Playwright Smoke ${Date.now()}`;
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString() .toISOString()
.slice(0, 10); .slice(0, 10);
if (!token) {
throw new Error('Failed to acquire admin token');
}
const eventResponse = await api.post('/api/admin/events', { const eventResponse = await api.post('/api/admin/events', {
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -32,6 +41,8 @@ async function createEventWithPhotos(page: Page) {
event_type: 'wedding', event_type: 'wedding',
event_name: eventName, event_name: eventName,
event_date: eventDate, event_date: eventDate,
customer_name: 'Playwright Host',
customer_email: 'host@example.com',
host_name: 'Playwright Host', host_name: 'Playwright Host',
host_email: 'host@example.com', host_email: 'host@example.com',
admin_email: ADMIN_EMAIL, admin_email: ADMIN_EMAIL,
@@ -43,7 +54,17 @@ async function createEventWithPhotos(page: Page) {
watermark_downloads: false, watermark_downloads: false,
}, },
}); });
expect(eventResponse.ok()).toBeTruthy(); if (!eventResponse.ok()) {
const message = await eventResponse.text();
if (
attempt < 3 &&
/UNIQUE constraint failed: events\.slug/i.test(message || '')
) {
await page.waitForTimeout(150);
return createEventWithPhotos(page, token, attempt + 1);
}
throw new Error(`Event creation failed: ${eventResponse.status()} ${message}`);
}
const event = await eventResponse.json(); const event = await eventResponse.json();
const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png'); const imagePath = path.join(process.cwd(), 'test-assets', 'img1.png');
@@ -67,11 +88,83 @@ async function createEventWithPhotos(page: Page) {
event, event,
shareLink: event.share_link, shareLink: event.share_link,
slug: event.slug, slug: event.slug,
adminToken: token,
}; };
} }
async function updateShortGallerySetting(page: Page, adminToken: string, enabled: boolean) {
const response = await page.request.put('/api/admin/settings/general', {
headers: {
Authorization: `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
data: {
general_short_gallery_urls: enabled,
},
});
expect(response.ok()).toBeTruthy();
}
async function openGalleryShareLink(page: Page, shareLink: string) {
await page.context().clearCookies();
await page.goto(shareLink);
await page.waitForLoadState('domcontentloaded');
try {
await page.getByText(/Enter Gallery Password/i).first().waitFor({ timeout: 5000 });
} catch {
// No password prompt shown (public gallery)
}
let passwordEntered = false;
const passwordTextbox = page.getByRole('textbox', { name: /password/i }).first();
if (await passwordTextbox.count()) {
await passwordTextbox.fill(GALLERY_PASSWORD);
passwordEntered = true;
}
const galleryPasswordField = page.getByPlaceholder(/gallery password/i);
if (!passwordEntered && await galleryPasswordField.count()) {
await galleryPasswordField.fill(GALLERY_PASSWORD);
passwordEntered = true;
} else if (!passwordEntered) {
const genericPasswordField = page.getByPlaceholder(/password/i).first();
if (await genericPasswordField.count()) {
await genericPasswordField.fill(GALLERY_PASSWORD);
passwordEntered = true;
} else {
const labelledPasswordField = page.getByLabel(/password/i).first();
if (await labelledPasswordField.count()) {
await labelledPasswordField.fill(GALLERY_PASSWORD);
passwordEntered = true;
}
}
}
if (!passwordEntered) {
const fallbackPasswordField = page.locator('input').first();
if (await fallbackPasswordField.count()) {
await fallbackPasswordField.fill(GALLERY_PASSWORD);
passwordEntered = true;
}
}
const viewButton = page.getByRole('button', { name: /View Gallery/i });
if (await viewButton.count()) {
try {
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
} catch {
// Already navigated into gallery view.
}
}
const tiles = page.locator('.relative.group');
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
return tiles;
}
test('admin login and gallery viewing smoke test', async ({ page }) => { test('admin login and gallery viewing smoke test', async ({ page }) => {
const { shareLink } = await createEventWithPhotos(page); const { shareLink, adminToken } = await createEventWithPhotos(page);
// Admin UI login // Admin UI login
await page.goto('/admin/login'); await page.goto('/admin/login');
@@ -83,18 +176,39 @@ test('admin login and gallery viewing smoke test', async ({ page }) => {
} }
await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 }); await expect(page.getByRole('heading', { name: /Dashboard/i })).toBeVisible({ timeout: 20000 });
// Visit gallery share link and authenticate let resetToken = adminToken;
await page.goto(shareLink); try {
const passwordField = page.getByPlaceholder(/gallery password/i); // Verify long-form share link works
await passwordField.fill(GALLERY_PASSWORD); const tiles = await openGalleryShareLink(page, shareLink);
await page.getByRole('button', { name: /View Gallery/i }).click(); await tiles.first().hover();
await tiles.first().getByRole('button', { name: /View full size/i }).click();
await expect(page.getByRole('button', { name: /Close/i })).toBeVisible();
await page.getByRole('button', { name: /Close/i }).click();
// Wait for photos grid to appear // Enable short gallery URLs
const tiles = page.locator('.relative.group'); await updateShortGallerySetting(page, adminToken, true);
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
// Open lightbox to ensure media renders const settingsResponse = await page.request.get('/api/admin/settings', {
await tiles.first().hover(); headers: {
await tiles.first().getByRole('button', { name: /View full size/i }).click(); Authorization: `Bearer ${adminToken}`,
await expect(page.getByRole('button', { name: /Close/i })).toBeVisible(); },
});
expect(settingsResponse.ok()).toBeTruthy();
const adminSettings = await settingsResponse.json();
expect(adminSettings.general_short_gallery_urls === true || adminSettings.general_short_gallery_urls === 'true').toBeTruthy();
const { shareLink: shortShareLink, event: shortEvent } = await createEventWithPhotos(page, adminToken);
expect(shortShareLink).toMatch(/\/gallery\/[0-9a-fA-F]{32}$/);
expect(shortShareLink).not.toContain(shortEvent.slug);
// Verify short share link works
await openGalleryShareLink(page, shortShareLink);
// Legacy share link should still work after enabling short URLs
await openGalleryShareLink(page, shareLink);
} finally {
await updateShortGallerySetting(page, resetToken, false).catch(() => {
/* noop */
});
}
}); });
+29 -6
View File
@@ -1,10 +1,26 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com'; const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1'; const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1';
async function createExternalGallery(page) { async function createExternalGallery(page) {
const externalRoot = path.join(process.cwd(), 'storage', 'external-media', 'picsum-demo', 'individual');
if (!fs.existsSync(externalRoot)) {
fs.mkdirSync(externalRoot, { recursive: true });
}
const sampleImages = ['img1.png', 'img2.png'];
for (const imageName of sampleImages) {
const source = path.join(process.cwd(), 'test-assets', imageName);
const target = path.join(externalRoot, imageName);
if (!fs.existsSync(target)) {
fs.copyFileSync(source, target);
}
}
const loginResponse = await page.request.post('/api/auth/admin/login', { const loginResponse = await page.request.post('/api/auth/admin/login', {
data: { data: {
username: ADMIN_EMAIL, username: ADMIN_EMAIL,
@@ -30,8 +46,8 @@ async function createExternalGallery(page) {
event_type: 'wedding', event_type: 'wedding',
event_name: eventName, event_name: eventName,
event_date: eventDate, event_date: eventDate,
host_name: 'External Host', customer_name: 'External Host',
host_email: 'host@example.com', customer_email: 'host@example.com',
admin_email: ADMIN_EMAIL, admin_email: ADMIN_EMAIL,
password: GALLERY_PASSWORD, password: GALLERY_PASSWORD,
expiration_days: 30, expiration_days: 30,
@@ -72,7 +88,10 @@ async function createExternalGallery(page) {
failOnStatusCode: false, failOnStatusCode: false,
}); });
expect(importResponse.ok()).toBeTruthy(); if (!importResponse.ok()) {
const bodyText = await importResponse.text();
throw new Error(`Failed to import external media: ${importResponse.status()} ${bodyText}`);
}
const importBody = await importResponse.json(); const importBody = await importResponse.json();
expect(importBody.imported).toBeGreaterThan(0); expect(importBody.imported).toBeGreaterThan(0);
@@ -113,9 +132,13 @@ test.describe('External media gallery behavior', () => {
await page.waitForLoadState('domcontentloaded'); await page.waitForLoadState('domcontentloaded');
const passwordField = page.getByPlaceholder(/gallery password/i).first(); const passwordField = page.getByPlaceholder(/gallery password/i).first();
await expect(passwordField).toBeVisible(); if (await passwordField.count()) {
await passwordField.fill(GALLERY_PASSWORD); await passwordField.fill(GALLERY_PASSWORD);
await page.getByRole('button', { name: /View Gallery/i }).click(); const viewButton = page.getByRole('button', { name: /View Gallery/i });
if (await viewButton.count()) {
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
}
}
const tiles = page.locator('.relative.group'); const tiles = page.locator('.relative.group');
await expect(tiles.first()).toBeVisible({ timeout: 20000 }); await expect(tiles.first()).toBeVisible({ timeout: 20000 });
+2 -2
View File
@@ -43,8 +43,8 @@ async function createGalleryWithModeratedComments(page: Page): Promise<GallerySe
event_type: 'wedding', event_type: 'wedding',
event_name: eventName, event_name: eventName,
event_date: eventDate, event_date: eventDate,
host_name: 'Playwright Host', customer_name: 'Playwright Host',
host_email: 'host@example.com', customer_email: 'host@example.com',
admin_email: ADMIN_EMAIL, admin_email: ADMIN_EMAIL,
password: GALLERY_PASSWORD, password: GALLERY_PASSWORD,
expiration_days: 30, expiration_days: 30,
+2 -2
View File
@@ -32,8 +32,8 @@ async function ensureGalleryWithPhotos(page) {
event_type: 'wedding', event_type: 'wedding',
event_name: eventName, event_name: eventName,
event_date: eventDate, event_date: eventDate,
host_name: 'Playwright Host', customer_name: 'Playwright Host',
host_email: 'host@example.com', customer_email: 'host@example.com',
admin_email: ADMIN_EMAIL, admin_email: ADMIN_EMAIL,
password: GALLERY_PASSWORD, password: GALLERY_PASSWORD,
expiration_days: 90, expiration_days: 90,
+93
View File
@@ -0,0 +1,93 @@
import { test, expect } from '@playwright/test';
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
test('clearing old notifications removes read entries', async ({ request }) => {
const loginResponse = await request.post('/api/auth/admin/login', {
data: {
username: ADMIN_EMAIL,
password: ADMIN_PASSWORD,
},
});
expect(loginResponse.ok()).toBeTruthy();
const { token } = await loginResponse.json();
const authHeaders = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
const eventName = `Notification Clear ${Date.now()}`;
const eventDate = new Date().toISOString().slice(0, 10);
const createEventResponse = await request.post('/api/admin/events', {
headers: authHeaders,
data: {
event_type: 'wedding',
event_name: eventName,
event_date: eventDate,
customer_name: 'Notification Test',
customer_email: 'notify@example.com',
admin_email: ADMIN_EMAIL,
password: 'NotifyClearPass!1',
expiration_days: 30,
allow_user_uploads: false,
allow_downloads: true,
disable_right_click: false,
watermark_downloads: false,
},
});
expect(createEventResponse.ok()).toBeTruthy();
const createdEvent = await createEventResponse.json();
const eventId = createdEvent.id;
const collectedNotifications = async () => {
const notificationsResponse = await request.get('/api/admin/notifications', {
headers: authHeaders,
params: { includeRead: true, limit: 200 },
});
expect(notificationsResponse.ok()).toBeTruthy();
return notificationsResponse.json();
};
let notificationsPayload = await collectedNotifications();
const start = Date.now();
while (notificationsPayload.notifications.length === 0 && Date.now() - start < 5000) {
await new Promise((resolve) => setTimeout(resolve, 200));
notificationsPayload = await collectedNotifications();
}
const targetEventNotifications = notificationsPayload.notifications.filter(
(notification: any) => notification.eventId === eventId
);
expect(targetEventNotifications.length).toBeGreaterThan(0);
const markReadResponse = await request.put('/api/admin/notifications/read-all', {
headers: authHeaders,
});
expect(markReadResponse.ok()).toBeTruthy();
const postMarkPayload = await collectedNotifications();
const postMarkEventNotifications = postMarkPayload.notifications.filter(
(notification: any) => notification.eventId === eventId
);
const readNotificationIds = postMarkEventNotifications
.filter((notification: any) => notification.isRead)
.map((notification: any) => notification.id);
expect(readNotificationIds.length).toBeGreaterThan(0);
const clearResponse = await request.delete('/api/admin/notifications/clear-old', {
headers: { Authorization: `Bearer ${token}` },
});
expect(clearResponse.ok()).toBeTruthy();
const clearPayload = await clearResponse.json();
expect(clearPayload.deletedCount).toBeGreaterThanOrEqual(0);
const afterClearPayload = await collectedNotifications();
expect(Array.isArray(afterClearPayload.notifications)).toBe(true);
const remainingIds = new Set(afterClearPayload.notifications.map((notification: any) => notification.id));
readNotificationIds.forEach((id) => {
expect(remainingIds.has(id)).toBe(false);
});
});