Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6f1c31369 | |||
| a1e9fb6ffc | |||
| 665ce5a6e7 | |||
| 8c41dd626d | |||
| 775e417e55 | |||
| fc1bf53412 | |||
| 5d6c061f1c |
@@ -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
+29
-29
@@ -35,7 +35,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
@@ -5620,13 +5620,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-validator": {
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
|
||||
"integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==",
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
|
||||
"integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21",
|
||||
"validator": "~13.12.0"
|
||||
"validator": "~13.15.15"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
@@ -8355,9 +8355,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
|
||||
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
|
||||
"version": "7.0.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
|
||||
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
@@ -9008,24 +9008,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/prebuild-install/node_modules/tar-fs": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -10246,6 +10228,24 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs/node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
@@ -10624,9 +10624,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.12.0",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
|
||||
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
|
||||
"version": "13.15.20",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
|
||||
"integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"mime-types": "^3.0.1",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "7.0.5",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pg": "^8.16.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
|
||||
@@ -82,6 +82,7 @@ async function initializeDatabase() {
|
||||
table.boolean('watermark_downloads').defaultTo(false);
|
||||
table.text('watermark_text');
|
||||
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||
table.boolean('require_password').defaultTo(true);
|
||||
});
|
||||
} else {
|
||||
// Check if color_theme needs to be updated to TEXT type
|
||||
@@ -116,7 +117,8 @@ async function initializeDatabase() {
|
||||
disable_right_click BOOLEAN DEFAULT 0,
|
||||
watermark_downloads BOOLEAN DEFAULT 0,
|
||||
watermark_text TEXT,
|
||||
hero_photo_id INTEGER
|
||||
hero_photo_id INTEGER,
|
||||
require_password BOOLEAN DEFAULT 1
|
||||
)
|
||||
`);
|
||||
|
||||
@@ -138,6 +140,8 @@ async function initializeDatabase() {
|
||||
return 'watermark_text';
|
||||
case 'hero_photo_id':
|
||||
return 'hero_photo_id';
|
||||
case 'require_password':
|
||||
return 'COALESCE(require_password, 1) as require_password';
|
||||
default:
|
||||
return col;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,48 @@ const jwt = require('jsonwebtoken');
|
||||
const { db, withRetry } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Middleware to verify gallery access
|
||||
async function verifyGalleryAccess(req, res, next) {
|
||||
try {
|
||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
||||
let event;
|
||||
|
||||
if (!token) {
|
||||
if (!requestedSlug) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
event = await withRetry(async () => {
|
||||
return await db('events')
|
||||
.where({
|
||||
slug: requestedSlug,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false)
|
||||
})
|
||||
.select('*')
|
||||
.first();
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
|
||||
req.clientInfo = {
|
||||
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||
userAgent: req.get('User-Agent') || 'unknown',
|
||||
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
|
||||
timestamp: Date.now()
|
||||
};
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
@@ -26,10 +61,9 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId);
|
||||
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
||||
|
||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||
let event;
|
||||
if (requestedSlug) {
|
||||
// Verify by slug and ensure it matches the token's event
|
||||
event = await withRetry(async () => {
|
||||
@@ -62,11 +96,11 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId);
|
||||
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
console.log('[verifyGalleryAccess] Event found:', event.id, event.slug);
|
||||
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||
req.event = event;
|
||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||
|
||||
@@ -78,10 +112,10 @@ async function verifyGalleryAccess(req, res, next) {
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
console.log('[verifyGalleryAccess] Access granted for event:', event.id);
|
||||
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Error verifying gallery access:', error);
|
||||
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,13 @@ const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
async function photoAuth(req, res, next) {
|
||||
try {
|
||||
// Extract event slug from the path
|
||||
let eventSlug;
|
||||
|
||||
console.log('PhotoAuth middleware - path:', req.path);
|
||||
|
||||
// For thumbnails, we need to parse the filename to get the event info
|
||||
if (req.path.startsWith('/thumb_')) {
|
||||
// For now, we'll rely on JWT token for thumbnail access
|
||||
@@ -80,29 +79,36 @@ async function photoAuth(req, res, next) {
|
||||
// For both thumbnails and photos with admin token, allow access
|
||||
return next();
|
||||
}
|
||||
} catch (err) {
|
||||
// Token invalid, fall through to password check
|
||||
console.error('JWT verification failed:', err.message);
|
||||
} catch (err) {
|
||||
// Token invalid, fall through to password check
|
||||
logger.warn('JWT verification failed in photoAuth', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for password header (legacy support)
|
||||
const password = req.headers['x-gallery-password'];
|
||||
|
||||
if (!password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
||||
if (!eventSlug && !password) {
|
||||
if (!eventSlug && !password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
||||
}
|
||||
|
||||
|
||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Gallery not found' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
if (!requiresPassword) {
|
||||
req.event = event;
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!password && !tokenFromRequest) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
if (password) {
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
@@ -122,7 +128,7 @@ async function photoAuth(req, res, next) {
|
||||
req.event = event;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Photo auth error:', error);
|
||||
logger.error('Photo auth error', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({ error: 'Authentication error' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
router.post('/logout', adminAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -96,4 +158,4 @@ router.post('/logout', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -13,6 +13,29 @@ const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
// formatDate import removed - dates are formatted by email processor
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const 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
|
||||
router.post('/', adminAuth, [
|
||||
@@ -21,7 +44,30 @@ router.post('/', adminAuth, [
|
||||
body('event_date').isDate(),
|
||||
body('host_email').isEmail().normalizeEmail(),
|
||||
body('admin_email').isEmail().normalizeEmail(),
|
||||
body('password').isLength({ min: 6 }),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const input = req.body.require_password;
|
||||
const normalizeBoolean = (val, defaultValue = true) => {
|
||||
if (val === undefined || val === null) return defaultValue;
|
||||
if (typeof val === 'boolean') return val;
|
||||
if (typeof val === 'number') return val !== 0;
|
||||
if (typeof val === 'string') {
|
||||
const normalized = val.trim().toLowerCase();
|
||||
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||
}
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
const requirePassword = normalizeBoolean(input, true);
|
||||
if (!requirePassword) {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||
body('welcome_message').optional().trim(),
|
||||
body('color_theme').optional().trim(),
|
||||
@@ -34,7 +80,7 @@ router.post('/', adminAuth, [
|
||||
body('watermark_text').optional().trim()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Create event request body:', req.body);
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.error('Validation errors:', errors.array());
|
||||
@@ -58,6 +104,7 @@ router.post('/', adminAuth, [
|
||||
disable_right_click = false,
|
||||
watermark_downloads = false,
|
||||
watermark_text = null,
|
||||
require_password: requirePasswordInput = true,
|
||||
// Feedback settings
|
||||
feedback_enabled = false,
|
||||
allow_ratings = true,
|
||||
@@ -69,12 +116,15 @@ router.post('/', adminAuth, [
|
||||
show_feedback_to_guests = true
|
||||
} = req.body;
|
||||
|
||||
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||
|
||||
// Debug logging
|
||||
console.log('Download control values:', {
|
||||
logger.debug('Download control values', {
|
||||
allow_downloads,
|
||||
disable_right_click,
|
||||
watermark_downloads,
|
||||
watermark_text,
|
||||
require_password: requirePassword,
|
||||
types: {
|
||||
allow_downloads: typeof allow_downloads,
|
||||
disable_right_click: typeof disable_right_click,
|
||||
@@ -82,18 +132,24 @@ router.post('/', adminAuth, [
|
||||
}
|
||||
});
|
||||
|
||||
// Validate password strength
|
||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
let passwordValidation = null;
|
||||
let galleryPassword = password;
|
||||
|
||||
if (requirePassword) {
|
||||
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||
eventName: event_name
|
||||
});
|
||||
|
||||
if (!passwordValidation.valid) {
|
||||
return res.status(400).json({
|
||||
error: 'Password does not meet security requirements',
|
||||
details: passwordValidation.errors,
|
||||
score: passwordValidation.score,
|
||||
feedback: passwordValidation.feedback
|
||||
});
|
||||
}
|
||||
} else {
|
||||
galleryPassword = '';
|
||||
}
|
||||
|
||||
// Generate unique slug
|
||||
@@ -113,10 +169,14 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Generate share link
|
||||
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
|
||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
||||
// Hash password with configurable rounds (random placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
@@ -155,7 +215,8 @@ router.post('/', adminAuth, [
|
||||
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||
watermark_text
|
||||
watermark_text,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -180,7 +241,7 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_created',
|
||||
{ event_type, expires_at },
|
||||
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
@@ -197,7 +258,7 @@ router.post('/', adminAuth, [
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
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
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
@@ -211,6 +272,7 @@ router.post('/', adminAuth, [
|
||||
slug,
|
||||
event_name,
|
||||
event_type,
|
||||
require_password: requirePassword,
|
||||
share_link: shareLink,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString()
|
||||
@@ -398,19 +460,45 @@ router.put('/:id', adminAuth, [
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim(),
|
||||
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||
body('external_path').optional({ nullable: true }).isString().trim()
|
||||
body('external_path').optional({ nullable: true }).isString().trim(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||
throw new Error('Password must be at least 6 characters long');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2));
|
||||
console.log('Request body:', req.body);
|
||||
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = { ...req.body };
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
}
|
||||
|
||||
let newPasswordPlain;
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||
delete updates.password;
|
||||
} else {
|
||||
newPasswordPlain = updates.password;
|
||||
delete updates.password;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
|
||||
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
|
||||
}
|
||||
@@ -429,7 +517,7 @@ router.put('/:id', adminAuth, [
|
||||
}
|
||||
|
||||
// Log the update request for debugging
|
||||
console.log('Update event request:', {
|
||||
logger.debug('Update event request', {
|
||||
id,
|
||||
updates,
|
||||
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
|
||||
@@ -444,6 +532,18 @@ router.put('/:id', adminAuth, [
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||
}
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
// Update event
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
|
||||
@@ -119,4 +119,18 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
// 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;
|
||||
|
||||
@@ -13,6 +13,24 @@ const router = express.Router();
|
||||
// Get storage path from environment or default
|
||||
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
|
||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||
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
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
const numericCategoryId = parseCategoryId(category_id);
|
||||
|
||||
// Determine photo type from category_id parameter (for backwards compatibility)
|
||||
let photoType = 'individual'; // default
|
||||
let categoryName = 'individual';
|
||||
|
||||
if (parsedCategoryId === 1 || category_id === 'collage') {
|
||||
if (numericCategoryId === 1 || category_id === 'collage') {
|
||||
photoType = 'collage';
|
||||
categoryName = 'collages';
|
||||
} else if (parsedCategoryId === 2 || category_id === 'individual') {
|
||||
} else if (numericCategoryId === 2 || category_id === 'individual') {
|
||||
photoType = 'individual';
|
||||
categoryName = 'individual';
|
||||
}
|
||||
@@ -239,7 +257,9 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
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);
|
||||
@@ -475,9 +495,11 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Update photo
|
||||
const normalizedCategoryId = parseCategoryId(category_id);
|
||||
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ category_id: category_id || null });
|
||||
.update({ category_id: normalizedCategoryId });
|
||||
|
||||
res.json({ message: 'Photo updated successfully' });
|
||||
} catch (error) {
|
||||
@@ -578,7 +600,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
||||
// Update photos
|
||||
const updateData = {};
|
||||
if (updates.category_id !== undefined) {
|
||||
updateData.category_id = updates.category_id || null;
|
||||
updateData.category_id = parseCategoryId(updates.category_id);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
let query = db('photos')
|
||||
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
|
||||
.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
|
||||
if (category_id !== undefined) {
|
||||
if (category_id === '' || category_id === '0') {
|
||||
// For backwards compatibility, empty category means no filter
|
||||
// Don't filter anything
|
||||
if (category_id === '') {
|
||||
// No filter when empty string is provided
|
||||
} 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') {
|
||||
query = query.where({ 'photos.type': category_id });
|
||||
}
|
||||
@@ -665,7 +695,11 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const photos = await query.orderBy(orderByColumn, order);
|
||||
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ photos: [] });
|
||||
}
|
||||
|
||||
// Get comment counts separately
|
||||
const commentCounts = await db('photo_feedback')
|
||||
.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
|
||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||
type: photo.type,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
category_id: photo.category_id !== null && photo.category_id !== undefined
|
||||
? Number(photo.category_id)
|
||||
: 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,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
|
||||
@@ -220,7 +220,7 @@ router.post('/logout', async (req, res) => {
|
||||
// Gallery password verification with enhanced security
|
||||
router.post('/gallery/verify', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('password').notEmpty()
|
||||
body('password').optional().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -232,53 +232,69 @@ router.post('/gallery/verify', [
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check gallery-specific lockout
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
|
||||
|
||||
if (requiresPassword) {
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
// Don't reveal if gallery exists
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
if (requiresPassword) {
|
||||
if (!password) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
action: 'login_success'
|
||||
});
|
||||
} else {
|
||||
logger.info('Public gallery access granted without password', { slug, ipAddress });
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
// Successful access
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
// Log successful access
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
|
||||
// Generate session token with additional security info
|
||||
const token = jwt.sign({
|
||||
eventId: event.id,
|
||||
@@ -302,7 +318,8 @@ router.post('/gallery/verify', [
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -372,4 +389,4 @@ router.post('/password-strength', [
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -162,7 +162,7 @@ router.post('/logout', async (req, res) => {
|
||||
// Gallery password verification with enhanced security
|
||||
router.post('/gallery/verify', [
|
||||
body('slug').notEmpty().trim(),
|
||||
body('password').notEmpty()
|
||||
body('password').optional().isString()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -173,55 +173,68 @@ router.post('/gallery/verify', [
|
||||
const { slug, password, recaptchaToken } = req.body;
|
||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
// Check gallery-specific lockout
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reCAPTCHA
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||
const event = await db('events')
|
||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||
.first();
|
||||
|
||||
if (!event) {
|
||||
// Don't reveal if gallery exists
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
if (requiresPassword) {
|
||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||
if (lockoutStatus.isLocked) {
|
||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||
return res.status(423).json({
|
||||
error: 'Too many failed attempts. Please try again later.',
|
||||
retryAfter: lockoutStatus.remainingTime
|
||||
});
|
||||
}
|
||||
|
||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||
if (!recaptchaValid) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||
if (!validPassword) {
|
||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_fail'
|
||||
action: 'login_success'
|
||||
});
|
||||
} else {
|
||||
logger.info('Public gallery access granted without password', { slug, ipAddress });
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||
}
|
||||
|
||||
// Successful access
|
||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||
|
||||
// Log successful access
|
||||
await db('access_logs').insert({
|
||||
event_id: event.id,
|
||||
ip_address: ipAddress,
|
||||
user_agent: userAgent,
|
||||
action: 'login_success'
|
||||
});
|
||||
|
||||
// Generate session token with additional security info
|
||||
|
||||
const token = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
@@ -246,7 +259,8 @@ router.post('/gallery/verify', [
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -301,6 +315,8 @@ router.post('/gallery/share-login', [
|
||||
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
token: jwtToken,
|
||||
event: {
|
||||
@@ -312,7 +328,8 @@ router.post('/gallery/share-login', [
|
||||
color_theme: event.color_theme,
|
||||
expires_at: event.expires_at,
|
||||
allow_user_uploads: event.allow_user_uploads,
|
||||
upload_category_id: event.upload_category_id
|
||||
upload_category_id: event.upload_category_id,
|
||||
require_password: requiresPassword
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
+104
-17
@@ -4,11 +4,34 @@ const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
|
||||
const 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
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
@@ -16,7 +39,17 @@ router.post('/', adminAuth, [
|
||||
body('event_date').isDate(),
|
||||
body('host_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()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
@@ -32,10 +65,28 @@ router.post('/', adminAuth, [
|
||||
host_email,
|
||||
admin_email,
|
||||
password,
|
||||
require_password: requirePasswordInput = true,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
expiration_days = 30
|
||||
} = 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
|
||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||
@@ -49,10 +100,15 @@ router.post('/', adminAuth, [
|
||||
|
||||
// Generate share link (just slug/token, not full URL)
|
||||
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
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
// Hash password (or placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
? await bcrypt.hash(password, getBcryptRounds())
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
@@ -75,8 +131,9 @@ router.post('/', adminAuth, [
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
share_link: shareLink,
|
||||
expires_at
|
||||
share_link: shareLinkSlug,
|
||||
expires_at,
|
||||
require_password: formatBoolean(requirePassword)
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -88,17 +145,18 @@ router.post('/', adminAuth, [
|
||||
host_name: host_email.split('@')[0], // Extract name from email
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
gallery_link: fullShareLink,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
});
|
||||
|
||||
|
||||
res.json({
|
||||
id: eventId,
|
||||
slug,
|
||||
share_link: shareLink,
|
||||
expires_at
|
||||
share_link: fullShareLink,
|
||||
expires_at,
|
||||
require_password: requirePassword
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -137,17 +195,46 @@ router.get('/', adminAuth, async (req, res) => {
|
||||
router.put('/:id', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
const updates = { ...req.body };
|
||||
|
||||
// Don't allow updating certain fields
|
||||
delete updates.id;
|
||||
delete updates.slug;
|
||||
delete updates.created_at;
|
||||
|
||||
// If updating password, hash it
|
||||
if (updates.password) {
|
||||
updates.password_hash = await bcrypt.hash(updates.password, 10);
|
||||
delete updates.password;
|
||||
delete updates.password_confirmation;
|
||||
|
||||
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||
let requirePasswordUpdate;
|
||||
if (hasRequirePasswordUpdate) {
|
||||
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||
}
|
||||
|
||||
let newPasswordPlain;
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||
delete updates.password;
|
||||
} else {
|
||||
newPasswordPlain = updates.password;
|
||||
delete updates.password;
|
||||
}
|
||||
}
|
||||
|
||||
const event = await db('events').where('id', id).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||
|
||||
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||
}
|
||||
|
||||
if (newPasswordPlain) {
|
||||
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
await db('events').where('id', id).update(updates);
|
||||
|
||||
+110
-47
@@ -1,5 +1,4 @@
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const archiver = require('archiver');
|
||||
@@ -8,8 +7,8 @@ const router = express.Router();
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
|
||||
// Get storage path from environment or default
|
||||
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 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')
|
||||
.first();
|
||||
|
||||
@@ -48,9 +47,22 @@ router.get('/:slug/info', async (req, res) => {
|
||||
const { token } = req.query;
|
||||
|
||||
const event = await db('events')
|
||||
.where({ slug: slug })
|
||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link',
|
||||
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text')
|
||||
.where({ slug })
|
||||
.select(
|
||||
'event_name',
|
||||
'event_type',
|
||||
'event_date',
|
||||
'expires_at',
|
||||
'is_active',
|
||||
'is_archived',
|
||||
'share_link',
|
||||
'allow_downloads',
|
||||
'disable_right_click',
|
||||
'watermark_downloads',
|
||||
'watermark_text',
|
||||
'require_password',
|
||||
'color_theme'
|
||||
)
|
||||
.first();
|
||||
|
||||
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({
|
||||
event_name: event.event_name,
|
||||
event_type: event.event_type,
|
||||
@@ -81,11 +95,11 @@ router.get('/:slug/info', async (req, res) => {
|
||||
expires_at: event.expires_at,
|
||||
is_active: event.is_active,
|
||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||
requires_password: true,
|
||||
requires_password: requiresPassword,
|
||||
color_theme: event.color_theme,
|
||||
allow_downloads: event.allow_downloads !== false,
|
||||
disable_right_click: event.disable_right_click === true,
|
||||
watermark_downloads: event.watermark_downloads === true,
|
||||
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||
watermark_text: event.watermark_text
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -99,7 +113,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
// Get filter parameters from query
|
||||
const { filter, guest_id } = req.query;
|
||||
const feedbackService = require('../services/feedbackService');
|
||||
|
||||
// First get all photos
|
||||
let photos = await db('photos')
|
||||
@@ -317,16 +330,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
photo_id: photoId
|
||||
});
|
||||
|
||||
// Photo path should be in storage/events/active directory
|
||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
||||
const storagePath = getStoragePath();
|
||||
let filePath;
|
||||
if (photo.path.startsWith('events/active/')) {
|
||||
// New format: path already includes events/active/ prefix
|
||||
filePath = path.join(storagePath, photo.path);
|
||||
} else {
|
||||
// Legacy format: path is just slug/filename
|
||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Get watermark settings
|
||||
@@ -345,9 +359,24 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send original file
|
||||
res.download(filePath, photo.filename);
|
||||
res.download(filePath, photo.filename, (downloadError) => {
|
||||
if (downloadError) {
|
||||
logger.error('Error streaming gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: downloadError.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Unexpected error processing gallery download', {
|
||||
slug: req.params.slug,
|
||||
photoId: req.params.photoId,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download photo' });
|
||||
}
|
||||
});
|
||||
@@ -390,16 +419,17 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
// Add photos to archive
|
||||
for (const photo of photos) {
|
||||
// Photo path should be in storage/events/active directory
|
||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
||||
const storagePath = getStoragePath();
|
||||
let filePath;
|
||||
if (photo.path.startsWith('events/active/')) {
|
||||
// New format: path already includes events/active/ prefix
|
||||
filePath = path.join(storagePath, photo.path);
|
||||
} else {
|
||||
// Legacy format: path is just slug/filename
|
||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.warn('Skipping photo in bulk download due to unresolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine the file name in the archive
|
||||
@@ -414,11 +444,18 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
}
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name: archiveName });
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name: archiveName });
|
||||
} catch (watermarkError) {
|
||||
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: watermarkError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Add original file
|
||||
archive.file(filePath, { name: archiveName });
|
||||
}
|
||||
}
|
||||
@@ -433,6 +470,11 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
||||
action: 'download_all'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error creating bulk gallery download', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to create download archive' });
|
||||
}
|
||||
});
|
||||
@@ -477,30 +519,47 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||
archive.on('error', (err) => {
|
||||
console.error('Zip error:', err);
|
||||
try { res.status(500).end(); } catch (e) {}
|
||||
logger.error('Zip error generating selected download', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: err.message,
|
||||
});
|
||||
try {
|
||||
res.status(500).end();
|
||||
} catch (_) {
|
||||
// ignore double-send errors
|
||||
}
|
||||
});
|
||||
archive.pipe(res);
|
||||
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const fs = require('fs');
|
||||
// Check watermark settings similar to download-all
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark like download-all
|
||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
try {
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
archive.append(watermarkedBuffer, { name });
|
||||
} else {
|
||||
archive.file(filePath, { name });
|
||||
} catch (watermarkError) {
|
||||
logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: watermarkError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
archive.file(filePath, { name });
|
||||
}
|
||||
} catch (e) {
|
||||
// skip missing/inaccessible files
|
||||
} catch (resolveError) {
|
||||
logger.warn('Skipping selected photo due to unresolved path', {
|
||||
slug: req.params.slug,
|
||||
photoId: photo.id,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,7 +572,11 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
||||
action: 'download_selected'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in download-selected:', error);
|
||||
logger.error('Error in download-selected:', {
|
||||
slug: req.params.slug,
|
||||
eventId: req.event?.id,
|
||||
error: error.message,
|
||||
});
|
||||
res.status(500).json({ error: 'Failed to download selected photos' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||
const logger = require('../utils/logger');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Generate secure token for image access
|
||||
*/
|
||||
@@ -94,11 +91,11 @@ router.get('/:slug/secure/:photoId/:token',
|
||||
const { slug, photoId, token } = req.params; // Move outside try block for error handler access
|
||||
|
||||
try {
|
||||
console.log('Secure image route hit:', {
|
||||
slug: slug,
|
||||
photoId: photoId,
|
||||
logger.debug('Secure image route hit', {
|
||||
slug,
|
||||
photoId,
|
||||
tokenLength: token?.length,
|
||||
headers: req.headers.authorization ? 'present' : 'absent'
|
||||
hasAuthHeader: Boolean(req.headers.authorization),
|
||||
});
|
||||
const { fragment } = req.query;
|
||||
|
||||
@@ -142,7 +139,18 @@ router.get('/:slug/secure/:photoId/:token',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for secure token generation', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Get protection settings for this event
|
||||
const protectionSettings = {
|
||||
@@ -284,7 +292,18 @@ router.get('/:slug/secure-download/:photoId/:token',
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||
let filePath;
|
||||
try {
|
||||
filePath = resolvePhotoFilePath(req.event, photo);
|
||||
} catch (resolveError) {
|
||||
logger.error('Failed to resolve photo path for secure download', {
|
||||
slug: req.params.slug,
|
||||
photoId,
|
||||
eventId: req.event.id,
|
||||
error: resolveError.message,
|
||||
});
|
||||
return res.status(404).json({ error: 'Photo file not found' });
|
||||
}
|
||||
|
||||
// Apply watermark if enabled
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
@@ -426,4 +445,4 @@ async function getSuspiciousActivityStats() {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
|
||||
@@ -132,6 +132,12 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? 'Kein Passwort erforderlich'
|
||||
: 'No password required';
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
if (processedVariables.event_date) {
|
||||
@@ -546,4 +552,4 @@ module.exports = {
|
||||
queueEmail,
|
||||
stopEmailQueueProcessor,
|
||||
testEmailConnection
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,9 +1,52 @@
|
||||
const fs = require('fs').promises;
|
||||
const fsSync = require('fs');
|
||||
const path = require('path');
|
||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||
|
||||
let cachedRoot = null;
|
||||
|
||||
function resolveDefaultRoot() {
|
||||
const containerDefault = '/external-media';
|
||||
try {
|
||||
if (fsSync.existsSync(containerDefault)) {
|
||||
return containerDefault;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore lookup errors, fallback below
|
||||
}
|
||||
|
||||
const localFallback = path.resolve(__dirname, '../../..', 'storage/external-media');
|
||||
try {
|
||||
if (fsSync.existsSync(localFallback)) {
|
||||
return localFallback;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore and return container default
|
||||
}
|
||||
|
||||
return containerDefault;
|
||||
}
|
||||
|
||||
function getExternalMediaRoot() {
|
||||
return process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
|
||||
if (cachedRoot) {
|
||||
return cachedRoot;
|
||||
}
|
||||
|
||||
const configured = process.env.EXTERNAL_MEDIA_ROOT;
|
||||
if (configured && configured.trim()) {
|
||||
const resolvedConfigured = path.resolve(configured.trim());
|
||||
try {
|
||||
if (fsSync.existsSync(resolvedConfigured)) {
|
||||
cachedRoot = resolvedConfigured;
|
||||
return cachedRoot;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore lookup errors and fall back to defaults
|
||||
}
|
||||
}
|
||||
|
||||
cachedRoot = resolveDefaultRoot();
|
||||
return cachedRoot;
|
||||
}
|
||||
|
||||
function isUnderRoot(p) {
|
||||
@@ -64,4 +107,3 @@ module.exports = {
|
||||
list,
|
||||
resolveExternalPath,
|
||||
};
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
|
||||
function resolvePhotoFilePath(event, photo) {
|
||||
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
|
||||
|
||||
const mode = (event.source_mode || photo.source_origin || 'managed');
|
||||
if (mode === 'reference' || photo.source_origin === 'external') {
|
||||
const isExternal = photo.source_origin === 'external' ||
|
||||
(!!photo.external_relpath && (event.source_mode === 'reference' || event.source_mode === 'external'));
|
||||
|
||||
if (isExternal) {
|
||||
if (!photo.external_relpath) {
|
||||
throw new Error('Missing external_relpath for external photo');
|
||||
}
|
||||
|
||||
+31
-5
@@ -3,19 +3,45 @@
|
||||
|
||||
set -e
|
||||
|
||||
host="$DB_HOST"
|
||||
host="${DB_HOST:-postgres}"
|
||||
port="${DB_PORT:-5432}"
|
||||
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..."
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
until PGPASSWORD=$DB_PASSWORD psql -h "$host" -p "$port" -U "$user" -d "${DB_NAME:-picpeak}" -c '\q' 2>/dev/null; do
|
||||
# Wait for PostgreSQL server to accept connections (using the default database)
|
||||
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"
|
||||
sleep 2
|
||||
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)
|
||||
echo "Running database migrations..."
|
||||
@@ -26,4 +52,4 @@ else
|
||||
fi
|
||||
|
||||
# Execute the main command
|
||||
exec "$@"
|
||||
exec "$@"
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ services:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
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_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
||||
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
||||
|
||||
@@ -19,5 +19,28 @@ export default tseslint.config([
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'no-useless-escape': 'off',
|
||||
'no-case-declarations': 'off',
|
||||
'prefer-const': 'off',
|
||||
'no-control-regex': 'off',
|
||||
'no-useless-catch': 'off',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
'no-empty': 'off',
|
||||
'no-debugger': 'off',
|
||||
'@typescript-eslint/no-unused-expressions': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.d.ts'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+1287
-8
File diff suppressed because it is too large
Load Diff
+12
-5
@@ -5,10 +5,11 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:check": "tsc -b && vite build",
|
||||
"build": "cross-env ROLLUP_USE_NODE_JS=true vite build",
|
||||
"build:check": "tsc -b && cross-env ROLLUP_USE_NODE_JS=true vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run src/components/admin/__tests__/ThemeCustomizerEnhanced.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
@@ -47,18 +48,24 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"@vitejs/plugin-react": "^4.5.3",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.29.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.2.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.21",
|
||||
"tailwindcss": "^3.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.34.1",
|
||||
"vite": "^7.1.6"
|
||||
"vite": "^7.1.12",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const MaintenanceMode: React.FC = () => {
|
||||
try {
|
||||
const response = await api.get('/public/settings');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Return empty object if settings can't be fetched
|
||||
return {};
|
||||
}
|
||||
@@ -110,4 +110,4 @@ export const MaintenanceMode: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
|
||||
if (isMounted) {
|
||||
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (isMounted) {
|
||||
setHasAdminSession(false);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const loadImage = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setImageSrc(null);
|
||||
|
||||
// Make authenticated request to get the image
|
||||
const response = await api.get(src, {
|
||||
@@ -31,11 +33,11 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
|
||||
if (!cancelled) {
|
||||
// Create object URL from blob
|
||||
const imageUrl = URL.createObjectURL(response.data);
|
||||
setImageSrc(imageUrl);
|
||||
objectUrl = URL.createObjectURL(response.data);
|
||||
setImageSrc(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
} catch {
|
||||
// Image loading failed - handled by error state
|
||||
if (!cancelled) {
|
||||
setError(true);
|
||||
@@ -51,8 +53,8 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
// Cleanup function
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (imageSrc) {
|
||||
URL.revokeObjectURL(imageSrc);
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
@@ -74,4 +76,4 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
||||
}
|
||||
|
||||
return <img src={imageSrc || ''} alt={alt} {...props} />;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -55,12 +55,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
},
|
||||
});
|
||||
|
||||
// Clear old notifications mutation
|
||||
const clearOldMutation = useMutation({
|
||||
mutationFn: notificationsService.clearOldNotifications,
|
||||
// Clear notifications mutation
|
||||
const clearAllMutation = useMutation({
|
||||
mutationFn: notificationsService.clearAllNotifications,
|
||||
onSuccess: (data) => {
|
||||
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
|
||||
onClick={() => clearOldMutation.mutate()}
|
||||
onClick={() => clearAllMutation.mutate()}
|
||||
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" />
|
||||
{t('admin.clearOld')}
|
||||
{t('admin.clearAll')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
await photosService.deletePhoto(eventId, photo.id);
|
||||
toast.success('Photo deleted successfully');
|
||||
onPhotosDeleted();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to delete photo');
|
||||
setDeletingPhotos(prev => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -90,7 +90,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
setSelectedPhotos(new Set());
|
||||
setIsSelectionMode(false);
|
||||
onPhotosDeleted();
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to delete photos');
|
||||
setDeletingPhotos(new Set());
|
||||
} finally {
|
||||
@@ -103,7 +103,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
try {
|
||||
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
|
||||
toast.success('Download started');
|
||||
} catch (error) {
|
||||
} catch {
|
||||
toast.error('Failed to download photo');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ interface ThemeCustomizerEnhancedProps {
|
||||
isPreviewMode?: boolean;
|
||||
showGalleryLayouts?: boolean;
|
||||
hideActions?: boolean;
|
||||
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
|
||||
isApplying?: boolean;
|
||||
}
|
||||
|
||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
@@ -34,7 +36,9 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onPresetChange,
|
||||
isPreviewMode = false,
|
||||
showGalleryLayouts = true,
|
||||
hideActions = false
|
||||
hideActions = false,
|
||||
onApply,
|
||||
isApplying = false
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
@@ -80,8 +84,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
onChange({ ...localTheme, customCss });
|
||||
const handleApply = async () => {
|
||||
const themeWithCss = { ...localTheme, customCss };
|
||||
onChange(themeWithCss);
|
||||
|
||||
if (onApply) {
|
||||
await onApply(themeWithCss, { presetName: selectedPreset });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
@@ -587,11 +596,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
variant="primary"
|
||||
leftIcon={<Palette className="w-4 h-4" />}
|
||||
onClick={handleApply}
|
||||
disabled={isApplying}
|
||||
>
|
||||
{t('branding.applyTheme')}
|
||||
{isApplying ? t('common.applying', 'Applying...') : t('branding.applyTheme')}
|
||||
</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
|
||||
// Fall back to global theme
|
||||
if (settingsData.theme_config) {
|
||||
|
||||
@@ -12,7 +12,7 @@ interface GridPhotoProps {
|
||||
photo: Photo;
|
||||
isSelected: boolean;
|
||||
isSelectionMode: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onClick: () => void;
|
||||
onDownload: (e: React.MouseEvent) => void;
|
||||
onToggleSelect: () => void;
|
||||
animationType?: string;
|
||||
@@ -57,6 +57,79 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
liked = false,
|
||||
onLikeSuccess
|
||||
}) => {
|
||||
const [overlayVisible, setOverlayVisible] = React.useState(false);
|
||||
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
|
||||
const overlayTimeoutRef = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(hover: none) and (pointer: coarse)');
|
||||
const updateTouchState = () => {
|
||||
const hasNavigator = typeof navigator !== 'undefined';
|
||||
setIsTouchDevice(
|
||||
mediaQuery.matches ||
|
||||
('ontouchstart' in window) ||
|
||||
(hasNavigator && navigator.maxTouchPoints > 0)
|
||||
);
|
||||
};
|
||||
|
||||
updateTouchState();
|
||||
|
||||
const listener = (event: MediaQueryListEvent) => {
|
||||
setIsTouchDevice(event.matches);
|
||||
};
|
||||
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
} else if (mediaQuery.addListener) {
|
||||
mediaQuery.addListener(listener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (mediaQuery.removeEventListener) {
|
||||
mediaQuery.removeEventListener('change', listener);
|
||||
} else if (mediaQuery.removeListener) {
|
||||
mediaQuery.removeListener(listener);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hideOverlay = React.useCallback(() => {
|
||||
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(overlayTimeoutRef.current);
|
||||
}
|
||||
overlayTimeoutRef.current = null;
|
||||
setOverlayVisible(false);
|
||||
}, []);
|
||||
|
||||
const showOverlayTemporarily = React.useCallback(() => {
|
||||
setOverlayVisible(true);
|
||||
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(overlayTimeoutRef.current);
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
overlayTimeoutRef.current = window.setTimeout(() => {
|
||||
overlayTimeoutRef.current = null;
|
||||
setOverlayVisible(false);
|
||||
}, 2500);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(overlayTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSelectionMode) {
|
||||
hideOverlay();
|
||||
}
|
||||
}, [isSelectionMode, hideOverlay]);
|
||||
|
||||
// handled by parent layout; kept here for type completeness but not used
|
||||
const { ref, inView } = useInView({
|
||||
triggerOnce: true,
|
||||
@@ -73,11 +146,34 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
const commentCount = photo.comment_count ?? 0;
|
||||
const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions);
|
||||
|
||||
const overlayVisibilityClass = overlayVisible
|
||||
? 'opacity-100 md:opacity-100'
|
||||
: 'opacity-0 md:opacity-0';
|
||||
|
||||
const checkboxVisibilityClass =
|
||||
isSelected || isSelectionMode || overlayVisible
|
||||
? 'opacity-100 md:opacity-100'
|
||||
: 'opacity-0 md:opacity-0';
|
||||
|
||||
const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isTouchDevice && !overlayVisible && !isSelectionMode) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showOverlayTemporarily();
|
||||
return;
|
||||
}
|
||||
|
||||
onClick();
|
||||
if (isTouchDevice) {
|
||||
hideOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative group cursor-pointer aspect-square ${animationClass}`}
|
||||
onClick={onClick}
|
||||
onClick={handlePhotoClick}
|
||||
style={{
|
||||
opacity: !inView && animationType === 'fade' ? 0 : 1
|
||||
}}
|
||||
@@ -108,14 +204,15 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||
<div className={`absolute inset-0 bg-black/40 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2 ${overlayVisibilityClass} md:group-hover:opacity-100`}>
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
onClick();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="View full size"
|
||||
>
|
||||
@@ -124,7 +221,11 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
{allowDownloads && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={onDownload}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload(e);
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Download photo"
|
||||
>
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
@@ -133,7 +234,11 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
{showFeedbackActions && onQuickComment && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onQuickComment();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Comment on photo"
|
||||
title="Comment"
|
||||
>
|
||||
@@ -148,6 +253,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
e.stopPropagation();
|
||||
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
|
||||
onRequireIdentity('like', photo.id);
|
||||
hideOverlay();
|
||||
return;
|
||||
}
|
||||
// Optimistic UI: mark as liked immediately
|
||||
@@ -163,6 +269,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
console.warn('Like submit failed, keeping optimistic UI', err);
|
||||
}
|
||||
if (onFeedbackChange) onFeedbackChange();
|
||||
hideOverlay();
|
||||
}}
|
||||
aria-label="Like photo"
|
||||
aria-pressed={liked}
|
||||
@@ -182,9 +289,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
role="checkbox"
|
||||
aria-checked={isSelected}
|
||||
data-testid={`gallery-photo-checkbox-${photo.id}`}
|
||||
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
className={`absolute top-2 right-2 z-20 transition-opacity ${checkboxVisibilityClass} md:group-hover:opacity-100`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||
|
||||
@@ -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 { parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -50,6 +50,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -163,30 +169,40 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<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>
|
||||
|
||||
{/* 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) => {
|
||||
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative group cursor-pointer aspect-square"
|
||||
className="relative group cursor-pointer overflow-hidden rounded-lg"
|
||||
onClick={() => onPhotoClick(actualIndex)}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
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"
|
||||
isGallery={true}
|
||||
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 && (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
inferGallerySlugFromLocation,
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../utils/galleryAuthStorage';
|
||||
import { getApiBaseUrl } from '../utils/url';
|
||||
|
||||
// Maintenance mode callback
|
||||
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
|
||||
@@ -15,7 +16,7 @@ export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void)
|
||||
|
||||
// Create axios instance
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||
baseURL: getApiBaseUrl(),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AdminAuthContextType {
|
||||
error: string | null;
|
||||
mustChangePassword: boolean;
|
||||
updatePasswordChanged: () => void;
|
||||
updateProfile: (user: AdminUser) => void;
|
||||
}
|
||||
|
||||
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 (
|
||||
<AdminAuthContext.Provider
|
||||
value={{
|
||||
@@ -115,6 +121,7 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
||||
error,
|
||||
mustChangePassword,
|
||||
updatePasswordChanged,
|
||||
updateProfile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
||||
import { api } from '../config/api';
|
||||
import { authService, galleryService } from '../services';
|
||||
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
import {
|
||||
clearActiveGallerySlug,
|
||||
clearGalleryToken,
|
||||
@@ -18,12 +19,24 @@ interface GalleryEvent {
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
require_password?: boolean;
|
||||
}
|
||||
|
||||
const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent | null => {
|
||||
if (!incoming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...incoming,
|
||||
require_password: normalizeRequirePassword(incoming.require_password, true),
|
||||
};
|
||||
};
|
||||
|
||||
interface GalleryAuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
event: GalleryEvent | null;
|
||||
login: (slug: string, password: string, recaptchaToken?: string | null) => Promise<void>;
|
||||
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
|
||||
logout: () => void;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
@@ -83,7 +96,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
try {
|
||||
const parsed = JSON.parse(storedEvent);
|
||||
if (parsed && parsed.id) {
|
||||
setEvent(parsed);
|
||||
const normalizedStored = normalizeEvent(parsed);
|
||||
setEvent(normalizedStored);
|
||||
if (normalizedStored) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
@@ -104,8 +121,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
// Fetch gallery details to hydrate context
|
||||
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
||||
if (galleryData?.event) {
|
||||
setEvent(galleryData.event);
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(galleryData.event));
|
||||
const normalizedEvent = normalizeEvent(galleryData.event);
|
||||
setEvent(normalizedEvent);
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,9 +141,12 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
if (verify?.valid) {
|
||||
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
||||
if (response?.event) {
|
||||
setEvent(response.event);
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
setIsAuthenticated(true);
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
if (response.token) {
|
||||
storeGalleryToken(currentSlug, response.token);
|
||||
}
|
||||
@@ -154,12 +177,13 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
};
|
||||
}, []);
|
||||
|
||||
const login = async (slug: string, password: string, recaptchaToken?: string | null) => {
|
||||
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
|
||||
try {
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||
setEvent(response.event);
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
setIsAuthenticated(true);
|
||||
if (response.token) {
|
||||
storeGalleryToken(slug, response.token);
|
||||
@@ -167,7 +191,9 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
setActiveGallerySlug(slug);
|
||||
|
||||
// Store event data for quick reloads (non-sensitive)
|
||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Invalid password');
|
||||
throw err;
|
||||
|
||||
@@ -496,6 +496,8 @@
|
||||
"expiresIn": "Galerie läuft in {{count}} Tag ab",
|
||||
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
|
||||
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.",
|
||||
"publicGalleryTitle": "Diese Galerie ist öffentlich zugänglich",
|
||||
"publicGallerySubtitle": "Fotos werden geladen...",
|
||||
"viewGallery": "Galerie anzeigen",
|
||||
"downloadAll": "Alle herunterladen",
|
||||
"downloading": "Lade {{count}} Foto herunter...",
|
||||
@@ -607,6 +609,7 @@
|
||||
"created": "Erstellt",
|
||||
"expires": "Läuft ab",
|
||||
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
|
||||
"shareWithGuestsPublic": "Teilen Sie diesen Link mit Gästen. Für diese Galerie ist kein Passwort erforderlich.",
|
||||
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
|
||||
"resendCreationEmail": "Erstellungs-E-Mail erneut senden",
|
||||
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
|
||||
@@ -632,10 +635,14 @@
|
||||
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
||||
"securityAccess": "Sicherheit & Zugriff",
|
||||
"galleryPassword": "Galerie-Passwort",
|
||||
"requirePasswordToggle": "Galerie mit Passwort schützen",
|
||||
"requirePasswordToggleHelp": "Deaktivieren Sie diese Option, wenn die Galerie ohne Passwort geteilt werden soll. Jeder mit dem Link kann die Fotos ansehen.",
|
||||
"publicGalleryWarning": "Öffentliche Galerien sind für jeden mit dem Link zugänglich. Aktivieren Sie gegebenenfalls Wasserzeichen und behalten Sie die Aktivität im Blick.",
|
||||
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
|
||||
"passwordPlaceholder": "Sicheres Passwort eingeben",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"showPasswords": "Passwörter anzeigen",
|
||||
"newPasswordLabel": "Neues Galerie-Passwort",
|
||||
"gallerySettings": "Galerie-Einstellungen",
|
||||
"colorTheme": "Farbthema",
|
||||
"galleryExpiration": "Galerie-Ablauf",
|
||||
@@ -735,6 +742,9 @@
|
||||
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
|
||||
"eventsSelected": "{{count}} Veranstaltung ausgewählt",
|
||||
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
||||
"publicAccess": "Öffentlicher Zugriff",
|
||||
"passwordProtected": "Passwortgeschützt",
|
||||
"newPasswordRequired": "Bitte legen Sie vor dem Aktivieren des Passwortschutzes ein Passwort fest.",
|
||||
"viewDetails": "Details anzeigen",
|
||||
"archiveEventAction": "Veranstaltung archivieren",
|
||||
"downloadArchiveAction": "Archiv herunterladen",
|
||||
@@ -853,7 +863,6 @@
|
||||
"security": {
|
||||
"title": "Sicherheit",
|
||||
"passwordSettings": "Passworteinstellungen",
|
||||
"requirePassword": "Passwort für alle Galerien erforderlich",
|
||||
"minPasswordLength": "Minimale Passwortlänge",
|
||||
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
||||
"passwordComplexity": "Passwort-Komplexität",
|
||||
@@ -1071,7 +1080,7 @@
|
||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||
"markAllRead": "Alle als gelesen markieren",
|
||||
"clearOld": "Alte löschen",
|
||||
"clearAll": "Alle löschen",
|
||||
"close": "Schließen",
|
||||
"noNotificationsMessage": "Keine Benachrichtigungen",
|
||||
"notificationMessages": {
|
||||
@@ -1104,11 +1113,13 @@
|
||||
"archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen",
|
||||
"archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht",
|
||||
"archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt",
|
||||
"systemActivity": "Systemaktivität: {{type}}"
|
||||
"systemActivity": "Systemaktivität: {{type}}",
|
||||
"adminProfileUpdated": "Admin-Profil aktualisiert von {{actorName}}"
|
||||
},
|
||||
"notificationToasts": {
|
||||
"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",
|
||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||
@@ -1116,6 +1127,16 @@
|
||||
"markAllAsRead": "Alle als gelesen markieren",
|
||||
"notificationSettings": "Benachrichtigungseinstellungen",
|
||||
"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...",
|
||||
"activeEvents": "Aktive Veranstaltungen",
|
||||
"expiringSoon": "Demnächst ablaufend",
|
||||
|
||||
@@ -161,6 +161,8 @@
|
||||
"expiresIn": "Gallery expires in {{count}} day",
|
||||
"expiresIn_plural": "Gallery expires in {{count}} days",
|
||||
"downloadBefore": "Download your photos before they're no longer available.",
|
||||
"publicGalleryTitle": "This gallery is publicly accessible",
|
||||
"publicGallerySubtitle": "Loading the photos now...",
|
||||
"viewGallery": "View Gallery",
|
||||
"downloadAll": "Download All",
|
||||
"downloading": "Downloading {{count}} photo...",
|
||||
@@ -290,6 +292,7 @@
|
||||
"created": "Created",
|
||||
"expires": "Expires",
|
||||
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
|
||||
"shareWithGuestsPublic": "Share this link with guests. No password is required for this gallery.",
|
||||
"resetGalleryPassword": "Reset Gallery Password",
|
||||
"resendCreationEmail": "Resend Creation Email",
|
||||
"creationEmailResent": "Creation email has been queued for sending",
|
||||
@@ -316,9 +319,13 @@
|
||||
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
||||
"securityAccess": "Security & Access",
|
||||
"galleryPassword": "Gallery Password",
|
||||
"requirePasswordToggle": "Require password for this gallery",
|
||||
"requirePasswordToggleHelp": "Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.",
|
||||
"publicGalleryWarning": "Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.",
|
||||
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
|
||||
"confirmPassword": "Confirm Password",
|
||||
"showPasswords": "Show passwords",
|
||||
"newPasswordLabel": "New Gallery Password",
|
||||
"gallerySettings": "Gallery Settings",
|
||||
"themeAndStyle": "Theme & Style",
|
||||
"colorTheme": "Color Theme",
|
||||
@@ -373,6 +380,9 @@
|
||||
"eventsSelected_plural": "{{count}} events selected",
|
||||
"clear": "Clear",
|
||||
"archiveSelected": "Archive Selected",
|
||||
"publicAccess": "Public access",
|
||||
"passwordProtected": "Password protected",
|
||||
"newPasswordRequired": "Please set a password before enabling protection.",
|
||||
"event": "Event",
|
||||
"type": "Type",
|
||||
"date": "Date",
|
||||
@@ -533,7 +543,6 @@
|
||||
"security": {
|
||||
"title": "Security",
|
||||
"passwordSettings": "Password Settings",
|
||||
"requirePassword": "Require password for all galleries",
|
||||
"minPasswordLength": "Minimum Password Length",
|
||||
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
||||
"passwordComplexity": "Password Complexity",
|
||||
@@ -809,7 +818,7 @@
|
||||
"viewAllNotifications": "View all notifications",
|
||||
"noNotifications": "No new notifications",
|
||||
"markAllRead": "Mark all read",
|
||||
"clearOld": "Clear old",
|
||||
"clearAll": "Clear all",
|
||||
"close": "Close",
|
||||
"noNotificationsMessage": "No notifications",
|
||||
"notificationMessages": {
|
||||
@@ -842,16 +851,28 @@
|
||||
"archiveDownloaded": "Archive downloaded for \"{{eventName}}\"",
|
||||
"archiveDeleted": "Archive deleted for \"{{eventName}}\"",
|
||||
"archiveRestored": "Archive restored for \"{{eventName}}\"",
|
||||
"systemActivity": "System activity: {{type}}"
|
||||
"systemActivity": "System activity: {{type}}",
|
||||
"adminProfileUpdated": "Admin profile updated by {{actorName}}"
|
||||
},
|
||||
"notificationToasts": {
|
||||
"markedAllRead": "All notifications marked as read",
|
||||
"clearedOld": "Cleared {{count}} old notifications"
|
||||
"clearedAll": "Cleared {{count}} notifications",
|
||||
"profileUpdated": "Admin profile updated"
|
||||
},
|
||||
"markAsRead": "Mark as read",
|
||||
"markAllAsRead": "Mark all as read",
|
||||
"notificationSettings": "Notification Settings",
|
||||
"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...",
|
||||
"activeEvents": "Active Events",
|
||||
"expiringSoon": "Expiring Soon",
|
||||
|
||||
@@ -14,6 +14,7 @@ import { analyticsService } from '../services/analytics.service';
|
||||
import { api } from '../config/api';
|
||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||
import { buildResourceUrl } from '../utils/url';
|
||||
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
export const GalleryPage: React.FC = () => {
|
||||
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||
@@ -25,9 +26,11 @@ export const GalleryPage: React.FC = () => {
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
|
||||
|
||||
// Fetch gallery info (public data)
|
||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
||||
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
@@ -87,6 +90,30 @@ export const GalleryPage: React.FC = () => {
|
||||
}
|
||||
}, [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
|
||||
const daysUntilExpiration = galleryInfo
|
||||
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
||||
@@ -96,7 +123,7 @@ export const GalleryPage: React.FC = () => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Prevent any bubbling
|
||||
|
||||
if (!password.trim()) {
|
||||
if (requiresPassword && !password.trim()) {
|
||||
setLoginError(t('auth.pleaseEnterPassword'));
|
||||
return;
|
||||
}
|
||||
@@ -104,13 +131,14 @@ export const GalleryPage: React.FC = () => {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
await login(slug!, password, recaptchaToken);
|
||||
await login(slug!, requiresPassword ? password : '', recaptchaToken);
|
||||
|
||||
// Track successful password entry
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
success: true
|
||||
});
|
||||
if (requiresPassword) {
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
const errorMessage = error.response?.data?.error || 'Invalid password';
|
||||
@@ -128,11 +156,13 @@ export const GalleryPage: React.FC = () => {
|
||||
}
|
||||
|
||||
// Track failed password entry
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
success: false,
|
||||
statusCode
|
||||
});
|
||||
if (requiresPassword) {
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
success: false,
|
||||
statusCode
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the password field to allow retry
|
||||
// Do not clear the password
|
||||
@@ -311,43 +341,61 @@ export const GalleryPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Login Card */}
|
||||
<Card>
|
||||
<CardContent className="p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
label={t('auth.password')}
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={loginError || undefined}
|
||||
autoFocus
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
|
||||
<ReCaptcha
|
||||
onChange={setRecaptchaToken}
|
||||
onExpired={() => setRecaptchaToken(null)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full text-sm sm:text-base"
|
||||
isLoading={isLoggingIn}
|
||||
disabled={isLoggingIn}
|
||||
>
|
||||
{t('gallery.viewGallery')}
|
||||
</Button>
|
||||
</form>
|
||||
{requiresPassword ? (
|
||||
<>
|
||||
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
label={t('auth.password')}
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={loginError || undefined}
|
||||
autoFocus
|
||||
className="text-sm sm:text-base"
|
||||
/>
|
||||
|
||||
<ReCaptcha
|
||||
onChange={setRecaptchaToken}
|
||||
onExpired={() => setRecaptchaToken(null)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full text-sm sm:text-base"
|
||||
isLoading={isLoggingIn}
|
||||
disabled={isLoggingIn}
|
||||
>
|
||||
{t('gallery.viewGallery')}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
|
||||
{t('auth.passwordHint')}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
|
||||
{t('auth.passwordHint')}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center space-y-3">
|
||||
<h2 className="text-base sm:text-lg lg:text-xl font-semibold">
|
||||
{t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')}
|
||||
</p>
|
||||
<div className="flex justify-center py-4">
|
||||
<Loading size="sm" text={t('gallery.loading')} />
|
||||
</div>
|
||||
{loginError && (
|
||||
<p className="text-xs text-red-600">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -376,4 +424,4 @@ export const GalleryPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ interface FormData {
|
||||
event_date: string;
|
||||
host_email: string;
|
||||
admin_email: string;
|
||||
require_password: boolean;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
welcome_message: string;
|
||||
@@ -123,6 +124,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
host_email: '',
|
||||
admin_email: '',
|
||||
require_password: true,
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
welcome_message: '',
|
||||
@@ -208,17 +210,18 @@ export const CreateEventPage: React.FC = () => {
|
||||
newErrors.admin_email = t('validation.invalidEmailFormat');
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t('validation.passwordMinLength');
|
||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||
// Prevent simple numeric passwords like "123456"
|
||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
if (formData.require_password) {
|
||||
if (!formData.password) {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t('validation.passwordMinLength');
|
||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
||||
@@ -238,19 +241,22 @@ export const CreateEventPage: React.FC = () => {
|
||||
|
||||
const selectedTheme = COLOR_THEMES.find(t => t.value === formData.color_theme);
|
||||
|
||||
createMutation.mutate({
|
||||
const payload = {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
host_email: formData.host_email,
|
||||
admin_email: formData.admin_email,
|
||||
password: formData.password,
|
||||
require_password: formData.require_password,
|
||||
password: formData.require_password ? formData.password : undefined,
|
||||
welcome_message: formData.welcome_message || '',
|
||||
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
|
||||
expiration_days: formData.expires_in_days,
|
||||
allow_user_uploads: formData.allow_user_uploads,
|
||||
upload_category_id: formData.upload_category_id,
|
||||
});
|
||||
};
|
||||
|
||||
createMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof FormData) => (
|
||||
@@ -426,81 +432,114 @@ export const CreateEventPage: React.FC = () => {
|
||||
<Card padding="md" className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.galleryPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
placeholder={t('events.enterPassword')}
|
||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
style={{ top: errors.password ? '0' : '0' }}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
checked={formData.require_password}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
require_password: checked,
|
||||
password: checked ? prev.password : '',
|
||||
confirm_password: checked ? prev.confirm_password : ''
|
||||
}));
|
||||
if (!checked) {
|
||||
setErrors(prev => ({ ...prev, password: '', confirm_password: '' }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Password Generator */}
|
||||
<div className="mt-2">
|
||||
<PasswordGenerator
|
||||
eventName={formData.event_name}
|
||||
eventDate={formData.event_date}
|
||||
eventType={formData.event_type}
|
||||
onPasswordGenerated={handlePasswordGenerated}
|
||||
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.confirm_password}
|
||||
onChange={handleInputChange('confirm_password')}
|
||||
error={errors.confirm_password}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
style={{ top: errors.confirm_password ? '0' : '0' }}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
{!formData.require_password && (
|
||||
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.require_password && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.galleryPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
placeholder={t('events.enterPassword')}
|
||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
style={{ top: errors.password ? '0' : '0' }}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<PasswordGenerator
|
||||
eventName={formData.event_name}
|
||||
eventDate={formData.event_date}
|
||||
eventType={formData.event_type}
|
||||
onPasswordGenerated={handlePasswordGenerated}
|
||||
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.confirmPassword')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="confirm_password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={formData.confirm_password}
|
||||
onChange={handleInputChange('confirm_password')}
|
||||
error={errors.confirm_password}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
style={{ top: errors.confirm_password ? '0' : '0' }}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -645,4 +684,4 @@ export const CreateEventPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
CreateEventPage.displayName = 'CreateEventPage';
|
||||
CreateEventPage.displayName = 'CreateEventPage';
|
||||
|
||||
@@ -30,6 +30,7 @@ interface FormData {
|
||||
host_name: string;
|
||||
host_email: string;
|
||||
admin_email: string;
|
||||
require_password: boolean;
|
||||
password: string;
|
||||
confirm_password: string;
|
||||
welcome_message: string;
|
||||
@@ -88,6 +89,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
host_name: '',
|
||||
host_email: '',
|
||||
admin_email: '',
|
||||
require_password: true,
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
welcome_message: '',
|
||||
@@ -198,17 +200,19 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
newErrors.admin_email = t('validation.invalidEmailFormat');
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t('validation.passwordMinLength');
|
||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||
// Prevent simple numeric passwords like "123456"
|
||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
if (formData.require_password) {
|
||||
if (!formData.password) {
|
||||
newErrors.password = t('validation.passwordRequired');
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = t('validation.passwordMinLength');
|
||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||
// Prevent simple numeric passwords like "123456"
|
||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
||||
@@ -235,7 +239,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
host_name: formData.host_name,
|
||||
host_email: formData.host_email,
|
||||
admin_email: formData.admin_email,
|
||||
password: formData.password,
|
||||
require_password: formData.require_password,
|
||||
password: formData.require_password ? formData.password : undefined,
|
||||
welcome_message: formData.welcome_message || '',
|
||||
color_theme: JSON.stringify(formData.theme_config),
|
||||
expiration_days: formData.expires_in_days,
|
||||
@@ -495,51 +500,89 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
checked={formData.require_password}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
require_password: checked,
|
||||
password: checked ? prev.password : '',
|
||||
confirm_password: checked ? prev.confirm_password : '',
|
||||
}));
|
||||
if (!checked) {
|
||||
setErrors(prev => ({ ...prev, password: undefined, confirm_password: undefined }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{t('events.requirePasswordToggle')}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!formData.require_password && (
|
||||
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.require_password && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label={t('events.galleryPassword')}
|
||||
placeholder={t('events.passwordPlaceholder')}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="p-1"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Password Generator */}
|
||||
<div className="mt-2">
|
||||
<PasswordGenerator
|
||||
eventName={formData.event_name}
|
||||
eventDate={formData.event_date}
|
||||
eventType={formData.event_type}
|
||||
onPasswordGenerated={handlePasswordGenerated}
|
||||
passwordComplexity="moderate"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label={t('events.galleryPassword')}
|
||||
placeholder={t('events.passwordPlaceholder')}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange('password')}
|
||||
error={errors.password}
|
||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||
label={t('events.confirmPassword')}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
value={formData.confirm_password}
|
||||
onChange={handleInputChange('confirm_password')}
|
||||
error={errors.confirm_password}
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
rightIcon={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="p-1"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Password Generator */}
|
||||
<div className="mt-2">
|
||||
<PasswordGenerator
|
||||
eventName={formData.event_name}
|
||||
eventDate={formData.event_date}
|
||||
eventType={formData.event_type}
|
||||
onPasswordGenerated={handlePasswordGenerated}
|
||||
passwordComplexity="moderate"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
label={t('events.confirmPassword')}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
value={formData.confirm_password}
|
||||
onChange={handleInputChange('confirm_password')}
|
||||
error={errors.confirm_password}
|
||||
leftIcon={<Lock className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
Image,
|
||||
Key,
|
||||
Mail,
|
||||
MessageSquare
|
||||
MessageSquare,
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -27,6 +30,7 @@ import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { externalMediaService } from '../../services/externalMedia.service';
|
||||
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
|
||||
@@ -121,6 +125,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
host_name: string;
|
||||
source_mode: 'managed' | 'reference';
|
||||
external_path: string;
|
||||
require_password: boolean;
|
||||
new_password: string;
|
||||
confirm_new_password: string;
|
||||
};
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -134,6 +141,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
host_name: '',
|
||||
source_mode: 'managed',
|
||||
external_path: '',
|
||||
require_password: true,
|
||||
new_password: '',
|
||||
confirm_new_password: '',
|
||||
});
|
||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||
feedback_enabled: false,
|
||||
@@ -156,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [importing, setImporting] = useState<boolean>(false);
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
|
||||
@@ -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
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
|
||||
@@ -274,7 +307,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
host_name: event.host_name || '',
|
||||
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
||||
external_path: event.external_path || '',
|
||||
require_password: normalizeRequirePassword(event.require_password),
|
||||
new_password: '',
|
||||
confirm_new_password: '',
|
||||
});
|
||||
|
||||
setShowNewPassword(false);
|
||||
|
||||
// Set feedback settings if available
|
||||
if (eventFeedbackSettings) {
|
||||
@@ -324,6 +362,26 @@ export const EventDetailsPage: React.FC = () => {
|
||||
|
||||
const externalPathToSave = editForm.external_path?.trim() || '';
|
||||
|
||||
const currentRequirePassword = normalizeRequirePassword(event.require_password);
|
||||
const requirePasswordChanged = editForm.require_password !== currentRequirePassword;
|
||||
|
||||
if (editForm.require_password) {
|
||||
if (requirePasswordChanged && !editForm.new_password) {
|
||||
toast.error(t('events.newPasswordRequired', 'Please set a password before enabling protection.'));
|
||||
return;
|
||||
}
|
||||
if (editForm.new_password) {
|
||||
if (editForm.new_password.length < 6) {
|
||||
toast.error(t('validation.passwordMinLength'));
|
||||
return;
|
||||
}
|
||||
if (editForm.new_password !== editForm.confirm_new_password) {
|
||||
toast.error(t('validation.passwordsDoNotMatch'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (editForm.source_mode === 'reference' && !externalPathToSave) {
|
||||
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
|
||||
return;
|
||||
@@ -333,6 +391,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const updateData: any = {
|
||||
expires_at: editForm.expires_at,
|
||||
allow_user_uploads: editForm.allow_user_uploads,
|
||||
require_password: editForm.require_password,
|
||||
};
|
||||
|
||||
// Only include fields that have defined values
|
||||
@@ -355,6 +414,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
||||
updateData.host_name = editForm.host_name;
|
||||
}
|
||||
|
||||
if (editForm.new_password) {
|
||||
updateData.password = editForm.new_password;
|
||||
}
|
||||
|
||||
// Remove any keys with undefined values
|
||||
Object.keys(updateData).forEach(key => {
|
||||
@@ -437,6 +500,15 @@ export const EventDetailsPage: React.FC = () => {
|
||||
{format(parseISO(event.event_date), 'PPP')}
|
||||
</span>
|
||||
<span className="capitalize">{event.event_type}</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
isGalleryPublic(event.require_password)
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-neutral-100 text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
||||
</span>
|
||||
{event.is_archived ? (
|
||||
<span className="text-neutral-500 flex items-center">
|
||||
<Archive className="w-4 h-4 mr-1" />
|
||||
@@ -641,6 +713,84 @@ export const EventDetailsPage: React.FC = () => {
|
||||
isEditing={isEditing}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
checked={editForm.require_password}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setEditForm(prev => ({
|
||||
...prev,
|
||||
require_password: checked,
|
||||
new_password: checked ? prev.new_password : '',
|
||||
confirm_new_password: checked ? prev.confirm_new_password : '',
|
||||
}));
|
||||
if (!checked) {
|
||||
setShowNewPassword(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!editForm.require_password && (
|
||||
<div className="mt-2 rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editForm.require_password && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.newPasswordLabel', 'New gallery password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={editForm.new_password}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, new_password: e.target.value }))}
|
||||
placeholder={t('events.enterPassword')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.confirmPassword')}
|
||||
</label>
|
||||
<Input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
value={editForm.confirm_new_password}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, confirm_new_password: e.target.value }))}
|
||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.sourceMode', 'Source Mode')}
|
||||
@@ -848,7 +998,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600 mt-2">
|
||||
{t('events.shareWithGuests')}
|
||||
{isGalleryPublic(event.require_password)
|
||||
? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.')
|
||||
: t('events.shareWithGuests')}
|
||||
</p>
|
||||
|
||||
{!event.is_archived && (
|
||||
@@ -994,6 +1146,20 @@ export const EventDetailsPage: React.FC = () => {
|
||||
}}
|
||||
isPreviewMode={false}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../compone
|
||||
import { BulkArchiveModal } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { isGalleryPublic } from '../../utils/accessControl';
|
||||
import type { Event } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -428,6 +429,17 @@ export const EventsListPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
|
||||
<p className="text-xs text-neutral-500">{event.host_email}</p>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||
isGalleryPublic(event.require_password)
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-neutral-100 text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Save,
|
||||
Database,
|
||||
@@ -19,7 +19,9 @@ import { CategoryManager } from '../../components/admin/CategoryManager';
|
||||
import { WordFilterManager } from '../../components/admin/WordFilterManager';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { authService } from '../../services/auth.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
|
||||
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 queryClient = useQueryClient();
|
||||
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
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
@@ -78,6 +97,20 @@ export const SettingsPage: React.FC = () => {
|
||||
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
|
||||
const [generalSettings, setGeneralSettings] = useState({
|
||||
site_url: '',
|
||||
@@ -343,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 = () => {
|
||||
if (saveCapacityOverrideMutation.isPending) {
|
||||
return;
|
||||
@@ -466,6 +512,46 @@ export const SettingsPage: React.FC = () => {
|
||||
{/* General Settings Tab */}
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-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">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
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 = {
|
||||
// Admin authentication
|
||||
@@ -24,7 +35,7 @@ export const authService = {
|
||||
},
|
||||
|
||||
// Gallery authentication
|
||||
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
||||
async verifyGalleryPassword(slug: string, password?: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
||||
const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
|
||||
slug,
|
||||
password,
|
||||
@@ -32,7 +43,7 @@ export const authService = {
|
||||
});
|
||||
|
||||
// Token is now handled by GalleryAuthContext with slug-specific storage
|
||||
return response.data;
|
||||
return normalizeGalleryResponse(response.data);
|
||||
},
|
||||
|
||||
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
|
||||
@@ -40,7 +51,7 @@ export const authService = {
|
||||
slug,
|
||||
token,
|
||||
});
|
||||
return response.data;
|
||||
return normalizeGalleryResponse(response.data);
|
||||
},
|
||||
|
||||
async galleryLogout(slug?: string | null) {
|
||||
@@ -50,4 +61,9 @@ export const authService = {
|
||||
// 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 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 {
|
||||
event_type: string;
|
||||
@@ -7,7 +13,8 @@ interface CreateEventData {
|
||||
event_date: string;
|
||||
host_email: string;
|
||||
admin_email: string;
|
||||
password: string;
|
||||
require_password?: boolean;
|
||||
password?: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expiration_days: number;
|
||||
@@ -28,6 +35,7 @@ interface UpdateEventData {
|
||||
event_date?: string;
|
||||
host_email?: string;
|
||||
admin_email?: string;
|
||||
require_password?: boolean;
|
||||
password?: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
@@ -64,19 +72,25 @@ export const eventsService = {
|
||||
}
|
||||
|
||||
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
||||
return response.data;
|
||||
const data: any = response.data;
|
||||
if (Array.isArray(data?.events)) {
|
||||
data.events = data.events.map((event: Event) => normalizeEvent(event));
|
||||
} else if (Array.isArray(data)) {
|
||||
return data.map((event: Event) => normalizeEvent(event)) as any;
|
||||
}
|
||||
return data;
|
||||
},
|
||||
|
||||
// Get single event details (admin)
|
||||
async getEvent(id: number): Promise<Event> {
|
||||
const response = await api.get<Event>(`/admin/events/${id}`);
|
||||
return response.data;
|
||||
return normalizeEvent(response.data as Event);
|
||||
},
|
||||
|
||||
// Create new event (admin)
|
||||
async createEvent(data: CreateEventData): Promise<Event> {
|
||||
const response = await api.post<Event>('/admin/events', data);
|
||||
return response.data;
|
||||
return normalizeEvent(response.data as Event);
|
||||
},
|
||||
|
||||
// Update event (admin)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { api } from '../config/api';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
export const galleryService = {
|
||||
// Verify share token
|
||||
@@ -12,7 +13,11 @@ export const galleryService = {
|
||||
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
|
||||
const params = token ? { token } : {};
|
||||
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params });
|
||||
return response.data;
|
||||
const data = response.data;
|
||||
return {
|
||||
...data,
|
||||
requires_password: normalizeRequirePassword((data as any)?.requires_password, true),
|
||||
};
|
||||
},
|
||||
|
||||
// Get gallery photos (requires auth)
|
||||
@@ -29,7 +34,17 @@ export const galleryService = {
|
||||
}
|
||||
}
|
||||
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
|
||||
return response.data;
|
||||
const data = response.data;
|
||||
const normalizedEvent = data?.event
|
||||
? {
|
||||
...data.event,
|
||||
require_password: normalizeRequirePassword((data.event as any)?.require_password, true),
|
||||
}
|
||||
: data.event;
|
||||
return {
|
||||
...data,
|
||||
event: normalizedEvent,
|
||||
};
|
||||
},
|
||||
|
||||
// Download single photo
|
||||
|
||||
@@ -38,9 +38,9 @@ export const notificationsService = {
|
||||
await api.put('/admin/notifications/read-all');
|
||||
},
|
||||
|
||||
// Clear old notifications
|
||||
async clearOldNotifications(): Promise<{ deletedCount: number }> {
|
||||
const response = await api.delete('/admin/notifications/clear-old');
|
||||
// Clear all notifications
|
||||
async clearAllNotifications(): Promise<{ deletedCount: number }> {
|
||||
const response = await api.delete('/admin/notifications/clear-all');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -133,6 +133,10 @@ export const notificationsService = {
|
||||
return t('admin.notificationMessages.generalSettingsUpdated');
|
||||
case 'security_settings_updated':
|
||||
return t('admin.notificationMessages.securitySettingsUpdated');
|
||||
case 'admin_profile_updated':
|
||||
return t('admin.notificationMessages.adminProfileUpdated', {
|
||||
actorName: notification.actorName,
|
||||
});
|
||||
case 'theme_updated':
|
||||
return t('admin.notificationMessages.themeUpdated');
|
||||
case 'archive_downloaded':
|
||||
@@ -184,6 +188,8 @@ export const notificationsService = {
|
||||
case 'security_settings_updated':
|
||||
case 'theme_updated':
|
||||
return { icon: 'Settings', color: 'text-gray-600' };
|
||||
case 'admin_profile_updated':
|
||||
return { icon: 'User', color: 'text-primary-600' };
|
||||
case 'email_template_updated':
|
||||
case 'email_config_updated':
|
||||
return { icon: 'Mail', color: 'text-teal-600' };
|
||||
@@ -209,4 +215,4 @@ export const notificationsService = {
|
||||
return { icon: 'Bell', color: 'text-gray-600' };
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface Event {
|
||||
is_archived: boolean;
|
||||
archive_path?: string;
|
||||
archived_at?: string;
|
||||
require_password?: boolean;
|
||||
photo_count?: number;
|
||||
total_size?: number;
|
||||
recent_photos?: Array<{
|
||||
@@ -92,6 +93,7 @@ export interface GalleryData {
|
||||
disable_right_click?: boolean;
|
||||
watermark_downloads?: boolean;
|
||||
watermark_text?: string;
|
||||
require_password?: boolean;
|
||||
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
image_quality?: number;
|
||||
use_canvas_rendering?: boolean;
|
||||
@@ -134,6 +136,7 @@ export interface GalleryAuthResponse {
|
||||
expires_at: string;
|
||||
allow_user_uploads?: boolean;
|
||||
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);
|
||||
};
|
||||
@@ -24,4 +24,19 @@ export const cleanupOldGalleryAuth = () => {
|
||||
sessionStorage.removeItem('gallery_event');
|
||||
sessionStorage.removeItem('gallery_token');
|
||||
sessionStorage.removeItem('gallery_active_slug');
|
||||
|
||||
// Remove slug-specific session storage entries as well
|
||||
try {
|
||||
const sessionKeysToRemove: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i += 1) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key && (key.startsWith('gallery_event_') || key.startsWith('gallery_token_'))) {
|
||||
sessionKeysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
|
||||
} catch {
|
||||
// Session storage may be unavailable; ignore cleanup failures
|
||||
}
|
||||
};
|
||||
|
||||
+108
-20
@@ -2,39 +2,126 @@
|
||||
* 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
|
||||
*/
|
||||
export const getApiBaseUrl = (): string => {
|
||||
// If VITE_API_URL is explicitly set, use it
|
||||
if (import.meta.env.VITE_API_URL && import.meta.env.VITE_API_URL !== '/api') {
|
||||
return import.meta.env.VITE_API_URL;
|
||||
const envUrl = getEnvApiUrl();
|
||||
|
||||
if (envUrl && envUrl !== '/api') {
|
||||
if (ABSOLUTE_URL_REGEX.test(envUrl) && shouldFallbackToRelative(envUrl)) {
|
||||
return '/api';
|
||||
}
|
||||
return envUrl;
|
||||
}
|
||||
|
||||
// In production, use relative URL
|
||||
|
||||
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.)
|
||||
* 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
|
||||
* @returns The full URL
|
||||
*/
|
||||
export const buildResourceUrl = (path: string): string => {
|
||||
// Remove leading slash if present
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
|
||||
// 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}`;
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// In production (relative API), use current origin
|
||||
return `${window.location.origin}/${cleanPath}`;
|
||||
|
||||
// Absolute paths (http/https) should generally be respected,
|
||||
// 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
|
||||
*/
|
||||
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="vitest" />
|
||||
|
||||
+14
-3
@@ -1,8 +1,12 @@
|
||||
/// <reference types="vitest" />
|
||||
// @ts-nocheck
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import type { UserConfig as VitestUserConfig } from 'vitest/config'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
const config: VitestUserConfig = {
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
@@ -15,6 +19,11 @@ export default defineConfig({
|
||||
},
|
||||
sourcemap: true,
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: './vitest.setup.ts',
|
||||
globals: true
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
@@ -28,5 +37,7 @@ export default defineConfig({
|
||||
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;
|
||||
+28
-8
@@ -383,17 +383,27 @@ setup_docker_installation() {
|
||||
app_dir="/home/$SUDO_USER/picpeak"
|
||||
fi
|
||||
|
||||
log_step "Creating application directory at $app_dir"
|
||||
mkdir -p "$app_dir"/{storage/events/{active,archived},logs,backup,config,data,events}
|
||||
|
||||
# Clone repository
|
||||
log_step "Downloading PicPeak..."
|
||||
log_step "Preparing application directory at $app_dir"
|
||||
local app_parent_dir
|
||||
app_parent_dir=$(dirname "$app_dir")
|
||||
mkdir -p "$app_parent_dir"
|
||||
|
||||
if [[ -d "$app_dir/.git" ]]; then
|
||||
log_step "Existing PicPeak repository detected; pulling latest changes"
|
||||
cd "$app_dir"
|
||||
git pull
|
||||
git pull --rebase --autostash || git pull
|
||||
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"
|
||||
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)
|
||||
local host_uid host_gid
|
||||
@@ -630,7 +640,11 @@ setup_native_installation() {
|
||||
apt-get install -y build-essential python3
|
||||
;;
|
||||
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
|
||||
;;
|
||||
esac
|
||||
@@ -1006,7 +1020,13 @@ print_success_message() {
|
||||
fi
|
||||
else
|
||||
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
||||
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run node scripts/reset-admin-password.js manually)${NC}"
|
||||
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
|
||||
echo
|
||||
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
||||
|
||||
Reference in New Issue
Block a user