Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d42a11680f | |||
| 38dd74b893 | |||
| fc1bf53412 | |||
| 5d6c061f1c | |||
| 45e835a51a | |||
| afc00090cf | |||
| 59750dea15 | |||
| 2fe32e9a69 | |||
| 5f8c8c5508 | |||
| fb739f221d | |||
| b5399aaa9b |
+11
-1
@@ -420,7 +420,17 @@ Upon first login, the system will **automatically redirect** you to change your
|
|||||||
|
|
||||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||||
|
|
||||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference.
|
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Native reinstall example
|
||||||
|
sudo ./setup.sh --native --force-admin-password-reset
|
||||||
|
|
||||||
|
# Docker reinstall example
|
||||||
|
sudo ./setup.sh --docker --force-admin-password-reset
|
||||||
|
```
|
||||||
|
|
||||||
|
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||||
|
|
||||||
#### Configuring Admin Email
|
#### Configuring Admin Email
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const fsPromises = fs.promises;
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
describe('Admin settings logo upload flow', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let router;
|
||||||
|
let app;
|
||||||
|
let settingsStore;
|
||||||
|
|
||||||
|
const resetModules = () => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetModules();
|
||||||
|
|
||||||
|
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
|
||||||
|
process.env.STORAGE_PATH = tmpDir;
|
||||||
|
|
||||||
|
settingsStore = new Map();
|
||||||
|
|
||||||
|
const buildQuery = (table) => {
|
||||||
|
const filters = [];
|
||||||
|
const applyFilters = (rows) => {
|
||||||
|
if (filters.length === 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.filter((row) =>
|
||||||
|
filters.every(({ column, value }) => row[column] === value)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRow = (row) => ({ ...row });
|
||||||
|
|
||||||
|
return {
|
||||||
|
where(column, value) {
|
||||||
|
filters.push({ column, value });
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
first() {
|
||||||
|
if (table === 'app_settings') {
|
||||||
|
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
|
||||||
|
return Promise.resolve(rows[0]);
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
select() {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
sum() {
|
||||||
|
return Promise.resolve({ total: 0 });
|
||||||
|
},
|
||||||
|
join() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
groupBy() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
orderBy() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
limit() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
insert(payload) {
|
||||||
|
const rows = Array.isArray(payload) ? payload : [payload];
|
||||||
|
const upsert = (row, overrides = {}) => {
|
||||||
|
if (table === 'app_settings') {
|
||||||
|
const key = row.setting_key;
|
||||||
|
const existing = settingsStore.get(key) || {};
|
||||||
|
settingsStore.set(key, { ...existing, ...row, ...overrides });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onConflict() {
|
||||||
|
return {
|
||||||
|
merge(overrides) {
|
||||||
|
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbMock = jest.fn((table) => buildQuery(table));
|
||||||
|
dbMock.raw = jest.fn();
|
||||||
|
dbMock.transaction = async (handler) => handler({
|
||||||
|
commit: async () => {},
|
||||||
|
rollback: async () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.doMock('../src/database/db', () => ({
|
||||||
|
db: dbMock,
|
||||||
|
logActivity: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/middleware/auth', () => ({
|
||||||
|
adminAuth: (req, res, next) => {
|
||||||
|
req.admin = { id: 1, username: 'tester' };
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/services/publicSiteService', () => ({
|
||||||
|
clearPublicSiteCache: jest.fn(),
|
||||||
|
getDefaultPublicSitePayload: jest.fn(),
|
||||||
|
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/services/rateLimitService', () => ({
|
||||||
|
clearSettingsCache: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/middleware/maintenance', () => ({
|
||||||
|
maintenanceMiddleware: (req, res, next) => next(),
|
||||||
|
clearMaintenanceCache: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
router = require('../src/routes/adminSettings');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin/settings', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
resetModules();
|
||||||
|
if (tmpDir) {
|
||||||
|
await fsPromises.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
tmpDir = null;
|
||||||
|
}
|
||||||
|
delete process.env.STORAGE_PATH;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
|
||||||
|
const fileBuffer = Buffer.from('fake image data');
|
||||||
|
|
||||||
|
const uploadResponse = await request(app)
|
||||||
|
.post('/api/admin/settings/logo')
|
||||||
|
.attach('logo', fileBuffer, 'logo.png');
|
||||||
|
|
||||||
|
expect(uploadResponse.status).toBe(200);
|
||||||
|
expect(uploadResponse.body).toHaveProperty('logoUrl');
|
||||||
|
const logoUrl = uploadResponse.body.logoUrl;
|
||||||
|
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
|
||||||
|
|
||||||
|
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
|
||||||
|
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put('/api/admin/settings/branding')
|
||||||
|
.send({
|
||||||
|
company_name: 'Test Co',
|
||||||
|
company_tagline: 'Tagline',
|
||||||
|
support_email: 'test@example.com',
|
||||||
|
footer_text: 'Footer',
|
||||||
|
watermark_enabled: false,
|
||||||
|
watermark_position: 'bottom-right',
|
||||||
|
watermark_opacity: 0.5,
|
||||||
|
watermark_size: 'medium',
|
||||||
|
favicon_url: null,
|
||||||
|
logo_url: '',
|
||||||
|
watermark_logo_url: null,
|
||||||
|
logo_size: 'medium',
|
||||||
|
logo_max_height: 120,
|
||||||
|
logo_position: 'left',
|
||||||
|
logo_display_header: true,
|
||||||
|
logo_display_hero: false,
|
||||||
|
logo_display_mode: 'default'
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await expect(fsPromises.access(storedPath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const mockPath = path;
|
||||||
|
|
||||||
|
jest.mock('../../src/services/externalMediaService', () => ({
|
||||||
|
resolveExternalPath: jest.fn((event, relPath) => mockPath.join('/mock/external', event.external_path || '', relPath || '')),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { resolveExternalPath } = require('../../src/services/externalMediaService');
|
||||||
|
const { resolvePhotoFilePath } = require('../../src/services/photoResolver');
|
||||||
|
|
||||||
|
describe('resolvePhotoFilePath', () => {
|
||||||
|
const backendRoot = path.resolve(__dirname, '../../');
|
||||||
|
const originalStoragePath = process.env.STORAGE_PATH;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.STORAGE_PATH = path.join(backendRoot, 'storage');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (typeof originalStoragePath === 'string') {
|
||||||
|
process.env.STORAGE_PATH = originalStoragePath;
|
||||||
|
} else {
|
||||||
|
delete process.env.STORAGE_PATH;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns absolute path for managed photos with legacy slug paths', () => {
|
||||||
|
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||||
|
const photo = { path: 'wedding-party/hero.jpg' };
|
||||||
|
|
||||||
|
const result = resolvePhotoFilePath(event, photo);
|
||||||
|
|
||||||
|
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes prefixed managed paths without duplicating segments', () => {
|
||||||
|
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||||
|
const photo = { path: 'events/active/wedding-party/hero.jpg' };
|
||||||
|
|
||||||
|
const result = resolvePhotoFilePath(event, photo);
|
||||||
|
|
||||||
|
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates external photos to external media resolver', () => {
|
||||||
|
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||||
|
const photo = { source_origin: 'external', external_relpath: 'individual/look-01.jpg' };
|
||||||
|
|
||||||
|
const result = resolvePhotoFilePath(event, photo);
|
||||||
|
|
||||||
|
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'individual/look-01.jpg');
|
||||||
|
expect(result).toBe(path.join('/mock/external', 'picsum-demo', 'individual', 'look-01.jpg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates folder names when event external path already ends with segment', () => {
|
||||||
|
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo/individual' };
|
||||||
|
const photo = { source_origin: 'external', external_relpath: 'individual/look-02.jpg' };
|
||||||
|
|
||||||
|
const result = resolvePhotoFilePath(event, photo);
|
||||||
|
|
||||||
|
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'look-02.jpg');
|
||||||
|
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when external photo is missing relative path data', () => {
|
||||||
|
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||||
|
const photo = { source_origin: 'external' };
|
||||||
|
|
||||||
|
expect(() => resolvePhotoFilePath(event, photo)).toThrow('Missing external_relpath for external photo');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.3",
|
"version": "1.1.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.3",
|
"version": "1.1.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.3",
|
"version": "1.1.9",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Executable
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const fsp = fs.promises;
|
||||||
|
|
||||||
|
async function pathExists(location) {
|
||||||
|
try {
|
||||||
|
await fsp.access(location);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveFile(source, destination) {
|
||||||
|
await fsp.mkdir(path.dirname(destination), { recursive: true });
|
||||||
|
try {
|
||||||
|
await fsp.rename(source, destination);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'EXDEV') {
|
||||||
|
await fsp.copyFile(source, destination);
|
||||||
|
await fsp.unlink(source);
|
||||||
|
} else if (error.code === 'EEXIST') {
|
||||||
|
console.warn(`Destination already exists, leaving original in place: ${destination}`);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
const backendRoot = path.resolve(__dirname, '..');
|
||||||
|
const defaultStorage = path.resolve(backendRoot, '../storage');
|
||||||
|
const targetStorage = path.resolve(process.env.STORAGE_PATH || defaultStorage);
|
||||||
|
const legacyUploadsRoot = path.resolve(backendRoot, 'storage/uploads');
|
||||||
|
const targetUploadsRoot = path.join(targetStorage, 'uploads');
|
||||||
|
|
||||||
|
if (legacyUploadsRoot === targetUploadsRoot) {
|
||||||
|
console.log('Legacy uploads directory already matches target STORAGE_PATH. Nothing to migrate.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(legacyUploadsRoot)) {
|
||||||
|
console.log(`Legacy uploads directory not found at ${legacyUploadsRoot}. Nothing to migrate.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = ['logos', 'favicons'];
|
||||||
|
let migratedCounter = 0;
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const legacyDir = path.join(legacyUploadsRoot, category);
|
||||||
|
if (!fs.existsSync(legacyDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDir = path.join(targetUploadsRoot, category);
|
||||||
|
await fsp.mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const entries = await fsp.readdir(legacyDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = path.join(legacyDir, entry.name);
|
||||||
|
const destinationPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (await pathExists(destinationPath)) {
|
||||||
|
console.warn(`Skipping ${sourcePath} because ${destinationPath} already exists.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveFile(sourcePath, destinationPath);
|
||||||
|
migratedCounter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = await fsp.readdir(legacyDir);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
await fsp.rm(legacyDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migratedCounter === 0) {
|
||||||
|
console.log('No legacy logo or favicon files needed migration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migrated ${migratedCounter} files into ${targetUploadsRoot}.`);
|
||||||
|
console.log('If the database still references legacy absolute paths, they will be cleaned up automatically on the next upload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((error) => {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const readline = require('readline');
|
const readline = require('readline');
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
const args = process.argv.slice(2);
|
||||||
|
const hasFlag = (flag) => args.includes(flag);
|
||||||
|
const getOption = (name) => {
|
||||||
|
const index = args.indexOf(`--${name}`);
|
||||||
|
if (index !== -1 && index + 1 < args.length) {
|
||||||
|
return args[index + 1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
|
||||||
|
const credentialsFileArg = getOption('credentials-file');
|
||||||
|
const resolvedCredentialsFile = credentialsFileArg
|
||||||
|
? path.resolve(process.cwd(), credentialsFileArg)
|
||||||
|
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||||
|
|
||||||
|
const rl = force ? null : readline.createInterface({
|
||||||
input: process.stdin,
|
input: process.stdin,
|
||||||
output: process.stdout
|
output: process.stdout
|
||||||
});
|
});
|
||||||
|
|
||||||
async function question(prompt) {
|
async function ask(prompt) {
|
||||||
|
if (force) {
|
||||||
|
return 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
rl.question(prompt, resolve);
|
rl.question(prompt, resolve);
|
||||||
});
|
});
|
||||||
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
|
|||||||
|
|
||||||
console.log('Found admin user:', admin.username);
|
console.log('Found admin user:', admin.username);
|
||||||
console.log('Email:', admin.email);
|
console.log('Email:', admin.email);
|
||||||
console.log('\nThis will reset the password for this admin account.');
|
if (!force) {
|
||||||
|
console.log('\nThis will reset the password for this admin account.');
|
||||||
const confirm = await question('\nDo you want to continue? (yes/no): ');
|
}
|
||||||
|
|
||||||
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
|
const confirm = await ask('\nDo you want to continue? (yes/no): ');
|
||||||
console.log('\n❌ Password reset cancelled.');
|
|
||||||
process.exit(0);
|
if (!force) {
|
||||||
|
const normalized = confirm.trim().toLowerCase();
|
||||||
|
if (normalized !== 'yes' && normalized !== 'y') {
|
||||||
|
console.log('\n❌ Password reset cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new password
|
// Generate new password
|
||||||
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Save to file
|
// Save to file
|
||||||
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
const credentialsDir = path.dirname(resolvedCredentialsFile);
|
||||||
|
await fs.mkdir(credentialsDir, { recursive: true });
|
||||||
|
|
||||||
|
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
|
||||||
const resetInfo = `
|
const resetInfo = `
|
||||||
========================================
|
========================================
|
||||||
PicPeak Admin Password Reset
|
PicPeak Admin Credentials
|
||||||
========================================
|
========================================
|
||||||
|
|
||||||
Password has been reset for admin account:
|
Your admin account has been reset with these credentials:
|
||||||
|
|
||||||
Username: admin
|
Username: ${admin.username}
|
||||||
New Password: ${newPassword}
|
Email: ${admin.email}
|
||||||
|
Password: ${newPassword}
|
||||||
|
|
||||||
IMPORTANT:
|
IMPORTANT SECURITY NOTES:
|
||||||
1. You MUST change this password on next login
|
1. You MUST change this password after first login
|
||||||
2. This file contains sensitive information
|
2. This file contains sensitive information
|
||||||
3. Delete this file after noting the password
|
3. Delete this file after noting the password
|
||||||
|
|
||||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
Login URL: ${adminUrl}
|
||||||
|
|
||||||
Reset performed on: ${new Date().toISOString()}
|
Reset performed on: ${new Date().toISOString()}
|
||||||
========================================
|
========================================
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await fs.writeFile(resetInfoPath, resetInfo, 'utf8');
|
await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
|
||||||
|
|
||||||
console.log('\n✅ Password reset successful!\n');
|
console.log('\n✅ Password reset successful!\n');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('New Credentials:');
|
console.log('New Credentials:');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('Username: admin');
|
console.log(`Username: ${admin.username}`);
|
||||||
|
console.log(`Email: ${admin.email}`);
|
||||||
console.log(`Password: ${newPassword}`);
|
console.log(`Password: ${newPassword}`);
|
||||||
console.log('\n⚠️ IMPORTANT:');
|
console.log('\n⚠️ IMPORTANT:');
|
||||||
console.log('1. You will be required to change this password on next login');
|
console.log('1. You will be required to change this password on next login');
|
||||||
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt');
|
console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
|
||||||
console.log('3. Delete the file after noting the password');
|
console.log('3. Delete the file after noting the password');
|
||||||
console.log('========================================\n');
|
console.log('========================================\n');
|
||||||
|
|
||||||
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
|
|||||||
console.error('❌ Error resetting password:', error.message);
|
console.error('❌ Error resetting password:', error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
rl.close();
|
if (rl) {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the reset
|
// Run the reset
|
||||||
resetAdminPassword();
|
resetAdminPassword();
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ async function initializeDatabase() {
|
|||||||
table.boolean('watermark_downloads').defaultTo(false);
|
table.boolean('watermark_downloads').defaultTo(false);
|
||||||
table.text('watermark_text');
|
table.text('watermark_text');
|
||||||
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||||
|
table.boolean('require_password').defaultTo(true);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Check if color_theme needs to be updated to TEXT type
|
// Check if color_theme needs to be updated to TEXT type
|
||||||
@@ -116,7 +117,8 @@ async function initializeDatabase() {
|
|||||||
disable_right_click BOOLEAN DEFAULT 0,
|
disable_right_click BOOLEAN DEFAULT 0,
|
||||||
watermark_downloads BOOLEAN DEFAULT 0,
|
watermark_downloads BOOLEAN DEFAULT 0,
|
||||||
watermark_text TEXT,
|
watermark_text TEXT,
|
||||||
hero_photo_id INTEGER
|
hero_photo_id INTEGER,
|
||||||
|
require_password BOOLEAN DEFAULT 1
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -138,6 +140,8 @@ async function initializeDatabase() {
|
|||||||
return 'watermark_text';
|
return 'watermark_text';
|
||||||
case 'hero_photo_id':
|
case 'hero_photo_id':
|
||||||
return 'hero_photo_id';
|
return 'hero_photo_id';
|
||||||
|
case 'require_password':
|
||||||
|
return 'COALESCE(require_password, 1) as require_password';
|
||||||
default:
|
default:
|
||||||
return col;
|
return col;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,48 @@ const jwt = require('jsonwebtoken');
|
|||||||
const { db, withRetry } = require('../database/db');
|
const { db, withRetry } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
// Middleware to verify gallery access
|
// Middleware to verify gallery access
|
||||||
async function verifyGalleryAccess(req, res, next) {
|
async function verifyGalleryAccess(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||||
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
const token = getGalleryTokenFromRequest(req, requestedSlug);
|
||||||
|
let event;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
if (!requestedSlug) {
|
||||||
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
event = await withRetry(async () => {
|
||||||
|
return await db('events')
|
||||||
|
.where({
|
||||||
|
slug: requestedSlug,
|
||||||
|
is_active: formatBoolean(true),
|
||||||
|
is_archived: formatBoolean(false)
|
||||||
|
})
|
||||||
|
.select('*')
|
||||||
|
.first();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
if (!requiresPassword) {
|
||||||
|
req.event = event;
|
||||||
|
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
|
||||||
|
req.clientInfo = {
|
||||||
|
ip: req.ip || req.connection.remoteAddress || 'unknown',
|
||||||
|
userAgent: req.get('User-Agent') || 'unknown',
|
||||||
|
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,10 +61,9 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId);
|
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
|
||||||
|
|
||||||
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
// If we have a slug in the URL params or from pre-middleware, verify it matches
|
||||||
let event;
|
|
||||||
if (requestedSlug) {
|
if (requestedSlug) {
|
||||||
// Verify by slug and ensure it matches the token's event
|
// Verify by slug and ensure it matches the token's event
|
||||||
event = await withRetry(async () => {
|
event = await withRetry(async () => {
|
||||||
@@ -62,11 +96,11 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId);
|
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
|
||||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[verifyGalleryAccess] Event found:', event.id, event.slug);
|
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
|
||||||
req.event = event;
|
req.event = event;
|
||||||
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
|
||||||
|
|
||||||
@@ -78,10 +112,10 @@ async function verifyGalleryAccess(req, res, next) {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[verifyGalleryAccess] Access granted for event:', event.id);
|
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying gallery access:', error);
|
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
|
||||||
res.status(401).json({ error: 'Invalid token' });
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ const jwt = require('jsonwebtoken');
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
async function photoAuth(req, res, next) {
|
async function photoAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
// Extract event slug from the path
|
// Extract event slug from the path
|
||||||
let eventSlug;
|
let eventSlug;
|
||||||
|
|
||||||
console.log('PhotoAuth middleware - path:', req.path);
|
|
||||||
|
|
||||||
// For thumbnails, we need to parse the filename to get the event info
|
// For thumbnails, we need to parse the filename to get the event info
|
||||||
if (req.path.startsWith('/thumb_')) {
|
if (req.path.startsWith('/thumb_')) {
|
||||||
// For now, we'll rely on JWT token for thumbnail access
|
// For now, we'll rely on JWT token for thumbnail access
|
||||||
@@ -80,29 +79,36 @@ async function photoAuth(req, res, next) {
|
|||||||
// For both thumbnails and photos with admin token, allow access
|
// For both thumbnails and photos with admin token, allow access
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token invalid, fall through to password check
|
// Token invalid, fall through to password check
|
||||||
console.error('JWT verification failed:', err.message);
|
logger.warn('JWT verification failed in photoAuth', { error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for password header (legacy support)
|
// Check for password header (legacy support)
|
||||||
const password = req.headers['x-gallery-password'];
|
const password = req.headers['x-gallery-password'];
|
||||||
|
|
||||||
if (!password && !tokenFromRequest) {
|
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
|
||||||
if (!eventSlug && !password) {
|
if (!eventSlug && !password && !tokenFromRequest) {
|
||||||
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
return res.status(401).json({ error: 'Authentication required for thumbnails' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return res.status(404).json({ error: 'Gallery not found' });
|
return res.status(404).json({ error: 'Gallery not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
|
||||||
|
if (!requiresPassword) {
|
||||||
|
req.event = event;
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password && !tokenFromRequest) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
@@ -122,7 +128,7 @@ async function photoAuth(req, res, next) {
|
|||||||
req.event = event;
|
req.event = event;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Photo auth error:', error);
|
logger.error('Photo auth error', { error: error.message, stack: error.stack });
|
||||||
res.status(500).json({ error: 'Authentication error' });
|
res.status(500).json({ error: 'Authentication error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,29 @@ const { queueEmail } = require('../services/emailProcessor');
|
|||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
// formatDate import removed - dates are formatted by email processor
|
// formatDate import removed - dates are formatted by email processor
|
||||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const parseBooleanInput = (value, defaultValue = true) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
@@ -21,7 +44,30 @@ router.post('/', adminAuth, [
|
|||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('host_email').isEmail().normalizeEmail(),
|
body('host_email').isEmail().normalizeEmail(),
|
||||||
body('admin_email').isEmail().normalizeEmail(),
|
body('admin_email').isEmail().normalizeEmail(),
|
||||||
body('password').isLength({ min: 6 }),
|
body('require_password').optional().isBoolean(),
|
||||||
|
body('password').optional().isString().custom((value, { req }) => {
|
||||||
|
const input = req.body.require_password;
|
||||||
|
const normalizeBoolean = (val, defaultValue = true) => {
|
||||||
|
if (val === undefined || val === null) return defaultValue;
|
||||||
|
if (typeof val === 'boolean') return val;
|
||||||
|
if (typeof val === 'number') return val !== 0;
|
||||||
|
if (typeof val === 'string') {
|
||||||
|
const normalized = val.trim().toLowerCase();
|
||||||
|
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
||||||
|
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requirePassword = normalizeBoolean(input, true);
|
||||||
|
if (!requirePassword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||||
|
throw new Error('Password must be at least 6 characters long');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
|
||||||
body('welcome_message').optional().trim(),
|
body('welcome_message').optional().trim(),
|
||||||
body('color_theme').optional().trim(),
|
body('color_theme').optional().trim(),
|
||||||
@@ -34,7 +80,7 @@ router.post('/', adminAuth, [
|
|||||||
body('watermark_text').optional().trim()
|
body('watermark_text').optional().trim()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
console.log('Create event request body:', req.body);
|
logger.debug('Create event request body', { body: req.body });
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
console.error('Validation errors:', errors.array());
|
console.error('Validation errors:', errors.array());
|
||||||
@@ -58,6 +104,7 @@ router.post('/', adminAuth, [
|
|||||||
disable_right_click = false,
|
disable_right_click = false,
|
||||||
watermark_downloads = false,
|
watermark_downloads = false,
|
||||||
watermark_text = null,
|
watermark_text = null,
|
||||||
|
require_password: requirePasswordInput = true,
|
||||||
// Feedback settings
|
// Feedback settings
|
||||||
feedback_enabled = false,
|
feedback_enabled = false,
|
||||||
allow_ratings = true,
|
allow_ratings = true,
|
||||||
@@ -69,12 +116,15 @@ router.post('/', adminAuth, [
|
|||||||
show_feedback_to_guests = true
|
show_feedback_to_guests = true
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('Download control values:', {
|
logger.debug('Download control values', {
|
||||||
allow_downloads,
|
allow_downloads,
|
||||||
disable_right_click,
|
disable_right_click,
|
||||||
watermark_downloads,
|
watermark_downloads,
|
||||||
watermark_text,
|
watermark_text,
|
||||||
|
require_password: requirePassword,
|
||||||
types: {
|
types: {
|
||||||
allow_downloads: typeof allow_downloads,
|
allow_downloads: typeof allow_downloads,
|
||||||
disable_right_click: typeof disable_right_click,
|
disable_right_click: typeof disable_right_click,
|
||||||
@@ -82,18 +132,24 @@ router.post('/', adminAuth, [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate password strength
|
let passwordValidation = null;
|
||||||
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
let galleryPassword = password;
|
||||||
eventName: event_name
|
|
||||||
});
|
if (requirePassword) {
|
||||||
|
passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||||
if (!passwordValidation.valid) {
|
eventName: event_name
|
||||||
return res.status(400).json({
|
|
||||||
error: 'Password does not meet security requirements',
|
|
||||||
details: passwordValidation.errors,
|
|
||||||
score: passwordValidation.score,
|
|
||||||
feedback: passwordValidation.feedback
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Password does not meet security requirements',
|
||||||
|
details: passwordValidation.errors,
|
||||||
|
score: passwordValidation.score,
|
||||||
|
feedback: passwordValidation.feedback
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
galleryPassword = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique slug
|
// Generate unique slug
|
||||||
@@ -113,10 +169,14 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
// Generate share link
|
// Generate share link
|
||||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||||
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
|
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||||
|
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||||
|
const shareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||||
|
|
||||||
// Hash password with configurable rounds
|
// Hash password with configurable rounds (random placeholder when not required)
|
||||||
const password_hash = await bcrypt.hash(password, getBcryptRounds());
|
const password_hash = requirePassword
|
||||||
|
? await bcrypt.hash(password, getBcryptRounds())
|
||||||
|
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||||
|
|
||||||
// Calculate expiration date (days after event date)
|
// Calculate expiration date (days after event date)
|
||||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||||
@@ -155,7 +215,8 @@ router.post('/', adminAuth, [
|
|||||||
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
|
||||||
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||||
watermark_text
|
watermark_text,
|
||||||
|
require_password: formatBoolean(requirePassword)
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||||
@@ -180,7 +241,7 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await logActivity('event_created',
|
await logActivity('event_created',
|
||||||
{ event_type, expires_at },
|
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
|
||||||
eventId,
|
eventId,
|
||||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
);
|
);
|
||||||
@@ -197,7 +258,7 @@ router.post('/', adminAuth, [
|
|||||||
event_name,
|
event_name,
|
||||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: shareLink,
|
gallery_link: shareLink,
|
||||||
gallery_password: password,
|
gallery_password: requirePassword ? password : 'No password required',
|
||||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
}),
|
}),
|
||||||
@@ -211,6 +272,7 @@ router.post('/', adminAuth, [
|
|||||||
slug,
|
slug,
|
||||||
event_name,
|
event_name,
|
||||||
event_type,
|
event_type,
|
||||||
|
require_password: requirePassword,
|
||||||
share_link: shareLink,
|
share_link: shareLink,
|
||||||
expires_at: expires_at.toISOString(),
|
expires_at: expires_at.toISOString(),
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString()
|
||||||
@@ -398,19 +460,45 @@ router.put('/:id', adminAuth, [
|
|||||||
body('watermark_downloads').optional().isBoolean(),
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
body('watermark_text').optional().trim(),
|
body('watermark_text').optional().trim(),
|
||||||
body('source_mode').optional().isIn(['managed', 'reference']),
|
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||||
body('external_path').optional({ nullable: true }).isString().trim()
|
body('external_path').optional({ nullable: true }).isString().trim(),
|
||||||
|
body('require_password').optional().isBoolean(),
|
||||||
|
body('password').optional().isString().custom((value, { req }) => {
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||||
|
throw new Error('Password must be at least 6 characters long');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2));
|
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
|
||||||
console.log('Request body:', req.body);
|
|
||||||
return res.status(400).json({ errors: errors.array() });
|
return res.status(400).json({ errors: errors.array() });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updates = { ...req.body };
|
const updates = { ...req.body };
|
||||||
|
|
||||||
|
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||||
|
let requirePasswordUpdate;
|
||||||
|
if (hasRequirePasswordUpdate) {
|
||||||
|
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||||
|
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPasswordPlain;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||||
|
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||||
|
delete updates.password;
|
||||||
|
} else {
|
||||||
|
newPasswordPlain = updates.password;
|
||||||
|
delete updates.password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
|
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
|
||||||
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
|
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
|
||||||
}
|
}
|
||||||
@@ -429,7 +517,7 @@ router.put('/:id', adminAuth, [
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log the update request for debugging
|
// Log the update request for debugging
|
||||||
console.log('Update event request:', {
|
logger.debug('Update event request', {
|
||||||
id,
|
id,
|
||||||
updates,
|
updates,
|
||||||
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
|
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
|
||||||
@@ -444,6 +532,18 @@ router.put('/:id', adminAuth, [
|
|||||||
return res.status(404).json({ error: 'Event not found' });
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||||
|
|
||||||
|
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||||
|
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPasswordPlain) {
|
||||||
|
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||||
|
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||||
|
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||||
|
}
|
||||||
|
|
||||||
// Update event
|
// Update event
|
||||||
await db('events')
|
await db('events')
|
||||||
.where('id', id)
|
.where('id', id)
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ const {
|
|||||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
// Configure multer for logo uploads
|
// Configure multer for logo uploads
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: async (req, file, cb) => {
|
destination: async (req, file, cb) => {
|
||||||
const uploadDir = path.join(__dirname, '../../storage/uploads/logos');
|
const uploadDir = path.join(getStoragePath(), 'uploads/logos');
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
cb(null, uploadDir);
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
@@ -53,7 +55,7 @@ const upload = multer({
|
|||||||
// Configure multer for favicon uploads
|
// Configure multer for favicon uploads
|
||||||
const faviconStorage = multer.diskStorage({
|
const faviconStorage = multer.diskStorage({
|
||||||
destination: async (req, file, cb) => {
|
destination: async (req, file, cb) => {
|
||||||
const uploadDir = path.join(__dirname, '../../storage/uploads/favicons');
|
const uploadDir = path.join(getStoragePath(), 'uploads/favicons');
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
cb(null, uploadDir);
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
@@ -228,7 +230,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
||||||
// Delete the file from filesystem
|
// Delete the file from filesystem
|
||||||
const faviconPath = path.join(__dirname, '..', '..', 'storage', currentFaviconUrl.replace('/uploads/', ''));
|
const relativePath = currentFaviconUrl.replace(/^\//, '');
|
||||||
|
const faviconPath = path.join(getStoragePath(), relativePath);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(faviconPath);
|
await fs.unlink(faviconPath);
|
||||||
console.log('Deleted favicon file:', faviconPath);
|
console.log('Deleted favicon file:', faviconPath);
|
||||||
@@ -258,7 +261,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
||||||
// Delete the file from filesystem
|
// Delete the file from filesystem
|
||||||
const logoPath = path.join(__dirname, '..', '..', 'storage', currentLogoUrl.replace('/uploads/', ''));
|
const relativePath = currentLogoUrl.replace(/^\//, '');
|
||||||
|
const logoPath = path.join(getStoragePath(), relativePath);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(logoPath);
|
await fs.unlink(logoPath);
|
||||||
console.log('Deleted logo file:', logoPath);
|
console.log('Deleted logo file:', logoPath);
|
||||||
@@ -643,7 +647,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
|||||||
for (const archive of archives) {
|
for (const archive of archives) {
|
||||||
if (archive.archive_path) {
|
if (archive.archive_path) {
|
||||||
try {
|
try {
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const storagePath = getStoragePath();
|
||||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||||
const stats = await fs.stat(fullArchivePath);
|
const stats = await fs.stat(fullArchivePath);
|
||||||
archiveStorage += stats.size;
|
archiveStorage += stats.size;
|
||||||
@@ -654,7 +658,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const storagePath = getStoragePath();
|
||||||
|
|
||||||
let diskStats = null;
|
let diskStats = null;
|
||||||
let rawDiskTotal = null;
|
let rawDiskTotal = null;
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ router.post('/logout', async (req, res) => {
|
|||||||
// Gallery password verification with enhanced security
|
// Gallery password verification with enhanced security
|
||||||
router.post('/gallery/verify', [
|
router.post('/gallery/verify', [
|
||||||
body('slug').notEmpty().trim(),
|
body('slug').notEmpty().trim(),
|
||||||
body('password').notEmpty()
|
body('password').optional().isString()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -232,53 +232,69 @@ router.post('/gallery/verify', [
|
|||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
|
||||||
// Check gallery-specific lockout
|
|
||||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
|
||||||
if (lockoutStatus.isLocked) {
|
|
||||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
|
||||||
return res.status(423).json({
|
|
||||||
error: 'Too many failed attempts. Please try again later.',
|
|
||||||
retryAfter: lockoutStatus.remainingTime
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify reCAPTCHA
|
|
||||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
|
||||||
if (!recaptchaValid) {
|
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
|
||||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
||||||
|
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
|
||||||
|
|
||||||
|
if (requiresPassword) {
|
||||||
|
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||||
|
if (lockoutStatus.isLocked) {
|
||||||
|
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||||
|
return res.status(423).json({
|
||||||
|
error: 'Too many failed attempts. Please try again later.',
|
||||||
|
retryAfter: lockoutStatus.remainingTime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||||
|
if (!recaptchaValid) {
|
||||||
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
// Don't reveal if gallery exists
|
// Don't reveal if gallery exists
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
if (requiresPassword) {
|
||||||
if (!validPassword) {
|
if (!password) {
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||||
|
if (!validPassword) {
|
||||||
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
await db('access_logs').insert({
|
||||||
|
event_id: event.id,
|
||||||
|
ip_address: ipAddress,
|
||||||
|
user_agent: userAgent,
|
||||||
|
action: 'login_fail'
|
||||||
|
});
|
||||||
|
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
|
||||||
await db('access_logs').insert({
|
await db('access_logs').insert({
|
||||||
event_id: event.id,
|
event_id: event.id,
|
||||||
ip_address: ipAddress,
|
ip_address: ipAddress,
|
||||||
user_agent: userAgent,
|
user_agent: userAgent,
|
||||||
action: 'login_fail'
|
action: 'login_success'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info('Public gallery access granted without password', { slug, ipAddress });
|
||||||
|
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
await db('access_logs').insert({
|
||||||
|
event_id: event.id,
|
||||||
|
ip_address: ipAddress,
|
||||||
|
user_agent: userAgent,
|
||||||
|
action: 'login_success'
|
||||||
});
|
});
|
||||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful access
|
|
||||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
|
||||||
|
|
||||||
// Log successful access
|
|
||||||
await db('access_logs').insert({
|
|
||||||
event_id: event.id,
|
|
||||||
ip_address: ipAddress,
|
|
||||||
user_agent: userAgent,
|
|
||||||
action: 'login_success'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate session token with additional security info
|
// Generate session token with additional security info
|
||||||
const token = jwt.sign({
|
const token = jwt.sign({
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
@@ -302,7 +318,8 @@ router.post('/gallery/verify', [
|
|||||||
color_theme: event.color_theme,
|
color_theme: event.color_theme,
|
||||||
expires_at: event.expires_at,
|
expires_at: event.expires_at,
|
||||||
allow_user_uploads: event.allow_user_uploads,
|
allow_user_uploads: event.allow_user_uploads,
|
||||||
upload_category_id: event.upload_category_id
|
upload_category_id: event.upload_category_id,
|
||||||
|
require_password: requiresPassword
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -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
|
// Gallery password verification with enhanced security
|
||||||
router.post('/gallery/verify', [
|
router.post('/gallery/verify', [
|
||||||
body('slug').notEmpty().trim(),
|
body('slug').notEmpty().trim(),
|
||||||
body('password').notEmpty()
|
body('password').optional().isString()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@@ -173,55 +173,68 @@ router.post('/gallery/verify', [
|
|||||||
const { slug, password, recaptchaToken } = req.body;
|
const { slug, password, recaptchaToken } = req.body;
|
||||||
const ipAddress = req.ip || req.connection.remoteAddress;
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
const userAgent = req.headers['user-agent'] || '';
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
const event = await db('events')
|
||||||
// Check gallery-specific lockout
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
.first();
|
||||||
if (lockoutStatus.isLocked) {
|
|
||||||
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
|
||||||
return res.status(423).json({
|
|
||||||
error: 'Too many failed attempts. Please try again later.',
|
|
||||||
retryAfter: lockoutStatus.remainingTime
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify reCAPTCHA
|
|
||||||
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
|
||||||
if (!recaptchaValid) {
|
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
|
||||||
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
// Don't reveal if gallery exists
|
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const validPassword = await bcrypt.compare(password, event.password_hash);
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
if (!validPassword) {
|
|
||||||
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
if (requiresPassword) {
|
||||||
|
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
|
||||||
|
if (lockoutStatus.isLocked) {
|
||||||
|
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
|
||||||
|
return res.status(423).json({
|
||||||
|
error: 'Too many failed attempts. Please try again later.',
|
||||||
|
retryAfter: lockoutStatus.remainingTime
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
|
||||||
|
if (!recaptchaValid) {
|
||||||
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await bcrypt.compare(password, event.password_hash);
|
||||||
|
if (!validPassword) {
|
||||||
|
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
await db('access_logs').insert({
|
||||||
|
event_id: event.id,
|
||||||
|
ip_address: ipAddress,
|
||||||
|
user_agent: userAgent,
|
||||||
|
action: 'login_fail'
|
||||||
|
});
|
||||||
|
return res.status(401).json({ error: 'Invalid gallery or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
await db('access_logs').insert({
|
await db('access_logs').insert({
|
||||||
event_id: event.id,
|
event_id: event.id,
|
||||||
ip_address: ipAddress,
|
ip_address: ipAddress,
|
||||||
user_agent: userAgent,
|
user_agent: userAgent,
|
||||||
action: 'login_fail'
|
action: 'login_success'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.info('Public gallery access granted without password', { slug, ipAddress });
|
||||||
|
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
||||||
|
await db('access_logs').insert({
|
||||||
|
event_id: event.id,
|
||||||
|
ip_address: ipAddress,
|
||||||
|
user_agent: userAgent,
|
||||||
|
action: 'login_success'
|
||||||
});
|
});
|
||||||
return res.status(401).json({ error: 'Invalid gallery or password' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successful access
|
|
||||||
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
|
|
||||||
|
|
||||||
// Log successful access
|
|
||||||
await db('access_logs').insert({
|
|
||||||
event_id: event.id,
|
|
||||||
ip_address: ipAddress,
|
|
||||||
user_agent: userAgent,
|
|
||||||
action: 'login_success'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate session token with additional security info
|
|
||||||
const token = jwt.sign({
|
const token = jwt.sign({
|
||||||
eventId: event.id,
|
eventId: event.id,
|
||||||
eventSlug: event.slug,
|
eventSlug: event.slug,
|
||||||
@@ -246,7 +259,8 @@ router.post('/gallery/verify', [
|
|||||||
color_theme: event.color_theme,
|
color_theme: event.color_theme,
|
||||||
expires_at: event.expires_at,
|
expires_at: event.expires_at,
|
||||||
allow_user_uploads: event.allow_user_uploads,
|
allow_user_uploads: event.allow_user_uploads,
|
||||||
upload_category_id: event.upload_category_id
|
upload_category_id: event.upload_category_id,
|
||||||
|
require_password: requiresPassword
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -301,6 +315,8 @@ router.post('/gallery/share-login', [
|
|||||||
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
||||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||||
|
|
||||||
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token: jwtToken,
|
token: jwtToken,
|
||||||
event: {
|
event: {
|
||||||
@@ -312,7 +328,8 @@ router.post('/gallery/share-login', [
|
|||||||
color_theme: event.color_theme,
|
color_theme: event.color_theme,
|
||||||
expires_at: event.expires_at,
|
expires_at: event.expires_at,
|
||||||
allow_user_uploads: event.allow_user_uploads,
|
allow_user_uploads: event.allow_user_uploads,
|
||||||
upload_category_id: event.upload_category_id
|
upload_category_id: event.upload_category_id,
|
||||||
|
require_password: requiresPassword
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+104
-17
@@ -4,11 +4,34 @@ const bcrypt = require('bcrypt');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||||
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
const { adminAuth } = require('../middleware/auth-enhanced-v2');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const parseBooleanInput = (value, defaultValue = true) => {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return value !== 0;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (['false', '0', 'no', 'off'].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (['true', '1', 'yes', 'on'].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
// Create new event
|
// Create new event
|
||||||
router.post('/', adminAuth, [
|
router.post('/', adminAuth, [
|
||||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||||
@@ -16,7 +39,17 @@ router.post('/', adminAuth, [
|
|||||||
body('event_date').isDate(),
|
body('event_date').isDate(),
|
||||||
body('host_email').isEmail(),
|
body('host_email').isEmail(),
|
||||||
body('admin_email').isEmail(),
|
body('admin_email').isEmail(),
|
||||||
body('password').isLength({ min: 6 }),
|
body('require_password').optional().isBoolean(),
|
||||||
|
body('password').optional().isString().custom((value, { req }) => {
|
||||||
|
const requirePassword = parseBooleanInput(req.body.require_password, true);
|
||||||
|
if (!requirePassword) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string' || value.trim().length < 6) {
|
||||||
|
throw new Error('Password must be at least 6 characters long');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
|
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
|
||||||
], async (req, res) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -32,10 +65,28 @@ router.post('/', adminAuth, [
|
|||||||
host_email,
|
host_email,
|
||||||
admin_email,
|
admin_email,
|
||||||
password,
|
password,
|
||||||
|
require_password: requirePasswordInput = true,
|
||||||
welcome_message,
|
welcome_message,
|
||||||
color_theme,
|
color_theme,
|
||||||
expiration_days = 30
|
expiration_days = 30
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
|
const requirePassword = parseBooleanInput(requirePasswordInput, true);
|
||||||
|
|
||||||
|
if (requirePassword) {
|
||||||
|
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
|
||||||
|
eventName: event_name
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!passwordValidation.valid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Password does not meet security requirements',
|
||||||
|
details: passwordValidation.errors,
|
||||||
|
score: passwordValidation.score,
|
||||||
|
feedback: passwordValidation.feedback
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate unique slug
|
// Generate unique slug
|
||||||
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
|
||||||
@@ -49,10 +100,15 @@ router.post('/', adminAuth, [
|
|||||||
|
|
||||||
// Generate share link (just slug/token, not full URL)
|
// Generate share link (just slug/token, not full URL)
|
||||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||||
const shareLink = `${slug}/${shareToken}`;
|
const sharePath = `/gallery/${slug}/${shareToken}`;
|
||||||
|
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
|
||||||
|
const fullShareLink = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
|
||||||
|
const shareLinkSlug = `${slug}/${shareToken}`;
|
||||||
|
|
||||||
// Hash password
|
// Hash password (or placeholder when not required)
|
||||||
const password_hash = await bcrypt.hash(password, 10);
|
const password_hash = requirePassword
|
||||||
|
? await bcrypt.hash(password, getBcryptRounds())
|
||||||
|
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||||
|
|
||||||
// Calculate expiration date (days after event date)
|
// Calculate expiration date (days after event date)
|
||||||
const expires_at = new Date(event_date);
|
const expires_at = new Date(event_date);
|
||||||
@@ -75,8 +131,9 @@ router.post('/', adminAuth, [
|
|||||||
password_hash,
|
password_hash,
|
||||||
welcome_message,
|
welcome_message,
|
||||||
color_theme,
|
color_theme,
|
||||||
share_link: shareLink,
|
share_link: shareLinkSlug,
|
||||||
expires_at
|
expires_at,
|
||||||
|
require_password: formatBoolean(requirePassword)
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||||
@@ -88,17 +145,18 @@ router.post('/', adminAuth, [
|
|||||||
host_name: host_email.split('@')[0], // Extract name from email
|
host_name: host_email.split('@')[0], // Extract name from email
|
||||||
event_name,
|
event_name,
|
||||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||||
gallery_link: shareLink,
|
gallery_link: fullShareLink,
|
||||||
gallery_password: password,
|
gallery_password: requirePassword ? password : 'No password required',
|
||||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||||
welcome_message: welcome_message || ''
|
welcome_message: welcome_message || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
id: eventId,
|
id: eventId,
|
||||||
slug,
|
slug,
|
||||||
share_link: shareLink,
|
share_link: fullShareLink,
|
||||||
expires_at
|
expires_at,
|
||||||
|
require_password: requirePassword
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -137,17 +195,46 @@ router.get('/', adminAuth, async (req, res) => {
|
|||||||
router.put('/:id', adminAuth, async (req, res) => {
|
router.put('/:id', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const updates = req.body;
|
const updates = { ...req.body };
|
||||||
|
|
||||||
// Don't allow updating certain fields
|
// Don't allow updating certain fields
|
||||||
delete updates.id;
|
delete updates.id;
|
||||||
delete updates.slug;
|
delete updates.slug;
|
||||||
delete updates.created_at;
|
delete updates.created_at;
|
||||||
|
delete updates.password_confirmation;
|
||||||
// If updating password, hash it
|
|
||||||
if (updates.password) {
|
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
|
||||||
updates.password_hash = await bcrypt.hash(updates.password, 10);
|
let requirePasswordUpdate;
|
||||||
delete updates.password;
|
if (hasRequirePasswordUpdate) {
|
||||||
|
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
|
||||||
|
updates.require_password = formatBoolean(requirePasswordUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPasswordPlain;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
|
||||||
|
if (updates.password === undefined || updates.password === null || updates.password === '') {
|
||||||
|
delete updates.password;
|
||||||
|
} else {
|
||||||
|
newPasswordPlain = updates.password;
|
||||||
|
delete updates.password;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await db('events').where('id', id).first();
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRequirePassword = parseBooleanInput(event.require_password, true);
|
||||||
|
|
||||||
|
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
|
||||||
|
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPasswordPlain) {
|
||||||
|
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
|
||||||
|
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
|
||||||
|
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||||
}
|
}
|
||||||
|
|
||||||
await db('events').where('id', id).update(updates);
|
await db('events').where('id', id).update(updates);
|
||||||
|
|||||||
+110
-47
@@ -1,5 +1,4 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const jwt = require('jsonwebtoken');
|
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
@@ -8,8 +7,8 @@ const router = express.Router();
|
|||||||
const watermarkService = require('../services/watermarkService');
|
const watermarkService = require('../services/watermarkService');
|
||||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||||
const secureImageService = require('../services/secureImageService');
|
const secureImageService = require('../services/secureImageService');
|
||||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||||
|
|
||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||||
@@ -20,7 +19,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
|
|||||||
const { slug, token } = req.params;
|
const { slug, token } = req.params;
|
||||||
|
|
||||||
const event = await db('events')
|
const event = await db('events')
|
||||||
.where({ share_link: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
.select('id', 'share_link')
|
.select('id', 'share_link')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -48,9 +47,22 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
const { token } = req.query;
|
const { token } = req.query;
|
||||||
|
|
||||||
const event = await db('events')
|
const event = await db('events')
|
||||||
.where({ slug: slug })
|
.where({ slug })
|
||||||
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link',
|
.select(
|
||||||
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text')
|
'event_name',
|
||||||
|
'event_type',
|
||||||
|
'event_date',
|
||||||
|
'expires_at',
|
||||||
|
'is_active',
|
||||||
|
'is_archived',
|
||||||
|
'share_link',
|
||||||
|
'allow_downloads',
|
||||||
|
'disable_right_click',
|
||||||
|
'watermark_downloads',
|
||||||
|
'watermark_text',
|
||||||
|
'require_password',
|
||||||
|
'color_theme'
|
||||||
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -74,6 +86,8 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
event_name: event.event_name,
|
event_name: event.event_name,
|
||||||
event_type: event.event_type,
|
event_type: event.event_type,
|
||||||
@@ -81,11 +95,11 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
expires_at: event.expires_at,
|
expires_at: event.expires_at,
|
||||||
is_active: event.is_active,
|
is_active: event.is_active,
|
||||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||||
requires_password: true,
|
requires_password: requiresPassword,
|
||||||
color_theme: event.color_theme,
|
color_theme: event.color_theme,
|
||||||
allow_downloads: event.allow_downloads !== false,
|
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||||
disable_right_click: event.disable_right_click === true,
|
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||||
watermark_downloads: event.watermark_downloads === true,
|
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||||
watermark_text: event.watermark_text
|
watermark_text: event.watermark_text
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -99,7 +113,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
// Get filter parameters from query
|
// Get filter parameters from query
|
||||||
const { filter, guest_id } = req.query;
|
const { filter, guest_id } = req.query;
|
||||||
const feedbackService = require('../services/feedbackService');
|
|
||||||
|
|
||||||
// First get all photos
|
// First get all photos
|
||||||
let photos = await db('photos')
|
let photos = await db('photos')
|
||||||
@@ -317,16 +330,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
|||||||
photo_id: photoId
|
photo_id: photoId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Photo path should be in storage/events/active directory
|
|
||||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
|
||||||
const storagePath = getStoragePath();
|
|
||||||
let filePath;
|
let filePath;
|
||||||
if (photo.path.startsWith('events/active/')) {
|
try {
|
||||||
// New format: path already includes events/active/ prefix
|
filePath = resolvePhotoFilePath(req.event, photo);
|
||||||
filePath = path.join(storagePath, photo.path);
|
} catch (resolveError) {
|
||||||
} else {
|
logger.error('Failed to resolve photo path for download', {
|
||||||
// Legacy format: path is just slug/filename
|
slug: req.params.slug,
|
||||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: resolveError.message,
|
||||||
|
});
|
||||||
|
return res.status(404).json({ error: 'Photo file not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get watermark settings
|
// Get watermark settings
|
||||||
@@ -345,9 +359,24 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
|
|||||||
res.send(watermarkedBuffer);
|
res.send(watermarkedBuffer);
|
||||||
} else {
|
} else {
|
||||||
// Send original file
|
// Send original file
|
||||||
res.download(filePath, photo.filename);
|
res.download(filePath, photo.filename, (downloadError) => {
|
||||||
|
if (downloadError) {
|
||||||
|
logger.error('Error streaming gallery download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: downloadError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Unexpected error processing gallery download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId: req.params.photoId,
|
||||||
|
eventId: req.event?.id,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
res.status(500).json({ error: 'Failed to download photo' });
|
res.status(500).json({ error: 'Failed to download photo' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -390,16 +419,17 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
|||||||
|
|
||||||
// Add photos to archive
|
// Add photos to archive
|
||||||
for (const photo of photos) {
|
for (const photo of photos) {
|
||||||
// Photo path should be in storage/events/active directory
|
|
||||||
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
|
|
||||||
const storagePath = getStoragePath();
|
|
||||||
let filePath;
|
let filePath;
|
||||||
if (photo.path.startsWith('events/active/')) {
|
try {
|
||||||
// New format: path already includes events/active/ prefix
|
filePath = resolvePhotoFilePath(req.event, photo);
|
||||||
filePath = path.join(storagePath, photo.path);
|
} catch (resolveError) {
|
||||||
} else {
|
logger.warn('Skipping photo in bulk download due to unresolved path', {
|
||||||
// Legacy format: path is just slug/filename
|
slug: req.params.slug,
|
||||||
filePath = path.join(storagePath, 'events/active', photo.path);
|
photoId: photo.id,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: resolveError.message,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the file name in the archive
|
// Determine the file name in the archive
|
||||||
@@ -414,11 +444,18 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (watermarkSettings && watermarkSettings.enabled) {
|
if (watermarkSettings && watermarkSettings.enabled) {
|
||||||
// Apply watermark
|
try {
|
||||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||||
archive.append(watermarkedBuffer, { name: archiveName });
|
archive.append(watermarkedBuffer, { name: archiveName });
|
||||||
|
} catch (watermarkError) {
|
||||||
|
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId: photo.id,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: watermarkError.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add original file
|
|
||||||
archive.file(filePath, { name: archiveName });
|
archive.file(filePath, { name: archiveName });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,6 +470,11 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
|||||||
action: 'download_all'
|
action: 'download_all'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Error creating bulk gallery download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
eventId: req.event?.id,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
res.status(500).json({ error: 'Failed to create download archive' });
|
res.status(500).json({ error: 'Failed to create download archive' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -477,30 +519,47 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
|||||||
|
|
||||||
const archive = archiver('zip', { zlib: { level: 5 } });
|
const archive = archiver('zip', { zlib: { level: 5 } });
|
||||||
archive.on('error', (err) => {
|
archive.on('error', (err) => {
|
||||||
console.error('Zip error:', err);
|
logger.error('Zip error generating selected download', {
|
||||||
try { res.status(500).end(); } catch (e) {}
|
slug: req.params.slug,
|
||||||
|
eventId: req.event?.id,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
res.status(500).end();
|
||||||
|
} catch (_) {
|
||||||
|
// ignore double-send errors
|
||||||
|
}
|
||||||
});
|
});
|
||||||
archive.pipe(res);
|
archive.pipe(res);
|
||||||
|
|
||||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
|
||||||
const fs = require('fs');
|
|
||||||
// Check watermark settings similar to download-all
|
// Check watermark settings similar to download-all
|
||||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
for (const photo of photos) {
|
for (const photo of photos) {
|
||||||
try {
|
try {
|
||||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||||
if (filePath && fs.existsSync(filePath)) {
|
const name = photo.filename || `photo-${photo.id}.jpg`;
|
||||||
const name = photo.filename || `photo-${photo.id}.jpg`;
|
if (watermarkSettings && watermarkSettings.enabled) {
|
||||||
if (watermarkSettings && watermarkSettings.enabled) {
|
try {
|
||||||
// Apply watermark like download-all
|
|
||||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||||
archive.append(watermarkedBuffer, { name });
|
archive.append(watermarkedBuffer, { name });
|
||||||
} else {
|
} catch (watermarkError) {
|
||||||
archive.file(filePath, { name });
|
logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId: photo.id,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: watermarkError.message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
archive.file(filePath, { name });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (resolveError) {
|
||||||
// skip missing/inaccessible files
|
logger.warn('Skipping selected photo due to unresolved path', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId: photo.id,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: resolveError.message,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,7 +572,11 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
|
|||||||
action: 'download_selected'
|
action: 'download_selected'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in download-selected:', error);
|
logger.error('Error in download-selected:', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
eventId: req.event?.id,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
res.status(500).json({ error: 'Failed to download selected photos' });
|
res.status(500).json({ error: 'Failed to download selected photos' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||||
const secureImageService = require('../services/secureImageService');
|
const secureImageService = require('../services/secureImageService');
|
||||||
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get storage path from environment or default
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate secure token for image access
|
* Generate secure token for image access
|
||||||
*/
|
*/
|
||||||
@@ -94,11 +91,11 @@ router.get('/:slug/secure/:photoId/:token',
|
|||||||
const { slug, photoId, token } = req.params; // Move outside try block for error handler access
|
const { slug, photoId, token } = req.params; // Move outside try block for error handler access
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Secure image route hit:', {
|
logger.debug('Secure image route hit', {
|
||||||
slug: slug,
|
slug,
|
||||||
photoId: photoId,
|
photoId,
|
||||||
tokenLength: token?.length,
|
tokenLength: token?.length,
|
||||||
headers: req.headers.authorization ? 'present' : 'absent'
|
hasAuthHeader: Boolean(req.headers.authorization),
|
||||||
});
|
});
|
||||||
const { fragment } = req.query;
|
const { fragment } = req.query;
|
||||||
|
|
||||||
@@ -142,7 +139,18 @@ router.get('/:slug/secure/:photoId/:token',
|
|||||||
return res.status(404).json({ error: 'Photo not found' });
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
let filePath;
|
||||||
|
try {
|
||||||
|
filePath = resolvePhotoFilePath(req.event, photo);
|
||||||
|
} catch (resolveError) {
|
||||||
|
logger.error('Failed to resolve photo path for secure token generation', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: resolveError.message,
|
||||||
|
});
|
||||||
|
return res.status(404).json({ error: 'Photo file not found' });
|
||||||
|
}
|
||||||
|
|
||||||
// Get protection settings for this event
|
// Get protection settings for this event
|
||||||
const protectionSettings = {
|
const protectionSettings = {
|
||||||
@@ -284,7 +292,18 @@ router.get('/:slug/secure-download/:photoId/:token',
|
|||||||
return res.status(404).json({ error: 'Photo not found' });
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
let filePath;
|
||||||
|
try {
|
||||||
|
filePath = resolvePhotoFilePath(req.event, photo);
|
||||||
|
} catch (resolveError) {
|
||||||
|
logger.error('Failed to resolve photo path for secure download', {
|
||||||
|
slug: req.params.slug,
|
||||||
|
photoId,
|
||||||
|
eventId: req.event.id,
|
||||||
|
error: resolveError.message,
|
||||||
|
});
|
||||||
|
return res.status(404).json({ error: 'Photo file not found' });
|
||||||
|
}
|
||||||
|
|
||||||
// Apply watermark if enabled
|
// Apply watermark if enabled
|
||||||
const watermarkService = require('../services/watermarkService');
|
const watermarkService = require('../services/watermarkService');
|
||||||
@@ -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)'
|
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||||
: '(Not shown for security reasons)';
|
: '(Not shown for security reasons)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (processedVariables.gallery_password === 'No password required') {
|
||||||
|
processedVariables.gallery_password = language === 'de'
|
||||||
|
? 'Kein Passwort erforderlich'
|
||||||
|
: 'No password required';
|
||||||
|
}
|
||||||
|
|
||||||
// Format dates if they exist
|
// Format dates if they exist
|
||||||
if (processedVariables.event_date) {
|
if (processedVariables.event_date) {
|
||||||
@@ -546,4 +552,4 @@ module.exports = {
|
|||||||
queueEmail,
|
queueEmail,
|
||||||
stopEmailQueueProcessor,
|
stopEmailQueueProcessor,
|
||||||
testEmailConnection
|
testEmailConnection
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,52 @@
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
const fsSync = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||||
|
|
||||||
|
let cachedRoot = null;
|
||||||
|
|
||||||
|
function resolveDefaultRoot() {
|
||||||
|
const containerDefault = '/external-media';
|
||||||
|
try {
|
||||||
|
if (fsSync.existsSync(containerDefault)) {
|
||||||
|
return containerDefault;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ignore lookup errors, fallback below
|
||||||
|
}
|
||||||
|
|
||||||
|
const localFallback = path.resolve(__dirname, '../../..', 'storage/external-media');
|
||||||
|
try {
|
||||||
|
if (fsSync.existsSync(localFallback)) {
|
||||||
|
return localFallback;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ignore and return container default
|
||||||
|
}
|
||||||
|
|
||||||
|
return containerDefault;
|
||||||
|
}
|
||||||
|
|
||||||
function getExternalMediaRoot() {
|
function getExternalMediaRoot() {
|
||||||
return process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
|
if (cachedRoot) {
|
||||||
|
return cachedRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configured = process.env.EXTERNAL_MEDIA_ROOT;
|
||||||
|
if (configured && configured.trim()) {
|
||||||
|
const resolvedConfigured = path.resolve(configured.trim());
|
||||||
|
try {
|
||||||
|
if (fsSync.existsSync(resolvedConfigured)) {
|
||||||
|
cachedRoot = resolvedConfigured;
|
||||||
|
return cachedRoot;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ignore lookup errors and fall back to defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedRoot = resolveDefaultRoot();
|
||||||
|
return cachedRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUnderRoot(p) {
|
function isUnderRoot(p) {
|
||||||
@@ -64,4 +107,3 @@ module.exports = {
|
|||||||
list,
|
list,
|
||||||
resolveExternalPath,
|
resolveExternalPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
|
|||||||
- Check the console output from when you ran migrations
|
- Check the console output from when you ran migrations
|
||||||
- File is created in the backend directory root
|
- File is created in the backend directory root
|
||||||
- File might have been deleted for security (as recommended)
|
- File might have been deleted for security (as recommended)
|
||||||
|
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
|
||||||
|
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
@@ -161,4 +163,4 @@ If upgrading from the old system with hardcoded `admin123`:
|
|||||||
- [ ] Stored new password in password manager
|
- [ ] Stored new password in password manager
|
||||||
- [ ] Tested login with new password
|
- [ ] Tested login with new password
|
||||||
- [ ] Set up additional admin accounts if needed
|
- [ ] Set up additional admin accounts if needed
|
||||||
- [ ] Configured password policies for organization
|
- [ ] Configured password policies for organization
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^2.26.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.1",
|
"version": "1.1.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import { authService, galleryService } from '../services';
|
import { authService, galleryService } from '../services';
|
||||||
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
||||||
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
import {
|
import {
|
||||||
clearActiveGallerySlug,
|
clearActiveGallerySlug,
|
||||||
clearGalleryToken,
|
clearGalleryToken,
|
||||||
@@ -18,12 +19,24 @@ interface GalleryEvent {
|
|||||||
welcome_message?: string;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
require_password?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeEvent = (incoming: GalleryEvent | null | undefined): GalleryEvent | null => {
|
||||||
|
if (!incoming) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...incoming,
|
||||||
|
require_password: normalizeRequirePassword(incoming.require_password, true),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface GalleryAuthContextType {
|
interface GalleryAuthContextType {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
event: GalleryEvent | null;
|
event: GalleryEvent | null;
|
||||||
login: (slug: string, password: string, recaptchaToken?: string | null) => Promise<void>;
|
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -83,7 +96,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(storedEvent);
|
const parsed = JSON.parse(storedEvent);
|
||||||
if (parsed && parsed.id) {
|
if (parsed && parsed.id) {
|
||||||
setEvent(parsed);
|
const normalizedStored = normalizeEvent(parsed);
|
||||||
|
setEvent(normalizedStored);
|
||||||
|
if (normalizedStored) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||||
@@ -104,8 +121,11 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
// Fetch gallery details to hydrate context
|
// Fetch gallery details to hydrate context
|
||||||
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
||||||
if (galleryData?.event) {
|
if (galleryData?.event) {
|
||||||
setEvent(galleryData.event);
|
const normalizedEvent = normalizeEvent(galleryData.event);
|
||||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(galleryData.event));
|
setEvent(normalizedEvent);
|
||||||
|
if (normalizedEvent) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +141,12 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
if (verify?.valid) {
|
if (verify?.valid) {
|
||||||
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
||||||
if (response?.event) {
|
if (response?.event) {
|
||||||
setEvent(response.event);
|
const normalizedEvent = normalizeEvent(response.event);
|
||||||
|
setEvent(normalizedEvent);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(response.event));
|
if (normalizedEvent) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||||
|
}
|
||||||
if (response.token) {
|
if (response.token) {
|
||||||
storeGalleryToken(currentSlug, 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 {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||||
setEvent(response.event);
|
const normalizedEvent = normalizeEvent(response.event);
|
||||||
|
setEvent(normalizedEvent);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
if (response.token) {
|
if (response.token) {
|
||||||
storeGalleryToken(slug, response.token);
|
storeGalleryToken(slug, response.token);
|
||||||
@@ -167,7 +191,9 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
setActiveGallerySlug(slug);
|
setActiveGallerySlug(slug);
|
||||||
|
|
||||||
// Store event data for quick reloads (non-sensitive)
|
// Store event data for quick reloads (non-sensitive)
|
||||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(response.event));
|
if (normalizedEvent) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Invalid password');
|
setError(err.response?.data?.error || 'Invalid password');
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -496,6 +496,8 @@
|
|||||||
"expiresIn": "Galerie läuft in {{count}} Tag ab",
|
"expiresIn": "Galerie läuft in {{count}} Tag ab",
|
||||||
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
|
"expiresIn_plural": "Galerie läuft in {{count}} Tagen ab",
|
||||||
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.",
|
"downloadBefore": "Laden Sie Ihre Fotos herunter, bevor sie nicht mehr verfügbar sind.",
|
||||||
|
"publicGalleryTitle": "Diese Galerie ist öffentlich zugänglich",
|
||||||
|
"publicGallerySubtitle": "Fotos werden geladen...",
|
||||||
"viewGallery": "Galerie anzeigen",
|
"viewGallery": "Galerie anzeigen",
|
||||||
"downloadAll": "Alle herunterladen",
|
"downloadAll": "Alle herunterladen",
|
||||||
"downloading": "Lade {{count}} Foto herunter...",
|
"downloading": "Lade {{count}} Foto herunter...",
|
||||||
@@ -607,6 +609,7 @@
|
|||||||
"created": "Erstellt",
|
"created": "Erstellt",
|
||||||
"expires": "Läuft ab",
|
"expires": "Läuft ab",
|
||||||
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
|
"shareWithGuests": "Teilen Sie diesen Link mit Gästen. Sie benötigen das Passwort, um auf die Galerie zuzugreifen.",
|
||||||
|
"shareWithGuestsPublic": "Teilen Sie diesen Link mit Gästen. Für diese Galerie ist kein Passwort erforderlich.",
|
||||||
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
|
"resetGalleryPassword": "Galerie-Passwort zurücksetzen",
|
||||||
"resendCreationEmail": "Erstellungs-E-Mail erneut senden",
|
"resendCreationEmail": "Erstellungs-E-Mail erneut senden",
|
||||||
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
|
"creationEmailResent": "Die Erstellungs-E-Mail wurde zur Warteschlange hinzugefügt",
|
||||||
@@ -632,10 +635,14 @@
|
|||||||
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
||||||
"securityAccess": "Sicherheit & Zugriff",
|
"securityAccess": "Sicherheit & Zugriff",
|
||||||
"galleryPassword": "Galerie-Passwort",
|
"galleryPassword": "Galerie-Passwort",
|
||||||
|
"requirePasswordToggle": "Galerie mit Passwort schützen",
|
||||||
|
"requirePasswordToggleHelp": "Deaktivieren Sie diese Option, wenn die Galerie ohne Passwort geteilt werden soll. Jeder mit dem Link kann die Fotos ansehen.",
|
||||||
|
"publicGalleryWarning": "Öffentliche Galerien sind für jeden mit dem Link zugänglich. Aktivieren Sie gegebenenfalls Wasserzeichen und behalten Sie die Aktivität im Blick.",
|
||||||
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
|
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
|
||||||
"passwordPlaceholder": "Sicheres Passwort eingeben",
|
"passwordPlaceholder": "Sicheres Passwort eingeben",
|
||||||
"confirmPassword": "Passwort bestätigen",
|
"confirmPassword": "Passwort bestätigen",
|
||||||
"showPasswords": "Passwörter anzeigen",
|
"showPasswords": "Passwörter anzeigen",
|
||||||
|
"newPasswordLabel": "Neues Galerie-Passwort",
|
||||||
"gallerySettings": "Galerie-Einstellungen",
|
"gallerySettings": "Galerie-Einstellungen",
|
||||||
"colorTheme": "Farbthema",
|
"colorTheme": "Farbthema",
|
||||||
"galleryExpiration": "Galerie-Ablauf",
|
"galleryExpiration": "Galerie-Ablauf",
|
||||||
@@ -735,6 +742,9 @@
|
|||||||
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
|
"noEventsDescription": "Erstellen Sie Ihre erste Veranstaltung, um zu beginnen.",
|
||||||
"eventsSelected": "{{count}} Veranstaltung ausgewählt",
|
"eventsSelected": "{{count}} Veranstaltung ausgewählt",
|
||||||
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
|
||||||
|
"publicAccess": "Öffentlicher Zugriff",
|
||||||
|
"passwordProtected": "Passwortgeschützt",
|
||||||
|
"newPasswordRequired": "Bitte legen Sie vor dem Aktivieren des Passwortschutzes ein Passwort fest.",
|
||||||
"viewDetails": "Details anzeigen",
|
"viewDetails": "Details anzeigen",
|
||||||
"archiveEventAction": "Veranstaltung archivieren",
|
"archiveEventAction": "Veranstaltung archivieren",
|
||||||
"downloadArchiveAction": "Archiv herunterladen",
|
"downloadArchiveAction": "Archiv herunterladen",
|
||||||
@@ -853,7 +863,6 @@
|
|||||||
"security": {
|
"security": {
|
||||||
"title": "Sicherheit",
|
"title": "Sicherheit",
|
||||||
"passwordSettings": "Passworteinstellungen",
|
"passwordSettings": "Passworteinstellungen",
|
||||||
"requirePassword": "Passwort für alle Galerien erforderlich",
|
|
||||||
"minPasswordLength": "Minimale Passwortlänge",
|
"minPasswordLength": "Minimale Passwortlänge",
|
||||||
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
"minPasswordLengthHelp": "Mindestanzahl von Zeichen für Galerie-Passwörter",
|
||||||
"passwordComplexity": "Passwort-Komplexität",
|
"passwordComplexity": "Passwort-Komplexität",
|
||||||
|
|||||||
@@ -161,6 +161,8 @@
|
|||||||
"expiresIn": "Gallery expires in {{count}} day",
|
"expiresIn": "Gallery expires in {{count}} day",
|
||||||
"expiresIn_plural": "Gallery expires in {{count}} days",
|
"expiresIn_plural": "Gallery expires in {{count}} days",
|
||||||
"downloadBefore": "Download your photos before they're no longer available.",
|
"downloadBefore": "Download your photos before they're no longer available.",
|
||||||
|
"publicGalleryTitle": "This gallery is publicly accessible",
|
||||||
|
"publicGallerySubtitle": "Loading the photos now...",
|
||||||
"viewGallery": "View Gallery",
|
"viewGallery": "View Gallery",
|
||||||
"downloadAll": "Download All",
|
"downloadAll": "Download All",
|
||||||
"downloading": "Downloading {{count}} photo...",
|
"downloading": "Downloading {{count}} photo...",
|
||||||
@@ -290,6 +292,7 @@
|
|||||||
"created": "Created",
|
"created": "Created",
|
||||||
"expires": "Expires",
|
"expires": "Expires",
|
||||||
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
|
"shareWithGuests": "Share this link with guests. They'll need the password to access the gallery.",
|
||||||
|
"shareWithGuestsPublic": "Share this link with guests. No password is required for this gallery.",
|
||||||
"resetGalleryPassword": "Reset Gallery Password",
|
"resetGalleryPassword": "Reset Gallery Password",
|
||||||
"resendCreationEmail": "Resend Creation Email",
|
"resendCreationEmail": "Resend Creation Email",
|
||||||
"creationEmailResent": "Creation email has been queued for sending",
|
"creationEmailResent": "Creation email has been queued for sending",
|
||||||
@@ -316,9 +319,13 @@
|
|||||||
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
||||||
"securityAccess": "Security & Access",
|
"securityAccess": "Security & Access",
|
||||||
"galleryPassword": "Gallery Password",
|
"galleryPassword": "Gallery Password",
|
||||||
|
"requirePasswordToggle": "Require password for this gallery",
|
||||||
|
"requirePasswordToggleHelp": "Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.",
|
||||||
|
"publicGalleryWarning": "Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.",
|
||||||
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
|
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
|
||||||
"confirmPassword": "Confirm Password",
|
"confirmPassword": "Confirm Password",
|
||||||
"showPasswords": "Show passwords",
|
"showPasswords": "Show passwords",
|
||||||
|
"newPasswordLabel": "New Gallery Password",
|
||||||
"gallerySettings": "Gallery Settings",
|
"gallerySettings": "Gallery Settings",
|
||||||
"themeAndStyle": "Theme & Style",
|
"themeAndStyle": "Theme & Style",
|
||||||
"colorTheme": "Color Theme",
|
"colorTheme": "Color Theme",
|
||||||
@@ -373,6 +380,9 @@
|
|||||||
"eventsSelected_plural": "{{count}} events selected",
|
"eventsSelected_plural": "{{count}} events selected",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"archiveSelected": "Archive Selected",
|
"archiveSelected": "Archive Selected",
|
||||||
|
"publicAccess": "Public access",
|
||||||
|
"passwordProtected": "Password protected",
|
||||||
|
"newPasswordRequired": "Please set a password before enabling protection.",
|
||||||
"event": "Event",
|
"event": "Event",
|
||||||
"type": "Type",
|
"type": "Type",
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
@@ -533,7 +543,6 @@
|
|||||||
"security": {
|
"security": {
|
||||||
"title": "Security",
|
"title": "Security",
|
||||||
"passwordSettings": "Password Settings",
|
"passwordSettings": "Password Settings",
|
||||||
"requirePassword": "Require password for all galleries",
|
|
||||||
"minPasswordLength": "Minimum Password Length",
|
"minPasswordLength": "Minimum Password Length",
|
||||||
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
"minPasswordLengthHelp": "Minimum number of characters for gallery passwords",
|
||||||
"passwordComplexity": "Password Complexity",
|
"passwordComplexity": "Password Complexity",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { analyticsService } from '../services/analytics.service';
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||||
import { buildResourceUrl } from '../utils/url';
|
import { buildResourceUrl } from '../utils/url';
|
||||||
|
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
export const GalleryPage: React.FC = () => {
|
export const GalleryPage: React.FC = () => {
|
||||||
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||||
@@ -25,9 +26,11 @@ export const GalleryPage: React.FC = () => {
|
|||||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
const [loginError, setLoginError] = useState<string | null>(null);
|
||||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||||
|
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
|
||||||
|
|
||||||
// Fetch gallery info (public data)
|
// Fetch gallery info (public data)
|
||||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
||||||
|
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
|
||||||
|
|
||||||
// Fetch branding settings
|
// Fetch branding settings
|
||||||
const { data: settingsData } = useQuery({
|
const { data: settingsData } = useQuery({
|
||||||
@@ -87,6 +90,30 @@ export const GalleryPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!slug) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
|
||||||
|
setAutoLoginAttempted(true);
|
||||||
|
setIsLoggingIn(true);
|
||||||
|
login(slug, '')
|
||||||
|
.then(() => {
|
||||||
|
setLoginError(null);
|
||||||
|
})
|
||||||
|
.catch((error: any) => {
|
||||||
|
const message = error?.response?.data?.error;
|
||||||
|
if (message) {
|
||||||
|
setLoginError(message);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoggingIn(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]);
|
||||||
|
|
||||||
// Calculate days until expiration
|
// Calculate days until expiration
|
||||||
const daysUntilExpiration = galleryInfo
|
const daysUntilExpiration = galleryInfo
|
||||||
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
||||||
@@ -96,7 +123,7 @@ export const GalleryPage: React.FC = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation(); // Prevent any bubbling
|
e.stopPropagation(); // Prevent any bubbling
|
||||||
|
|
||||||
if (!password.trim()) {
|
if (requiresPassword && !password.trim()) {
|
||||||
setLoginError(t('auth.pleaseEnterPassword'));
|
setLoginError(t('auth.pleaseEnterPassword'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -104,13 +131,14 @@ export const GalleryPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
setIsLoggingIn(true);
|
setIsLoggingIn(true);
|
||||||
setLoginError(null);
|
setLoginError(null);
|
||||||
await login(slug!, password, recaptchaToken);
|
await login(slug!, requiresPassword ? password : '', recaptchaToken);
|
||||||
|
|
||||||
// Track successful password entry
|
if (requiresPassword) {
|
||||||
analyticsService.trackGalleryEvent('password_entry', {
|
analyticsService.trackGalleryEvent('password_entry', {
|
||||||
gallery: slug,
|
gallery: slug,
|
||||||
success: true
|
success: true
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
const errorMessage = error.response?.data?.error || 'Invalid password';
|
const errorMessage = error.response?.data?.error || 'Invalid password';
|
||||||
@@ -128,11 +156,13 @@ export const GalleryPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Track failed password entry
|
// Track failed password entry
|
||||||
analyticsService.trackGalleryEvent('password_entry', {
|
if (requiresPassword) {
|
||||||
gallery: slug,
|
analyticsService.trackGalleryEvent('password_entry', {
|
||||||
success: false,
|
gallery: slug,
|
||||||
statusCode
|
success: false,
|
||||||
});
|
statusCode
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Keep the password field to allow retry
|
// Keep the password field to allow retry
|
||||||
// Do not clear the password
|
// Do not clear the password
|
||||||
@@ -311,43 +341,61 @@ export const GalleryPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Login Card */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 sm:p-6">
|
<CardContent className="p-4 sm:p-6">
|
||||||
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
|
{requiresPassword ? (
|
||||||
|
<>
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
|
||||||
<Input
|
|
||||||
type="password"
|
<form onSubmit={handleLogin} className="space-y-4">
|
||||||
label={t('auth.password')}
|
<Input
|
||||||
placeholder={t('auth.passwordPlaceholder')}
|
type="password"
|
||||||
value={password}
|
label={t('auth.password')}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
placeholder={t('auth.passwordPlaceholder')}
|
||||||
error={loginError || undefined}
|
value={password}
|
||||||
autoFocus
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="text-sm sm:text-base"
|
error={loginError || undefined}
|
||||||
/>
|
autoFocus
|
||||||
|
className="text-sm sm:text-base"
|
||||||
<ReCaptcha
|
/>
|
||||||
onChange={setRecaptchaToken}
|
|
||||||
onExpired={() => setRecaptchaToken(null)}
|
<ReCaptcha
|
||||||
/>
|
onChange={setRecaptchaToken}
|
||||||
|
onExpired={() => setRecaptchaToken(null)}
|
||||||
<Button
|
/>
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
<Button
|
||||||
size="lg"
|
type="submit"
|
||||||
className="w-full text-sm sm:text-base"
|
variant="primary"
|
||||||
isLoading={isLoggingIn}
|
size="lg"
|
||||||
disabled={isLoggingIn}
|
className="w-full text-sm sm:text-base"
|
||||||
>
|
isLoading={isLoggingIn}
|
||||||
{t('gallery.viewGallery')}
|
disabled={isLoggingIn}
|
||||||
</Button>
|
>
|
||||||
</form>
|
{t('gallery.viewGallery')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
|
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
|
||||||
{t('auth.passwordHint')}
|
{t('auth.passwordHint')}
|
||||||
</p>
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<h2 className="text-base sm:text-lg lg:text-xl font-semibold">
|
||||||
|
{t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Loading size="sm" text={t('gallery.loading')} />
|
||||||
|
</div>
|
||||||
|
{loginError && (
|
||||||
|
<p className="text-xs text-red-600">{loginError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -376,4 +424,4 @@ export const GalleryPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface FormData {
|
|||||||
event_date: string;
|
event_date: string;
|
||||||
host_email: string;
|
host_email: string;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
|
require_password: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
confirm_password: string;
|
confirm_password: string;
|
||||||
welcome_message: string;
|
welcome_message: string;
|
||||||
@@ -123,6 +124,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
event_date: format(new Date(), 'yyyy-MM-dd'),
|
||||||
host_email: '',
|
host_email: '',
|
||||||
admin_email: '',
|
admin_email: '',
|
||||||
|
require_password: true,
|
||||||
password: '',
|
password: '',
|
||||||
confirm_password: '',
|
confirm_password: '',
|
||||||
welcome_message: '',
|
welcome_message: '',
|
||||||
@@ -208,17 +210,18 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
newErrors.admin_email = t('validation.invalidEmailFormat');
|
newErrors.admin_email = t('validation.invalidEmailFormat');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.password) {
|
if (formData.require_password) {
|
||||||
newErrors.password = t('validation.passwordRequired');
|
if (!formData.password) {
|
||||||
} else if (formData.password.length < 6) {
|
newErrors.password = t('validation.passwordRequired');
|
||||||
newErrors.password = t('validation.passwordMinLength');
|
} else if (formData.password.length < 6) {
|
||||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
newErrors.password = t('validation.passwordMinLength');
|
||||||
// Prevent simple numeric passwords like "123456"
|
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.password !== formData.confirm_password) {
|
if (formData.password !== formData.confirm_password) {
|
||||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
||||||
@@ -238,19 +241,22 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
|
|
||||||
const selectedTheme = COLOR_THEMES.find(t => t.value === formData.color_theme);
|
const selectedTheme = COLOR_THEMES.find(t => t.value === formData.color_theme);
|
||||||
|
|
||||||
createMutation.mutate({
|
const payload = {
|
||||||
event_type: formData.event_type,
|
event_type: formData.event_type,
|
||||||
event_name: formData.event_name,
|
event_name: formData.event_name,
|
||||||
event_date: formData.event_date,
|
event_date: formData.event_date,
|
||||||
host_email: formData.host_email,
|
host_email: formData.host_email,
|
||||||
admin_email: formData.admin_email,
|
admin_email: formData.admin_email,
|
||||||
password: formData.password,
|
require_password: formData.require_password,
|
||||||
|
password: formData.require_password ? formData.password : undefined,
|
||||||
welcome_message: formData.welcome_message || '',
|
welcome_message: formData.welcome_message || '',
|
||||||
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
|
color_theme: selectedTheme ? JSON.stringify(selectedTheme.theme) : undefined,
|
||||||
expiration_days: formData.expires_in_days,
|
expiration_days: formData.expires_in_days,
|
||||||
allow_user_uploads: formData.allow_user_uploads,
|
allow_user_uploads: formData.allow_user_uploads,
|
||||||
upload_category_id: formData.upload_category_id,
|
upload_category_id: formData.upload_category_id,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
createMutation.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (field: keyof FormData) => (
|
const handleInputChange = (field: keyof FormData) => (
|
||||||
@@ -426,81 +432,114 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
<Card padding="md" className="mb-6">
|
<Card padding="md" className="mb-6">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.securityAndAccess')}</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-4">
|
||||||
{/* Password */}
|
<label className="flex items-start gap-2">
|
||||||
<div>
|
<input
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
|
type="checkbox"
|
||||||
{t('events.galleryPassword')}
|
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
</label>
|
checked={formData.require_password}
|
||||||
<div className="relative">
|
onChange={(e) => {
|
||||||
<Input
|
const checked = e.target.checked;
|
||||||
id="password"
|
setFormData(prev => ({
|
||||||
type={showPassword ? 'text' : 'password'}
|
...prev,
|
||||||
value={formData.password}
|
require_password: checked,
|
||||||
onChange={handleInputChange('password')}
|
password: checked ? prev.password : '',
|
||||||
error={errors.password}
|
confirm_password: checked ? prev.confirm_password : ''
|
||||||
placeholder={t('events.enterPassword')}
|
}));
|
||||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
if (!checked) {
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
setErrors(prev => ({ ...prev, password: '', confirm_password: '' }));
|
||||||
className="pr-10"
|
}
|
||||||
/>
|
}}
|
||||||
<button
|
/>
|
||||||
type="button"
|
<div>
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
style={{ top: errors.password ? '0' : '0' }}
|
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||||
>
|
</p>
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</label>
|
||||||
{/* Password Generator */}
|
|
||||||
<div className="mt-2">
|
|
||||||
<PasswordGenerator
|
|
||||||
eventName={formData.event_name}
|
|
||||||
eventDate={formData.event_date}
|
|
||||||
eventType={formData.event_type}
|
|
||||||
onPasswordGenerated={handlePasswordGenerated}
|
|
||||||
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirm Password */}
|
{!formData.require_password && (
|
||||||
<div>
|
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||||
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
|
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||||
{t('events.confirmPassword')}
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id="confirm_password"
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
value={formData.confirm_password}
|
|
||||||
onChange={handleInputChange('confirm_password')}
|
|
||||||
error={errors.confirm_password}
|
|
||||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
|
||||||
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
|
||||||
className="pr-10"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
|
||||||
style={{ top: errors.confirm_password ? '0' : '0' }}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
|
||||||
) : (
|
|
||||||
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{formData.require_password && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.galleryPassword')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange('password')}
|
||||||
|
error={errors.password}
|
||||||
|
placeholder={t('events.enterPassword')}
|
||||||
|
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||||
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
style={{ top: errors.password ? '0' : '0' }}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2">
|
||||||
|
<PasswordGenerator
|
||||||
|
eventName={formData.event_name}
|
||||||
|
eventDate={formData.event_date}
|
||||||
|
eventType={formData.event_type}
|
||||||
|
onPasswordGenerated={handlePasswordGenerated}
|
||||||
|
passwordComplexity={passwordComplexity?.complexityLevel || 'moderate'}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm_password" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.confirmPassword')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="confirm_password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={formData.confirm_password}
|
||||||
|
onChange={handleInputChange('confirm_password')}
|
||||||
|
error={errors.confirm_password}
|
||||||
|
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||||
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
style={{ top: errors.confirm_password ? '0' : '0' }}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -645,4 +684,4 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
CreateEventPage.displayName = 'CreateEventPage';
|
CreateEventPage.displayName = 'CreateEventPage';
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface FormData {
|
|||||||
host_name: string;
|
host_name: string;
|
||||||
host_email: string;
|
host_email: string;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
|
require_password: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
confirm_password: string;
|
confirm_password: string;
|
||||||
welcome_message: string;
|
welcome_message: string;
|
||||||
@@ -88,6 +89,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
host_name: '',
|
host_name: '',
|
||||||
host_email: '',
|
host_email: '',
|
||||||
admin_email: '',
|
admin_email: '',
|
||||||
|
require_password: true,
|
||||||
password: '',
|
password: '',
|
||||||
confirm_password: '',
|
confirm_password: '',
|
||||||
welcome_message: '',
|
welcome_message: '',
|
||||||
@@ -198,17 +200,19 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
newErrors.admin_email = t('validation.invalidEmailFormat');
|
newErrors.admin_email = t('validation.invalidEmailFormat');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.password) {
|
if (formData.require_password) {
|
||||||
newErrors.password = t('validation.passwordRequired');
|
if (!formData.password) {
|
||||||
} else if (formData.password.length < 6) {
|
newErrors.password = t('validation.passwordRequired');
|
||||||
newErrors.password = t('validation.passwordMinLength');
|
} else if (formData.password.length < 6) {
|
||||||
} else if (/^\d{1,6}$/.test(formData.password)) {
|
newErrors.password = t('validation.passwordMinLength');
|
||||||
// Prevent simple numeric passwords like "123456"
|
} else if (/^\d{1,6}$/.test(formData.password)) {
|
||||||
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
// Prevent simple numeric passwords like "123456"
|
||||||
}
|
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.password !== formData.confirm_password) {
|
if (formData.password !== formData.confirm_password) {
|
||||||
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
newErrors.confirm_password = t('validation.passwordsDoNotMatch');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
||||||
@@ -235,7 +239,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
host_name: formData.host_name,
|
host_name: formData.host_name,
|
||||||
host_email: formData.host_email,
|
host_email: formData.host_email,
|
||||||
admin_email: formData.admin_email,
|
admin_email: formData.admin_email,
|
||||||
password: formData.password,
|
require_password: formData.require_password,
|
||||||
|
password: formData.require_password ? formData.password : undefined,
|
||||||
welcome_message: formData.welcome_message || '',
|
welcome_message: formData.welcome_message || '',
|
||||||
color_theme: JSON.stringify(formData.theme_config),
|
color_theme: JSON.stringify(formData.theme_config),
|
||||||
expiration_days: formData.expires_in_days,
|
expiration_days: formData.expires_in_days,
|
||||||
@@ -495,51 +500,89 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-3">
|
||||||
<div>
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
checked={formData.require_password}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
require_password: checked,
|
||||||
|
password: checked ? prev.password : '',
|
||||||
|
confirm_password: checked ? prev.confirm_password : '',
|
||||||
|
}));
|
||||||
|
if (!checked) {
|
||||||
|
setErrors(prev => ({ ...prev, password: undefined, confirm_password: undefined }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-neutral-700">
|
||||||
|
{t('events.requirePasswordToggle')}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!formData.require_password && (
|
||||||
|
<div className="rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||||
|
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formData.require_password && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
label={t('events.galleryPassword')}
|
||||||
|
placeholder={t('events.passwordPlaceholder')}
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange('password')}
|
||||||
|
error={errors.password}
|
||||||
|
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
||||||
|
leftIcon={<Lock className="w-5 h-5" />}
|
||||||
|
rightIcon={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="p-1"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Password Generator */}
|
||||||
|
<div className="mt-2">
|
||||||
|
<PasswordGenerator
|
||||||
|
eventName={formData.event_name}
|
||||||
|
eventDate={formData.event_date}
|
||||||
|
eventType={formData.event_type}
|
||||||
|
onPasswordGenerated={handlePasswordGenerated}
|
||||||
|
passwordComplexity="moderate"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
label={t('events.galleryPassword')}
|
label={t('events.confirmPassword')}
|
||||||
placeholder={t('events.passwordPlaceholder')}
|
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||||
value={formData.password}
|
value={formData.confirm_password}
|
||||||
onChange={handleInputChange('password')}
|
onChange={handleInputChange('confirm_password')}
|
||||||
error={errors.password}
|
error={errors.confirm_password}
|
||||||
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
|
|
||||||
leftIcon={<Lock className="w-5 h-5" />}
|
leftIcon={<Lock className="w-5 h-5" />}
|
||||||
rightIcon={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="p-1"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Password Generator */}
|
|
||||||
<div className="mt-2">
|
|
||||||
<PasswordGenerator
|
|
||||||
eventName={formData.event_name}
|
|
||||||
eventDate={formData.event_date}
|
|
||||||
eventType={formData.event_type}
|
|
||||||
onPasswordGenerated={handlePasswordGenerated}
|
|
||||||
passwordComplexity="moderate"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<Input
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
label={t('events.confirmPassword')}
|
|
||||||
placeholder={t('events.confirmPasswordPlaceholder')}
|
|
||||||
value={formData.confirm_password}
|
|
||||||
onChange={handleInputChange('confirm_password')}
|
|
||||||
error={errors.confirm_password}
|
|
||||||
leftIcon={<Lock className="w-5 h-5" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
Key,
|
Key,
|
||||||
Mail,
|
Mail,
|
||||||
MessageSquare
|
MessageSquare,
|
||||||
|
Lock,
|
||||||
|
Eye,
|
||||||
|
EyeOff
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO, differenceInDays } from 'date-fns';
|
import { parseISO, differenceInDays } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
@@ -27,6 +30,7 @@ import { Button, Input, Card, Loading } from '../../components/common';
|
|||||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
|
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel } from '../../components/admin';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
|
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
||||||
import { archiveService } from '../../services/archive.service';
|
import { archiveService } from '../../services/archive.service';
|
||||||
import { externalMediaService } from '../../services/externalMedia.service';
|
import { externalMediaService } from '../../services/externalMedia.service';
|
||||||
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
|
import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } from '../../services/photos.service';
|
||||||
@@ -121,6 +125,9 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
host_name: string;
|
host_name: string;
|
||||||
source_mode: 'managed' | 'reference';
|
source_mode: 'managed' | 'reference';
|
||||||
external_path: string;
|
external_path: string;
|
||||||
|
require_password: boolean;
|
||||||
|
new_password: string;
|
||||||
|
confirm_new_password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
@@ -134,6 +141,9 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
host_name: '',
|
host_name: '',
|
||||||
source_mode: 'managed',
|
source_mode: 'managed',
|
||||||
external_path: '',
|
external_path: '',
|
||||||
|
require_password: true,
|
||||||
|
new_password: '',
|
||||||
|
confirm_new_password: '',
|
||||||
});
|
});
|
||||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||||
feedback_enabled: false,
|
feedback_enabled: false,
|
||||||
@@ -156,6 +166,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
const [importing, setImporting] = useState<boolean>(false);
|
const [importing, setImporting] = useState<boolean>(false);
|
||||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||||
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||||
|
|
||||||
@@ -274,7 +285,12 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
host_name: event.host_name || '',
|
host_name: event.host_name || '',
|
||||||
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
||||||
external_path: event.external_path || '',
|
external_path: event.external_path || '',
|
||||||
|
require_password: normalizeRequirePassword(event.require_password),
|
||||||
|
new_password: '',
|
||||||
|
confirm_new_password: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setShowNewPassword(false);
|
||||||
|
|
||||||
// Set feedback settings if available
|
// Set feedback settings if available
|
||||||
if (eventFeedbackSettings) {
|
if (eventFeedbackSettings) {
|
||||||
@@ -324,6 +340,26 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
|
|
||||||
const externalPathToSave = editForm.external_path?.trim() || '';
|
const externalPathToSave = editForm.external_path?.trim() || '';
|
||||||
|
|
||||||
|
const currentRequirePassword = normalizeRequirePassword(event.require_password);
|
||||||
|
const requirePasswordChanged = editForm.require_password !== currentRequirePassword;
|
||||||
|
|
||||||
|
if (editForm.require_password) {
|
||||||
|
if (requirePasswordChanged && !editForm.new_password) {
|
||||||
|
toast.error(t('events.newPasswordRequired', 'Please set a password before enabling protection.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editForm.new_password) {
|
||||||
|
if (editForm.new_password.length < 6) {
|
||||||
|
toast.error(t('validation.passwordMinLength'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editForm.new_password !== editForm.confirm_new_password) {
|
||||||
|
toast.error(t('validation.passwordsDoNotMatch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (editForm.source_mode === 'reference' && !externalPathToSave) {
|
if (editForm.source_mode === 'reference' && !externalPathToSave) {
|
||||||
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
|
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
|
||||||
return;
|
return;
|
||||||
@@ -333,6 +369,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
const updateData: any = {
|
const updateData: any = {
|
||||||
expires_at: editForm.expires_at,
|
expires_at: editForm.expires_at,
|
||||||
allow_user_uploads: editForm.allow_user_uploads,
|
allow_user_uploads: editForm.allow_user_uploads,
|
||||||
|
require_password: editForm.require_password,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only include fields that have defined values
|
// Only include fields that have defined values
|
||||||
@@ -355,6 +392,10 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
||||||
updateData.host_name = editForm.host_name;
|
updateData.host_name = editForm.host_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editForm.new_password) {
|
||||||
|
updateData.password = editForm.new_password;
|
||||||
|
}
|
||||||
|
|
||||||
// Remove any keys with undefined values
|
// Remove any keys with undefined values
|
||||||
Object.keys(updateData).forEach(key => {
|
Object.keys(updateData).forEach(key => {
|
||||||
@@ -437,6 +478,15 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
{format(parseISO(event.event_date), 'PPP')}
|
{format(parseISO(event.event_date), 'PPP')}
|
||||||
</span>
|
</span>
|
||||||
<span className="capitalize">{event.event_type}</span>
|
<span className="capitalize">{event.event_type}</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
isGalleryPublic(event.require_password)
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-neutral-100 text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
||||||
|
</span>
|
||||||
{event.is_archived ? (
|
{event.is_archived ? (
|
||||||
<span className="text-neutral-500 flex items-center">
|
<span className="text-neutral-500 flex items-center">
|
||||||
<Archive className="w-4 h-4 mr-1" />
|
<Archive className="w-4 h-4 mr-1" />
|
||||||
@@ -641,6 +691,84 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
isEditing={isEditing}
|
isEditing={isEditing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-start gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
checked={editForm.require_password}
|
||||||
|
onChange={(e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
setEditForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
require_password: checked,
|
||||||
|
new_password: checked ? prev.new_password : '',
|
||||||
|
confirm_new_password: checked ? prev.confirm_new_password : '',
|
||||||
|
}));
|
||||||
|
if (!checked) {
|
||||||
|
setShowNewPassword(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-neutral-700">{t('events.requirePasswordToggle')}</span>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('events.requirePasswordToggleHelp', 'Disable this if you want to share the gallery without a password. Anyone with the link will be able to view the photos.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{!editForm.require_password && (
|
||||||
|
<div className="mt-2 rounded-md border border-orange-200 bg-orange-50 p-3 text-xs text-orange-800">
|
||||||
|
{t('events.publicGalleryWarning', 'Public galleries are accessible to anyone with the link. Consider enabling download watermarks and monitoring activity.')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editForm.require_password && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.newPasswordLabel', 'New gallery password')}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
value={editForm.new_password}
|
||||||
|
onChange={(e) => setEditForm(prev => ({ ...prev, new_password: e.target.value }))}
|
||||||
|
placeholder={t('events.enterPassword')}
|
||||||
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
|
className="pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
>
|
||||||
|
{showNewPassword ? (
|
||||||
|
<EyeOff className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5 text-neutral-400 hover:text-neutral-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('events.confirmPassword')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type={showNewPassword ? 'text' : 'password'}
|
||||||
|
value={editForm.confirm_new_password}
|
||||||
|
onChange={(e) => setEditForm(prev => ({ ...prev, confirm_new_password: e.target.value }))}
|
||||||
|
placeholder={t('events.confirmPasswordPlaceholder')}
|
||||||
|
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
{t('events.sourceMode', 'Source Mode')}
|
{t('events.sourceMode', 'Source Mode')}
|
||||||
@@ -848,7 +976,9 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-sm text-neutral-600 mt-2">
|
<p className="text-sm text-neutral-600 mt-2">
|
||||||
{t('events.shareWithGuests')}
|
{isGalleryPublic(event.require_password)
|
||||||
|
? t('events.shareWithGuestsPublic', 'Anyone with this link can view the gallery. No password is required.')
|
||||||
|
: t('events.shareWithGuests')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!event.is_archived && (
|
{!event.is_archived && (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../compone
|
|||||||
import { BulkArchiveModal } from '../../components/admin';
|
import { BulkArchiveModal } from '../../components/admin';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
|
import { isGalleryPublic } from '../../utils/accessControl';
|
||||||
import type { Event } from '../../types';
|
import type { Event } from '../../types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -428,6 +429,17 @@ export const EventsListPage: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
|
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
|
||||||
<p className="text-xs text-neutral-500">{event.host_email}</p>
|
<p className="text-xs text-neutral-500">{event.host_email}</p>
|
||||||
|
<div className="mt-1">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||||
|
isGalleryPublic(event.require_password)
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-neutral-100 text-neutral-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-neutral-700">
|
<td className="px-6 py-4 text-sm text-neutral-700">
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Security settings state
|
// Security settings state
|
||||||
const [securitySettings, setSecuritySettings] = useState({
|
const [securitySettings, setSecuritySettings] = useState({
|
||||||
require_password: true,
|
|
||||||
password_min_length: 8,
|
password_min_length: 8,
|
||||||
password_complexity: 'moderate',
|
password_complexity: 'moderate',
|
||||||
enable_2fa: false,
|
enable_2fa: false,
|
||||||
@@ -146,7 +145,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Extract security settings
|
// Extract security settings
|
||||||
setSecuritySettings({
|
setSecuritySettings({
|
||||||
require_password: toBoolean(settings.security_require_password, true),
|
|
||||||
password_min_length: toNumber(settings.security_password_min_length, 8),
|
password_min_length: toNumber(settings.security_password_min_length, 8),
|
||||||
password_complexity: settings.security_password_complexity ?? 'moderate',
|
password_complexity: settings.security_password_complexity ?? 'moderate',
|
||||||
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
||||||
@@ -1105,16 +1103,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={securitySettings.require_password}
|
|
||||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, require_password: e.target.checked }))}
|
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.requirePassword')}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
{t('settings.security.minPasswordLength')}
|
{t('settings.security.minPasswordLength')}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { LoginResponse, GalleryAuthResponse } from '../types';
|
import type { LoginResponse, GalleryAuthResponse } from '../types';
|
||||||
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
|
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
|
||||||
|
...response,
|
||||||
|
event: response.event
|
||||||
|
? {
|
||||||
|
...response.event,
|
||||||
|
require_password: normalizeRequirePassword((response.event as any)?.require_password, true),
|
||||||
|
}
|
||||||
|
: response.event,
|
||||||
|
});
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
// Admin authentication
|
// Admin authentication
|
||||||
@@ -24,7 +35,7 @@ export const authService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Gallery authentication
|
// Gallery authentication
|
||||||
async verifyGalleryPassword(slug: string, password: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
async verifyGalleryPassword(slug: string, password?: string, recaptchaToken?: string | null): Promise<GalleryAuthResponse> {
|
||||||
const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
|
const response = await api.post<GalleryAuthResponse>('/auth/gallery/verify', {
|
||||||
slug,
|
slug,
|
||||||
password,
|
password,
|
||||||
@@ -32,7 +43,7 @@ export const authService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Token is now handled by GalleryAuthContext with slug-specific storage
|
// Token is now handled by GalleryAuthContext with slug-specific storage
|
||||||
return response.data;
|
return normalizeGalleryResponse(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
|
async shareLinkLogin(slug: string, token: string): Promise<GalleryAuthResponse> {
|
||||||
@@ -40,7 +51,7 @@ export const authService = {
|
|||||||
slug,
|
slug,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
return response.data;
|
return normalizeGalleryResponse(response.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
async galleryLogout(slug?: string | null) {
|
async galleryLogout(slug?: string | null) {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { Event } from '../types';
|
import type { Event } from '../types';
|
||||||
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
|
const normalizeEvent = (event: Event): Event => ({
|
||||||
|
...event,
|
||||||
|
require_password: normalizeRequirePassword((event as any)?.require_password, true),
|
||||||
|
});
|
||||||
|
|
||||||
interface CreateEventData {
|
interface CreateEventData {
|
||||||
event_type: string;
|
event_type: string;
|
||||||
@@ -7,7 +13,8 @@ interface CreateEventData {
|
|||||||
event_date: string;
|
event_date: string;
|
||||||
host_email: string;
|
host_email: string;
|
||||||
admin_email: string;
|
admin_email: string;
|
||||||
password: string;
|
require_password?: boolean;
|
||||||
|
password?: string;
|
||||||
welcome_message?: string;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
expiration_days: number;
|
expiration_days: number;
|
||||||
@@ -28,6 +35,7 @@ interface UpdateEventData {
|
|||||||
event_date?: string;
|
event_date?: string;
|
||||||
host_email?: string;
|
host_email?: string;
|
||||||
admin_email?: string;
|
admin_email?: string;
|
||||||
|
require_password?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
welcome_message?: string;
|
welcome_message?: string;
|
||||||
color_theme?: string;
|
color_theme?: string;
|
||||||
@@ -64,19 +72,25 @@ export const eventsService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
||||||
return response.data;
|
const data: any = response.data;
|
||||||
|
if (Array.isArray(data?.events)) {
|
||||||
|
data.events = data.events.map((event: Event) => normalizeEvent(event));
|
||||||
|
} else if (Array.isArray(data)) {
|
||||||
|
return data.map((event: Event) => normalizeEvent(event)) as any;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get single event details (admin)
|
// Get single event details (admin)
|
||||||
async getEvent(id: number): Promise<Event> {
|
async getEvent(id: number): Promise<Event> {
|
||||||
const response = await api.get<Event>(`/admin/events/${id}`);
|
const response = await api.get<Event>(`/admin/events/${id}`);
|
||||||
return response.data;
|
return normalizeEvent(response.data as Event);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create new event (admin)
|
// Create new event (admin)
|
||||||
async createEvent(data: CreateEventData): Promise<Event> {
|
async createEvent(data: CreateEventData): Promise<Event> {
|
||||||
const response = await api.post<Event>('/admin/events', data);
|
const response = await api.post<Event>('/admin/events', data);
|
||||||
return response.data;
|
return normalizeEvent(response.data as Event);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Update event (admin)
|
// Update event (admin)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { api } from '../config/api';
|
import { api } from '../config/api';
|
||||||
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
||||||
|
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||||
|
|
||||||
export const galleryService = {
|
export const galleryService = {
|
||||||
// Verify share token
|
// Verify share token
|
||||||
@@ -12,7 +13,11 @@ export const galleryService = {
|
|||||||
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
|
async getGalleryInfo(slug: string, token?: string): Promise<GalleryInfo> {
|
||||||
const params = token ? { token } : {};
|
const params = token ? { token } : {};
|
||||||
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params });
|
const response = await api.get<GalleryInfo>(`/gallery/${slug}/info`, { params });
|
||||||
return response.data;
|
const data = response.data;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
requires_password: normalizeRequirePassword((data as any)?.requires_password, true),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get gallery photos (requires auth)
|
// Get gallery photos (requires auth)
|
||||||
@@ -29,7 +34,17 @@ export const galleryService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
|
const response = await api.get<GalleryData>(`/gallery/${slug}/photos`, { params });
|
||||||
return response.data;
|
const data = response.data;
|
||||||
|
const normalizedEvent = data?.event
|
||||||
|
? {
|
||||||
|
...data.event,
|
||||||
|
require_password: normalizeRequirePassword((data.event as any)?.require_password, true),
|
||||||
|
}
|
||||||
|
: data.event;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
event: normalizedEvent,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// Download single photo
|
// Download single photo
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface Event {
|
|||||||
is_archived: boolean;
|
is_archived: boolean;
|
||||||
archive_path?: string;
|
archive_path?: string;
|
||||||
archived_at?: string;
|
archived_at?: string;
|
||||||
|
require_password?: boolean;
|
||||||
photo_count?: number;
|
photo_count?: number;
|
||||||
total_size?: number;
|
total_size?: number;
|
||||||
recent_photos?: Array<{
|
recent_photos?: Array<{
|
||||||
@@ -92,6 +93,7 @@ export interface GalleryData {
|
|||||||
disable_right_click?: boolean;
|
disable_right_click?: boolean;
|
||||||
watermark_downloads?: boolean;
|
watermark_downloads?: boolean;
|
||||||
watermark_text?: string;
|
watermark_text?: string;
|
||||||
|
require_password?: boolean;
|
||||||
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protection_level?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
image_quality?: number;
|
image_quality?: number;
|
||||||
use_canvas_rendering?: boolean;
|
use_canvas_rendering?: boolean;
|
||||||
@@ -134,6 +136,7 @@ export interface GalleryAuthResponse {
|
|||||||
expires_at: string;
|
expires_at: string;
|
||||||
allow_user_uploads?: boolean;
|
allow_user_uploads?: boolean;
|
||||||
upload_category_id?: number | null;
|
upload_category_id?: number | null;
|
||||||
|
require_password?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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_event');
|
||||||
sessionStorage.removeItem('gallery_token');
|
sessionStorage.removeItem('gallery_token');
|
||||||
sessionStorage.removeItem('gallery_active_slug');
|
sessionStorage.removeItem('gallery_active_slug');
|
||||||
|
|
||||||
|
// Remove slug-specific session storage entries as well
|
||||||
|
try {
|
||||||
|
const sessionKeysToRemove: string[] = [];
|
||||||
|
for (let i = 0; i < sessionStorage.length; i += 1) {
|
||||||
|
const key = sessionStorage.key(i);
|
||||||
|
if (key && (key.startsWith('gallery_event_') || key.startsWith('gallery_token_'))) {
|
||||||
|
sessionKeysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKeysToRemove.forEach((key) => sessionStorage.removeItem(key));
|
||||||
|
} catch {
|
||||||
|
// Session storage may be unavailable; ignore cleanup failures
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+23
-1
@@ -55,6 +55,7 @@ CUSTOM_PORT=""
|
|||||||
UNATTENDED=false
|
UNATTENDED=false
|
||||||
UPDATE_MODE=false
|
UPDATE_MODE=false
|
||||||
UNINSTALL_MODE=false
|
UNINSTALL_MODE=false
|
||||||
|
FORCE_ADMIN_PASSWORD_RESET=false
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
@@ -487,6 +488,15 @@ EOF
|
|||||||
# Run database migrations
|
# Run database migrations
|
||||||
log_step "Running database migrations..."
|
log_step "Running database migrations..."
|
||||||
docker compose exec -T backend npm run migrate
|
docker compose exec -T backend npm run migrate
|
||||||
|
|
||||||
|
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
|
||||||
|
log_step "Resetting admin credentials..."
|
||||||
|
if docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt; then
|
||||||
|
docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
log_warn "Automatic admin password reset failed; run reset-admin-password.js inside the backend container."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
log_success "Docker installation completed!"
|
log_success "Docker installation completed!"
|
||||||
}
|
}
|
||||||
@@ -737,6 +747,13 @@ EOF
|
|||||||
log_step "Initializing database..."
|
log_step "Initializing database..."
|
||||||
cd "$NATIVE_APP_DIR/app/backend"
|
cd "$NATIVE_APP_DIR/app/backend"
|
||||||
run_as_user "npm run migrate"
|
run_as_user "npm run migrate"
|
||||||
|
|
||||||
|
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
|
||||||
|
log_step "Resetting admin credentials..."
|
||||||
|
if ! run_as_user "node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"; then
|
||||||
|
log_warn "Automatic admin password reset failed; please run reset-admin-password.js manually."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Create systemd services
|
# Create systemd services
|
||||||
create_systemd_services
|
create_systemd_services
|
||||||
@@ -989,7 +1006,7 @@ print_success_message() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
||||||
echo -e "Password: ${YELLOW}(credentials file not found)${NC}"
|
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run node scripts/reset-admin-password.js manually)${NC}"
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
||||||
@@ -1256,6 +1273,10 @@ parse_arguments() {
|
|||||||
SMTP_PASS="$2"
|
SMTP_PASS="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--force-admin-password-reset)
|
||||||
|
FORCE_ADMIN_PASSWORD_RESET=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--enable-ssl)
|
--enable-ssl)
|
||||||
ENABLE_SSL=true
|
ENABLE_SSL=true
|
||||||
shift
|
shift
|
||||||
@@ -1301,6 +1322,7 @@ Options:
|
|||||||
--smtp-port PORT SMTP server port
|
--smtp-port PORT SMTP server port
|
||||||
--smtp-user USER SMTP username
|
--smtp-user USER SMTP username
|
||||||
--smtp-pass PASS SMTP password
|
--smtp-pass PASS SMTP password
|
||||||
|
--force-admin-password-reset Regenerate admin credentials after setup
|
||||||
--enable-ssl Enable HTTPS with Let's Encrypt
|
--enable-ssl Enable HTTPS with Let's Encrypt
|
||||||
--port PORT Custom port (native only)
|
--port PORT Custom port (native only)
|
||||||
--update Update existing installation
|
--update Update existing installation
|
||||||
|
|||||||
Reference in New Issue
Block a user