Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6f1c31369 | |||
| a1e9fb6ffc | |||
| 665ce5a6e7 | |||
| 8c41dd626d | |||
| 775e417e55 | |||
| fc1bf53412 | |||
| 5d6c061f1c | |||
| 45e835a51a | |||
| afc00090cf | |||
| 59750dea15 | |||
| 2fe32e9a69 | |||
| 5f8c8c5508 | |||
| fb739f221d | |||
| b5399aaa9b | |||
| a4595e2ab2 | |||
| 0911711a37 | |||
| f2c7594b23 | |||
| 32355fabad | |||
| c127fd829d | |||
| cab5b0d795 | |||
| ba95aad3c6 | |||
| c1be7d6785 | |||
| 0024686dc2 | |||
| 96b8b77792 | |||
| 9d2726b3d3 | |||
| 8d6ddd257d |
@@ -128,10 +128,17 @@ jobs:
|
|||||||
MINOR="${version_parts[1]}"
|
MINOR="${version_parts[1]}"
|
||||||
PATCH="${version_parts[2]}"
|
PATCH="${version_parts[2]}"
|
||||||
|
|
||||||
# Increment patch version
|
# Increment patch version and ensure tag uniqueness
|
||||||
|
git fetch --tags --quiet || true
|
||||||
NEW_PATCH=$((PATCH + 1))
|
NEW_PATCH=$((PATCH + 1))
|
||||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||||
|
|
||||||
|
while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do
|
||||||
|
echo "Tag v${NEW_VERSION} already exists, bumping patch version again"
|
||||||
|
NEW_PATCH=$((NEW_PATCH + 1))
|
||||||
|
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||||
|
done
|
||||||
|
|
||||||
echo "New version: $NEW_VERSION"
|
echo "New version: $NEW_VERSION"
|
||||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||||
@@ -260,4 +267,3 @@ jobs:
|
|||||||
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||||
echo "Drone will automatically trigger on the new tag"
|
echo "Drone will automatically trigger on the new tag"
|
||||||
# Drone CI will automatically trigger on the tag push event
|
# Drone CI will automatically trigger on the tag push event
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -420,7 +420,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 ./setup.sh --native --force-admin-password-reset
|
||||||
|
|
||||||
|
# Docker reinstall example
|
||||||
|
sudo ./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
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
# Add build arguments
|
# Add build arguments
|
||||||
ARG CACHEBUST=1
|
ARG CACHEBUST=1
|
||||||
@@ -23,7 +23,7 @@ RUN npm ci --only=production
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -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,207 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
describe('Admin photos in reference mode', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let storagePath;
|
||||||
|
let db;
|
||||||
|
let app;
|
||||||
|
let categoryId;
|
||||||
|
|
||||||
|
const resetModules = () => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-admin-photos-'));
|
||||||
|
storagePath = path.join(tmpDir, 'storage');
|
||||||
|
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'photo_sharing_test.db');
|
||||||
|
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(process.env.TEST_DATABASE_PATH);
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
process.env.STORAGE_PATH = storagePath;
|
||||||
|
|
||||||
|
resetModules();
|
||||||
|
|
||||||
|
jest.doMock('../../src/middleware/auth', () => ({
|
||||||
|
adminAuth: (req, _res, next) => {
|
||||||
|
req.admin = { id: 1, username: 'tester' };
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||||
|
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
|
||||||
|
ensureThumbnail: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../../src/middleware/uploadValidation', () => ({
|
||||||
|
validateUploadedFiles: (_req, _res, next) => next()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../../src/utils/fileSecurityUtils', () => {
|
||||||
|
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
validateFileType: () => true,
|
||||||
|
createFileUploadValidator: () => (_req, _res, next) => next()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.doMock('../../src/utils/logger', () => ({
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dbModule = require('../../src/database/db');
|
||||||
|
db = dbModule.db;
|
||||||
|
|
||||||
|
await db.schema.dropTableIfExists('photo_feedback');
|
||||||
|
await db.schema.dropTableIfExists('photos');
|
||||||
|
await db.schema.dropTableIfExists('photo_categories');
|
||||||
|
await db.schema.dropTableIfExists('events');
|
||||||
|
|
||||||
|
await db.schema.createTable('events', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('slug').notNullable();
|
||||||
|
table.string('event_name').notNullable();
|
||||||
|
table.string('source_mode').notNullable();
|
||||||
|
table.string('external_path');
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.createTable('photo_categories', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('name').notNullable();
|
||||||
|
table.string('slug').notNullable();
|
||||||
|
table.boolean('is_global').defaultTo(true);
|
||||||
|
table.integer('event_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.createTable('photos', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.integer('event_id').notNullable();
|
||||||
|
table.string('filename').notNullable();
|
||||||
|
table.string('path').notNullable();
|
||||||
|
table.string('thumbnail_path');
|
||||||
|
table.string('type').notNullable();
|
||||||
|
table.integer('size_bytes');
|
||||||
|
table.integer('category_id');
|
||||||
|
table.string('source_origin');
|
||||||
|
table.string('external_relpath');
|
||||||
|
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||||
|
table.float('average_rating').defaultTo(0);
|
||||||
|
table.integer('like_count').defaultTo(0);
|
||||||
|
table.integer('favorite_count').defaultTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.createTable('photo_feedback', (table) => {
|
||||||
|
table.increments('id');
|
||||||
|
table.integer('photo_id');
|
||||||
|
table.string('feedback_type');
|
||||||
|
table.boolean('is_approved');
|
||||||
|
table.boolean('is_hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
await db('events').insert({
|
||||||
|
id: 1,
|
||||||
|
slug: 'test-event',
|
||||||
|
event_name: 'Test Event',
|
||||||
|
source_mode: 'reference',
|
||||||
|
external_path: 'external/library'
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertedCategory = await db('photo_categories').insert({
|
||||||
|
name: 'Highlights',
|
||||||
|
slug: 'highlights',
|
||||||
|
is_global: true
|
||||||
|
});
|
||||||
|
categoryId = Array.isArray(insertedCategory) ? insertedCategory[0] : insertedCategory;
|
||||||
|
|
||||||
|
const router = require('../../src/routes/adminPhotos');
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin/events', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (db) {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
resetModules();
|
||||||
|
delete process.env.TEST_DATABASE_PATH;
|
||||||
|
delete process.env.STORAGE_PATH;
|
||||||
|
if (tmpDir) {
|
||||||
|
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores managed uploads with category information and managed origin', async () => {
|
||||||
|
const uploadResponse = await request(app)
|
||||||
|
.post(`/api/admin/events/1/upload`)
|
||||||
|
.field('category_id', String(categoryId))
|
||||||
|
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
|
||||||
|
|
||||||
|
expect(uploadResponse.status).toBe(200);
|
||||||
|
expect(uploadResponse.body).toHaveProperty('photos');
|
||||||
|
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
|
||||||
|
|
||||||
|
const photo = await db('photos').first();
|
||||||
|
expect(photo).toBeTruthy();
|
||||||
|
expect(photo.category_id).toBe(categoryId);
|
||||||
|
expect(photo.source_origin).toBe('managed');
|
||||||
|
expect(photo.external_relpath).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns numeric category metadata when listing photos', async () => {
|
||||||
|
await db('photos').insert({
|
||||||
|
event_id: 1,
|
||||||
|
filename: 'external.jpg',
|
||||||
|
path: 'test-event/external.jpg',
|
||||||
|
thumbnail_path: null,
|
||||||
|
type: 'individual',
|
||||||
|
size_bytes: 123,
|
||||||
|
source_origin: 'external',
|
||||||
|
external_relpath: 'individual/external.jpg'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get(`/api/admin/events/1/photos`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(Array.isArray(response.body.photos)).toBe(true);
|
||||||
|
const managedPhoto = response.body.photos.find((p) => p.category_id === categoryId);
|
||||||
|
expect(managedPhoto).toBeTruthy();
|
||||||
|
expect(managedPhoto.category_name).toBe('Highlights');
|
||||||
|
|
||||||
|
const filtered = await request(app)
|
||||||
|
.get(`/api/admin/events/1/photos`)
|
||||||
|
.query({ category_id: String(categoryId) })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(filtered.body.photos.every((p) => p.category_id === categoryId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes category updates', async () => {
|
||||||
|
const photo = await db('photos').first();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||||
|
.send({ category_id: '0' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const updated = await db('photos').where({ id: photo.id }).first();
|
||||||
|
expect(updated.category_id).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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('falls back to managed storage when external metadata is missing', () => {
|
||||||
|
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||||
|
const photo = { path: 'fashion-show/new-upload.jpg' };
|
||||||
|
|
||||||
|
const result = resolvePhotoFilePath(event, photo);
|
||||||
|
|
||||||
|
expect(resolveExternalPath).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'fashion-show', 'new-upload.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Generated
+31
-31
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.5",
|
||||||
"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",
|
||||||
@@ -5620,13 +5620,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 +8355,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 +9008,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 +10228,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 +10624,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"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.5",
|
||||||
"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",
|
||||||
|
|||||||
Executable
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const fsp = fs.promises;
|
||||||
|
|
||||||
|
async function pathExists(location) {
|
||||||
|
try {
|
||||||
|
await fsp.access(location);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveFile(source, destination) {
|
||||||
|
await fsp.mkdir(path.dirname(destination), { recursive: true });
|
||||||
|
try {
|
||||||
|
await fsp.rename(source, destination);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'EXDEV') {
|
||||||
|
await fsp.copyFile(source, destination);
|
||||||
|
await fsp.unlink(source);
|
||||||
|
} else if (error.code === 'EEXIST') {
|
||||||
|
console.warn(`Destination already exists, leaving original in place: ${destination}`);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
const backendRoot = path.resolve(__dirname, '..');
|
||||||
|
const defaultStorage = path.resolve(backendRoot, '../storage');
|
||||||
|
const targetStorage = path.resolve(process.env.STORAGE_PATH || defaultStorage);
|
||||||
|
const legacyUploadsRoot = path.resolve(backendRoot, 'storage/uploads');
|
||||||
|
const targetUploadsRoot = path.join(targetStorage, 'uploads');
|
||||||
|
|
||||||
|
if (legacyUploadsRoot === targetUploadsRoot) {
|
||||||
|
console.log('Legacy uploads directory already matches target STORAGE_PATH. Nothing to migrate.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(legacyUploadsRoot)) {
|
||||||
|
console.log(`Legacy uploads directory not found at ${legacyUploadsRoot}. Nothing to migrate.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = ['logos', 'favicons'];
|
||||||
|
let migratedCounter = 0;
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const legacyDir = path.join(legacyUploadsRoot, category);
|
||||||
|
if (!fs.existsSync(legacyDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDir = path.join(targetUploadsRoot, category);
|
||||||
|
await fsp.mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const entries = await fsp.readdir(legacyDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = path.join(legacyDir, entry.name);
|
||||||
|
const destinationPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (await pathExists(destinationPath)) {
|
||||||
|
console.warn(`Skipping ${sourcePath} because ${destinationPath} already exists.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveFile(sourcePath, destinationPath);
|
||||||
|
migratedCounter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = await fsp.readdir(legacyDir);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
await fsp.rm(legacyDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migratedCounter === 0) {
|
||||||
|
console.log('No legacy logo or favicon files needed migration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migrated ${migratedCounter} files into ${targetUploadsRoot}.`);
|
||||||
|
console.log('If the database still references legacy absolute paths, they will be cleaned up automatically on the next upload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((error) => {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
|
|||||||
const path = require('path');
|
const 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): ');
|
const confirm = await ask('\nDo you want to continue? (yes/no): ');
|
||||||
|
|
||||||
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
|
if (!force) {
|
||||||
console.log('\n❌ Password reset cancelled.');
|
const normalized = confirm.trim().toLowerCase();
|
||||||
process.exit(0);
|
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,7 +130,9 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,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
|
||||||
@@ -116,7 +117,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 +140,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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +79,17 @@ 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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +98,17 @@ async function photoAuth(req, res, next) {
|
|||||||
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' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const buildChain = ({ firstResult, updateResult } = {}) => {
|
||||||
|
const chain = {
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
whereNot: jest.fn().mockReturnThis(),
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
update: jest.fn().mockResolvedValue(updateResult ?? 1),
|
||||||
|
first: jest.fn().mockResolvedValue(firstResult),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('../../database/db', () => {
|
||||||
|
const dbMock = jest.fn();
|
||||||
|
dbMock.raw = jest.fn();
|
||||||
|
dbMock.__setImplementations = (...chains) => {
|
||||||
|
dbMock.mockReset();
|
||||||
|
chains.forEach((chain) => {
|
||||||
|
dbMock.mockImplementationOnce(() => chain);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
db: dbMock,
|
||||||
|
logActivity: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../../middleware/auth-enhanced-v2', () => ({
|
||||||
|
adminAuth: (_req, _res, next) => {
|
||||||
|
_req.admin = { id: 1, username: 'admin' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { db, logActivity } = require('../../database/db');
|
||||||
|
const adminAuthRouter = require('../adminAuth');
|
||||||
|
|
||||||
|
describe('adminAuth profile updates', () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/auth/admin', adminAuthRouter);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the admin profile', async () => {
|
||||||
|
const updatedUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'newadmin',
|
||||||
|
email: 'newadmin@example.com',
|
||||||
|
must_change_password: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.__setImplementations(
|
||||||
|
buildChain({ firstResult: null }), // email check
|
||||||
|
buildChain({ firstResult: null }), // username check
|
||||||
|
buildChain({ updateResult: 1 }), // update
|
||||||
|
buildChain({ firstResult: updatedUser }), // fetch updated user
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/auth/admin/profile')
|
||||||
|
.send({ username: updatedUser.username, email: updatedUser.email })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({ user: updatedUser });
|
||||||
|
expect(logActivity).toHaveBeenCalledWith(
|
||||||
|
'admin_profile_updated',
|
||||||
|
{ admin_id: 1, updated_fields: ['username', 'email'] },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: 1, name: updatedUser.username }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects email conflicts', async () => {
|
||||||
|
db.__setImplementations(
|
||||||
|
buildChain({ firstResult: { id: 2 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/auth/admin/profile')
|
||||||
|
.send({ username: 'newadmin', email: 'taken@example.com' })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({ error: 'Email is already in use by another admin' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates input', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/auth/admin/profile')
|
||||||
|
.send({ username: '', email: 'not-an-email' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.errors).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
jest.mock('../../database/db', () => {
|
||||||
|
const deleteMock = jest.fn().mockResolvedValue(5);
|
||||||
|
const chain = {
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
leftJoin: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
limit: jest.fn().mockReturnThis(),
|
||||||
|
whereNull: jest.fn().mockReturnThis(),
|
||||||
|
whereNotNull: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
delete: deleteMock,
|
||||||
|
count: jest.fn().mockReturnThis(),
|
||||||
|
first: jest.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbMock = jest.fn(() => chain);
|
||||||
|
dbMock.raw = jest.fn();
|
||||||
|
dbMock.__chain = chain;
|
||||||
|
dbMock.__deleteMock = deleteMock;
|
||||||
|
return { db: dbMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../../middleware/auth-enhanced-v2', () => ({
|
||||||
|
adminAuth: (_req, _res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { db } = require('../../database/db');
|
||||||
|
const notificationsRouter = require('../adminNotifications');
|
||||||
|
|
||||||
|
describe('adminNotifications routes', () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/admin/notifications', notificationsRouter);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all notifications', async () => {
|
||||||
|
db.__deleteMock.mockResolvedValueOnce(8);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/admin/notifications/clear-all')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(db).toHaveBeenCalledWith('activity_logs');
|
||||||
|
expect(db.__deleteMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
message: 'All notifications cleared',
|
||||||
|
deletedCount: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles database errors when clearing notifications', async () => {
|
||||||
|
db.__deleteMock.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/admin/notifications/clear-all')
|
||||||
|
.expect(500);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({ error: 'Failed to clear notifications' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,6 +72,68 @@ router.post('/change-password', [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update admin profile
|
||||||
|
router.put('/profile', [
|
||||||
|
adminAuth,
|
||||||
|
body('username').trim().notEmpty().withMessage('Username is required'),
|
||||||
|
body('email').trim().isEmail().withMessage('Valid email is required')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, email } = req.body;
|
||||||
|
const userId = req.admin.id;
|
||||||
|
|
||||||
|
// Check for email conflicts
|
||||||
|
const existingEmail = await db('admin_users')
|
||||||
|
.where('email', email)
|
||||||
|
.whereNot('id', userId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existingEmail) {
|
||||||
|
return res.status(409).json({ error: 'Email is already in use by another admin' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check username conflict (if multiple admins are supported)
|
||||||
|
const existingUsername = await db('admin_users')
|
||||||
|
.where('username', username)
|
||||||
|
.whereNot('id', userId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existingUsername) {
|
||||||
|
return res.status(409).json({ error: 'Username is already in use by another admin' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('admin_users')
|
||||||
|
.where('id', userId)
|
||||||
|
.update({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedUser = await db('admin_users')
|
||||||
|
.select('id', 'username', 'email', 'must_change_password')
|
||||||
|
.where('id', userId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await logActivity(
|
||||||
|
'admin_profile_updated',
|
||||||
|
{ admin_id: userId, updated_fields: ['username', 'email'] },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: userId, name: username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ user: updatedUser });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin profile update error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update admin profile' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
router.post('/logout', adminAuth, async (req, res) => {
|
router.post('/logout', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ 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 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;
|
||||||
|
};
|
||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
@@ -21,7 +44,30 @@ router.post('/', adminAuth, [
|
|||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('host_email').isEmail().normalizeEmail(),
|
body('host_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(),
|
||||||
@@ -34,7 +80,7 @@ router.post('/', adminAuth, [
|
|||||||
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());
|
||||||
@@ -58,6 +104,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,
|
||||||
@@ -69,12 +116,15 @@ router.post('/', adminAuth, [
|
|||||||
show_feedback_to_guests = true
|
show_feedback_to_guests = true
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
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 +132,24 @@ router.post('/', adminAuth, [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate password strength
|
let passwordValidation = null;
|
||||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
let galleryPassword = password;
|
||||||
eventName: event_name
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!passwordValidation.valid) {
|
if (requirePassword) {
|
||||||
return res.status(400).json({
|
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||||
error: 'Password does not meet security requirements',
|
eventName: event_name
|
||||||
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
galleryPassword = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique slug
|
// Generate unique slug
|
||||||
@@ -113,10 +169,14 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
// Generate share link
|
// Generate share link
|
||||||
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 = `/gallery/${slug}/${shareToken}`;
|
||||||
|
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||||
|
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||||
|
|
||||||
// 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
|
||||||
@@ -155,7 +215,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 +241,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 }
|
||||||
);
|
);
|
||||||
@@ -197,7 +258,7 @@ router.post('/', adminAuth, [
|
|||||||
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: shareLink,
|
||||||
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,6 +272,7 @@ router.post('/', adminAuth, [
|
|||||||
slug,
|
slug,
|
||||||
event_name,
|
event_name,
|
||||||
event_type,
|
event_type,
|
||||||
|
require_password: requirePassword,
|
||||||
share_link: shareLink,
|
share_link: shareLink,
|
||||||
expires_at: expires_at.toISOString(),
|
expires_at: expires_at.toISOString(),
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
@@ -398,19 +460,45 @@ 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 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 +517,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 +532,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)
|
||||||
|
|||||||
@@ -62,11 +62,38 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
|||||||
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
|
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
|
||||||
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
|
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
|
||||||
|
|
||||||
let imported = 0;
|
// Prepare file metadata and deduplicate by filename within type (keep largest)
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
const preparedFiles = [];
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(f.full);
|
||||||
|
const segs = f.rel.split(path.sep);
|
||||||
|
let type = 'individual';
|
||||||
|
if (segs[0] === map.collages) type = 'collage';
|
||||||
|
if (segs[0] === map.individual) type = 'individual';
|
||||||
|
preparedFiles.push({ ...f, type, size: stats.size });
|
||||||
|
} catch (err) {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeMap = new Map();
|
||||||
|
for (const file of preparedFiles) {
|
||||||
|
const dedupeKey = `${file.type}:${path.basename(file.rel).toLowerCase()}`;
|
||||||
|
const existing = dedupeMap.get(dedupeKey);
|
||||||
|
if (!existing || file.size > existing.size) {
|
||||||
|
if (existing) skipped++;
|
||||||
|
dedupeMap.set(dedupeKey, file);
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
// Insert photos
|
// Insert photos
|
||||||
for (const f of files) {
|
for (const f of dedupeMap.values()) {
|
||||||
// Infer type by subfolder names
|
// Infer type by subfolder names
|
||||||
const segs = f.rel.split(path.sep);
|
const segs = f.rel.split(path.sep);
|
||||||
let type = 'individual';
|
let type = 'individual';
|
||||||
@@ -79,7 +106,6 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
|||||||
.where({ event_id: eventId, external_relpath: f.rel })
|
.where({ event_id: eventId, external_relpath: f.rel })
|
||||||
.first();
|
.first();
|
||||||
if (exists) { skipped++; continue; }
|
if (exists) { skipped++; continue; }
|
||||||
|
|
||||||
const stats = await fs.stat(f.full);
|
const stats = await fs.stat(f.full);
|
||||||
const inserted = await db('photos')
|
const inserted = await db('photos')
|
||||||
.insert({
|
.insert({
|
||||||
|
|||||||
@@ -119,4 +119,18 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Delete all notifications
|
||||||
|
router.delete('/clear-all', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const deletedCount = await db('activity_logs').delete();
|
||||||
|
res.json({
|
||||||
|
message: 'All notifications cleared',
|
||||||
|
deletedCount
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clear all notifications error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to clear notifications' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -13,6 +13,24 @@ const router = express.Router();
|
|||||||
// 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');
|
||||||
|
|
||||||
|
const parseCategoryId = (value) => {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
|
return value === 0 ? null : value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed === 'null') return null;
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
const parsed = parseInt(trimmed, 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed === 0 ? null : parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Configure multer for file uploads
|
// Configure multer for file uploads
|
||||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
@@ -158,16 +176,16 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse category_id to number if provided
|
// Parse category_id to number if provided
|
||||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
const numericCategoryId = parseCategoryId(category_id);
|
||||||
|
|
||||||
// Determine photo type from category_id parameter (for backwards compatibility)
|
// Determine photo type from category_id parameter (for backwards compatibility)
|
||||||
let photoType = 'individual'; // default
|
let photoType = 'individual'; // default
|
||||||
let categoryName = 'individual';
|
let categoryName = 'individual';
|
||||||
|
|
||||||
if (parsedCategoryId === 1 || category_id === 'collage') {
|
if (numericCategoryId === 1 || category_id === 'collage') {
|
||||||
photoType = 'collage';
|
photoType = 'collage';
|
||||||
categoryName = 'collages';
|
categoryName = 'collages';
|
||||||
} else if (parsedCategoryId === 2 || category_id === 'individual') {
|
} else if (numericCategoryId === 2 || category_id === 'individual') {
|
||||||
photoType = 'individual';
|
photoType = 'individual';
|
||||||
categoryName = 'individual';
|
categoryName = 'individual';
|
||||||
}
|
}
|
||||||
@@ -239,7 +257,9 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
|||||||
path: relativePath,
|
path: relativePath,
|
||||||
thumbnail_path: null, // Will generate after successful commit
|
thumbnail_path: null, // Will generate after successful commit
|
||||||
type: photoType,
|
type: photoType,
|
||||||
size_bytes: tempStats.size // Use actual file size from stat
|
size_bytes: tempStats.size, // Use actual file size from stat
|
||||||
|
category_id: numericCategoryId,
|
||||||
|
source_origin: 'managed'
|
||||||
};
|
};
|
||||||
|
|
||||||
batchPhotos.push(photoData);
|
batchPhotos.push(photoData);
|
||||||
@@ -475,9 +495,11 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update photo
|
// Update photo
|
||||||
|
const normalizedCategoryId = parseCategoryId(category_id);
|
||||||
|
|
||||||
await db('photos')
|
await db('photos')
|
||||||
.where({ id: photoId })
|
.where({ id: photoId })
|
||||||
.update({ category_id: category_id || null });
|
.update({ category_id: normalizedCategoryId });
|
||||||
|
|
||||||
res.json({ message: 'Photo updated successfully' });
|
res.json({ message: 'Photo updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -578,7 +600,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
|||||||
// Update photos
|
// Update photos
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
if (updates.category_id !== undefined) {
|
if (updates.category_id !== undefined) {
|
||||||
updateData.category_id = updates.category_id || null;
|
updateData.category_id = parseCategoryId(updates.category_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db('photos')
|
await db('photos')
|
||||||
@@ -632,14 +654,22 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
|||||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||||
|
|
||||||
let query = db('photos')
|
let query = db('photos')
|
||||||
|
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
|
||||||
.where({ 'photos.event_id': eventId })
|
.where({ 'photos.event_id': eventId })
|
||||||
.select('photos.*');
|
.select(
|
||||||
|
'photos.*',
|
||||||
|
'pc.name as category_display_name',
|
||||||
|
'pc.slug as category_display_slug'
|
||||||
|
);
|
||||||
|
|
||||||
// Filter by type (individual/collage) - category_id maps to type
|
// Filter by type (individual/collage) - category_id maps to type
|
||||||
if (category_id !== undefined) {
|
if (category_id !== undefined) {
|
||||||
if (category_id === '' || category_id === '0') {
|
if (category_id === '') {
|
||||||
// For backwards compatibility, empty category means no filter
|
// No filter when empty string is provided
|
||||||
// Don't filter anything
|
} else if (category_id === '0') {
|
||||||
|
query = query.whereNull('photos.category_id');
|
||||||
|
} else if (/^\d+$/.test(category_id)) {
|
||||||
|
query = query.where('photos.category_id', parseInt(category_id, 10));
|
||||||
} else if (category_id === 'individual' || category_id === 'collage') {
|
} else if (category_id === 'individual' || category_id === 'collage') {
|
||||||
query = query.where({ 'photos.type': category_id });
|
query = query.where({ 'photos.type': category_id });
|
||||||
}
|
}
|
||||||
@@ -666,6 +696,10 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
const photos = await query.orderBy(orderByColumn, order);
|
const photos = await query.orderBy(orderByColumn, order);
|
||||||
|
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return res.json({ photos: [] });
|
||||||
|
}
|
||||||
|
|
||||||
// Get comment counts separately
|
// Get comment counts separately
|
||||||
const commentCounts = await db('photo_feedback')
|
const commentCounts = await db('photo_feedback')
|
||||||
.whereIn('photo_id', photos.map(p => p.id))
|
.whereIn('photo_id', photos.map(p => p.id))
|
||||||
@@ -690,9 +724,11 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
|||||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||||
type: photo.type,
|
type: photo.type,
|
||||||
category_id: photo.type,
|
category_id: photo.category_id !== null && photo.category_id !== undefined
|
||||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
? Number(photo.category_id)
|
||||||
category_slug: photo.type,
|
: null,
|
||||||
|
category_name: photo.category_display_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||||
|
category_slug: photo.category_display_slug || photo.type,
|
||||||
size: photo.size_bytes,
|
size: photo.size_bytes,
|
||||||
uploaded_at: photo.uploaded_at,
|
uploaded_at: photo.uploaded_at,
|
||||||
// Feedback data
|
// Feedback data
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ const {
|
|||||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
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 +55,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 +230,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 +261,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);
|
||||||
@@ -643,7 +647,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 +658,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;
|
||||||
|
|||||||
@@ -220,7 +220,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);
|
||||||
@@ -232,53 +232,69 @@ router.post('/gallery/verify', [
|
|||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
|
||||||
// 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}`);
|
||||||
|
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 +318,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) {
|
||||||
|
|||||||
@@ -162,7 +162,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);
|
||||||
@@ -173,55 +173,68 @@ 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 = req.ip || req.connection.remoteAddress;
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
const event = await db('events')
|
||||||
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
|
.first();
|
||||||
|
|
||||||
// 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();
|
|
||||||
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}`);
|
||||||
|
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 +259,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) {
|
||||||
@@ -301,6 +315,8 @@ router.post('/gallery/share-login', [
|
|||||||
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
await trackSuccessfulLogin(`gallery:${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 +328,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) {
|
||||||
|
|||||||
+102
-15
@@ -4,11 +4,34 @@ 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 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;
|
||||||
|
};
|
||||||
|
|
||||||
// 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']),
|
||||||
@@ -16,7 +39,17 @@ router.post('/', adminAuth, [
|
|||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('host_email').isEmail(),
|
body('host_email').isEmail(),
|
||||||
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 {
|
||||||
@@ -32,11 +65,29 @@ router.post('/', adminAuth, [
|
|||||||
host_email,
|
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 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}`;
|
||||||
let slug = baseSlug;
|
let slug = baseSlug;
|
||||||
@@ -49,10 +100,15 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
// Generate share link (just slug/token, not full URL)
|
// Generate share link (just slug/token, not full URL)
|
||||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||||
const shareLink = `${slug}/${shareToken}`;
|
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||||
|
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||||
|
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||||
|
const shareLinkSlug = `${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);
|
||||||
@@ -75,8 +131,9 @@ router.post('/', adminAuth, [
|
|||||||
password_hash,
|
password_hash,
|
||||||
welcome_message,
|
welcome_message,
|
||||||
color_theme,
|
color_theme,
|
||||||
share_link: shareLink,
|
share_link: shareLinkSlug,
|
||||||
expires_at
|
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)
|
||||||
@@ -88,8 +145,8 @@ router.post('/', adminAuth, [
|
|||||||
host_name: host_email.split('@')[0], // Extract name from email
|
host_name: host_email.split('@')[0], // Extract name from email
|
||||||
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: fullShareLink,
|
||||||
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 || ''
|
||||||
});
|
});
|
||||||
@@ -97,8 +154,9 @@ router.post('/', adminAuth, [
|
|||||||
res.json({
|
res.json({
|
||||||
id: eventId,
|
id: eventId,
|
||||||
slug,
|
slug,
|
||||||
share_link: shareLink,
|
share_link: fullShareLink,
|
||||||
expires_at
|
expires_at,
|
||||||
|
require_password: requirePassword
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -137,17 +195,46 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
router.put('/:id', adminAuth, async (req, res) => {
|
router.put('/:id', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updates = req.body;
|
const updates = { ...req.body };
|
||||||
|
|
||||||
// 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
|
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||||
if (updates.password) {
|
let requirePasswordUpdate;
|
||||||
updates.password_hash = await bcrypt.hash(updates.password, 10);
|
if (hasRequirePasswordUpdate) {
|
||||||
delete updates.password;
|
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);
|
||||||
|
|||||||
+110
-47
@@ -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,8 +7,8 @@ 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');
|
||||||
|
|
||||||
// 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');
|
||||||
@@ -20,7 +19,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
|
|||||||
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')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -48,9 +47,22 @@ 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',
|
||||||
|
'allow_downloads',
|
||||||
|
'disable_right_click',
|
||||||
|
'watermark_downloads',
|
||||||
|
'watermark_text',
|
||||||
|
'require_password',
|
||||||
|
'color_theme'
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -74,6 +86,8 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +95,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 +113,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 +330,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 +359,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 +419,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 +444,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 +470,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 +519,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 +572,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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
: '(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) {
|
||||||
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
|
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { resolveExternalPath } = require('./externalMediaService');
|
const { resolveExternalPath } = require('./externalMediaService');
|
||||||
|
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||||
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
@@ -11,8 +12,10 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
|
|||||||
function resolvePhotoFilePath(event, photo) {
|
function resolvePhotoFilePath(event, photo) {
|
||||||
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
|
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
|
||||||
|
|
||||||
const mode = (event.source_mode || photo.source_origin || 'managed');
|
const isExternal = photo.source_origin === 'external' ||
|
||||||
if (mode === 'reference' || photo.source_origin === 'external') {
|
(!!photo.external_relpath && (event.source_mode === 'reference' || event.source_mode === 'external'));
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
if (!photo.external_relpath) {
|
if (!photo.external_relpath) {
|
||||||
throw new Error('Missing external_relpath for external photo');
|
throw new Error('Missing external_relpath for external photo');
|
||||||
}
|
}
|
||||||
@@ -33,10 +36,15 @@ function resolvePhotoFilePath(event, photo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
|
const eventsRoot = path.join(storagePath, 'events/active');
|
||||||
|
|
||||||
if (photo.path && photo.path.startsWith('events/active/')) {
|
if (photo.path && photo.path.startsWith('events/active/')) {
|
||||||
return path.join(storagePath, photo.path);
|
// Legacy paths already include prefix; normalize via safe join
|
||||||
|
return safePathJoin(storagePath, photo.path.replace(/^events\/active\/?/, 'events/active/'));
|
||||||
}
|
}
|
||||||
return path.join(storagePath, 'events/active', photo.path || '');
|
|
||||||
|
const relativeSegment = photo.path ? photo.path.replace(/^\/+/, '') : '';
|
||||||
|
return safePathJoin(eventsRoot, relativeSegment);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
+30
-4
@@ -3,19 +3,45 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
host="$DB_HOST"
|
host="${DB_HOST:-postgres}"
|
||||||
port="${DB_PORT:-5432}"
|
port="${DB_PORT:-5432}"
|
||||||
user="${DB_USER:-picpeak}"
|
user="${DB_USER:-picpeak}"
|
||||||
|
target_db="${DB_NAME:-picpeak}"
|
||||||
|
default_db="${DB_CHECK_DB:-postgres}"
|
||||||
|
|
||||||
|
sanitize_identifier() {
|
||||||
|
printf '%s' "$1" | sed "s/'/''/g"
|
||||||
|
}
|
||||||
|
|
||||||
echo "Waiting for PostgreSQL at $host:$port..."
|
echo "Waiting for PostgreSQL at $host:$port..."
|
||||||
|
|
||||||
# Wait for PostgreSQL to be ready
|
# Wait for PostgreSQL server to accept connections (using the default database)
|
||||||
until PGPASSWORD=$DB_PASSWORD psql -h "$host" -p "$port" -U "$user" -d "${DB_NAME:-picpeak}" -c '\q' 2>/dev/null; do
|
until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c '\q' >/dev/null 2>&1; do
|
||||||
>&2 echo "PostgreSQL is unavailable - sleeping"
|
>&2 echo "PostgreSQL is unavailable - sleeping"
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
>&2 echo "PostgreSQL is up - executing command"
|
>&2 echo "PostgreSQL is up - verifying target database \"$target_db\""
|
||||||
|
|
||||||
|
# Ensure the target database exists (helps when volumes are reused or DB_NAME is customised)
|
||||||
|
db_exists=$(PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -tAc "SELECT 1 FROM pg_database WHERE datname = '$(sanitize_identifier "$target_db")'" 2>/dev/null || echo 0)
|
||||||
|
|
||||||
|
if [ "$db_exists" != "1" ]; then
|
||||||
|
>&2 echo "Database \"$target_db\" not found. Attempting to create..."
|
||||||
|
if ! PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c "CREATE DATABASE \"$target_db\";" >/dev/null 2>&1; then
|
||||||
|
>&2 echo "Failed to create database \"$target_db\". Please ensure it exists and is accessible."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
>&2 echo "Database \"$target_db\" created successfully."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait until the target database itself is ready to accept connections
|
||||||
|
until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$target_db" -c '\q' >/dev/null 2>&1; do
|
||||||
|
>&2 echo "Waiting for database \"$target_db\" to accept connections..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
>&2 echo "Target database \"$target_db\" is ready."
|
||||||
|
|
||||||
# Run migrations (use safe runner in production)
|
# Run migrations (use safe runner in production)
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:3001/api}
|
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||||
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
||||||
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
||||||
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
||||||
|
|||||||
@@ -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 `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
Generated
+1289
-10
File diff suppressed because it is too large
Load Diff
+13
-6
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.1.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "cross-env ROLLUP_USE_NODE_JS=true vite build",
|
||||||
"build:check": "tsc -b && vite build",
|
"build:check": "tsc -b && cross-env ROLLUP_USE_NODE_JS=true vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run src/components/admin/__tests__/ThemeCustomizerEnhanced.test.tsx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
@@ -47,18 +48,24 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@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",
|
||||||
"globals": "^16.2.0",
|
"globals": "^16.2.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -55,12 +55,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear old notifications mutation
|
// Clear notifications mutation
|
||||||
const clearOldMutation = useMutation({
|
const clearAllMutation = useMutation({
|
||||||
mutationFn: notificationsService.clearOldNotifications,
|
mutationFn: notificationsService.clearAllNotifications,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
toast.success(t('admin.notificationToasts.clearedOld', { count: data.deletedCount }));
|
toast.success(t('admin.notificationToasts.clearedAll', { count: data.deletedCount }));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,12 +128,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => clearOldMutation.mutate()}
|
onClick={() => clearAllMutation.mutate()}
|
||||||
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
||||||
title={t('admin.clearOld')}
|
title={t('admin.clearAll')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
{t('admin.clearOld')}
|
{t('admin.clearAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface ThemeCustomizerEnhancedProps {
|
|||||||
isPreviewMode?: boolean;
|
isPreviewMode?: boolean;
|
||||||
showGalleryLayouts?: boolean;
|
showGalleryLayouts?: boolean;
|
||||||
hideActions?: boolean;
|
hideActions?: boolean;
|
||||||
|
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
|
||||||
|
isApplying?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||||
@@ -34,7 +36,9 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onPresetChange,
|
onPresetChange,
|
||||||
isPreviewMode = false,
|
isPreviewMode = false,
|
||||||
showGalleryLayouts = true,
|
showGalleryLayouts = true,
|
||||||
hideActions = false
|
hideActions = false,
|
||||||
|
onApply,
|
||||||
|
isApplying = false
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||||
@@ -80,8 +84,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = async () => {
|
||||||
onChange({ ...localTheme, customCss });
|
const themeWithCss = { ...localTheme, customCss };
|
||||||
|
onChange(themeWithCss);
|
||||||
|
|
||||||
|
if (onApply) {
|
||||||
|
await onApply(themeWithCss, { presetName: selectedPreset });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
@@ -587,8 +596,9 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
leftIcon={<Palette className="w-4 h-4" />}
|
leftIcon={<Palette className="w-4 h-4" />}
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
|
disabled={isApplying}
|
||||||
>
|
>
|
||||||
{t('branding.applyTheme')}
|
{isApplying ? t('common.applying', 'Applying...') : t('branding.applyTheme')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ThemeCustomizerEnhanced } from '../ThemeCustomizerEnhanced';
|
||||||
|
import type { ThemeConfig } from '../../../types/theme.types';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (_key: string, fallback?: string) => fallback ?? _key
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeCustomizerEnhanced', () => {
|
||||||
|
const baseTheme: ThemeConfig = {
|
||||||
|
primaryColor: '#000000',
|
||||||
|
accentColor: '#ffffff',
|
||||||
|
backgroundColor: '#eeeeee',
|
||||||
|
textColor: '#111111',
|
||||||
|
galleryLayout: 'grid',
|
||||||
|
gallerySettings: {
|
||||||
|
spacing: 'normal'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it('invokes onApply when Apply Theme is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const handleApply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeCustomizerEnhanced
|
||||||
|
value={baseTheme}
|
||||||
|
onChange={handleChange}
|
||||||
|
presetName="default"
|
||||||
|
onApply={handleApply}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyButton = screen.getByRole('button', { name: /branding\.applyTheme/i });
|
||||||
|
await user.click(applyButton);
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalled();
|
||||||
|
expect(handleApply).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleApply).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ primaryColor: '#000000' }),
|
||||||
|
expect.objectContaining({ presetName: 'default' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the Apply button while applying', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeCustomizerEnhanced
|
||||||
|
value={baseTheme}
|
||||||
|
onChange={handleChange}
|
||||||
|
presetName="default"
|
||||||
|
isApplying={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyButton = screen.getByRole('button', { name: /applying/i });
|
||||||
|
expect(applyButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { feedbackService } from '../../services/feedback.service';
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
import { PhotoRating } from './PhotoRating';
|
import { PhotoRating } from './PhotoRating';
|
||||||
import { PhotoLikes } from './PhotoLikes';
|
import { PhotoLikes } from './PhotoLikes';
|
||||||
|
import { PhotoFavorites } from './PhotoFavorites';
|
||||||
import { PhotoComments } from './PhotoComments';
|
import { PhotoComments } from './PhotoComments';
|
||||||
import { Skeleton } from '../common';
|
import { Skeleton } from '../common';
|
||||||
|
|
||||||
@@ -42,13 +43,17 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
const [currentRating, setCurrentRating] = useState(0);
|
const [currentRating, setCurrentRating] = useState(0);
|
||||||
const [isLiked, setIsLiked] = useState(false);
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
const [likeCount, setLikeCount] = useState(0);
|
const [likeCount, setLikeCount] = useState(0);
|
||||||
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
|
const [favoriteCount, setFavoriteCount] = useState(0);
|
||||||
|
|
||||||
// Update local state when data loads
|
// Update local state when data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (feedbackData) {
|
if (feedbackData) {
|
||||||
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
||||||
setIsLiked(feedbackData.my_feedback.liked);
|
setIsLiked(Boolean(feedbackData.my_feedback.liked));
|
||||||
setLikeCount(feedbackData.summary.like_count);
|
setLikeCount(Number(feedbackData.summary.like_count) || 0);
|
||||||
|
setIsFavorited(Boolean(feedbackData.my_feedback.favorited));
|
||||||
|
setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0);
|
||||||
}
|
}
|
||||||
}, [feedbackData]);
|
}, [feedbackData]);
|
||||||
|
|
||||||
@@ -64,6 +69,12 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFavoriteChange = (favorited: boolean) => {
|
||||||
|
setIsFavorited(favorited);
|
||||||
|
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
|
||||||
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
if (settingsLoading) {
|
if (settingsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-3 ${className}`}>
|
<div className={`space-y-3 ${className}`}>
|
||||||
@@ -78,7 +89,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
||||||
settings.allow_comments;
|
settings.allow_comments || settings.allow_favorites;
|
||||||
|
|
||||||
if (!hasAnyFeedbackType) {
|
if (!hasAnyFeedbackType) {
|
||||||
return null;
|
return null;
|
||||||
@@ -101,8 +112,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
{settings.allow_likes && (
|
{(settings.allow_likes || settings.allow_favorites) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{settings.allow_likes && (
|
{settings.allow_likes && (
|
||||||
<PhotoLikes
|
<PhotoLikes
|
||||||
photoId={photoId}
|
photoId={photoId}
|
||||||
@@ -114,6 +125,18 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
onLikeChange={handleLikeChange}
|
onLikeChange={handleLikeChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{settings.allow_favorites && (
|
||||||
|
<PhotoFavorites
|
||||||
|
photoId={photoId}
|
||||||
|
gallerySlug={gallerySlug}
|
||||||
|
isFavorited={isFavorited}
|
||||||
|
favoriteCount={favoriteCount}
|
||||||
|
isEnabled={true}
|
||||||
|
requireNameEmail={settings.require_name_email || false}
|
||||||
|
onFavoriteChange={handleFavoriteChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
initialShowFeedback={openFeedbackInitially}
|
initialShowFeedback={openFeedbackInitially}
|
||||||
|
onFeedbackChange={onFeedbackChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface PhotoLightboxProps {
|
|||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
initialShowFeedback?: boolean;
|
initialShowFeedback?: boolean;
|
||||||
|
onFeedbackChange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||||
@@ -30,6 +31,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
initialShowFeedback = false,
|
initialShowFeedback = false,
|
||||||
|
onFeedbackChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
@@ -533,6 +535,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
gallerySlug={slug}
|
gallerySlug={slug}
|
||||||
showComments={true}
|
showComments={true}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
|
onFeedbackUpdate={() => {
|
||||||
|
if (onFeedbackChange) onFeedbackChange();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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`}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, MessageSquare } from 'lucide-react';
|
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, MessageSquare } from 'lucide-react';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -50,6 +50,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
||||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||||
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||||
|
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const handleScrollToGrid = useCallback(() => {
|
||||||
|
if (gridRef.current) {
|
||||||
|
gridRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// If an override is provided, always use it and skip initialization logic
|
// If an override is provided, always use it and skip initialization logic
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -163,30 +169,40 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
|
|
||||||
{/* Scroll Indicator */}
|
{/* Scroll Indicator */}
|
||||||
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
|
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
|
||||||
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleScrollToGrid}
|
||||||
|
className="rounded-full border border-white/30 bg-white/10 p-3 text-white transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 hover:bg-white/20"
|
||||||
|
aria-label={t('gallery.scrollToGallery', 'Scroll to gallery')}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-8 h-8 drop-shadow-lg" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid Section */}
|
{/* Grid Section */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div
|
||||||
|
ref={gridRef}
|
||||||
|
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"
|
||||||
|
>
|
||||||
{remainingPhotos.map((photo) => {
|
{remainingPhotos.map((photo) => {
|
||||||
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
className="relative group cursor-pointer aspect-square"
|
className="relative group cursor-pointer overflow-hidden rounded-lg"
|
||||||
onClick={() => onPhotoClick(actualIndex)}
|
onClick={() => onPhotoClick(actualIndex)}
|
||||||
>
|
>
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
src={photo.thumbnail_url || photo.url}
|
src={photo.thumbnail_url || photo.url}
|
||||||
alt={photo.filename}
|
alt={photo.filename}
|
||||||
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-105"
|
className="w-full h-auto object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
isGallery={true}
|
isGallery={true}
|
||||||
protectFromDownload={!allowDownloads}
|
protectFromDownload={!allowDownloads}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 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 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
|
||||||
{!isSelectionMode && (
|
{!isSelectionMode && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import axios from 'axios';
|
import axios, { AxiosHeaders } from 'axios';
|
||||||
|
import {
|
||||||
|
getActiveGallerySlug,
|
||||||
|
getGalleryToken,
|
||||||
|
inferGallerySlugFromLocation,
|
||||||
|
resolveSlugFromRequestUrl,
|
||||||
|
} from '../utils/galleryAuthStorage';
|
||||||
|
import { getApiBaseUrl } from '../utils/url';
|
||||||
|
|
||||||
// Maintenance mode callback
|
// Maintenance mode callback
|
||||||
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
|
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
|
||||||
@@ -9,7 +16,7 @@ export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void)
|
|||||||
|
|
||||||
// Create axios instance
|
// Create axios instance
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
baseURL: getApiBaseUrl(),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -23,6 +30,60 @@ api.interceptors.request.use(
|
|||||||
delete config.headers?.['Content-Type'];
|
delete config.headers?.['Content-Type'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const pathSlug = resolveSlugFromRequestUrl(config.url || '');
|
||||||
|
const params = config.params as Record<string, unknown> | undefined;
|
||||||
|
const paramSlug = typeof params?.slug === 'string' ? (params.slug as string) : null;
|
||||||
|
|
||||||
|
const rawPath = (() => {
|
||||||
|
if (!config.url) return '';
|
||||||
|
try {
|
||||||
|
if (config.url.startsWith('http://') || config.url.startsWith('https://')) {
|
||||||
|
return new URL(config.url).pathname;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return config.url;
|
||||||
|
}
|
||||||
|
return config.url;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const pathname = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
||||||
|
|
||||||
|
const isGalleryEndpoint = /^\/gallery\//.test(pathname)
|
||||||
|
|| /^\/secure-images\//.test(pathname)
|
||||||
|
|| /^\/auth\/gallery\//.test(pathname);
|
||||||
|
|
||||||
|
const isGallerySessionCheck = pathname === '/auth/session'
|
||||||
|
&& (!!paramSlug || window.location.pathname.startsWith('/gallery/'));
|
||||||
|
|
||||||
|
if (isGalleryEndpoint || isGallerySessionCheck) {
|
||||||
|
const fallbackSlug = getActiveGallerySlug()
|
||||||
|
|| inferGallerySlugFromLocation();
|
||||||
|
const slug = pathSlug || paramSlug || fallbackSlug;
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
const token = getGalleryToken(slug);
|
||||||
|
if (token) {
|
||||||
|
if (!config.headers) {
|
||||||
|
config.headers = new AxiosHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.headers instanceof AxiosHeaders) {
|
||||||
|
const existing = config.headers.get('Authorization');
|
||||||
|
if (!existing) {
|
||||||
|
config.headers.set('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const headersRecord = config.headers as Record<string, string | undefined>;
|
||||||
|
if (!headersRecord.Authorization) {
|
||||||
|
headersRecord.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface AdminAuthContextType {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
updatePasswordChanged: () => void;
|
updatePasswordChanged: () => void;
|
||||||
|
updateProfile: (user: AdminUser) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
|
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
|
||||||
@@ -104,6 +105,11 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateProfile = (updatedUser: AdminUser) => {
|
||||||
|
setUser(updatedUser);
|
||||||
|
sessionStorage.setItem('admin_user', JSON.stringify(updatedUser));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminAuthContext.Provider
|
<AdminAuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -115,6 +121,7 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
|||||||
error,
|
error,
|
||||||
mustChangePassword,
|
mustChangePassword,
|
||||||
updatePasswordChanged,
|
updatePasswordChanged,
|
||||||
|
updateProfile,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import type { ReactNode } from 'react';
|
|||||||
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 {
|
||||||
|
clearActiveGallerySlug,
|
||||||
|
clearGalleryToken,
|
||||||
|
setActiveGallerySlug,
|
||||||
|
storeGalleryToken,
|
||||||
|
} from '../utils/galleryAuthStorage';
|
||||||
|
|
||||||
interface GalleryEvent {
|
interface GalleryEvent {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -12,12 +19,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;
|
||||||
@@ -55,6 +74,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cleanupOldGalleryAuth();
|
cleanupOldGalleryAuth();
|
||||||
|
|
||||||
|
const slugAtMount = getCurrentGallerySlug();
|
||||||
|
if (slugAtMount) {
|
||||||
|
setActiveGallerySlug(slugAtMount);
|
||||||
|
} else {
|
||||||
|
clearActiveGallerySlug();
|
||||||
|
}
|
||||||
|
|
||||||
const initialise = async () => {
|
const initialise = async () => {
|
||||||
const currentSlug = getCurrentGallerySlug();
|
const currentSlug = getCurrentGallerySlug();
|
||||||
|
|
||||||
@@ -63,12 +89,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setActiveGallerySlug(currentSlug);
|
||||||
|
|
||||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||||
if (storedEvent) {
|
if (storedEvent) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storedEvent);
|
const parsed = JSON.parse(storedEvent);
|
||||||
if (parsed && parsed.id) {
|
if (parsed && parsed.id) {
|
||||||
setEvent(parsed);
|
const normalizedStored = normalizeEvent(parsed);
|
||||||
|
setEvent(normalizedStored);
|
||||||
|
if (normalizedStored) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
@@ -89,8 +121,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
// Fetch gallery details to hydrate context
|
// 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,9 +141,16 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
if (verify?.valid) {
|
if (verify?.valid) {
|
||||||
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
||||||
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) {
|
||||||
|
storeGalleryToken(currentSlug, response.token);
|
||||||
|
}
|
||||||
|
setActiveGallerySlug(currentSlug);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,28 +160,40 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
|
clearGalleryToken(currentSlug);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
|
clearGalleryToken(currentSlug);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
initialise();
|
initialise();
|
||||||
|
return () => {
|
||||||
|
clearActiveGallerySlug();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
|
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
|
||||||
try {
|
try {
|
||||||
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) {
|
||||||
|
storeGalleryToken(slug, response.token);
|
||||||
|
}
|
||||||
|
setActiveGallerySlug(slug);
|
||||||
|
|
||||||
// Store event data for quick reloads (non-sensitive)
|
// Store event data for quick reloads (non-sensitive)
|
||||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
|
if (normalizedEvent) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Invalid password');
|
setError(err.response?.data?.error || 'Invalid password');
|
||||||
throw err;
|
throw err;
|
||||||
@@ -152,12 +206,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
const currentSlug = getCurrentGallerySlug();
|
const currentSlug = getCurrentGallerySlug();
|
||||||
if (currentSlug) {
|
if (currentSlug) {
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
|
clearGalleryToken(currentSlug);
|
||||||
}
|
}
|
||||||
authService.galleryLogout(currentSlug || undefined);
|
authService.galleryLogout(currentSlug || undefined);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setEvent(null);
|
setEvent(null);
|
||||||
}
|
clearActiveGallerySlug();
|
||||||
;
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GalleryAuthContext.Provider
|
<GalleryAuthContext.Provider
|
||||||
|
|||||||
@@ -496,6 +496,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...",
|
||||||
@@ -607,6 +609,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",
|
||||||
@@ -632,10 +635,14 @@
|
|||||||
"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",
|
||||||
@@ -735,6 +742,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",
|
||||||
@@ -853,7 +863,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",
|
||||||
@@ -1071,7 +1080,7 @@
|
|||||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||||
"markAllRead": "Alle als gelesen markieren",
|
"markAllRead": "Alle als gelesen markieren",
|
||||||
"clearOld": "Alte löschen",
|
"clearAll": "Alle löschen",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"noNotificationsMessage": "Keine Benachrichtigungen",
|
"noNotificationsMessage": "Keine Benachrichtigungen",
|
||||||
"notificationMessages": {
|
"notificationMessages": {
|
||||||
@@ -1104,11 +1113,13 @@
|
|||||||
"archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen",
|
"archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen",
|
||||||
"archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht",
|
"archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht",
|
||||||
"archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt",
|
"archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt",
|
||||||
"systemActivity": "Systemaktivität: {{type}}"
|
"systemActivity": "Systemaktivität: {{type}}",
|
||||||
|
"adminProfileUpdated": "Admin-Profil aktualisiert von {{actorName}}"
|
||||||
},
|
},
|
||||||
"notificationToasts": {
|
"notificationToasts": {
|
||||||
"markedAllRead": "Alle Benachrichtigungen als gelesen markiert",
|
"markedAllRead": "Alle Benachrichtigungen als gelesen markiert",
|
||||||
"clearedOld": "{{count}} alte Benachrichtigungen gelöscht"
|
"clearedAll": "{{count}} Benachrichtigungen gelöscht",
|
||||||
|
"profileUpdated": "Admin-Profil aktualisiert"
|
||||||
},
|
},
|
||||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||||
@@ -1116,6 +1127,16 @@
|
|||||||
"markAllAsRead": "Alle als gelesen markieren",
|
"markAllAsRead": "Alle als gelesen markieren",
|
||||||
"notificationSettings": "Benachrichtigungseinstellungen",
|
"notificationSettings": "Benachrichtigungseinstellungen",
|
||||||
"changePassword": "Passwort ändern",
|
"changePassword": "Passwort ändern",
|
||||||
|
"accountSettings": {
|
||||||
|
"title": "Admin-Konto",
|
||||||
|
"description": "Aktualisiere die Zugangsdaten für die PicPeak-Administration.",
|
||||||
|
"username": "Benutzername",
|
||||||
|
"usernamePlaceholder": "Admin",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"emailPlaceholder": "admin@example.com",
|
||||||
|
"updateButton": "Profil aktualisieren"
|
||||||
|
},
|
||||||
|
"profileUpdateError": "Admin-Profil konnte nicht aktualisiert werden. Bitte versuche es erneut.",
|
||||||
"loadingDashboard": "Dashboard wird geladen...",
|
"loadingDashboard": "Dashboard wird geladen...",
|
||||||
"activeEvents": "Aktive Veranstaltungen",
|
"activeEvents": "Aktive Veranstaltungen",
|
||||||
"expiringSoon": "Demnächst ablaufend",
|
"expiringSoon": "Demnächst ablaufend",
|
||||||
|
|||||||
@@ -161,6 +161,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...",
|
||||||
@@ -290,6 +292,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",
|
||||||
@@ -316,9 +319,13 @@
|
|||||||
"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 +380,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",
|
||||||
@@ -533,7 +543,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",
|
||||||
@@ -809,7 +818,7 @@
|
|||||||
"viewAllNotifications": "View all notifications",
|
"viewAllNotifications": "View all notifications",
|
||||||
"noNotifications": "No new notifications",
|
"noNotifications": "No new notifications",
|
||||||
"markAllRead": "Mark all read",
|
"markAllRead": "Mark all read",
|
||||||
"clearOld": "Clear old",
|
"clearAll": "Clear all",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"noNotificationsMessage": "No notifications",
|
"noNotificationsMessage": "No notifications",
|
||||||
"notificationMessages": {
|
"notificationMessages": {
|
||||||
@@ -842,16 +851,28 @@
|
|||||||
"archiveDownloaded": "Archive downloaded for \"{{eventName}}\"",
|
"archiveDownloaded": "Archive downloaded for \"{{eventName}}\"",
|
||||||
"archiveDeleted": "Archive deleted for \"{{eventName}}\"",
|
"archiveDeleted": "Archive deleted for \"{{eventName}}\"",
|
||||||
"archiveRestored": "Archive restored for \"{{eventName}}\"",
|
"archiveRestored": "Archive restored for \"{{eventName}}\"",
|
||||||
"systemActivity": "System activity: {{type}}"
|
"systemActivity": "System activity: {{type}}",
|
||||||
|
"adminProfileUpdated": "Admin profile updated by {{actorName}}"
|
||||||
},
|
},
|
||||||
"notificationToasts": {
|
"notificationToasts": {
|
||||||
"markedAllRead": "All notifications marked as read",
|
"markedAllRead": "All notifications marked as read",
|
||||||
"clearedOld": "Cleared {{count}} old notifications"
|
"clearedAll": "Cleared {{count}} notifications",
|
||||||
|
"profileUpdated": "Admin profile updated"
|
||||||
},
|
},
|
||||||
"markAsRead": "Mark as read",
|
"markAsRead": "Mark as read",
|
||||||
"markAllAsRead": "Mark all as read",
|
"markAllAsRead": "Mark all as read",
|
||||||
"notificationSettings": "Notification Settings",
|
"notificationSettings": "Notification Settings",
|
||||||
"changePassword": "Change Password",
|
"changePassword": "Change Password",
|
||||||
|
"accountSettings": {
|
||||||
|
"title": "Admin account",
|
||||||
|
"description": "Update the credentials used to sign in to PicPeak.",
|
||||||
|
"username": "Username",
|
||||||
|
"usernamePlaceholder": "Admin",
|
||||||
|
"email": "Email",
|
||||||
|
"emailPlaceholder": "admin@example.com",
|
||||||
|
"updateButton": "Update profile"
|
||||||
|
},
|
||||||
|
"profileUpdateError": "Unable to update admin profile. Please try again.",
|
||||||
"loadingDashboard": "Loading dashboard...",
|
"loadingDashboard": "Loading dashboard...",
|
||||||
"activeEvents": "Active Events",
|
"activeEvents": "Active Events",
|
||||||
"expiringSoon": "Expiring Soon",
|
"expiringSoon": "Expiring Soon",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { analyticsService } from '../services/analytics.service';
|
|||||||
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, token } = useParams<{ slug: string; token?: string }>();
|
||||||
@@ -25,9 +26,11 @@ 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);
|
||||||
|
|
||||||
// Fetch gallery info (public data)
|
// Fetch gallery info (public data)
|
||||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
||||||
|
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
|
||||||
|
|
||||||
// Fetch branding settings
|
// Fetch branding settings
|
||||||
const { data: settingsData } = useQuery({
|
const { data: settingsData } = useQuery({
|
||||||
@@ -87,6 +90,30 @@ export const GalleryPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
|
||||||
|
setAutoLoginAttempted(true);
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
login(slug, '')
|
||||||
|
.then(() => {
|
||||||
|
setLoginError(null);
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
const message = error?.response?.data?.error;
|
||||||
|
if (message) {
|
||||||
|
setLoginError(message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]);
|
||||||
|
|
||||||
// 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 +123,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 +131,14 @@ export const GalleryPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setIsLoggingIn(true);
|
setIsLoggingIn(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
await login(slug!, password, recaptchaToken);
|
await login(slug!, requiresPassword ? password : '', recaptchaToken);
|
||||||
|
|
||||||
// Track successful password entry
|
if (requiresPassword) {
|
||||||
analyticsService.trackGalleryEvent('password_entry', {
|
analyticsService.trackGalleryEvent('password_entry', {
|
||||||
gallery: slug,
|
gallery: slug,
|
||||||
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 +156,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: slug,
|
||||||
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
|
||||||
@@ -311,43 +341,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 ? (
|
||||||
|
<>
|
||||||
|
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
label={t('auth.password')}
|
label={t('auth.password')}
|
||||||
placeholder={t('auth.passwordPlaceholder')}
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
error={loginError || undefined}
|
error={loginError || undefined}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="text-sm sm:text-base"
|
className="text-sm sm:text-base"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ReCaptcha
|
<ReCaptcha
|
||||||
onChange={setRecaptchaToken}
|
onChange={setRecaptchaToken}
|
||||||
onExpired={() => setRecaptchaToken(null)}
|
onExpired={() => setRecaptchaToken(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full text-sm sm:text-base"
|
className="w-full text-sm sm:text-base"
|
||||||
isLoading={isLoggingIn}
|
isLoading={isLoggingIn}
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
>
|
>
|
||||||
{t('gallery.viewGallery')}
|
{t('gallery.viewGallery')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface FormData {
|
|||||||
event_date: string;
|
event_date: string;
|
||||||
host_email: string;
|
host_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;
|
||||||
@@ -123,6 +124,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
event_date: format(new Date(), 'yyyy-MM-dd'),
|
||||||
host_email: '',
|
host_email: '',
|
||||||
admin_email: '',
|
admin_email: '',
|
||||||
|
require_password: true,
|
||||||
password: '',
|
password: '',
|
||||||
confirm_password: '',
|
confirm_password: '',
|
||||||
welcome_message: '',
|
welcome_message: '',
|
||||||
@@ -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,22 @@ 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,
|
host_email: formData.host_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) => (
|
||||||
@@ -426,81 +432,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 */}
|
{!formData.require_password && (
|
||||||
<div className="mt-2">
|
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||||
<PasswordGenerator
|
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||||
eventName={formData.event_name}
|
|
||||||
eventDate={formData.event_date}
|
|
||||||
eventType={formData.event_type}
|
|
||||||
onPasswordGenerated={handlePasswordGenerated}
|
|
||||||
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Confirm Password */}
|
{formData.require_password && (
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
|
<div>
|
||||||
{t('events.confirmPassword')}
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
</label>
|
{t('events.galleryPassword')}
|
||||||
<div className="relative">
|
</label>
|
||||||
<Input
|
<div className="relative">
|
||||||
id="confirm_password"
|
<Input
|
||||||
type={showPassword ? 'text' : 'password'}
|
id="password"
|
||||||
value={formData.confirm_password}
|
type={showPassword ? 'text' : 'password'}
|
||||||
onChange={handleInputChange('confirm_password')}
|
value={formData.password}
|
||||||
error={errors.confirm_password}
|
onChange={handleInputChange('password')}
|
||||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
error={errors.password}
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
placeholder={t('events.enterPassword')}
|
||||||
className="pr-10"
|
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" />}
|
||||||
<button
|
className="pr-10"
|
||||||
type="button"
|
/>
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
<button
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
type="button"
|
||||||
style={{ top: errors.confirm_password ? '0' : '0' }}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
>
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
{showPassword ? (
|
style={{ top: errors.password ? '0' : '0' }}
|
||||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
>
|
||||||
) : (
|
{showPassword ? (
|
||||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
)}
|
) : (
|
||||||
</button>
|
<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>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface FormData {
|
|||||||
host_name: string;
|
host_name: string;
|
||||||
host_email: string;
|
host_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;
|
||||||
@@ -88,6 +89,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
host_name: '',
|
host_name: '',
|
||||||
host_email: '',
|
host_email: '',
|
||||||
admin_email: '',
|
admin_email: '',
|
||||||
|
require_password: true,
|
||||||
password: '',
|
password: '',
|
||||||
confirm_password: '',
|
confirm_password: '',
|
||||||
welcome_message: '',
|
welcome_message: '',
|
||||||
@@ -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) {
|
||||||
@@ -226,6 +230,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const feedbackSettings = formData.feedback_settings;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
event_type: formData.event_type,
|
event_type: formData.event_type,
|
||||||
event_name: formData.event_name,
|
event_name: formData.event_name,
|
||||||
@@ -233,13 +239,21 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
host_name: formData.host_name,
|
host_name: formData.host_name,
|
||||||
host_email: formData.host_email,
|
host_email: formData.host_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,
|
||||||
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,
|
||||||
feedback_settings: formData.feedback_settings,
|
feedback_enabled: feedbackSettings.feedback_enabled,
|
||||||
|
allow_ratings: feedbackSettings.allow_ratings,
|
||||||
|
allow_likes: feedbackSettings.allow_likes,
|
||||||
|
allow_comments: feedbackSettings.allow_comments,
|
||||||
|
allow_favorites: feedbackSettings.allow_favorites,
|
||||||
|
require_name_email: feedbackSettings.require_name_email,
|
||||||
|
moderate_comments: feedbackSettings.moderate_comments,
|
||||||
|
show_feedback_to_guests: feedbackSettings.show_feedback_to_guests,
|
||||||
};
|
};
|
||||||
|
|
||||||
createMutation.mutate(payload);
|
createMutation.mutate(payload);
|
||||||
@@ -486,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">
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -121,6 +125,9 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
host_name: string;
|
host_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);
|
||||||
@@ -134,6 +141,9 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
host_name: '',
|
host_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');
|
||||||
|
|
||||||
@@ -225,6 +236,28 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const applyThemeMutation = useMutation({
|
||||||
|
mutationFn: async ({ theme, presetName }: { theme: ThemeConfig; presetName: string }) => {
|
||||||
|
if (!id) {
|
||||||
|
throw new Error('Missing event identifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorThemeValue = presetName && presetName !== 'custom'
|
||||||
|
? presetName
|
||||||
|
: JSON.stringify(theme);
|
||||||
|
|
||||||
|
return eventsService.updateEvent(parseInt(id), { color_theme: colorThemeValue });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||||
|
toast.success(t('branding.themeApplied', 'Theme updated'));
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const message = error?.response?.data?.error || t('branding.themeApplyError', 'Failed to apply theme');
|
||||||
|
toast.error(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Archive mutation
|
// Archive mutation
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
|
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
|
||||||
@@ -274,8 +307,13 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
host_name: event.host_name || '',
|
host_name: event.host_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) {
|
||||||
setFeedbackSettings(eventFeedbackSettings);
|
setFeedbackSettings(eventFeedbackSettings);
|
||||||
@@ -324,6 +362,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 +391,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
|
||||||
@@ -356,6 +415,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
updateData.host_name = editForm.host_name;
|
updateData.host_name = editForm.host_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editForm.new_password) {
|
||||||
|
updateData.password = editForm.new_password;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove any keys with undefined values
|
// Remove any keys with undefined values
|
||||||
Object.keys(updateData).forEach(key => {
|
Object.keys(updateData).forEach(key => {
|
||||||
if (updateData[key] === undefined) {
|
if (updateData[key] === undefined) {
|
||||||
@@ -437,6 +500,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" />
|
||||||
@@ -641,6 +713,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')}
|
||||||
@@ -848,7 +998,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 && (
|
||||||
@@ -994,6 +1146,20 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
isPreviewMode={false}
|
isPreviewMode={false}
|
||||||
showGalleryLayouts={true}
|
showGalleryLayouts={true}
|
||||||
|
onApply={async (theme, { presetName }) => {
|
||||||
|
const resolvedPreset = presetName || 'custom';
|
||||||
|
setCurrentTheme(theme);
|
||||||
|
setCurrentPresetName(resolvedPreset);
|
||||||
|
|
||||||
|
const themeValue = resolvedPreset !== 'custom'
|
||||||
|
? resolvedPreset
|
||||||
|
: JSON.stringify(theme);
|
||||||
|
|
||||||
|
setEditForm(prev => ({ ...prev, color_theme: themeValue }));
|
||||||
|
|
||||||
|
await applyThemeMutation.mutateAsync({ theme, presetName: resolvedPreset });
|
||||||
|
}}
|
||||||
|
isApplying={applyThemeMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -428,6 +429,17 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<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.host_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">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Save,
|
Save,
|
||||||
Database,
|
Database,
|
||||||
@@ -19,7 +19,9 @@ 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 { authService } from '../../services/auth.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;
|
||||||
|
|
||||||
@@ -56,6 +58,23 @@ 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 { user, updateProfile: updateAuthProfile } = useAdminAuth();
|
||||||
|
|
||||||
|
const [profileForm, setProfileForm] = useState({
|
||||||
|
username: user?.username ?? '',
|
||||||
|
email: user?.email ?? '',
|
||||||
|
});
|
||||||
|
const [profileError, setProfileError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
setProfileForm({ username: user.username, email: user.email });
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const isProfileDirty = user
|
||||||
|
? (profileForm.username !== user.username || profileForm.email !== user.email)
|
||||||
|
: Boolean(profileForm.username.trim() || profileForm.email.trim());
|
||||||
|
|
||||||
// Fetch settings
|
// Fetch settings
|
||||||
const { data: settings, isLoading } = useQuery({
|
const { data: settings, isLoading } = useQuery({
|
||||||
@@ -78,6 +97,20 @@ export const SettingsPage: React.FC = () => {
|
|||||||
refetchInterval: 30000 // Refresh every 30 seconds
|
refetchInterval: 30000 // Refresh every 30 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateProfileMutation = useMutation({
|
||||||
|
mutationFn: authService.updateAdminProfile,
|
||||||
|
onSuccess: (updatedUser) => {
|
||||||
|
updateAuthProfile(updatedUser);
|
||||||
|
setProfileError(null);
|
||||||
|
toast.success(t('admin.notificationToasts.profileUpdated'));
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
const message = error?.response?.data?.error || t('admin.profileUpdateError');
|
||||||
|
setProfileError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// General settings state
|
// General settings state
|
||||||
const [generalSettings, setGeneralSettings] = useState({
|
const [generalSettings, setGeneralSettings] = useState({
|
||||||
site_url: '',
|
site_url: '',
|
||||||
@@ -94,7 +127,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
// 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,
|
||||||
@@ -146,7 +178,6 @@ 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),
|
||||||
@@ -345,6 +376,19 @@ export const SettingsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleProfileSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!isProfileDirty || updateProfileMutation.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProfileError(null);
|
||||||
|
updateProfileMutation.mutate({
|
||||||
|
username: profileForm.username.trim(),
|
||||||
|
email: profileForm.email.trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveCapacityOverride = () => {
|
const handleSaveCapacityOverride = () => {
|
||||||
if (saveCapacityOverrideMutation.isPending) {
|
if (saveCapacityOverrideMutation.isPending) {
|
||||||
return;
|
return;
|
||||||
@@ -468,6 +512,46 @@ 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-2">{t('admin.accountSettings.title')}</h2>
|
||||||
|
<p className="text-sm text-neutral-500 mb-4">{t('admin.accountSettings.description')}</p>
|
||||||
|
<form className="space-y-4" onSubmit={handleProfileSubmit}>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('admin.accountSettings.username')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={profileForm.username}
|
||||||
|
onChange={(e) => setProfileForm(prev => ({ ...prev, username: e.target.value }))}
|
||||||
|
placeholder={t('admin.accountSettings.usernamePlaceholder')}
|
||||||
|
maxLength={120}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('admin.accountSettings.email')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={profileForm.email}
|
||||||
|
onChange={(e) => setProfileForm(prev => ({ ...prev, email: e.target.value }))}
|
||||||
|
placeholder={t('admin.accountSettings.emailPlaceholder')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{profileError && (
|
||||||
|
<p className="text-sm text-red-600">{profileError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isProfileDirty || updateProfileMutation.isPending}
|
||||||
|
>
|
||||||
|
{updateProfileMutation.isPending ? t('common.saving') : t('admin.accountSettings.updateButton')}
|
||||||
|
</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>
|
||||||
|
|
||||||
@@ -1105,16 +1189,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')}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { LoginResponse, GalleryAuthResponse } from '../types';
|
import type { LoginResponse, GalleryAuthResponse, AdminUser } 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) {
|
||||||
@@ -50,4 +61,9 @@ export const authService = {
|
|||||||
// Ignore; cookie will naturally expire if removal fails
|
// Ignore; cookie will naturally expire if removal fails
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateAdminProfile(profile: { username: string; email: string }): Promise<AdminUser> {
|
||||||
|
const response = await api.put<{ user: AdminUser }>('/auth/admin/profile', profile);
|
||||||
|
return response.data.user;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
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 => ({
|
||||||
|
...event,
|
||||||
|
require_password: normalizeRequirePassword((event as any)?.require_password, true),
|
||||||
|
});
|
||||||
|
|
||||||
interface CreateEventData {
|
interface CreateEventData {
|
||||||
event_type: string;
|
event_type: string;
|
||||||
@@ -7,12 +13,21 @@ interface CreateEventData {
|
|||||||
event_date: string;
|
event_date: string;
|
||||||
host_email: string;
|
host_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;
|
||||||
allow_user_uploads?: boolean;
|
allow_user_uploads?: boolean;
|
||||||
upload_category_id?: number | null;
|
upload_category_id?: number | null;
|
||||||
|
feedback_enabled?: boolean;
|
||||||
|
allow_ratings?: boolean;
|
||||||
|
allow_likes?: boolean;
|
||||||
|
allow_comments?: boolean;
|
||||||
|
allow_favorites?: boolean;
|
||||||
|
require_name_email?: boolean;
|
||||||
|
moderate_comments?: boolean;
|
||||||
|
show_feedback_to_guests?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateEventData {
|
interface UpdateEventData {
|
||||||
@@ -20,6 +35,7 @@ interface UpdateEventData {
|
|||||||
event_date?: string;
|
event_date?: string;
|
||||||
host_email?: string;
|
host_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;
|
||||||
@@ -56,19 +72,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)
|
||||||
|
|||||||
@@ -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 } 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
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ export const notificationsService = {
|
|||||||
await api.put('/admin/notifications/read-all');
|
await api.put('/admin/notifications/read-all');
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clear old notifications
|
// Clear all notifications
|
||||||
async clearOldNotifications(): Promise<{ deletedCount: number }> {
|
async clearAllNotifications(): Promise<{ deletedCount: number }> {
|
||||||
const response = await api.delete('/admin/notifications/clear-old');
|
const response = await api.delete('/admin/notifications/clear-all');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -133,6 +133,10 @@ export const notificationsService = {
|
|||||||
return t('admin.notificationMessages.generalSettingsUpdated');
|
return t('admin.notificationMessages.generalSettingsUpdated');
|
||||||
case 'security_settings_updated':
|
case 'security_settings_updated':
|
||||||
return t('admin.notificationMessages.securitySettingsUpdated');
|
return t('admin.notificationMessages.securitySettingsUpdated');
|
||||||
|
case 'admin_profile_updated':
|
||||||
|
return t('admin.notificationMessages.adminProfileUpdated', {
|
||||||
|
actorName: notification.actorName,
|
||||||
|
});
|
||||||
case 'theme_updated':
|
case 'theme_updated':
|
||||||
return t('admin.notificationMessages.themeUpdated');
|
return t('admin.notificationMessages.themeUpdated');
|
||||||
case 'archive_downloaded':
|
case 'archive_downloaded':
|
||||||
@@ -184,6 +188,8 @@ export const notificationsService = {
|
|||||||
case 'security_settings_updated':
|
case 'security_settings_updated':
|
||||||
case 'theme_updated':
|
case 'theme_updated':
|
||||||
return { icon: 'Settings', color: 'text-gray-600' };
|
return { icon: 'Settings', color: 'text-gray-600' };
|
||||||
|
case 'admin_profile_updated':
|
||||||
|
return { icon: 'User', color: 'text-primary-600' };
|
||||||
case 'email_template_updated':
|
case 'email_template_updated':
|
||||||
case 'email_config_updated':
|
case 'email_config_updated':
|
||||||
return { icon: 'Mail', color: 'text-teal-600' };
|
return { icon: 'Mail', color: 'text-teal-600' };
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -134,6 +136,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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { getApiBaseUrl, buildResourceUrl } from '../url';
|
||||||
|
const originalLocation = window.location;
|
||||||
|
|
||||||
|
const setLocation = (origin: string) => {
|
||||||
|
const parsed = new URL(origin);
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: {
|
||||||
|
origin: parsed.origin,
|
||||||
|
hostname: parsed.hostname,
|
||||||
|
href: parsed.href,
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('url utilities', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
setLocation('https://example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: originalLocation,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns relative API base by default', () => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
expect(getApiBaseUrl()).toBe('/api');
|
||||||
|
expect(buildResourceUrl('/api/gallery/test')).toBe('https://example.com/api/gallery/test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours absolute API URLs for non-local hosts', () => {
|
||||||
|
vi.stubEnv('VITE_API_URL', 'https://api.picpeak.cloud/api');
|
||||||
|
expect(getApiBaseUrl()).toBe('https://api.picpeak.cloud/api');
|
||||||
|
expect(buildResourceUrl('/api/gallery/test')).toBe('https://api.picpeak.cloud/api/gallery/test');
|
||||||
|
expect(buildResourceUrl('/uploads/logo.png')).toBe('https://api.picpeak.cloud/uploads/logo.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to relative when build-time URL is localhost but browser host is remote', () => {
|
||||||
|
vi.stubEnv('VITE_API_URL', 'http://localhost:3001/api');
|
||||||
|
setLocation('https://photos.example.com');
|
||||||
|
expect(getApiBaseUrl()).toBe('/api');
|
||||||
|
expect(buildResourceUrl('/api/gallery/test')).toBe('https://photos.example.com/api/gallery/test');
|
||||||
|
expect(buildResourceUrl('uploads/logo.png')).toBe('https://photos.example.com/uploads/logo.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps localhost API URL when browser is also localhost', () => {
|
||||||
|
vi.stubEnv('VITE_API_URL', 'http://127.0.0.1:3001/api');
|
||||||
|
setLocation('http://127.0.0.1:3000');
|
||||||
|
expect(getApiBaseUrl()).toBe('http://127.0.0.1:3001/api');
|
||||||
|
expect(buildResourceUrl('/api/gallery/test')).toBe('http://127.0.0.1:3001/api/gallery/test');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -23,4 +23,20 @@ export const cleanupOldGalleryAuth = () => {
|
|||||||
// Also clear session storage
|
// Also clear session storage
|
||||||
sessionStorage.removeItem('gallery_event');
|
sessionStorage.removeItem('gallery_event');
|
||||||
sessionStorage.removeItem('gallery_token');
|
sessionStorage.removeItem('gallery_token');
|
||||||
|
sessionStorage.removeItem('gallery_active_slug');
|
||||||
|
|
||||||
|
// Remove slug-specific session storage entries as well
|
||||||
|
try {
|
||||||
|
const sessionKeysToRemove: string[] = [];
|
||||||
|
for (let i = 0; i < sessionStorage.length; i += 1) {
|
||||||
|
const key = sessionStorage.key(i);
|
||||||
|
if (key && (key.startsWith('gallery_event_') || key.startsWith('gallery_token_'))) {
|
||||||
|
sessionKeysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
|
||||||
|
} catch {
|
||||||
|
// Session storage may be unavailable; ignore cleanup failures
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
const TOKEN_STORAGE_PREFIX = 'gallery_token_';
|
||||||
|
const ACTIVE_SLUG_KEY = 'gallery_active_slug';
|
||||||
|
|
||||||
|
const isBrowser = typeof window !== 'undefined';
|
||||||
|
|
||||||
|
const getSessionStorage = (): Storage | null => {
|
||||||
|
if (!isBrowser) return null;
|
||||||
|
try {
|
||||||
|
return window.sessionStorage;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Session storage unavailable', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractSlugFromPath = (path: string): string | null => {
|
||||||
|
if (!path) return null;
|
||||||
|
const match = path.match(/\/gallery\/([^\/?#]+)/);
|
||||||
|
return match ? decodeURIComponent(match[1]) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const inferGallerySlugFromLocation = (): string | null => {
|
||||||
|
if (!isBrowser) return null;
|
||||||
|
return extractSlugFromPath(window.location.pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setActiveGallerySlug = (slug: string | null) => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
if (slug) {
|
||||||
|
storage.setItem(ACTIVE_SLUG_KEY, slug);
|
||||||
|
} else {
|
||||||
|
storage.removeItem(ACTIVE_SLUG_KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getActiveGallerySlug = (): string | null => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return null;
|
||||||
|
return storage.getItem(ACTIVE_SLUG_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearActiveGallerySlug = () => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
storage.removeItem(ACTIVE_SLUG_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const storeGalleryToken = (slug: string, token: string) => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage || !slug) return;
|
||||||
|
storage.setItem(`${TOKEN_STORAGE_PREFIX}${slug}`, token);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGalleryToken = (slug?: string | null): string | null => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return null;
|
||||||
|
const resolvedSlug = slug || getActiveGallerySlug() || inferGallerySlugFromLocation();
|
||||||
|
if (!resolvedSlug) return null;
|
||||||
|
return storage.getItem(`${TOKEN_STORAGE_PREFIX}${resolvedSlug}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearGalleryToken = (slug?: string | null) => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
storage.removeItem(`${TOKEN_STORAGE_PREFIX}${slug}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = storage.getItem(ACTIVE_SLUG_KEY);
|
||||||
|
if (active) {
|
||||||
|
storage.removeItem(`${TOKEN_STORAGE_PREFIX}${active}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearAllGalleryTokens = () => {
|
||||||
|
const storage = getSessionStorage();
|
||||||
|
if (!storage) return;
|
||||||
|
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
for (let i = 0; i < storage.length; i += 1) {
|
||||||
|
const key = storage.key(i);
|
||||||
|
if (key && key.startsWith(TOKEN_STORAGE_PREFIX)) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach((key) => storage.removeItem(key));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveSlugFromRequestUrl = (url?: string | null): string | null => {
|
||||||
|
if (!url) return null;
|
||||||
|
let pathname = url;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
pathname = new URL(url).pathname;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Leave pathname as provided if URL parsing fails
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pathname.startsWith('/')) {
|
||||||
|
pathname = `/${pathname}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return extractSlugFromPath(pathname);
|
||||||
|
};
|
||||||
+105
-17
@@ -2,39 +2,126 @@
|
|||||||
* Utility functions for URL handling in production environments
|
* Utility functions for URL handling in production environments
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const ABSOLUTE_URL_REGEX = /^https?:\/\//i;
|
||||||
|
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
||||||
|
|
||||||
|
const isBrowser = typeof window !== 'undefined' && typeof window.location !== 'undefined';
|
||||||
|
|
||||||
|
const normalizeBase = (value: string): string => value.replace(/\/+$/, '');
|
||||||
|
|
||||||
|
const getEnvApiUrl = (): string | undefined => {
|
||||||
|
const raw = import.meta.env?.VITE_API_URL;
|
||||||
|
if (!raw || raw === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (raw === '/') {
|
||||||
|
return '/api';
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLocalHostname = (hostname: string): boolean => LOCAL_HOSTNAMES.has(hostname.toLowerCase());
|
||||||
|
|
||||||
|
const shouldFallbackToRelative = (url: string): boolean => {
|
||||||
|
if (!ABSOLUTE_URL_REGEX.test(url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBrowser) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
const envHostIsLocal = isLocalHostname(parsed.hostname);
|
||||||
|
const browserHost = window.location.hostname?.toLowerCase?.() ?? '';
|
||||||
|
const browserHostIsLocal = isLocalHostname(browserHost);
|
||||||
|
|
||||||
|
// Only fallback when the build-time URL points to localhost/loopback
|
||||||
|
// but the runtime browser location is remote (non-local).
|
||||||
|
return envHostIsLocal && !browserHostIsLocal;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFromOrigin = (path: string): string => {
|
||||||
|
if (!isBrowser) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${window.location.origin}${normalizedPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the base API URL, preferring relative URLs for production
|
* Get the base API URL, preferring relative URLs for production and
|
||||||
|
* falling back to relative when the build was created with localhost
|
||||||
|
* endpoints but is being accessed from a remote browser.
|
||||||
* @returns The API base URL
|
* @returns The API base URL
|
||||||
*/
|
*/
|
||||||
export const getApiBaseUrl = (): string => {
|
export const getApiBaseUrl = (): string => {
|
||||||
// If VITE_API_URL is explicitly set, use it
|
const envUrl = getEnvApiUrl();
|
||||||
if (import.meta.env.VITE_API_URL && import.meta.env.VITE_API_URL !== '/api') {
|
|
||||||
return import.meta.env.VITE_API_URL;
|
if (envUrl && envUrl !== '/api') {
|
||||||
|
if (ABSOLUTE_URL_REGEX.test(envUrl) && shouldFallbackToRelative(envUrl)) {
|
||||||
|
return '/api';
|
||||||
|
}
|
||||||
|
return envUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production, use relative URL
|
|
||||||
return '/api';
|
return '/api';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildFromAbsoluteApi = (base: string, path: string): string => {
|
||||||
|
const trimmedBase = normalizeBase(base);
|
||||||
|
|
||||||
|
// When the path already targets /api we want to preserve the suffix
|
||||||
|
if (path.startsWith('/api')) {
|
||||||
|
const pathWithoutLeadingApi = path.replace(/^\/api/, '');
|
||||||
|
return `${trimmedBase}${pathWithoutLeadingApi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-API assets (uploads, thumbnails, etc.) drop any /api suffix
|
||||||
|
const origin = trimmedBase.replace(/\/api$/, '');
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${origin}${normalizedPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a full URL for resources (images, files, etc.)
|
* Build a full URL for resources (images, files, etc.)
|
||||||
* In production, this will use the current origin
|
* In production, this will prefer the current origin unless an absolute
|
||||||
|
* API URL is explicitly configured and applicable.
|
||||||
* @param path - The resource path
|
* @param path - The resource path
|
||||||
* @returns The full URL
|
* @returns The full URL
|
||||||
*/
|
*/
|
||||||
export const buildResourceUrl = (path: string): string => {
|
export const buildResourceUrl = (path: string): string => {
|
||||||
// Remove leading slash if present
|
if (!path) {
|
||||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
return '';
|
||||||
|
|
||||||
// If we have an explicit API URL that's not relative, use it
|
|
||||||
const apiUrl = import.meta.env.VITE_API_URL;
|
|
||||||
if (apiUrl && apiUrl !== '/api' && apiUrl.startsWith('http')) {
|
|
||||||
const baseUrl = apiUrl.replace(/\/api\/?$/, ''); // Remove /api suffix if present
|
|
||||||
return `${baseUrl}/${cleanPath}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production (relative API), use current origin
|
// Absolute paths (http/https) should generally be respected,
|
||||||
return `${window.location.origin}/${cleanPath}`;
|
// except when they point to localhost but we're running remotely.
|
||||||
|
if (ABSOLUTE_URL_REGEX.test(path)) {
|
||||||
|
if (!shouldFallbackToRelative(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(path);
|
||||||
|
return buildFromOrigin(`${parsed.pathname}${parsed.search}${parsed.hash}`);
|
||||||
|
} catch {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
const apiBase = getApiBaseUrl();
|
||||||
|
|
||||||
|
if (ABSOLUTE_URL_REGEX.test(apiBase)) {
|
||||||
|
return buildFromAbsoluteApi(apiBase, normalizedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildFromOrigin(normalizedPath);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,5 +129,6 @@ export const buildResourceUrl = (path: string): string => {
|
|||||||
* @returns True if in production mode
|
* @returns True if in production mode
|
||||||
*/
|
*/
|
||||||
export const isProductionMode = (): boolean => {
|
export const isProductionMode = (): boolean => {
|
||||||
return !import.meta.env.VITE_API_URL || import.meta.env.VITE_API_URL === '/api';
|
const apiBase = getApiBaseUrl();
|
||||||
|
return !ABSOLUTE_URL_REGEX.test(apiBase);
|
||||||
};
|
};
|
||||||
Vendored
+1
@@ -1 +1,2 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vitest" />
|
||||||
|
|||||||
+14
-3
@@ -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: {
|
||||||
@@ -15,6 +19,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
},
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './vitest.setup.ts',
|
||||||
|
globals: true
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: true,
|
host: true,
|
||||||
@@ -28,5 +37,7 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
export default defineConfig(config as any)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { expect, vi } from 'vitest';
|
||||||
|
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||||
|
|
||||||
|
expect.extend(matchers);
|
||||||
|
|
||||||
|
// Provide Jest-compatible globals for existing tests that rely on jest.fn
|
||||||
|
(globalThis as any).jest = vi;
|
||||||
+94
-9
@@ -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
|
||||||
@@ -132,6 +133,37 @@ command_exists() {
|
|||||||
command -v "$1" >/dev/null 2>&1
|
command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_storage_layout() {
|
||||||
|
local base_dir="$1"
|
||||||
|
local storage_root="$base_dir/storage"
|
||||||
|
local storage_events_dir="$storage_root/events"
|
||||||
|
|
||||||
|
mkdir -p "$storage_events_dir/active" \
|
||||||
|
"$storage_events_dir/archived" \
|
||||||
|
"$storage_root/thumbnails" \
|
||||||
|
"$storage_root/tmp"
|
||||||
|
|
||||||
|
local legacy_dir="$base_dir/events"
|
||||||
|
if [[ -d "$legacy_dir" ]]; then
|
||||||
|
log_step "Migrating legacy events directory to storage/events..."
|
||||||
|
mkdir -p "$storage_events_dir"
|
||||||
|
|
||||||
|
local existing=""
|
||||||
|
if [[ -d "$storage_events_dir" ]]; then
|
||||||
|
existing=$(ls -A "$storage_events_dir" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$storage_events_dir" || -z "$existing" ]]; then
|
||||||
|
rm -rf "$storage_events_dir"
|
||||||
|
mv "$legacy_dir" "$storage_events_dir"
|
||||||
|
else
|
||||||
|
cp -a "$legacy_dir/." "$storage_events_dir/"
|
||||||
|
rm -rf "$legacy_dir"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
mkdir -p "$storage_events_dir/active" "$storage_events_dir/archived"
|
||||||
|
}
|
||||||
|
|
||||||
generate_password() {
|
generate_password() {
|
||||||
openssl rand -base64 32 | tr -d "=+/" | cut -c1-16
|
openssl rand -base64 32 | tr -d "=+/" | cut -c1-16
|
||||||
}
|
}
|
||||||
@@ -351,18 +383,28 @@ setup_docker_installation() {
|
|||||||
app_dir="/home/$SUDO_USER/picpeak"
|
app_dir="/home/$SUDO_USER/picpeak"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_step "Creating application directory at $app_dir"
|
log_step "Preparing application directory at $app_dir"
|
||||||
mkdir -p "$app_dir"/{storage/events/{active,archived},logs,backup,config,data,events}
|
local app_parent_dir
|
||||||
|
app_parent_dir=$(dirname "$app_dir")
|
||||||
|
mkdir -p "$app_parent_dir"
|
||||||
|
|
||||||
# Clone repository
|
|
||||||
log_step "Downloading PicPeak..."
|
|
||||||
if [[ -d "$app_dir/.git" ]]; then
|
if [[ -d "$app_dir/.git" ]]; then
|
||||||
|
log_step "Existing PicPeak repository detected; pulling latest changes"
|
||||||
cd "$app_dir"
|
cd "$app_dir"
|
||||||
git pull
|
git pull --rebase --autostash || git pull
|
||||||
else
|
else
|
||||||
|
if [[ -d "$app_dir" && -n "$(find "$app_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
|
||||||
|
die "Target directory $app_dir already exists and is not empty. Remove it or specify --install-dir before retrying."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_step "Cloning PicPeak..."
|
||||||
|
rm -rf "$app_dir"
|
||||||
git clone "$REPO_URL" "$app_dir"
|
git clone "$REPO_URL" "$app_dir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Ensure storage layout exists after cloning
|
||||||
|
mkdir -p "$app_dir"/{storage/events/{active,archived},logs,backup,config,data,events}
|
||||||
|
|
||||||
# Determine host user for container mapping (PUID/PGID)
|
# Determine host user for container mapping (PUID/PGID)
|
||||||
local host_uid host_gid
|
local host_uid host_gid
|
||||||
if [[ -n "${SUDO_USER:-}" ]]; then
|
if [[ -n "${SUDO_USER:-}" ]]; then
|
||||||
@@ -457,6 +499,15 @@ EOF
|
|||||||
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!"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,7 +640,11 @@ setup_native_installation() {
|
|||||||
apt-get install -y build-essential python3
|
apt-get install -y build-essential python3
|
||||||
;;
|
;;
|
||||||
dnf|yum)
|
dnf|yum)
|
||||||
$PACKAGE_MANAGER groupinstall -y "Development Tools"
|
if "$PACKAGE_MANAGER" --version 2>/dev/null | grep -Ei 'dnf( |-)5' >/dev/null; then
|
||||||
|
$PACKAGE_MANAGER install -y @development-tools
|
||||||
|
else
|
||||||
|
$PACKAGE_MANAGER groupinstall -y "Development Tools"
|
||||||
|
fi
|
||||||
$PACKAGE_MANAGER install -y python3
|
$PACKAGE_MANAGER install -y python3
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -602,7 +657,8 @@ setup_native_installation() {
|
|||||||
|
|
||||||
# Create application directory
|
# Create application directory
|
||||||
log_step "Creating application directory..."
|
log_step "Creating application directory..."
|
||||||
mkdir -p "$NATIVE_APP_DIR"/{app,events/{active,archived},logs,config}
|
mkdir -p "$NATIVE_APP_DIR"/{app,logs,config}
|
||||||
|
ensure_storage_layout "$NATIVE_APP_DIR"
|
||||||
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
|
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR"
|
||||||
|
|
||||||
# Clone repository
|
# Clone repository
|
||||||
@@ -668,7 +724,7 @@ DATABASE_CLIENT=sqlite3
|
|||||||
DATABASE_PATH=$NATIVE_APP_DIR/app/backend/data/photo_sharing.db
|
DATABASE_PATH=$NATIVE_APP_DIR/app/backend/data/photo_sharing.db
|
||||||
|
|
||||||
# Storage root (thumbnails/uploads live under this path)
|
# Storage root (thumbnails/uploads live under this path)
|
||||||
STORAGE_PATH=$NATIVE_APP_DIR
|
STORAGE_PATH=$NATIVE_APP_DIR/storage
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
SMTP_ENABLED=${SMTP_HOST:+true}
|
SMTP_ENABLED=${SMTP_HOST:+true}
|
||||||
@@ -706,6 +762,13 @@ EOF
|
|||||||
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
|
||||||
|
|
||||||
@@ -957,7 +1020,13 @@ 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}"
|
local reset_hint
|
||||||
|
if [[ "$INSTALL_METHOD" == "docker" ]]; then
|
||||||
|
reset_hint="docker compose exec backend node scripts/reset-admin-password.js"
|
||||||
|
else
|
||||||
|
reset_hint="cd $NATIVE_APP_DIR/app/backend && node scripts/reset-admin-password.js"
|
||||||
|
fi
|
||||||
|
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run $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}"
|
||||||
@@ -1102,6 +1171,17 @@ update_native_installation() {
|
|||||||
echo "FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist" >> "$NATIVE_APP_DIR/app/backend/.env"
|
echo "FRONTEND_DIR=$NATIVE_APP_DIR/app/frontend/dist" >> "$NATIVE_APP_DIR/app/backend/.env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
ensure_storage_layout "$NATIVE_APP_DIR"
|
||||||
|
chown -R $NATIVE_APP_USER:$NATIVE_APP_USER "$NATIVE_APP_DIR/storage"
|
||||||
|
|
||||||
|
if [[ -f "$NATIVE_APP_DIR/app/backend/.env" ]]; then
|
||||||
|
if grep -q '^STORAGE_PATH=' "$NATIVE_APP_DIR/app/backend/.env"; then
|
||||||
|
sed -i "s|^STORAGE_PATH=.*|STORAGE_PATH=$NATIVE_APP_DIR/storage|" "$NATIVE_APP_DIR/app/backend/.env"
|
||||||
|
else
|
||||||
|
echo "STORAGE_PATH=$NATIVE_APP_DIR/storage" >> "$NATIVE_APP_DIR/app/backend/.env"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Restart services
|
# Restart services
|
||||||
systemctl restart picpeak-backend
|
systemctl restart picpeak-backend
|
||||||
|
|
||||||
@@ -1213,6 +1293,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
|
||||||
@@ -1258,6 +1342,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
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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';
|
||||||
|
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'ExternalMediaPass!1';
|
||||||
|
|
||||||
|
async function createExternalGallery(page) {
|
||||||
|
const loginResponse = await page.request.post('/api/auth/admin/login', {
|
||||||
|
data: {
|
||||||
|
username: ADMIN_EMAIL,
|
||||||
|
password: ADMIN_PASSWORD,
|
||||||
|
},
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
expect(loginResponse.ok()).toBeTruthy();
|
||||||
|
const { token } = await loginResponse.json();
|
||||||
|
expect(token).toBeTruthy();
|
||||||
|
|
||||||
|
const eventName = `External Media Playwright ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const eventDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
const createResponse = await page.request.post('/api/admin/events', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
event_type: 'wedding',
|
||||||
|
event_name: eventName,
|
||||||
|
event_date: eventDate,
|
||||||
|
host_name: 'External Host',
|
||||||
|
host_email: 'host@example.com',
|
||||||
|
admin_email: ADMIN_EMAIL,
|
||||||
|
password: GALLERY_PASSWORD,
|
||||||
|
expiration_days: 30,
|
||||||
|
allow_user_uploads: false,
|
||||||
|
allow_downloads: true,
|
||||||
|
disable_right_click: false,
|
||||||
|
watermark_downloads: false,
|
||||||
|
feedback_enabled: true,
|
||||||
|
allow_ratings: true,
|
||||||
|
allow_likes: true,
|
||||||
|
allow_comments: true,
|
||||||
|
allow_favorites: true,
|
||||||
|
require_name_email: false,
|
||||||
|
moderate_comments: false,
|
||||||
|
show_feedback_to_guests: true,
|
||||||
|
source_mode: 'reference',
|
||||||
|
external_path: 'picsum-demo'
|
||||||
|
},
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!createResponse.ok()) {
|
||||||
|
const bodyText = await createResponse.text();
|
||||||
|
throw new Error(`Failed to create event: ${createResponse.status()} ${bodyText}`);
|
||||||
|
}
|
||||||
|
const createdEvent = await createResponse.json();
|
||||||
|
expect(createdEvent?.id).toBeTruthy();
|
||||||
|
|
||||||
|
const importResponse = await page.request.post(`/api/admin/external-media/events/${createdEvent.id}/import-external`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
external_path: 'picsum-demo',
|
||||||
|
recursive: true,
|
||||||
|
},
|
||||||
|
failOnStatusCode: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(importResponse.ok()).toBeTruthy();
|
||||||
|
const importBody = await importResponse.json();
|
||||||
|
expect(importBody.imported).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.request.put(`/api/admin/feedback/events/${createdEvent.id}/feedback-settings`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
feedback_enabled: true,
|
||||||
|
allow_ratings: true,
|
||||||
|
allow_likes: true,
|
||||||
|
allow_comments: true,
|
||||||
|
allow_favorites: true,
|
||||||
|
require_name_email: false,
|
||||||
|
moderate_comments: false,
|
||||||
|
show_feedback_to_guests: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
shareLink: createdEvent.share_link,
|
||||||
|
slug: createdEvent.slug,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('External media gallery behavior', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
test('Maintains session and favorites after reload', async ({ page, context }) => {
|
||||||
|
if (test.info().project.name.includes('mobile')) {
|
||||||
|
test.skip('Mobile viewport handling requires manual verification.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { shareLink, slug } = await createExternalGallery(page);
|
||||||
|
|
||||||
|
await page.goto(shareLink);
|
||||||
|
await page.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
const passwordField = page.getByPlaceholder(/gallery password/i).first();
|
||||||
|
await expect(passwordField).toBeVisible();
|
||||||
|
await passwordField.fill(GALLERY_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /View Gallery/i }).click();
|
||||||
|
|
||||||
|
const tiles = page.locator('.relative.group');
|
||||||
|
await expect(tiles.first()).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
const initialTileCount = await tiles.count();
|
||||||
|
expect(initialTileCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const firstTile = tiles.first();
|
||||||
|
await firstTile.scrollIntoViewIfNeeded();
|
||||||
|
await firstTile.getByRole('button', { name: /View full size/i }).click();
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const toggle = document.querySelector('[aria-label="Toggle feedback"]');
|
||||||
|
if (toggle instanceof HTMLElement) toggle.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
const favoritesButtonInLightbox = page.getByRole('button', { name: /Add to favorites|Remove from favorites/ }).first();
|
||||||
|
await expect(favoritesButtonInLightbox).toBeVisible();
|
||||||
|
|
||||||
|
const ariaLabel = await favoritesButtonInLightbox.getAttribute('aria-label');
|
||||||
|
const isAlreadyFavorited = ariaLabel ? /Remove from favorites/i.test(ariaLabel) : false;
|
||||||
|
const refetchPromise = page.waitForResponse((res) => {
|
||||||
|
return res.request().method() === 'GET' && res.url().includes(`/api/gallery/${slug}/photos`);
|
||||||
|
});
|
||||||
|
if (!isAlreadyFavorited) {
|
||||||
|
const favResponsePromise = page.waitForResponse((res) => {
|
||||||
|
return res.request().method() === 'POST' && res.url().includes(`/api/gallery/${slug}/photos/`);
|
||||||
|
});
|
||||||
|
await favoritesButtonInLightbox.click();
|
||||||
|
await Promise.all([favResponsePromise, refetchPromise]);
|
||||||
|
} else {
|
||||||
|
await refetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Favorited' }).click();
|
||||||
|
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/gallery\//);
|
||||||
|
await expect(page.locator('.relative.group').first()).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Favorited' }).click();
|
||||||
|
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'All', exact: true }).click();
|
||||||
|
await expect(page.locator('.relative.group')).toHaveCount(initialTileCount);
|
||||||
|
|
||||||
|
const cookies = await context.cookies();
|
||||||
|
expect(cookies.some((cookie) => cookie.name === 'gallery_token')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user