Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6f1c31369 | |||
| a1e9fb6ffc | |||
| 665ce5a6e7 | |||
| 8c41dd626d | |||
| 775e417e55 | |||
| fc1bf53412 | |||
| 5d6c061f1c | |||
| 45e835a51a | |||
| afc00090cf | |||
| 59750dea15 | |||
| 2fe32e9a69 | |||
| 5f8c8c5508 | |||
| fb739f221d | |||
| b5399aaa9b | |||
| a4595e2ab2 | |||
| 0911711a37 | |||
| f2c7594b23 | |||
| 32355fabad | |||
| c127fd829d | |||
| cab5b0d795 | |||
| ba95aad3c6 | |||
| c1be7d6785 | |||
| 0024686dc2 | |||
| 96b8b77792 | |||
| 9d2726b3d3 | |||
| 8d6ddd257d | |||
| e0865b81b6 | |||
| d4404e39bd | |||
| 8611206396 | |||
| 39d2244e1e | |||
| eb626be22c | |||
| aaaf59817b | |||
| 2a4d38813f | |||
| ad9c6d63d3 | |||
| 8c77b30de6 | |||
| e51347d0a1 | |||
| 71e7179145 | |||
| bda76ff513 | |||
| 097ce2c205 |
@@ -1,10 +1,7 @@
|
|||||||
name: Mirror to GitHub
|
name: Mirror to GitHub
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch: # Allow manual triggering only
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch: # Allow manual triggering
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
mirror:
|
mirror:
|
||||||
@@ -131,4 +128,5 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "✅ Mirror to GitHub workflow completed successfully!"
|
echo "✅ Mirror to GitHub workflow completed successfully!"
|
||||||
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
|
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
|
||||||
echo "🔒 Sensitive files have been removed from the mirror"
|
echo "🔒 Sensitive files have been removed from the mirror"
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
name: Version and Release
|
name: Version and Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
branches: [ main ]
|
|
||||||
paths-ignore:
|
|
||||||
- '**.md'
|
|
||||||
- '.gitea/**'
|
|
||||||
- '.drone.yml'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
version-bump:
|
version-bump:
|
||||||
@@ -72,8 +67,8 @@ jobs:
|
|||||||
echo "Root files changed: $ROOT_CHANGED"
|
echo "Root files changed: $ROOT_CHANGED"
|
||||||
|
|
||||||
# Get current versions
|
# Get current versions
|
||||||
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
|
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.1.0")
|
||||||
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
|
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.1.0")
|
||||||
|
|
||||||
echo "Current backend version: $BACKEND_VERSION"
|
echo "Current backend version: $BACKEND_VERSION"
|
||||||
echo "Current frontend version: $FRONTEND_VERSION"
|
echo "Current frontend version: $FRONTEND_VERSION"
|
||||||
@@ -133,10 +128,17 @@ jobs:
|
|||||||
MINOR="${version_parts[1]}"
|
MINOR="${version_parts[1]}"
|
||||||
PATCH="${version_parts[2]}"
|
PATCH="${version_parts[2]}"
|
||||||
|
|
||||||
# Increment patch version
|
# Increment patch version and ensure tag uniqueness
|
||||||
|
git fetch --tags --quiet || true
|
||||||
NEW_PATCH=$((PATCH + 1))
|
NEW_PATCH=$((PATCH + 1))
|
||||||
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||||
|
|
||||||
|
while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do
|
||||||
|
echo "Tag v${NEW_VERSION} already exists, bumping patch version again"
|
||||||
|
NEW_PATCH=$((NEW_PATCH + 1))
|
||||||
|
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
|
||||||
|
done
|
||||||
|
|
||||||
echo "New version: $NEW_VERSION"
|
echo "New version: $NEW_VERSION"
|
||||||
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
|
||||||
@@ -264,4 +266,4 @@ jobs:
|
|||||||
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
|
||||||
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
|
||||||
echo "Drone will automatically trigger on the new tag"
|
echo "Drone will automatically trigger on the new tag"
|
||||||
# Drone CI will automatically trigger on the tag push event
|
# Drone CI will automatically trigger on the tag push event
|
||||||
|
|||||||
+20
-1
@@ -201,6 +201,15 @@ openssl rand -base64 32 | tr -d '$'
|
|||||||
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
|
||||||
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
|
||||||
|
|
||||||
|
### Public Landing Page
|
||||||
|
|
||||||
|
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
|
||||||
|
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
|
||||||
|
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
|
||||||
|
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
|
||||||
|
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
|
||||||
|
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
|
||||||
|
|
||||||
### Backend Configuration (.env)
|
### Backend Configuration (.env)
|
||||||
Update `.env` with:
|
Update `.env` with:
|
||||||
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
|
||||||
@@ -411,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
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
|
|||||||
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
|
||||||
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
|
||||||
- 🎨 **Custom Themes** - Match your brand perfectly
|
- 🎨 **Custom Themes** - Match your brand perfectly
|
||||||
|
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
|
||||||
|
|
||||||
### For Clients
|
### For Clients
|
||||||
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
- 🖼️ **Beautiful Galleries** - Clean, modern interface
|
||||||
@@ -89,6 +90,18 @@ Note on Docker file permissions (PUID/PGID)
|
|||||||
- 🔒 [**Security**](SECURITY.md) - Security policies
|
- 🔒 [**Security**](SECURITY.md) - Security policies
|
||||||
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
|
||||||
|
|
||||||
|
## 🌐 Public Landing Page
|
||||||
|
|
||||||
|
Spotlight your studio with a customizable marketing page at `/`:
|
||||||
|
|
||||||
|
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
|
||||||
|
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
|
||||||
|
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
|
||||||
|
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
|
||||||
|
- Use **Reset to default** anytime to restore the bundled template.
|
||||||
|
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
|
||||||
|
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
|
||||||
|
|
||||||
## 🎯 Use Cases
|
## 🎯 Use Cases
|
||||||
|
|
||||||
Perfect for:
|
Perfect for:
|
||||||
@@ -209,6 +222,7 @@ These features are currently in beta testing and may have limited functionality
|
|||||||
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
|
||||||
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
|
||||||
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
|
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
|
||||||
|
| **Filtering & Export Options** | Add filters to show only rated, liked, or marked photos and export filtered selections for Capture One or Lightroom workflows | Low | 🔄 Open |
|
||||||
|
|
||||||
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
|
||||||
|
|
||||||
|
|||||||
@@ -292,6 +292,7 @@ sudo systemctl stop picpeak-backend picpeak-workers
|
|||||||
sudo systemctl restart picpeak-backend picpeak-workers
|
sudo systemctl restart picpeak-backend picpeak-workers
|
||||||
|
|
||||||
# Update PicPeak
|
# Update PicPeak
|
||||||
|
# (reruns migrations to pick up schema fixes for native installs)
|
||||||
sudo ./setup.sh --update
|
sudo ./setup.sh --update
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
FROM node:18-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
# Add build arguments
|
# Add build arguments
|
||||||
ARG CACHEBUST=1
|
ARG CACHEBUST=1
|
||||||
@@ -23,7 +23,7 @@ RUN npm ci --only=production
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:18-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const fsPromises = fs.promises;
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
describe('Admin settings logo upload flow', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let router;
|
||||||
|
let app;
|
||||||
|
let settingsStore;
|
||||||
|
|
||||||
|
const resetModules = () => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetModules();
|
||||||
|
|
||||||
|
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
|
||||||
|
process.env.STORAGE_PATH = tmpDir;
|
||||||
|
|
||||||
|
settingsStore = new Map();
|
||||||
|
|
||||||
|
const buildQuery = (table) => {
|
||||||
|
const filters = [];
|
||||||
|
const applyFilters = (rows) => {
|
||||||
|
if (filters.length === 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.filter((row) =>
|
||||||
|
filters.every(({ column, value }) => row[column] === value)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRow = (row) => ({ ...row });
|
||||||
|
|
||||||
|
return {
|
||||||
|
where(column, value) {
|
||||||
|
filters.push({ column, value });
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
first() {
|
||||||
|
if (table === 'app_settings') {
|
||||||
|
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
|
||||||
|
return Promise.resolve(rows[0]);
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
select() {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
sum() {
|
||||||
|
return Promise.resolve({ total: 0 });
|
||||||
|
},
|
||||||
|
join() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
groupBy() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
orderBy() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
limit() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
insert(payload) {
|
||||||
|
const rows = Array.isArray(payload) ? payload : [payload];
|
||||||
|
const upsert = (row, overrides = {}) => {
|
||||||
|
if (table === 'app_settings') {
|
||||||
|
const key = row.setting_key;
|
||||||
|
const existing = settingsStore.get(key) || {};
|
||||||
|
settingsStore.set(key, { ...existing, ...row, ...overrides });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onConflict() {
|
||||||
|
return {
|
||||||
|
merge(overrides) {
|
||||||
|
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbMock = jest.fn((table) => buildQuery(table));
|
||||||
|
dbMock.raw = jest.fn();
|
||||||
|
dbMock.transaction = async (handler) => handler({
|
||||||
|
commit: async () => {},
|
||||||
|
rollback: async () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.doMock('../src/database/db', () => ({
|
||||||
|
db: dbMock,
|
||||||
|
logActivity: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/middleware/auth', () => ({
|
||||||
|
adminAuth: (req, res, next) => {
|
||||||
|
req.admin = { id: 1, username: 'tester' };
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/services/publicSiteService', () => ({
|
||||||
|
clearPublicSiteCache: jest.fn(),
|
||||||
|
getDefaultPublicSitePayload: jest.fn(),
|
||||||
|
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/services/rateLimitService', () => ({
|
||||||
|
clearSettingsCache: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/middleware/maintenance', () => ({
|
||||||
|
maintenanceMiddleware: (req, res, next) => next(),
|
||||||
|
clearMaintenanceCache: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
router = require('../src/routes/adminSettings');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin/settings', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
resetModules();
|
||||||
|
if (tmpDir) {
|
||||||
|
await fsPromises.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
tmpDir = null;
|
||||||
|
}
|
||||||
|
delete process.env.STORAGE_PATH;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
|
||||||
|
const fileBuffer = Buffer.from('fake image data');
|
||||||
|
|
||||||
|
const uploadResponse = await request(app)
|
||||||
|
.post('/api/admin/settings/logo')
|
||||||
|
.attach('logo', fileBuffer, 'logo.png');
|
||||||
|
|
||||||
|
expect(uploadResponse.status).toBe(200);
|
||||||
|
expect(uploadResponse.body).toHaveProperty('logoUrl');
|
||||||
|
const logoUrl = uploadResponse.body.logoUrl;
|
||||||
|
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
|
||||||
|
|
||||||
|
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
|
||||||
|
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put('/api/admin/settings/branding')
|
||||||
|
.send({
|
||||||
|
company_name: 'Test Co',
|
||||||
|
company_tagline: 'Tagline',
|
||||||
|
support_email: 'test@example.com',
|
||||||
|
footer_text: 'Footer',
|
||||||
|
watermark_enabled: false,
|
||||||
|
watermark_position: 'bottom-right',
|
||||||
|
watermark_opacity: 0.5,
|
||||||
|
watermark_size: 'medium',
|
||||||
|
favicon_url: null,
|
||||||
|
logo_url: '',
|
||||||
|
watermark_logo_url: null,
|
||||||
|
logo_size: 'medium',
|
||||||
|
logo_max_height: 120,
|
||||||
|
logo_position: 'left',
|
||||||
|
logo_display_header: true,
|
||||||
|
logo_display_hero: false,
|
||||||
|
logo_display_mode: 'default'
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await expect(fsPromises.access(storedPath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
describe('Admin photos in reference mode', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let storagePath;
|
||||||
|
let db;
|
||||||
|
let app;
|
||||||
|
let categoryId;
|
||||||
|
|
||||||
|
const resetModules = () => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-admin-photos-'));
|
||||||
|
storagePath = path.join(tmpDir, 'storage');
|
||||||
|
await fs.promises.mkdir(storagePath, { recursive: true });
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'photo_sharing_test.db');
|
||||||
|
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(process.env.TEST_DATABASE_PATH);
|
||||||
|
} catch (_) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
process.env.STORAGE_PATH = storagePath;
|
||||||
|
|
||||||
|
resetModules();
|
||||||
|
|
||||||
|
jest.doMock('../../src/middleware/auth', () => ({
|
||||||
|
adminAuth: (req, _res, next) => {
|
||||||
|
req.admin = { id: 1, username: 'tester' };
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../../src/services/imageProcessor', () => ({
|
||||||
|
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
|
||||||
|
ensureThumbnail: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../../src/middleware/uploadValidation', () => ({
|
||||||
|
validateUploadedFiles: (_req, _res, next) => next()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../../src/utils/fileSecurityUtils', () => {
|
||||||
|
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
validateFileType: () => true,
|
||||||
|
createFileUploadValidator: () => (_req, _res, next) => next()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.doMock('../../src/utils/logger', () => ({
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dbModule = require('../../src/database/db');
|
||||||
|
db = dbModule.db;
|
||||||
|
|
||||||
|
await db.schema.dropTableIfExists('photo_feedback');
|
||||||
|
await db.schema.dropTableIfExists('photos');
|
||||||
|
await db.schema.dropTableIfExists('photo_categories');
|
||||||
|
await db.schema.dropTableIfExists('events');
|
||||||
|
|
||||||
|
await db.schema.createTable('events', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('slug').notNullable();
|
||||||
|
table.string('event_name').notNullable();
|
||||||
|
table.string('source_mode').notNullable();
|
||||||
|
table.string('external_path');
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.createTable('photo_categories', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('name').notNullable();
|
||||||
|
table.string('slug').notNullable();
|
||||||
|
table.boolean('is_global').defaultTo(true);
|
||||||
|
table.integer('event_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.createTable('photos', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.integer('event_id').notNullable();
|
||||||
|
table.string('filename').notNullable();
|
||||||
|
table.string('path').notNullable();
|
||||||
|
table.string('thumbnail_path');
|
||||||
|
table.string('type').notNullable();
|
||||||
|
table.integer('size_bytes');
|
||||||
|
table.integer('category_id');
|
||||||
|
table.string('source_origin');
|
||||||
|
table.string('external_relpath');
|
||||||
|
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||||
|
table.float('average_rating').defaultTo(0);
|
||||||
|
table.integer('like_count').defaultTo(0);
|
||||||
|
table.integer('favorite_count').defaultTo(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.createTable('photo_feedback', (table) => {
|
||||||
|
table.increments('id');
|
||||||
|
table.integer('photo_id');
|
||||||
|
table.string('feedback_type');
|
||||||
|
table.boolean('is_approved');
|
||||||
|
table.boolean('is_hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
await db('events').insert({
|
||||||
|
id: 1,
|
||||||
|
slug: 'test-event',
|
||||||
|
event_name: 'Test Event',
|
||||||
|
source_mode: 'reference',
|
||||||
|
external_path: 'external/library'
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertedCategory = await db('photo_categories').insert({
|
||||||
|
name: 'Highlights',
|
||||||
|
slug: 'highlights',
|
||||||
|
is_global: true
|
||||||
|
});
|
||||||
|
categoryId = Array.isArray(insertedCategory) ? insertedCategory[0] : insertedCategory;
|
||||||
|
|
||||||
|
const router = require('../../src/routes/adminPhotos');
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin/events', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (db) {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
resetModules();
|
||||||
|
delete process.env.TEST_DATABASE_PATH;
|
||||||
|
delete process.env.STORAGE_PATH;
|
||||||
|
if (tmpDir) {
|
||||||
|
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores managed uploads with category information and managed origin', async () => {
|
||||||
|
const uploadResponse = await request(app)
|
||||||
|
.post(`/api/admin/events/1/upload`)
|
||||||
|
.field('category_id', String(categoryId))
|
||||||
|
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
|
||||||
|
|
||||||
|
expect(uploadResponse.status).toBe(200);
|
||||||
|
expect(uploadResponse.body).toHaveProperty('photos');
|
||||||
|
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
|
||||||
|
|
||||||
|
const photo = await db('photos').first();
|
||||||
|
expect(photo).toBeTruthy();
|
||||||
|
expect(photo.category_id).toBe(categoryId);
|
||||||
|
expect(photo.source_origin).toBe('managed');
|
||||||
|
expect(photo.external_relpath).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns numeric category metadata when listing photos', async () => {
|
||||||
|
await db('photos').insert({
|
||||||
|
event_id: 1,
|
||||||
|
filename: 'external.jpg',
|
||||||
|
path: 'test-event/external.jpg',
|
||||||
|
thumbnail_path: null,
|
||||||
|
type: 'individual',
|
||||||
|
size_bytes: 123,
|
||||||
|
source_origin: 'external',
|
||||||
|
external_relpath: 'individual/external.jpg'
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get(`/api/admin/events/1/photos`)
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(Array.isArray(response.body.photos)).toBe(true);
|
||||||
|
const managedPhoto = response.body.photos.find((p) => p.category_id === categoryId);
|
||||||
|
expect(managedPhoto).toBeTruthy();
|
||||||
|
expect(managedPhoto.category_name).toBe('Highlights');
|
||||||
|
|
||||||
|
const filtered = await request(app)
|
||||||
|
.get(`/api/admin/events/1/photos`)
|
||||||
|
.query({ category_id: String(categoryId) })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(filtered.body.photos.every((p) => p.category_id === categoryId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes category updates', async () => {
|
||||||
|
const photo = await db('photos').first();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.patch(`/api/admin/events/1/photos/${photo.id}`)
|
||||||
|
.send({ category_id: '0' })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const updated = await db('photos').where({ id: photo.id }).first();
|
||||||
|
expect(updated.category_id).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
|
||||||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
@@ -503,4 +503,4 @@ describe('S3 Backup Integration Tests', () => {
|
|||||||
console.error('Failed to cleanup S3 objects:', error);
|
console.error('Failed to cleanup S3 objects:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
|
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
|
||||||
const mockFs = require('mock-fs');
|
const mockFs = require('mock-fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
@@ -748,4 +748,4 @@ describe('Enhanced Backup Service Tests', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const mockPath = path;
|
||||||
|
|
||||||
|
jest.mock('../../src/services/externalMediaService', () => ({
|
||||||
|
resolveExternalPath: jest.fn((event, relPath) => mockPath.join('/mock/external', event.external_path || '', relPath || '')),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { resolveExternalPath } = require('../../src/services/externalMediaService');
|
||||||
|
const { resolvePhotoFilePath } = require('../../src/services/photoResolver');
|
||||||
|
|
||||||
|
describe('resolvePhotoFilePath', () => {
|
||||||
|
const backendRoot = path.resolve(__dirname, '../../');
|
||||||
|
const originalStoragePath = process.env.STORAGE_PATH;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.STORAGE_PATH = path.join(backendRoot, 'storage');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
if (typeof originalStoragePath === 'string') {
|
||||||
|
process.env.STORAGE_PATH = originalStoragePath;
|
||||||
|
} else {
|
||||||
|
delete process.env.STORAGE_PATH;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns absolute path for managed photos with legacy slug paths', () => {
|
||||||
|
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||||
|
const photo = { path: 'wedding-party/hero.jpg' };
|
||||||
|
|
||||||
|
const result = resolvePhotoFilePath(event, photo);
|
||||||
|
|
||||||
|
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes prefixed managed paths without duplicating segments', () => {
|
||||||
|
const event = { slug: 'wedding-party', source_mode: 'managed' };
|
||||||
|
const photo = { path: 'events/active/wedding-party/hero.jpg' };
|
||||||
|
|
||||||
|
const result = resolvePhotoFilePath(event, photo);
|
||||||
|
|
||||||
|
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('delegates external photos to external media resolver', () => {
|
||||||
|
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||||
|
const photo = { source_origin: 'external', external_relpath: 'individual/look-01.jpg' };
|
||||||
|
|
||||||
|
const result = resolvePhotoFilePath(event, photo);
|
||||||
|
|
||||||
|
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'individual/look-01.jpg');
|
||||||
|
expect(result).toBe(path.join('/mock/external', 'picsum-demo', 'individual', 'look-01.jpg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates folder names when event external path already ends with segment', () => {
|
||||||
|
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo/individual' };
|
||||||
|
const photo = { source_origin: 'external', external_relpath: 'individual/look-02.jpg' };
|
||||||
|
|
||||||
|
const result = resolvePhotoFilePath(event, photo);
|
||||||
|
|
||||||
|
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'look-02.jpg');
|
||||||
|
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to managed storage when external metadata is missing', () => {
|
||||||
|
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||||
|
const photo = { path: 'fashion-show/new-upload.jpg' };
|
||||||
|
|
||||||
|
const result = resolvePhotoFilePath(event, photo);
|
||||||
|
|
||||||
|
expect(resolveExternalPath).not.toHaveBeenCalled();
|
||||||
|
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'fashion-show', 'new-upload.jpg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when external photo is missing relative path data', () => {
|
||||||
|
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
|
||||||
|
const photo = { source_origin: 'external' };
|
||||||
|
|
||||||
|
expect(() => resolvePhotoFilePath(event, photo)).toThrow('Missing external_relpath for external photo');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
process.env.JWT_SECRET = 'test-secret';
|
process.env.JWT_SECRET = 'test-secret';
|
||||||
|
if (!process.env.SKIP_S3_TESTS) {
|
||||||
|
process.env.SKIP_S3_TESTS = 'true';
|
||||||
|
}
|
||||||
|
if (!process.env.STORAGE_PATH) {
|
||||||
|
process.env.STORAGE_PATH = '/storage';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+72
-3
@@ -3,6 +3,54 @@ require('dotenv').config();
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
// Database configuration for different environments
|
// Database configuration for different environments
|
||||||
|
const resolveSqliteFilename = (filenameEnv) => {
|
||||||
|
const fallback = path.join(__dirname, './data/photo_sharing.db');
|
||||||
|
|
||||||
|
if (!filenameEnv) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = String(filenameEnv).trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved;
|
||||||
|
if (path.isAbsolute(trimmed)) {
|
||||||
|
resolved = trimmed;
|
||||||
|
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
|
||||||
|
resolved = path.resolve(__dirname, trimmed);
|
||||||
|
} else {
|
||||||
|
resolved = path.join(__dirname, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = path.normalize(resolved);
|
||||||
|
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
|
||||||
|
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
|
||||||
|
|
||||||
|
if (normalized.includes(duplicatePattern)) {
|
||||||
|
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sqliteConnection = (filenameEnv) => ({
|
||||||
|
filename: resolveSqliteFilename(filenameEnv)
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseSqliteConfig = {
|
||||||
|
client: 'sqlite3',
|
||||||
|
connection: sqliteConnection(),
|
||||||
|
useNullAsDefault: true,
|
||||||
|
migrations: {
|
||||||
|
directory: './migrations'
|
||||||
|
},
|
||||||
|
seeds: {
|
||||||
|
directory: './seeds'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
development: {
|
development: {
|
||||||
client: process.env.DATABASE_CLIENT || 'sqlite3',
|
client: process.env.DATABASE_CLIENT || 'sqlite3',
|
||||||
@@ -13,7 +61,7 @@ const config = {
|
|||||||
password: process.env.DB_PASSWORD || 'postgres',
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
database: process.env.DB_NAME || 'photo_sharing'
|
database: process.env.DB_NAME || 'photo_sharing'
|
||||||
} : {
|
} : {
|
||||||
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
|
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||||
},
|
},
|
||||||
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
|
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
|
||||||
migrations: {
|
migrations: {
|
||||||
@@ -24,6 +72,26 @@ const config = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
test: (() => {
|
||||||
|
const client = process.env.DATABASE_CLIENT || 'sqlite3';
|
||||||
|
const isPostgres = client === 'pg';
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseSqliteConfig,
|
||||||
|
client,
|
||||||
|
useNullAsDefault: !isPostgres,
|
||||||
|
connection: isPostgres
|
||||||
|
? {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
user: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || 'postgres',
|
||||||
|
database: process.env.DB_NAME || 'photo_sharing_test'
|
||||||
|
}
|
||||||
|
: sqliteConnection(process.env.TEST_DATABASE_PATH || './data/photo_sharing_test.db')
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
|
||||||
production: {
|
production: {
|
||||||
client: process.env.DATABASE_CLIENT || 'pg',
|
client: process.env.DATABASE_CLIENT || 'pg',
|
||||||
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
|
||||||
@@ -42,7 +110,7 @@ const config = {
|
|||||||
keepAliveInitialDelayMillis: 0
|
keepAliveInitialDelayMillis: 0
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
|
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
|
||||||
},
|
},
|
||||||
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
|
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
|
||||||
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
|
||||||
@@ -63,5 +131,6 @@ const config = {
|
|||||||
acquireConnectionTimeout: 60000
|
acquireConnectionTimeout: 60000
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const env = process.env.NODE_ENV || 'development';
|
||||||
|
|
||||||
module.exports = config[process.env.NODE_ENV || 'development'];
|
module.exports = config[env] || config.development;
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const logger = require('../../src/utils/logger');
|
||||||
|
|
||||||
|
async function ensureColumn(knex, tableName, columnName, alterFn) {
|
||||||
|
const exists = await knex.schema.hasColumn(tableName, columnName);
|
||||||
|
if (!exists) {
|
||||||
|
logger.info(`Adding column ${tableName}.${columnName}`);
|
||||||
|
await knex.schema.table(tableName, alterFn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
await ensureColumn(knex, 'events', 'host_name', (table) => {
|
||||||
|
table.string('host_name');
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureColumn(knex, 'events', 'allow_user_uploads', (table) => {
|
||||||
|
table.boolean('allow_user_uploads').defaultTo(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureColumn(knex, 'events', 'upload_category_id', (table) => {
|
||||||
|
table.integer('upload_category_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureColumn(knex, 'events', 'allow_downloads', (table) => {
|
||||||
|
table.boolean('allow_downloads').defaultTo(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureColumn(knex, 'events', 'disable_right_click', (table) => {
|
||||||
|
table.boolean('disable_right_click').defaultTo(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureColumn(knex, 'events', 'watermark_downloads', (table) => {
|
||||||
|
table.boolean('watermark_downloads').defaultTo(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureColumn(knex, 'events', 'watermark_text', (table) => {
|
||||||
|
table.text('watermark_text');
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureColumn(knex, 'events', 'hero_photo_id', (table) => {
|
||||||
|
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureColumn(knex, 'photos', 'uploaded_by', (table) => {
|
||||||
|
table.string('uploaded_by').defaultTo('admin');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function() {
|
||||||
|
// Non destructive migration; no rollback
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const {
|
||||||
|
DEFAULT_PUBLIC_SITE_HTML,
|
||||||
|
} = require('../../src/constants/publicSiteDefaults');
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
const defaults = [
|
||||||
|
{
|
||||||
|
setting_key: 'general_public_site_enabled',
|
||||||
|
setting_value: JSON.stringify(false),
|
||||||
|
setting_type: 'general'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'general_public_site_html',
|
||||||
|
setting_value: JSON.stringify(DEFAULT_PUBLIC_SITE_HTML.trim()),
|
||||||
|
setting_type: 'general'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
setting_key: 'general_public_site_custom_css',
|
||||||
|
setting_value: JSON.stringify(''),
|
||||||
|
setting_type: 'general'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const setting of defaults) {
|
||||||
|
const exists = await knex('app_settings')
|
||||||
|
.where('setting_key', setting.setting_key)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
await knex('app_settings').insert({
|
||||||
|
...setting,
|
||||||
|
updated_at: knex.fn.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
await knex('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'general_public_site_enabled',
|
||||||
|
'general_public_site_html',
|
||||||
|
'general_public_site_custom_css'
|
||||||
|
])
|
||||||
|
.del();
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -120,7 +120,9 @@ async function runMigrations() {
|
|||||||
|
|
||||||
// Check if this is a new deployment
|
// Check if this is a new deployment
|
||||||
// It's new if no essential tables exist OR no migrations have been applied
|
// It's new if no essential tables exist OR no migrations have been applied
|
||||||
const isNewDeployment = (!hasEventsTable || !hasPhotosTable || !hasAdminTable || !hasActivityLogsTable) || appliedFilenames.length === 0;
|
const hasEssentialTables = hasEventsTable && hasPhotosTable && hasAdminTable && hasActivityLogsTable;
|
||||||
|
const isDatabaseEmpty = !hasEventsTable && !hasPhotosTable && !hasAdminTable && !hasActivityLogsTable;
|
||||||
|
const isNewDeployment = isDatabaseEmpty || (appliedFilenames.length === 0 && !hasEssentialTables);
|
||||||
|
|
||||||
// Only detect existing schema for truly existing deployments
|
// Only detect existing schema for truly existing deployments
|
||||||
if (!isNewDeployment) {
|
if (!isNewDeployment) {
|
||||||
@@ -227,4 +229,4 @@ if (require.main === module) {
|
|||||||
waitAndRun();
|
waitAndRun();
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { runMigrations };
|
module.exports = { runMigrations };
|
||||||
|
|||||||
Generated
+241
-39
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.124",
|
"version": "1.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.124",
|
"version": "1.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.12.2",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@@ -34,9 +35,10 @@
|
|||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"nodemailer": "7.0.5",
|
"nodemailer": "^7.0.10",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
"sharp": "0.34.3",
|
"sharp": "0.34.3",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
@@ -46,6 +48,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
|
"mock-fs": "^5.5.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^6.3.3"
|
||||||
}
|
}
|
||||||
@@ -3889,13 +3892,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.10.0",
|
"version": "1.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||||
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
|
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.4",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -4727,6 +4730,28 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
@@ -4898,7 +4923,6 @@
|
|||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -4992,6 +5016,61 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "16.6.1",
|
"version": "16.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
|
||||||
@@ -5107,6 +5186,18 @@
|
|||||||
"once": "^1.4.0"
|
"once": "^1.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/env-paths": {
|
"node_modules/env-paths": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||||
@@ -5198,7 +5289,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
@@ -5530,13 +5620,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-validator": {
|
"node_modules/express-validator": {
|
||||||
"version": "7.2.1",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
|
||||||
"integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==",
|
"integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"validator": "~13.12.0"
|
"validator": "~13.15.15"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 8.0.0"
|
"node": ">= 8.0.0"
|
||||||
@@ -6146,6 +6236,25 @@
|
|||||||
"void-elements": "3.1.0"
|
"void-elements": "3.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-cache-semantics": {
|
"node_modules/http-cache-semantics": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
|
||||||
@@ -6540,6 +6649,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-object": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-stream": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
@@ -7991,6 +8109,16 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/mock-fs": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-d/P1M/RacgM3dB0sJ8rjeRNXxtapkPCUnMGmIN0ixJ16F/E4GUZCvWcSGfWGz8eaXYvn1s9baUwNjI4LOPEjiA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -8015,6 +8143,24 @@
|
|||||||
"node": ">= 10.16.0"
|
"node": ">= 10.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/napi-build-utils": {
|
"node_modules/napi-build-utils": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
@@ -8209,9 +8355,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "7.0.5",
|
"version": "7.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
|
||||||
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
|
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -8520,6 +8666,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-srcset": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -8669,7 +8821,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
@@ -8764,6 +8915,34 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss": {
|
||||||
|
"version": "8.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.11",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postgres-array": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@@ -8829,24 +9008,6 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prebuild-install/node_modules/chownr": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/prebuild-install/node_modules/tar-fs": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"chownr": "^1.1.1",
|
|
||||||
"mkdirp-classic": "^0.5.2",
|
|
||||||
"pump": "^3.0.0",
|
|
||||||
"tar-stream": "^2.1.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -9355,6 +9516,20 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sanitize-html": {
|
||||||
|
"version": "2.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
|
||||||
|
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deepmerge": "^4.2.2",
|
||||||
|
"escape-string-regexp": "^4.0.0",
|
||||||
|
"htmlparser2": "^8.0.0",
|
||||||
|
"is-plain-object": "^5.0.0",
|
||||||
|
"parse-srcset": "^1.0.2",
|
||||||
|
"postcss": "^8.3.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.2",
|
"version": "7.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||||
@@ -9727,6 +9902,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-support": {
|
"node_modules/source-map-support": {
|
||||||
"version": "0.5.13",
|
"version": "0.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz",
|
||||||
@@ -10044,6 +10228,24 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tar-fs": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chownr": "^1.1.1",
|
||||||
|
"mkdirp-classic": "^0.5.2",
|
||||||
|
"pump": "^3.0.0",
|
||||||
|
"tar-stream": "^2.1.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tar-fs/node_modules/chownr": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/tar-stream": {
|
"node_modules/tar-stream": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||||
@@ -10422,9 +10624,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/validator": {
|
"node_modules/validator": {
|
||||||
"version": "13.12.0",
|
"version": "13.15.20",
|
||||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
|
||||||
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
|
"integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.124",
|
"version": "1.1.5",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -17,9 +17,10 @@
|
|||||||
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
"@aws-sdk/s3-request-presigner": "^3.850.0",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^5.3.1",
|
"archiver": "^5.3.1",
|
||||||
"axios": "^1.10.0",
|
"axios": "^1.12.2",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"chokidar": "4.0.3",
|
"chokidar": "4.0.3",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
@@ -38,9 +39,10 @@
|
|||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"nodemailer": "7.0.5",
|
"nodemailer": "^7.0.10",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
"sharp": "0.34.3",
|
"sharp": "0.34.3",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
|
"mock-fs": "^5.5.0",
|
||||||
"nodemon": "^3.1.10",
|
"nodemon": "^3.1.10",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^6.3.3"
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
||||||
|
|||||||
+203
-5
@@ -26,6 +26,12 @@ const { startScheduledBackups } = require('./src/services/databaseBackup');
|
|||||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||||
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||||
|
const { getPublicSitePayload } = require('./src/services/publicSiteService');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const {
|
||||||
|
getAdminTokenFromRequest,
|
||||||
|
getGalleryTokenFromRequest,
|
||||||
|
} = require('./src/utils/tokenUtils');
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
const authRoutes = require('./src/routes/auth-enhanced');
|
const authRoutes = require('./src/routes/auth-enhanced');
|
||||||
@@ -47,14 +53,18 @@ app.set('trust proxy', 'loopback, linklocal, uniquelocal');
|
|||||||
const enableHsts = process.env.ENABLE_HSTS === 'true';
|
const enableHsts = process.env.ENABLE_HSTS === 'true';
|
||||||
const cspDirectives = {
|
const cspDirectives = {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
scriptSrc: ["'self'", "'unsafe-inline'"], // Required for React
|
scriptSrc: [
|
||||||
|
"'self'",
|
||||||
|
'https://www.google.com',
|
||||||
|
'https://www.gstatic.com'
|
||||||
|
],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
|
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
|
||||||
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
|
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
|
||||||
connectSrc: ["'self'"], // API connections
|
connectSrc: ["'self'", 'https://www.google.com', 'https://www.gstatic.com'], // API connections
|
||||||
fontSrc: ["'self'", "https:", "data:"], // Web fonts
|
fontSrc: ["'self'", "https:", "data:"], // Web fonts
|
||||||
objectSrc: ["'none'"], // Disable plugins
|
objectSrc: ["'none'"], // Disable plugins
|
||||||
mediaSrc: ["'self'"], // Audio/video
|
mediaSrc: ["'self'"], // Audio/video
|
||||||
frameSrc: ["'none'"], // Disable iframes
|
frameSrc: ["'self'", 'https://www.google.com'],
|
||||||
};
|
};
|
||||||
// Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment)
|
// Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment)
|
||||||
if (enableHsts) {
|
if (enableHsts) {
|
||||||
@@ -62,6 +72,45 @@ if (enableHsts) {
|
|||||||
cspDirectives.upgradeInsecureRequests = [];
|
cspDirectives.upgradeInsecureRequests = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.headers.authorization) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = req.path || '';
|
||||||
|
const slugMatch = path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
|
||||||
|
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
|
||||||
|
const adminToken = getAdminTokenFromRequest(req);
|
||||||
|
const galleryToken = getGalleryTokenFromRequest(req, slug);
|
||||||
|
|
||||||
|
const isAdminRequest = path.startsWith('/api/admin') || path.startsWith('/admin');
|
||||||
|
const isGalleryRequest = Boolean(slugMatch)
|
||||||
|
|| path.startsWith('/api/gallery')
|
||||||
|
|| path.startsWith('/gallery')
|
||||||
|
|| path.startsWith('/api/secure-images');
|
||||||
|
|
||||||
|
// Prefer admin credentials on admin routes so gallery sessions cannot override them.
|
||||||
|
if (isAdminRequest) {
|
||||||
|
if (adminToken) {
|
||||||
|
req.headers.authorization = `Bearer ${adminToken}`;
|
||||||
|
}
|
||||||
|
} else if (isGalleryRequest) {
|
||||||
|
if (galleryToken) {
|
||||||
|
req.headers.authorization = `Bearer ${galleryToken}`;
|
||||||
|
} else if (adminToken) {
|
||||||
|
req.headers.authorization = `Bearer ${adminToken}`;
|
||||||
|
}
|
||||||
|
} else if (adminToken) {
|
||||||
|
req.headers.authorization = `Bearer ${adminToken}`;
|
||||||
|
} else if (galleryToken) {
|
||||||
|
req.headers.authorization = `Bearer ${galleryToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
// Avoid helmet adding defaults like upgrade-insecure-requests when not desired
|
// Avoid helmet adding defaults like upgrade-insecure-requests when not desired
|
||||||
@@ -122,6 +171,145 @@ app.options('/api/*', cors(corsOptions));
|
|||||||
let generalRateLimiter;
|
let generalRateLimiter;
|
||||||
let authRateLimiter;
|
let authRateLimiter;
|
||||||
|
|
||||||
|
function composeInlineStyles(payload) {
|
||||||
|
const { branding } = payload;
|
||||||
|
const cssSegments = [];
|
||||||
|
|
||||||
|
cssSegments.push(`:root {
|
||||||
|
--brand-primary: ${branding.colors.primary};
|
||||||
|
--brand-accent: ${branding.colors.accent};
|
||||||
|
--brand-background: ${branding.colors.background};
|
||||||
|
--brand-text: ${branding.colors.text};
|
||||||
|
}`);
|
||||||
|
|
||||||
|
if (payload.baseCss) {
|
||||||
|
cssSegments.push(payload.baseCss);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.css) {
|
||||||
|
cssSegments.push(`/* Custom styles */\n${payload.css}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cssSegments.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBrandHeader(branding) {
|
||||||
|
const displayName = branding.companyName || 'PicPeak';
|
||||||
|
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
|
||||||
|
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
|
||||||
|
|
||||||
|
const tagline = branding.companyTagline
|
||||||
|
? `<p class="brand-tagline">${branding.companyTagline}</p>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `<header class="site-header">
|
||||||
|
<div class="header-inner">
|
||||||
|
<div class="brand">
|
||||||
|
${logo}
|
||||||
|
<div class="brand-copy">
|
||||||
|
<p class="brand-label">${displayName}</p>
|
||||||
|
${tagline}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a href="#features">${'Features'}</a>
|
||||||
|
<a href="#workflow">${'Workflow'}</a>
|
||||||
|
<a href="#collections">${'Collections'}</a>
|
||||||
|
<a href="#stories">${'Stories'}</a>
|
||||||
|
<a href="#contact">${'Contact'}</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBrandFooter(branding) {
|
||||||
|
const displayName = branding.companyName || 'PicPeak';
|
||||||
|
const footerNote = branding.footerText
|
||||||
|
? `<p>${branding.footerText}</p>`
|
||||||
|
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
|
||||||
|
|
||||||
|
const supportLink = branding.supportEmail
|
||||||
|
? `<a href="mailto:${branding.supportEmail}">Support</a>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const legalLinks = `
|
||||||
|
<a href="/datenschutz">Privacy Policy</a>
|
||||||
|
<a href="/impressum">Impressum</a>
|
||||||
|
${supportLink}
|
||||||
|
`;
|
||||||
|
|
||||||
|
return `<footer class="site-footer" id="contact">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div>
|
||||||
|
<h2>${displayName}</h2>
|
||||||
|
${footerNote}
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
${legalLinks}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPublicSiteDocument(payload) {
|
||||||
|
const inlineStyles = composeInlineStyles(payload);
|
||||||
|
const header = renderBrandHeader(payload.branding);
|
||||||
|
const footer = renderBrandFooter(payload.branding);
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${payload.title}</title>
|
||||||
|
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<style>${inlineStyles}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="site-shell">
|
||||||
|
${header}
|
||||||
|
<main class="site-main">
|
||||||
|
${payload.html}
|
||||||
|
</main>
|
||||||
|
${footer}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePublicSiteRequest(req, res, next) {
|
||||||
|
try {
|
||||||
|
const payload = await getPublicSitePayload();
|
||||||
|
|
||||||
|
if (!payload.enabled) {
|
||||||
|
res.redirect(302, '/admin/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.etag && req.headers['if-none-match'] === payload.etag) {
|
||||||
|
res.status(304).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const document = buildPublicSiteDocument(payload);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=30, must-revalidate');
|
||||||
|
res.setHeader('ETag', payload.etag);
|
||||||
|
res.setHeader('Vary', 'Accept-Encoding');
|
||||||
|
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; object-src 'none'; script-src 'self'; form-action 'self'");
|
||||||
|
|
||||||
|
res.status(200).send(document);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to render public site', { error: error.message });
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Function to initialize rate limiters
|
// Function to initialize rate limiters
|
||||||
async function initializeRateLimiters() {
|
async function initializeRateLimiters() {
|
||||||
generalRateLimiter = await createRateLimiter();
|
generalRateLimiter = await createRateLimiter();
|
||||||
@@ -260,13 +448,23 @@ try {
|
|||||||
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
|
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
|
||||||
if (shouldServe) {
|
if (shouldServe) {
|
||||||
logger.info(`Serving frontend from ${frontendDir}`);
|
logger.info(`Serving frontend from ${frontendDir}`);
|
||||||
|
// Serve pre-built assets
|
||||||
app.use(express.static(frontendDir));
|
app.use(express.static(frontendDir));
|
||||||
// SPA fallback for non-API routes
|
|
||||||
app.get([ '/', '/admin', '/admin/*', '/gallery/*' ], (req, res) => {
|
// Landing page handler or SPA fallback
|
||||||
|
app.get('/', handlePublicSiteRequest, (req, res) => {
|
||||||
|
res.sendFile(indexPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPA fallback for admin + gallery routes
|
||||||
|
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
|
||||||
res.sendFile(indexPath);
|
res.sendFile(indexPath);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
|
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
|
||||||
|
app.get('/', handlePublicSiteRequest, (req, res) => {
|
||||||
|
res.status(503).send('Frontend bundle not available. Build frontend or enable public site.');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
jest.mock('../database/db', () => {
|
||||||
|
const mockDb = jest.fn();
|
||||||
|
return {
|
||||||
|
db: mockDb,
|
||||||
|
logActivity: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../utils/logger', () => ({
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const { getPublicSitePayload, clearPublicSiteCache } = require('../services/publicSiteService');
|
||||||
|
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||||
|
|
||||||
|
const buildPublicSiteRows = (overrides = {}) => ([
|
||||||
|
{ setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) },
|
||||||
|
{ setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '<h1>{{company_name}}</h1>') },
|
||||||
|
{ setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const buildBrandingRows = (overrides = {}) => ([
|
||||||
|
{ setting_key: 'branding_company_name', setting_value: JSON.stringify(overrides.companyName ?? 'Willow & Pine Studio') },
|
||||||
|
{ setting_key: 'branding_company_tagline', setting_value: JSON.stringify(overrides.companyTagline ?? 'Stories told in colour and light.') },
|
||||||
|
{ setting_key: 'branding_support_email', setting_value: JSON.stringify(overrides.supportEmail ?? 'hello@example.com') },
|
||||||
|
{ setting_key: 'branding_logo_url', setting_value: JSON.stringify(overrides.logoUrl ?? '/uploads/logos/logo.png') },
|
||||||
|
{ setting_key: 'branding_footer_text', setting_value: JSON.stringify(overrides.footerText ?? 'Crafted with care for every celebration.') },
|
||||||
|
{ setting_key: 'theme_config', setting_value: JSON.stringify(overrides.themeConfig ?? {
|
||||||
|
primaryColor: '#2563eb',
|
||||||
|
accentColor: '#1d4ed8',
|
||||||
|
backgroundColor: '#f8fafc',
|
||||||
|
textColor: '#0f172a'
|
||||||
|
}) }
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe('publicSiteService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearPublicSiteCache();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes stored HTML by stripping script tags', async () => {
|
||||||
|
const publicSiteRows = buildPublicSiteRows({ html: '<h1>{{company_name}}</h1><script>alert(1)</script>' });
|
||||||
|
const brandingRows = buildBrandingRows();
|
||||||
|
|
||||||
|
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||||
|
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||||
|
|
||||||
|
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||||
|
|
||||||
|
expect(payload.enabled).toBe(true);
|
||||||
|
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
|
||||||
|
expect(payload.html).not.toContain('<script');
|
||||||
|
expect(payload.baseCss.length).toBeGreaterThan(0);
|
||||||
|
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sanitizes custom CSS and removes dangerous patterns', async () => {
|
||||||
|
const publicSiteRows = buildPublicSiteRows({
|
||||||
|
css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }"
|
||||||
|
});
|
||||||
|
const brandingRows = buildBrandingRows();
|
||||||
|
|
||||||
|
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||||
|
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||||
|
|
||||||
|
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||||
|
|
||||||
|
expect(payload.css).toContain('body { color: blue; }');
|
||||||
|
expect(payload.css).not.toContain('@import');
|
||||||
|
expect(payload.css).not.toContain('javascript:');
|
||||||
|
// Client-side util should match server sanitization expectations
|
||||||
|
const clientSanitized = sanitizeCss(publicSiteRows[2].setting_value ? JSON.parse(publicSiteRows[2].setting_value) : '');
|
||||||
|
expect(clientSanitized).not.toContain('@import');
|
||||||
|
expect(clientSanitized).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects branding tokens into the rendered payload', async () => {
|
||||||
|
const publicSiteRows = buildPublicSiteRows({ html: '<section><h1>{{company_name}}</h1><p>{{company_tagline}}</p><a href="mailto:{{support_email}}">Get in touch</a></section>' });
|
||||||
|
const brandingRows = buildBrandingRows({
|
||||||
|
companyName: 'Aurora Collective',
|
||||||
|
companyTagline: 'Modern photography for timeless celebrations.',
|
||||||
|
supportEmail: 'studio@aurora.co',
|
||||||
|
logoUrl: '/uploads/logos/aurora.png',
|
||||||
|
themeConfig: {
|
||||||
|
primaryColor: '#5C8762',
|
||||||
|
accentColor: '#1d4ed8',
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
textColor: '#171717'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
|
||||||
|
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
|
||||||
|
|
||||||
|
const payload = await getPublicSitePayload({ bypassCache: true });
|
||||||
|
|
||||||
|
expect(payload.html).toContain('Aurora Collective');
|
||||||
|
expect(payload.html).toContain('Modern photography for timeless celebrations.');
|
||||||
|
expect(payload.html).toContain('studio@aurora.co');
|
||||||
|
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
|
||||||
|
expect(payload.branding.colors.primary).toBe('#5C8762');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,690 @@
|
|||||||
|
const DEFAULT_PUBLIC_SITE_TITLE = 'PicPeak — Curated Galleries, Effortless Sharing';
|
||||||
|
|
||||||
|
const DEFAULT_PUBLIC_SITE_HTML = `
|
||||||
|
<section class="hero" id="welcome">
|
||||||
|
<div class="hero__inner">
|
||||||
|
<span class="hero__badge">PicPeak Showcase</span>
|
||||||
|
<h1>Share the story of {{company_name}}</h1>
|
||||||
|
<p class="hero__lead">{{company_tagline}}</p>
|
||||||
|
<div class="hero__cta">
|
||||||
|
<a href="#features" class="button button--primary">Explore Features</a>
|
||||||
|
<a href="#collections" class="button button--ghost">View Sample Galleries</a>
|
||||||
|
</div>
|
||||||
|
<dl class="hero__stats">
|
||||||
|
<div>
|
||||||
|
<dt>Private invites</dt>
|
||||||
|
<dd>Secure links for every guest</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Curated delivery</dt>
|
||||||
|
<dd>Highlight every favourite instantly</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Fully branded</dt>
|
||||||
|
<dd>Colours, typography, and logo that match you</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="hero__visual">
|
||||||
|
<article class="deck deck--primary">
|
||||||
|
<header class="deck__header">
|
||||||
|
<img src="{{brand_logo_url}}" alt="{{company_name}} logo" class="deck__logo" loading="lazy" decoding="async" />
|
||||||
|
<span class="deck__title">PicPeak Gallery</span>
|
||||||
|
</header>
|
||||||
|
<ul class="deck__list">
|
||||||
|
<li>Guided cover stories</li>
|
||||||
|
<li>Guest uploads with approvals</li>
|
||||||
|
<li>Protected high-res downloads</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
<article class="deck deck--secondary">
|
||||||
|
<p class="deck__quote">“PicPeak makes delivery feel like part of the celebration. Our couples relive the day the moment they open the link.”</p>
|
||||||
|
<p class="deck__author">— Studio Miraval</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="features" id="features">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-badge">Why teams pick PicPeak</span>
|
||||||
|
<h2>Design-first galleries with the workflow you already love</h2>
|
||||||
|
<p>Bring the PicPeak admin experience to your clients with branded, secure, and responsive public pages.</p>
|
||||||
|
</div>
|
||||||
|
<div class="feature-grid">
|
||||||
|
<article>
|
||||||
|
<h3>Beautiful by default</h3>
|
||||||
|
<p>Every gallery inherits your PicPeak theme, typography, and colour palette automatically.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Guided storytelling</h3>
|
||||||
|
<p>Create anchored sections, spotlight favourite collections, and embed testimonials that build trust.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Secure sharing</h3>
|
||||||
|
<p>Password gates, expiring links, and download protection keep every celebration personal.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="workflow" id="workflow">
|
||||||
|
<div class="workflow__content">
|
||||||
|
<h2>Launch in minutes</h2>
|
||||||
|
<ol class="workflow__steps">
|
||||||
|
<li>
|
||||||
|
<h4>Brand it once</h4>
|
||||||
|
<p>PicPeak automatically applies your logo, colours, and support details.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h4>Curate sections</h4>
|
||||||
|
<p>Highlight hero stories, featured galleries, and timeline moments with simple HTML blocks.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h4>Share confidently</h4>
|
||||||
|
<p>Send a single link that greets guests before they enter their private gallery.</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<div class="workflow__media">
|
||||||
|
<figure class="workflow__browser">
|
||||||
|
<img src="/picpeak-logo-transparent.png" alt="PicPeak interface" loading="lazy" decoding="async" />
|
||||||
|
<figcaption>PicPeak dashboard — trusted by studios worldwide.</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="collections" id="collections">
|
||||||
|
<div class="section-head">
|
||||||
|
<span class="section-badge">Showcase highlights</span>
|
||||||
|
<h2>Curated sample galleries that mirror your client experience</h2>
|
||||||
|
<p>Drop in featured stories, welcome messages, and callouts that prepare guests for what comes next.</p>
|
||||||
|
</div>
|
||||||
|
<div class="collection-showcase">
|
||||||
|
<article>
|
||||||
|
<h3>Signature Galleries</h3>
|
||||||
|
<p>Use responsive cards to preview your most loved collections or vendor partnerships.</p>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<h3>Welcome timelines</h3>
|
||||||
|
<p>Guide guests from arrival to download with steps that feel effortless and on-brand.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stories" id="stories">
|
||||||
|
<div class="section-head section-head--center">
|
||||||
|
<span class="section-badge">Client notes</span>
|
||||||
|
<h2>Experiences that keep guests coming back</h2>
|
||||||
|
</div>
|
||||||
|
<div class="story-grid">
|
||||||
|
<figure>
|
||||||
|
<blockquote>“From the welcome page to the final download, everything felt like us. PicPeak turned our gallery into part of the celebration.”</blockquote>
|
||||||
|
<figcaption>— Harper & Elias</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure>
|
||||||
|
<blockquote>“The public landing page gives every collection a narrative. Our couples feel the care we put into every image.”</blockquote>
|
||||||
|
<figcaption>— Jordan Rivera, Photographer</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="cta" id="contact">
|
||||||
|
<div class="cta__inner">
|
||||||
|
<div>
|
||||||
|
<h2>Ready to welcome your guests?</h2>
|
||||||
|
<p>Create a PicPeak landing page that matches your studio and introduces every celebration with confidence.</p>
|
||||||
|
</div>
|
||||||
|
<div class="cta__actions">
|
||||||
|
<a href="mailto:{{support_email}}" class="button button--primary">Contact us</a>
|
||||||
|
<a href="#features" class="button button--ghost">Review features</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="site-footer" id="legal">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div>
|
||||||
|
<h2>{{company_name}}</h2>
|
||||||
|
<p>Powered by PicPeak to keep every celebration beautifully organised.</p>
|
||||||
|
</div>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="/datenschutz">Privacy Policy</a>
|
||||||
|
<a href="/impressum">Impressum</a>
|
||||||
|
<a href="mailto:{{support_email}}">Support</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DEFAULT_PUBLIC_SITE_CSS = `
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: linear-gradient(180deg, var(--brand-background), #ffffff 55%);
|
||||||
|
color: var(--brand-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: contain;
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-label {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--brand-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tagline {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: rgba(15, 23, 42, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(15, 23, 42, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: -6px;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: transparent;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-nav a:hover::after {
|
||||||
|
background: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rem;
|
||||||
|
padding: 2.5rem 1.5rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.site-main {
|
||||||
|
padding: 3rem 0 5rem;
|
||||||
|
gap: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.features,
|
||||||
|
.workflow,
|
||||||
|
.collections,
|
||||||
|
.stories,
|
||||||
|
.cta {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 2.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: rgba(34, 197, 94, 0.18);
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2.65rem, 4.8vw, 3.6rem);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.08;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__lead {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 32rem;
|
||||||
|
color: rgba(15, 23, 42, 0.72);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__cta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__stats {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__stats dt {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--brand-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__stats dd {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: rgba(15, 23, 42, 0.6);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__visual {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck {
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35);
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
display: grid;
|
||||||
|
gap: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck--primary {
|
||||||
|
border-color: rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck--secondary {
|
||||||
|
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(15, 23, 42, 0.03));
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__logo {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__title {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
color: rgba(15, 23, 42, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__quote {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(15, 23, 42, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deck__author {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--brand-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head--center {
|
||||||
|
text-align: center;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: rgba(34, 197, 94, 0.14);
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2rem, 3vw, 2.6rem);
|
||||||
|
letter-spacing: -0.018em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(15, 23, 42, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-grid article {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow {
|
||||||
|
display: grid;
|
||||||
|
gap: 2rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
.workflow {
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow__steps {
|
||||||
|
margin: 1.75rem 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow__steps h4 {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--brand-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow__steps p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(15, 23, 42, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow__browser {
|
||||||
|
margin: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.05);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.1);
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(15, 23, 42, 0.55);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-showcase {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.collection-showcase article {
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-grid {
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-grid figure {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.75rem;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-grid blockquote {
|
||||||
|
margin: 0 0 1.2rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: rgba(15, 23, 42, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.story-grid figcaption {
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(15, 23, 42, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
background: linear-gradient(135deg, var(--brand-primary), var(--brand-accent));
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 28px;
|
||||||
|
padding: clamp(2.5rem, 5vw, 3.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta__inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.75rem;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta__inner h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(2rem, 3vw, 2.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta__inner p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.85rem 1.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: transform 160ms ease, box-shadow 200ms ease, background 200ms ease, color 200ms ease;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--primary {
|
||||||
|
background: var(--brand-primary);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 25px 45px -25px rgba(15, 23, 42, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--primary:hover {
|
||||||
|
background: var(--brand-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button--ghost {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.45);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
padding: 3rem 1.5rem;
|
||||||
|
background: rgba(15, 23, 42, 0.05);
|
||||||
|
border-top: 1px solid rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-inner {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-inner h2 {
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-inner p {
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(15, 23, 42, 0.65);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.site-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__visual {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflow {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta__inner {
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_PUBLIC_SITE_TITLE,
|
||||||
|
DEFAULT_PUBLIC_SITE_HTML,
|
||||||
|
DEFAULT_PUBLIC_SITE_CSS,
|
||||||
|
};
|
||||||
+155
-2
@@ -75,6 +75,14 @@ async function initializeDatabase() {
|
|||||||
table.boolean('is_archived').defaultTo(false);
|
table.boolean('is_archived').defaultTo(false);
|
||||||
table.string('archive_path');
|
table.string('archive_path');
|
||||||
table.datetime('archived_at');
|
table.datetime('archived_at');
|
||||||
|
table.boolean('allow_user_uploads').defaultTo(false);
|
||||||
|
table.integer('upload_category_id');
|
||||||
|
table.boolean('allow_downloads').defaultTo(true);
|
||||||
|
table.boolean('disable_right_click').defaultTo(false);
|
||||||
|
table.boolean('watermark_downloads').defaultTo(false);
|
||||||
|
table.text('watermark_text');
|
||||||
|
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
|
||||||
|
table.boolean('require_password').defaultTo(true);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Check if color_theme needs to be updated to TEXT type
|
// Check if color_theme needs to be updated to TEXT type
|
||||||
@@ -104,11 +112,42 @@ async function initializeDatabase() {
|
|||||||
archive_path TEXT,
|
archive_path TEXT,
|
||||||
archived_at DATETIME,
|
archived_at DATETIME,
|
||||||
allow_user_uploads BOOLEAN DEFAULT 0,
|
allow_user_uploads BOOLEAN DEFAULT 0,
|
||||||
upload_category_id INTEGER
|
upload_category_id INTEGER,
|
||||||
|
allow_downloads BOOLEAN DEFAULT 1,
|
||||||
|
disable_right_click BOOLEAN DEFAULT 0,
|
||||||
|
watermark_downloads BOOLEAN DEFAULT 0,
|
||||||
|
watermark_text TEXT,
|
||||||
|
hero_photo_id INTEGER,
|
||||||
|
require_password BOOLEAN DEFAULT 1
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await db.raw('INSERT INTO events_new SELECT * FROM events');
|
const pragmaRows = await db.raw("PRAGMA table_info('events')");
|
||||||
|
const existingColumns = pragmaRows.map(row => row.name);
|
||||||
|
const selectColumns = existingColumns.map((col) => {
|
||||||
|
switch (col) {
|
||||||
|
case 'allow_user_uploads':
|
||||||
|
return "COALESCE(allow_user_uploads, 0) as allow_user_uploads";
|
||||||
|
case 'upload_category_id':
|
||||||
|
return "upload_category_id";
|
||||||
|
case 'allow_downloads':
|
||||||
|
return "COALESCE(allow_downloads, 1) as allow_downloads";
|
||||||
|
case 'disable_right_click':
|
||||||
|
return "COALESCE(disable_right_click, 0) as disable_right_click";
|
||||||
|
case 'watermark_downloads':
|
||||||
|
return "COALESCE(watermark_downloads, 0) as watermark_downloads";
|
||||||
|
case 'watermark_text':
|
||||||
|
return 'watermark_text';
|
||||||
|
case 'hero_photo_id':
|
||||||
|
return 'hero_photo_id';
|
||||||
|
case 'require_password':
|
||||||
|
return 'COALESCE(require_password, 1) as require_password';
|
||||||
|
default:
|
||||||
|
return col;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.raw(`INSERT INTO events_new (${existingColumns.join(', ')}) SELECT ${selectColumns.join(', ')} FROM events`);
|
||||||
await db.raw('DROP TABLE events');
|
await db.raw('DROP TABLE events');
|
||||||
await db.raw('ALTER TABLE events_new RENAME TO events');
|
await db.raw('ALTER TABLE events_new RENAME TO events');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -129,6 +168,7 @@ async function initializeDatabase() {
|
|||||||
table.string('thumbnail_path');
|
table.string('thumbnail_path');
|
||||||
table.string('type').notNullable(); // 'collage' or 'individual'
|
table.string('type').notNullable(); // 'collage' or 'individual'
|
||||||
table.integer('size_bytes');
|
table.integer('size_bytes');
|
||||||
|
table.string('uploaded_by').defaultTo('admin');
|
||||||
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
table.datetime('uploaded_at').defaultTo(db.fn.now());
|
||||||
table.integer('view_count').defaultTo(0);
|
table.integer('view_count').defaultTo(0);
|
||||||
table.integer('download_count').defaultTo(0);
|
table.integer('download_count').defaultTo(0);
|
||||||
@@ -159,11 +199,24 @@ async function initializeDatabase() {
|
|||||||
table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete'
|
table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete'
|
||||||
table.json('email_data');
|
table.json('email_data');
|
||||||
table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed'
|
table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed'
|
||||||
|
table.datetime('created_at').defaultTo(db.fn.now());
|
||||||
table.datetime('scheduled_at').defaultTo(db.fn.now());
|
table.datetime('scheduled_at').defaultTo(db.fn.now());
|
||||||
table.datetime('sent_at');
|
table.datetime('sent_at');
|
||||||
table.text('error_message');
|
table.text('error_message');
|
||||||
table.integer('retry_count').defaultTo(0);
|
table.integer('retry_count').defaultTo(0);
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
const hasCreatedAt = await db.schema.hasColumn('email_queue', 'created_at');
|
||||||
|
if (!hasCreatedAt) {
|
||||||
|
await db.schema.alterTable('email_queue', (table) => {
|
||||||
|
table.datetime('created_at').defaultTo(db.fn.now());
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await db('email_queue').whereNull('created_at').update({ created_at: db.fn.now() });
|
||||||
|
} catch (updateError) {
|
||||||
|
logger.debug('Email queue created_at backfill skipped', { error: updateError.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin users table
|
// Admin users table
|
||||||
@@ -181,6 +234,7 @@ async function initializeDatabase() {
|
|||||||
table.datetime('updated_at').defaultTo(db.fn.now());
|
table.datetime('updated_at').defaultTo(db.fn.now());
|
||||||
table.datetime('last_login');
|
table.datetime('last_login');
|
||||||
table.string('last_login_ip');
|
table.string('last_login_ip');
|
||||||
|
table.string('language', 2).defaultTo('en');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Check if updated_at column exists
|
// Check if updated_at column exists
|
||||||
@@ -216,6 +270,13 @@ async function initializeDatabase() {
|
|||||||
table.string('last_login_ip');
|
table.string('last_login_ip');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasLanguage = await db.schema.hasColumn('admin_users', 'language');
|
||||||
|
if (!hasLanguage) {
|
||||||
|
await db.schema.table('admin_users', (table) => {
|
||||||
|
table.string('language', 2).defaultTo('en');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token revocation tables
|
// Token revocation tables
|
||||||
@@ -291,6 +352,18 @@ async function initializeDatabase() {
|
|||||||
table.datetime('updated_at').defaultTo(db.fn.now());
|
table.datetime('updated_at').defaultTo(db.fn.now());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultLanguageSetting = await db('app_settings')
|
||||||
|
.where('setting_key', 'default_language')
|
||||||
|
.first();
|
||||||
|
if (!defaultLanguageSetting) {
|
||||||
|
await db('app_settings').insert({
|
||||||
|
setting_key: 'default_language',
|
||||||
|
setting_value: JSON.stringify('en'),
|
||||||
|
setting_type: 'general',
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Activity logs table
|
// Activity logs table
|
||||||
const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
|
const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
|
||||||
@@ -315,6 +388,86 @@ async function initializeDatabase() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureGlobalCategories();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure photo categories exist for new deployments
|
||||||
|
async function ensureGlobalCategories() {
|
||||||
|
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
|
||||||
|
if (!hasPhotoCategoriesTable) {
|
||||||
|
await db.schema.createTable('photo_categories', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('name', 100).notNullable();
|
||||||
|
table.string('slug', 100).notNullable();
|
||||||
|
table.boolean('is_global').defaultTo(true);
|
||||||
|
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
|
||||||
|
table.timestamp('created_at').defaultTo(db.fn.now());
|
||||||
|
table.unique(['slug', 'event_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id');
|
||||||
|
if (!hasCategoryIdColumn) {
|
||||||
|
await db.schema.alterTable('photos', (table) => {
|
||||||
|
table.integer('category_id').references('id').inTable('photo_categories');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
|
||||||
|
if (!hasCmsPagesTable) {
|
||||||
|
await db.schema.createTable('cms_pages', (table) => {
|
||||||
|
table.increments('id').primary();
|
||||||
|
table.string('slug', 100).unique().notNullable();
|
||||||
|
table.text('title_en');
|
||||||
|
table.text('title_de');
|
||||||
|
table.text('content_en');
|
||||||
|
table.text('content_de');
|
||||||
|
table.timestamp('updated_at').defaultTo(db.fn.now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
|
||||||
|
const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0;
|
||||||
|
if (categoryCount === 0) {
|
||||||
|
const defaultCategories = [
|
||||||
|
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
|
||||||
|
{ name: 'Reception', slug: 'reception', is_global: true },
|
||||||
|
{ name: 'Portraits', slug: 'portraits', is_global: true },
|
||||||
|
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
|
||||||
|
{ name: 'Details', slug: 'details', is_global: true },
|
||||||
|
{ name: 'Party', slug: 'party', is_global: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
await db('photo_categories').insert(defaultCategories);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmsPages = await db('cms_pages').select('slug');
|
||||||
|
const existingSlugs = cmsPages.map((page) => page.slug);
|
||||||
|
const defaultPages = [
|
||||||
|
{
|
||||||
|
slug: 'impressum',
|
||||||
|
title_en: 'Legal Notice',
|
||||||
|
title_de: 'Impressum',
|
||||||
|
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
|
||||||
|
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'datenschutz',
|
||||||
|
title_en: 'Privacy Policy',
|
||||||
|
title_de: 'Datenschutzerklärung',
|
||||||
|
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
|
||||||
|
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const page of defaultPages) {
|
||||||
|
if (!existingSlugs.includes(page.slug)) {
|
||||||
|
await db('cms_pages').insert(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to log activities
|
// Helper function to log activities
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ const { db } = require('../database/db');
|
|||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced admin authentication middleware with revocation checking
|
* Enhanced admin authentication middleware with revocation checking
|
||||||
*/
|
*/
|
||||||
async function adminAuth(req, res, next) {
|
async function adminAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const token = getAdminTokenFromRequest(req);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,8 @@ async function adminAuth(req, res, next) {
|
|||||||
*/
|
*/
|
||||||
async function galleryAuth(req, res, next) {
|
async function galleryAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const slug = req.params?.slug || req.requestedSlug;
|
||||||
|
const token = getGalleryTokenFromRequest(req, slug);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
@@ -164,4 +166,4 @@ module.exports = {
|
|||||||
adminAuth,
|
adminAuth,
|
||||||
galleryAuth,
|
galleryAuth,
|
||||||
// ... other exports
|
// ... other exports
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced admin authentication middleware
|
* Enhanced admin authentication middleware
|
||||||
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
|
|||||||
*/
|
*/
|
||||||
async function adminAuth(req, res, next) {
|
async function adminAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const token = getAdminTokenFromRequest(req);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
@@ -89,7 +90,8 @@ async function adminAuth(req, res, next) {
|
|||||||
*/
|
*/
|
||||||
async function galleryAuth(req, res, next) {
|
async function galleryAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const slug = req.params?.slug || req.requestedSlug;
|
||||||
|
const token = getGalleryTokenFromRequest(req, slug);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
@@ -151,7 +153,8 @@ async function galleryAuth(req, res, next) {
|
|||||||
*/
|
*/
|
||||||
async function photoAuth(req, res, next) {
|
async function photoAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const slug = req.params?.slug || req.requestedSlug;
|
||||||
|
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
}
|
}
|
||||||
@@ -235,4 +238,4 @@ module.exports = {
|
|||||||
galleryAuth,
|
galleryAuth,
|
||||||
photoAuth,
|
photoAuth,
|
||||||
verifyGalleryAccess
|
verifyGalleryAccess
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ 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 logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
|
||||||
async function adminAuth(req, res, next) {
|
async function adminAuth(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const token = getAdminTokenFromRequest(req);
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||||
req.headers['x-real-ip'] ||
|
req.headers['x-real-ip'] ||
|
||||||
|
|||||||
@@ -1,17 +1,52 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
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 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 authHeader = req.headers.authorization;
|
const requestedSlug = req.params.slug || req.requestedSlug;
|
||||||
const token = authHeader?.split(' ')[1];
|
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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||||
let decoded;
|
let decoded;
|
||||||
try {
|
try {
|
||||||
@@ -26,12 +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
|
||||||
const requestedSlug = req.params.slug || req.requestedSlug;
|
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -64,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()}`;
|
||||||
|
|
||||||
@@ -80,14 +112,14 @@ 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', details: error.message });
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
verifyGalleryAccess
|
verifyGalleryAccess
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ const bcrypt = require('bcrypt');
|
|||||||
const jwt = require('jsonwebtoken');
|
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 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
|
||||||
@@ -20,9 +20,9 @@ async function photoAuth(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// First check for JWT token (from gallery access)
|
// First check for JWT token (from gallery access)
|
||||||
const authHeader = req.headers.authorization;
|
const tokenFromRequest = getGalleryTokenFromRequest(req, eventSlug);
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
if (tokenFromRequest) {
|
||||||
const token = authHeader.replace('Bearer ', '');
|
const token = tokenFromRequest;
|
||||||
try {
|
try {
|
||||||
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
// Try to verify with issuer first, fallback to no issuer for backward compatibility
|
||||||
let decoded;
|
let decoded;
|
||||||
@@ -79,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 && !authHeader) {
|
|
||||||
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) {
|
||||||
@@ -121,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' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
|
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
|
||||||
// In-memory session tracking (in production, use Redis)
|
// In-memory session tracking (in production, use Redis)
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
@@ -67,11 +68,7 @@ async function getSessionTimeout() {
|
|||||||
|
|
||||||
async function sessionTimeoutMiddleware(req, res, next) {
|
async function sessionTimeoutMiddleware(req, res, next) {
|
||||||
// Skip for non-authenticated routes
|
// Skip for non-authenticated routes
|
||||||
if (!req.headers.authorization) {
|
const token = getAdminTokenFromRequest(req);
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = req.headers.authorization.split(' ')[1];
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
@@ -150,4 +147,4 @@ module.exports = {
|
|||||||
sessionTimeoutMiddleware,
|
sessionTimeoutMiddleware,
|
||||||
endSession,
|
endSession,
|
||||||
getActiveSessions
|
getActiveSessions
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
const buildChain = ({ firstResult, updateResult } = {}) => {
|
||||||
|
const chain = {
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
whereNot: jest.fn().mockReturnThis(),
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
update: jest.fn().mockResolvedValue(updateResult ?? 1),
|
||||||
|
first: jest.fn().mockResolvedValue(firstResult),
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('../../database/db', () => {
|
||||||
|
const dbMock = jest.fn();
|
||||||
|
dbMock.raw = jest.fn();
|
||||||
|
dbMock.__setImplementations = (...chains) => {
|
||||||
|
dbMock.mockReset();
|
||||||
|
chains.forEach((chain) => {
|
||||||
|
dbMock.mockImplementationOnce(() => chain);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
db: dbMock,
|
||||||
|
logActivity: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../../middleware/auth-enhanced-v2', () => ({
|
||||||
|
adminAuth: (_req, _res, next) => {
|
||||||
|
_req.admin = { id: 1, username: 'admin' };
|
||||||
|
next();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { db, logActivity } = require('../../database/db');
|
||||||
|
const adminAuthRouter = require('../adminAuth');
|
||||||
|
|
||||||
|
describe('adminAuth profile updates', () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/auth/admin', adminAuthRouter);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the admin profile', async () => {
|
||||||
|
const updatedUser = {
|
||||||
|
id: 1,
|
||||||
|
username: 'newadmin',
|
||||||
|
email: 'newadmin@example.com',
|
||||||
|
must_change_password: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.__setImplementations(
|
||||||
|
buildChain({ firstResult: null }), // email check
|
||||||
|
buildChain({ firstResult: null }), // username check
|
||||||
|
buildChain({ updateResult: 1 }), // update
|
||||||
|
buildChain({ firstResult: updatedUser }), // fetch updated user
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/auth/admin/profile')
|
||||||
|
.send({ username: updatedUser.username, email: updatedUser.email })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({ user: updatedUser });
|
||||||
|
expect(logActivity).toHaveBeenCalledWith(
|
||||||
|
'admin_profile_updated',
|
||||||
|
{ admin_id: 1, updated_fields: ['username', 'email'] },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: 1, name: updatedUser.username }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects email conflicts', async () => {
|
||||||
|
db.__setImplementations(
|
||||||
|
buildChain({ firstResult: { id: 2 } })
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/auth/admin/profile')
|
||||||
|
.send({ username: 'newadmin', email: 'taken@example.com' })
|
||||||
|
.expect(409);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({ error: 'Email is already in use by another admin' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates input', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.put('/auth/admin/profile')
|
||||||
|
.send({ username: '', email: 'not-an-email' })
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.errors).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
jest.mock('../../database/db', () => {
|
||||||
|
const deleteMock = jest.fn().mockResolvedValue(5);
|
||||||
|
const chain = {
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
leftJoin: jest.fn().mockReturnThis(),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
limit: jest.fn().mockReturnThis(),
|
||||||
|
whereNull: jest.fn().mockReturnThis(),
|
||||||
|
whereNotNull: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
update: jest.fn().mockReturnThis(),
|
||||||
|
delete: deleteMock,
|
||||||
|
count: jest.fn().mockReturnThis(),
|
||||||
|
first: jest.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbMock = jest.fn(() => chain);
|
||||||
|
dbMock.raw = jest.fn();
|
||||||
|
dbMock.__chain = chain;
|
||||||
|
dbMock.__deleteMock = deleteMock;
|
||||||
|
return { db: dbMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../../middleware/auth-enhanced-v2', () => ({
|
||||||
|
adminAuth: (_req, _res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { db } = require('../../database/db');
|
||||||
|
const notificationsRouter = require('../adminNotifications');
|
||||||
|
|
||||||
|
describe('adminNotifications routes', () => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/admin/notifications', notificationsRouter);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all notifications', async () => {
|
||||||
|
db.__deleteMock.mockResolvedValueOnce(8);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/admin/notifications/clear-all')
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
expect(db).toHaveBeenCalledWith('activity_logs');
|
||||||
|
expect(db.__deleteMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
message: 'All notifications cleared',
|
||||||
|
deletedCount: 8,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles database errors when clearing notifications', async () => {
|
||||||
|
db.__deleteMock.mockRejectedValueOnce(new Error('boom'));
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.delete('/admin/notifications/clear-all')
|
||||||
|
.expect(500);
|
||||||
|
|
||||||
|
expect(response.body).toEqual({ error: 'Failed to clear notifications' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -72,6 +72,68 @@ router.post('/change-password', [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update admin profile
|
||||||
|
router.put('/profile', [
|
||||||
|
adminAuth,
|
||||||
|
body('username').trim().notEmpty().withMessage('Username is required'),
|
||||||
|
body('email').trim().isEmail().withMessage('Valid email is required')
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, email } = req.body;
|
||||||
|
const userId = req.admin.id;
|
||||||
|
|
||||||
|
// Check for email conflicts
|
||||||
|
const existingEmail = await db('admin_users')
|
||||||
|
.where('email', email)
|
||||||
|
.whereNot('id', userId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existingEmail) {
|
||||||
|
return res.status(409).json({ error: 'Email is already in use by another admin' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check username conflict (if multiple admins are supported)
|
||||||
|
const existingUsername = await db('admin_users')
|
||||||
|
.where('username', username)
|
||||||
|
.whereNot('id', userId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existingUsername) {
|
||||||
|
return res.status(409).json({ error: 'Username is already in use by another admin' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('admin_users')
|
||||||
|
.where('id', userId)
|
||||||
|
.update({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedUser = await db('admin_users')
|
||||||
|
.select('id', 'username', 'email', 'must_change_password')
|
||||||
|
.where('id', userId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
await logActivity(
|
||||||
|
'admin_profile_updated',
|
||||||
|
{ admin_id: userId, updated_fields: ['username', 'email'] },
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: userId, name: username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ user: updatedUser });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Admin profile update error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update admin profile' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
router.post('/logout', adminAuth, async (req, res) => {
|
router.post('/logout', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -96,4 +158,4 @@ router.post('/logout', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -221,7 +221,11 @@ router.post('/test-connection', adminAuth, async (req, res) => {
|
|||||||
await fs.access(config.path, fs.constants.W_OK);
|
await fs.access(config.path, fs.constants.W_OK);
|
||||||
res.json({ success: true, message: 'Local path is writable' });
|
res.json({ success: true, message: 'Local path is writable' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json({ success: false, message: 'Cannot write to local path: ' + error.message });
|
logger.warn('Local backup path not writable', {
|
||||||
|
path: config.path,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -243,7 +247,11 @@ router.post('/test-connection', adminAuth, async (req, res) => {
|
|||||||
const { stdout } = await execAsync(testCommand);
|
const { stdout } = await execAsync(testCommand);
|
||||||
res.json({ success: true, message: 'Rsync connection successful' });
|
res.json({ success: true, message: 'Rsync connection successful' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.json({ success: false, message: 'Rsync connection failed: ' + error.message });
|
logger.warn('Rsync connection test failed', {
|
||||||
|
destination: config.host || config.destination,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -274,7 +282,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get backup manifest:', error);
|
logger.error('Failed to get backup manifest:', error);
|
||||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
res.status(404).json({ error: 'Backup manifest not found' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -327,7 +335,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to download backup manifest:', error);
|
logger.error('Failed to download backup manifest:', error);
|
||||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
res.status(404).json({ error: 'Backup manifest not found' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,7 +352,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get backup manifest:', error);
|
logger.error('Failed to get backup manifest:', error);
|
||||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
res.status(404).json({ error: 'Backup manifest not found' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,7 +383,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to download backup manifest:', error);
|
logger.error('Failed to download backup manifest:', error);
|
||||||
res.status(404).json({ error: error.message || 'Backup manifest not found' });
|
res.status(404).json({ error: 'Backup manifest not found' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -436,7 +444,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to list S3 buckets:', error);
|
logger.error('Failed to list S3 buckets:', error);
|
||||||
res.status(500).json({ error: 'Failed to list S3 buckets: ' + error.message });
|
res.status(500).json({ error: 'Failed to list S3 buckets' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -473,7 +481,7 @@ router.get('/s3/files', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to list S3 files:', error);
|
logger.error('Failed to list S3 files:', error);
|
||||||
res.status(500).json({ error: 'Failed to list S3 files: ' + error.message });
|
res.status(500).json({ error: 'Failed to list S3 files' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -535,7 +543,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to cleanup S3 backups:', error);
|
logger.error('Failed to cleanup S3 backups:', error);
|
||||||
res.status(500).json({ error: 'Failed to cleanup S3 backups: ' + error.message });
|
res.status(500).json({ error: 'Failed to cleanup S3 backups' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -587,7 +595,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('S3 upload test failed:', error);
|
logger.error('S3 upload test failed:', error);
|
||||||
res.status(500).json({ error: 'S3 upload test failed: ' + error.message });
|
res.status(500).json({ error: 'S3 upload test failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -675,7 +683,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to download backup:', error);
|
logger.error('Failed to download backup:', error);
|
||||||
res.status(500).json({ error: 'Failed to download backup: ' + error.message });
|
res.status(500).json({ error: 'Failed to download backup' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -744,7 +752,7 @@ router.get('/checksums', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to get file checksums:', error);
|
logger.error('Failed to get file checksums:', error);
|
||||||
res.status(500).json({ error: 'Failed to get file checksums: ' + error.message });
|
res.status(500).json({ error: 'Failed to get file checksums' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -847,7 +855,7 @@ router.post('/estimate', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to estimate backup size:', error);
|
logger.error('Failed to estimate backup size:', error);
|
||||||
res.status(500).json({ error: 'Failed to estimate backup size: ' + error.message });
|
res.status(500).json({ error: 'Failed to estimate backup size' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -945,12 +953,13 @@ async function validateManifestData(manifestData) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
logger.error('Manifest validation error', { error: error.message });
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: `Validation error: ${error.message}`,
|
error: 'Validation error encountered while processing manifest',
|
||||||
details: { error: error.message }
|
details: { hint: 'See server logs for diagnostic details.' }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -396,21 +458,66 @@ router.put('/:id', adminAuth, [
|
|||||||
body('allow_downloads').optional().isBoolean(),
|
body('allow_downloads').optional().isBoolean(),
|
||||||
body('disable_right_click').optional().isBoolean(),
|
body('disable_right_click').optional().isBoolean(),
|
||||||
body('watermark_downloads').optional().isBoolean(),
|
body('watermark_downloads').optional().isBoolean(),
|
||||||
body('watermark_text').optional().trim()
|
body('watermark_text').optional().trim(),
|
||||||
|
body('source_mode').optional().isIn(['managed', 'reference']),
|
||||||
|
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')) {
|
||||||
|
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(updates, 'external_path')) {
|
||||||
|
const trimmedPath = updates.external_path ? String(updates.external_path).trim() : '';
|
||||||
|
updates.external_path = trimmedPath || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.source_mode === 'managed') {
|
||||||
|
updates.external_path = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.source_mode === 'reference' && (updates.external_path === null || updates.external_path === undefined)) {
|
||||||
|
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
|
||||||
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
@@ -425,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)
|
||||||
@@ -515,13 +634,11 @@ router.delete('/:id', adminAuth, async (req, res) => {
|
|||||||
// Provide more specific error messages
|
// Provide more specific error messages
|
||||||
if (error.message && error.message.includes('foreign key constraint')) {
|
if (error.message && error.message.includes('foreign key constraint')) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Cannot delete event due to existing references. Please contact support.',
|
error: 'Cannot delete event due to existing references. Please contact support.'
|
||||||
details: error.message
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Failed to delete event',
|
error: 'Failed to delete event'
|
||||||
details: process.env.NODE_ENV === 'development' ? error.message : undefined
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -780,7 +897,7 @@ router.post('/bulk-archive', adminAuth, [
|
|||||||
results.failed.push({
|
results.failed.push({
|
||||||
id: event.id,
|
id: event.id,
|
||||||
name: event.event_name,
|
name: event.event_name,
|
||||||
error: error.message
|
error: 'Failed to archive event. Check server logs for details.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -806,4 +923,4 @@ router.post('/bulk-archive', adminAuth, [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const fs = require('fs').promises;
|
|||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -14,7 +15,11 @@ router.get('/list', adminAuth, async (req, res) => {
|
|||||||
const result = await list(relPath);
|
const result = await list(relPath);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(400).json({ error: 'Invalid path', details: error.message });
|
logger.warn('Invalid external media path requested', {
|
||||||
|
path: req.query.path,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
res.status(400).json({ error: 'Invalid external media path' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,11 +62,38 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
|||||||
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
|
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
|
||||||
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
|
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
|
||||||
|
|
||||||
let imported = 0;
|
// Prepare file metadata and deduplicate by filename within type (keep largest)
|
||||||
let skipped = 0;
|
let skipped = 0;
|
||||||
|
const preparedFiles = [];
|
||||||
|
for (const f of files) {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(f.full);
|
||||||
|
const segs = f.rel.split(path.sep);
|
||||||
|
let type = 'individual';
|
||||||
|
if (segs[0] === map.collages) type = 'collage';
|
||||||
|
if (segs[0] === map.individual) type = 'individual';
|
||||||
|
preparedFiles.push({ ...f, type, size: stats.size });
|
||||||
|
} catch (err) {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeMap = new Map();
|
||||||
|
for (const file of preparedFiles) {
|
||||||
|
const dedupeKey = `${file.type}:${path.basename(file.rel).toLowerCase()}`;
|
||||||
|
const existing = dedupeMap.get(dedupeKey);
|
||||||
|
if (!existing || file.size > existing.size) {
|
||||||
|
if (existing) skipped++;
|
||||||
|
dedupeMap.set(dedupeKey, file);
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
|
||||||
// Insert photos
|
// Insert photos
|
||||||
for (const f of files) {
|
for (const f of dedupeMap.values()) {
|
||||||
// Infer type by subfolder names
|
// Infer type by subfolder names
|
||||||
const segs = f.rel.split(path.sep);
|
const segs = f.rel.split(path.sep);
|
||||||
let type = 'individual';
|
let type = 'individual';
|
||||||
@@ -74,7 +106,6 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
|||||||
.where({ event_id: eventId, external_relpath: f.rel })
|
.where({ event_id: eventId, external_relpath: f.rel })
|
||||||
.first();
|
.first();
|
||||||
if (exists) { skipped++; continue; }
|
if (exists) { skipped++; continue; }
|
||||||
|
|
||||||
const stats = await fs.stat(f.full);
|
const stats = await fs.stat(f.full);
|
||||||
const inserted = await db('photos')
|
const inserted = await db('photos')
|
||||||
.insert({
|
.insert({
|
||||||
@@ -104,9 +135,13 @@ router.post('/events/:id/import-external', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
res.json({ imported, skipped, thumbnailsQueued: 0 });
|
res.json({ imported, skipped, thumbnailsQueued: 0 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to import external media', details: error.message });
|
logger.error('External media import failed', {
|
||||||
|
eventId: req.params.id,
|
||||||
|
externalPath: req.body?.external_path,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: 'Failed to import external media' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
@@ -368,7 +368,7 @@ router.post('/word-filters',
|
|||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message === 'Word filter already exists') {
|
if (error.message === 'Word filter already exists') {
|
||||||
return res.status(409).json({ error: error.message });
|
return res.status(409).json({ error: 'Word filter already exists' });
|
||||||
}
|
}
|
||||||
logger.error('Error adding word filter:', error);
|
logger.error('Error adding word filter:', error);
|
||||||
res.status(500).json({ error: 'Failed to add word filter' });
|
res.status(500).json({ error: 'Failed to add word filter' });
|
||||||
@@ -430,4 +430,4 @@ function convertToCSV(data) {
|
|||||||
return [csvHeaders, ...csvRows].join('\n');
|
return [csvHeaders, ...csvRows].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -119,4 +119,18 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
// Delete all notifications
|
||||||
|
router.delete('/clear-all', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const deletedCount = await db('activity_logs').delete();
|
||||||
|
res.json({
|
||||||
|
message: 'All notifications cleared',
|
||||||
|
deletedCount
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Clear all notifications error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to clear notifications' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ const router = express.Router();
|
|||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
|
const parseCategoryId = (value) => {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
if (typeof value === 'number' && Number.isInteger(value)) {
|
||||||
|
return value === 0 ? null : value;
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || trimmed === 'null') return null;
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
const parsed = parseInt(trimmed, 10);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed === 0 ? null : parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Configure multer for file uploads
|
// Configure multer for file uploads
|
||||||
// IMPORTANT: Using synchronous functions to prevent file corruption
|
// IMPORTANT: Using synchronous functions to prevent file corruption
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
@@ -158,16 +176,16 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse category_id to number if provided
|
// Parse category_id to number if provided
|
||||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
const numericCategoryId = parseCategoryId(category_id);
|
||||||
|
|
||||||
// Determine photo type from category_id parameter (for backwards compatibility)
|
// Determine photo type from category_id parameter (for backwards compatibility)
|
||||||
let photoType = 'individual'; // default
|
let photoType = 'individual'; // default
|
||||||
let categoryName = 'individual';
|
let categoryName = 'individual';
|
||||||
|
|
||||||
if (parsedCategoryId === 1 || category_id === 'collage') {
|
if (numericCategoryId === 1 || category_id === 'collage') {
|
||||||
photoType = 'collage';
|
photoType = 'collage';
|
||||||
categoryName = 'collages';
|
categoryName = 'collages';
|
||||||
} else if (parsedCategoryId === 2 || category_id === 'individual') {
|
} else if (numericCategoryId === 2 || category_id === 'individual') {
|
||||||
photoType = 'individual';
|
photoType = 'individual';
|
||||||
categoryName = 'individual';
|
categoryName = 'individual';
|
||||||
}
|
}
|
||||||
@@ -239,7 +257,9 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, nex
|
|||||||
path: relativePath,
|
path: relativePath,
|
||||||
thumbnail_path: null, // Will generate after successful commit
|
thumbnail_path: null, // Will generate after successful commit
|
||||||
type: photoType,
|
type: photoType,
|
||||||
size_bytes: tempStats.size // Use actual file size from stat
|
size_bytes: tempStats.size, // Use actual file size from stat
|
||||||
|
category_id: numericCategoryId,
|
||||||
|
source_origin: 'managed'
|
||||||
};
|
};
|
||||||
|
|
||||||
batchPhotos.push(photoData);
|
batchPhotos.push(photoData);
|
||||||
@@ -475,9 +495,11 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update photo
|
// Update photo
|
||||||
|
const normalizedCategoryId = parseCategoryId(category_id);
|
||||||
|
|
||||||
await db('photos')
|
await db('photos')
|
||||||
.where({ id: photoId })
|
.where({ id: photoId })
|
||||||
.update({ category_id: category_id || null });
|
.update({ category_id: normalizedCategoryId });
|
||||||
|
|
||||||
res.json({ message: 'Photo updated successfully' });
|
res.json({ message: 'Photo updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -578,7 +600,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
|
|||||||
// Update photos
|
// Update photos
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
if (updates.category_id !== undefined) {
|
if (updates.category_id !== undefined) {
|
||||||
updateData.category_id = updates.category_id || null;
|
updateData.category_id = parseCategoryId(updates.category_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db('photos')
|
await db('photos')
|
||||||
@@ -632,14 +654,22 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
|||||||
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
|
||||||
|
|
||||||
let query = db('photos')
|
let query = db('photos')
|
||||||
|
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
|
||||||
.where({ 'photos.event_id': eventId })
|
.where({ 'photos.event_id': eventId })
|
||||||
.select('photos.*');
|
.select(
|
||||||
|
'photos.*',
|
||||||
|
'pc.name as category_display_name',
|
||||||
|
'pc.slug as category_display_slug'
|
||||||
|
);
|
||||||
|
|
||||||
// Filter by type (individual/collage) - category_id maps to type
|
// Filter by type (individual/collage) - category_id maps to type
|
||||||
if (category_id !== undefined) {
|
if (category_id !== undefined) {
|
||||||
if (category_id === '' || category_id === '0') {
|
if (category_id === '') {
|
||||||
// For backwards compatibility, empty category means no filter
|
// No filter when empty string is provided
|
||||||
// Don't filter anything
|
} else if (category_id === '0') {
|
||||||
|
query = query.whereNull('photos.category_id');
|
||||||
|
} else if (/^\d+$/.test(category_id)) {
|
||||||
|
query = query.where('photos.category_id', parseInt(category_id, 10));
|
||||||
} else if (category_id === 'individual' || category_id === 'collage') {
|
} else if (category_id === 'individual' || category_id === 'collage') {
|
||||||
query = query.where({ 'photos.type': category_id });
|
query = query.where({ 'photos.type': category_id });
|
||||||
}
|
}
|
||||||
@@ -665,7 +695,11 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const photos = await query.orderBy(orderByColumn, order);
|
const photos = await query.orderBy(orderByColumn, order);
|
||||||
|
|
||||||
|
if (photos.length === 0) {
|
||||||
|
return res.json({ photos: [] });
|
||||||
|
}
|
||||||
|
|
||||||
// Get comment counts separately
|
// Get comment counts separately
|
||||||
const commentCounts = await db('photo_feedback')
|
const commentCounts = await db('photo_feedback')
|
||||||
.whereIn('photo_id', photos.map(p => p.id))
|
.whereIn('photo_id', photos.map(p => p.id))
|
||||||
@@ -690,9 +724,11 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
|
|||||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||||
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
|
||||||
type: photo.type,
|
type: photo.type,
|
||||||
category_id: photo.type,
|
category_id: photo.category_id !== null && photo.category_id !== undefined
|
||||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
? Number(photo.category_id)
|
||||||
category_slug: photo.type,
|
: null,
|
||||||
|
category_name: photo.category_display_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||||
|
category_slug: photo.category_display_slug || photo.type,
|
||||||
size: photo.size_bytes,
|
size: photo.size_bytes,
|
||||||
uploaded_at: photo.uploaded_at,
|
uploaded_at: photo.uploaded_at,
|
||||||
// Feedback data
|
// Feedback data
|
||||||
@@ -802,7 +838,8 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
|
|||||||
storagePath: getStoragePath()
|
storagePath: getStoragePath()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
console.error('Error fetching admin photo debug data:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch photo debug data' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ router.post('/validate', [
|
|||||||
logger.error('Restore validation failed:', error);
|
logger.error('Restore validation failed:', error);
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: 'Restore validation failed',
|
||||||
logs: restoreService.restoreLog
|
logs: restoreService.restoreLog
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ router.post('/start', [
|
|||||||
logger.error('Failed to start restore:', error);
|
logger.error('Failed to start restore:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message
|
error: 'Failed to start restore operation'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -458,4 +458,4 @@ async function getBackupConfig() {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -8,12 +8,24 @@ const { formatBoolean } = require('../utils/dbCompat');
|
|||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
const { clearMaintenanceCache } = require('../middleware/maintenance');
|
||||||
const { clearSettingsCache } = require('../services/rateLimitService');
|
const { clearSettingsCache } = require('../services/rateLimitService');
|
||||||
|
const {
|
||||||
|
DEFAULT_PUBLIC_SITE_HTML,
|
||||||
|
DEFAULT_PUBLIC_SITE_CSS,
|
||||||
|
} = require('../constants/publicSiteDefaults');
|
||||||
|
const {
|
||||||
|
clearPublicSiteCache,
|
||||||
|
getDefaultPublicSitePayload,
|
||||||
|
getRawPublicSiteSettings,
|
||||||
|
} = require('../services/publicSiteService');
|
||||||
|
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);
|
||||||
},
|
},
|
||||||
@@ -43,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);
|
||||||
},
|
},
|
||||||
@@ -218,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);
|
||||||
@@ -248,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);
|
||||||
@@ -284,6 +298,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
metadata: JSON.stringify({ company_name })
|
metadata: JSON.stringify({ company_name })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearPublicSiteCache();
|
||||||
|
|
||||||
res.json({ message: 'Branding settings updated successfully' });
|
res.json({ message: 'Branding settings updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Branding update error:', error);
|
console.error('Branding update error:', error);
|
||||||
@@ -443,6 +459,8 @@ router.put('/theme', adminAuth, async (req, res) => {
|
|||||||
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
|
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
clearPublicSiteCache();
|
||||||
|
|
||||||
res.json({ message: 'Theme settings updated successfully' });
|
res.json({ message: 'Theme settings updated successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Theme update error:', error);
|
console.error('Theme update error:', error);
|
||||||
@@ -453,7 +471,39 @@ router.put('/theme', adminAuth, async (req, res) => {
|
|||||||
// Update general settings
|
// Update general settings
|
||||||
router.put('/general', adminAuth, async (req, res) => {
|
router.put('/general', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const settings = req.body;
|
const settings = { ...req.body };
|
||||||
|
|
||||||
|
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||||||
|
|
||||||
|
if (publicSiteKeysTouched) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
|
||||||
|
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_html') && typeof settings.general_public_site_html === 'string') {
|
||||||
|
settings.general_public_site_html = settings.general_public_site_html.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_enabled')) {
|
||||||
|
settings.general_public_site_enabled = formatBoolean(settings.general_public_site_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableToggle = settings.general_public_site_enabled;
|
||||||
|
if (enableToggle === true) {
|
||||||
|
let htmlValue = settings.general_public_site_html;
|
||||||
|
|
||||||
|
if (htmlValue === undefined) {
|
||||||
|
const currentSettings = await getRawPublicSiteSettings();
|
||||||
|
htmlValue = currentSettings.general_public_site_html;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!htmlValue || !String(htmlValue).trim()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Public site HTML must be provided before enabling the public landing page.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update or insert each setting
|
// Update or insert each setting
|
||||||
for (const [key, value] of Object.entries(settings)) {
|
for (const [key, value] of Object.entries(settings)) {
|
||||||
@@ -476,6 +526,10 @@ router.put('/general', adminAuth, async (req, res) => {
|
|||||||
clearMaintenanceCache();
|
clearMaintenanceCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (publicSiteKeysTouched) {
|
||||||
|
clearPublicSiteCache();
|
||||||
|
}
|
||||||
|
|
||||||
// Log activity
|
// Log activity
|
||||||
await db('activity_logs').insert({
|
await db('activity_logs').insert({
|
||||||
activity_type: 'general_settings_updated',
|
activity_type: 'general_settings_updated',
|
||||||
@@ -593,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;
|
||||||
@@ -603,11 +657,190 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
||||||
|
const storagePath = getStoragePath();
|
||||||
|
|
||||||
|
let diskStats = null;
|
||||||
|
let rawDiskTotal = null;
|
||||||
|
let rawDiskFree = null;
|
||||||
|
let rawDiskAvailable = null;
|
||||||
|
try {
|
||||||
|
diskStats = await fs.statfs(storagePath);
|
||||||
|
rawDiskTotal = Number(diskStats.bsize) * Number(diskStats.blocks);
|
||||||
|
rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree);
|
||||||
|
rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail);
|
||||||
|
} catch (diskError) {
|
||||||
|
console.error('Disk stats error:', diskError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampDiskValue = (value) => {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat unusually large virtualised values as unreliable (>50TB)
|
||||||
|
const MAX_REASONABLE_BYTES = 50 * 1024 * 1024 * 1024 * 1024;
|
||||||
|
if (value > MAX_REASONABLE_BYTES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
let diskTotal = null;
|
||||||
|
let diskFree = null;
|
||||||
|
let diskAvailable = null;
|
||||||
|
|
||||||
|
if (diskStats) {
|
||||||
|
diskTotal = clampDiskValue(rawDiskTotal);
|
||||||
|
diskFree = clampDiskValue(rawDiskFree);
|
||||||
|
diskAvailable = clampDiskValue(rawDiskAvailable);
|
||||||
|
|
||||||
|
if (diskTotal && diskAvailable && diskAvailable > diskTotal) {
|
||||||
|
diskAvailable = null;
|
||||||
|
}
|
||||||
|
if (diskTotal && diskFree && diskFree > diskTotal) {
|
||||||
|
diskFree = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUsed = totalStorage?.total || 0;
|
||||||
|
|
||||||
|
const parseBytesValue = (value) => {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.floor(numeric);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseEnvOverride = (bytesVar, gbVar) => {
|
||||||
|
if (process.env[bytesVar]) {
|
||||||
|
return parseBytesValue(process.env[bytesVar]);
|
||||||
|
}
|
||||||
|
if (process.env[gbVar]) {
|
||||||
|
const value = parseBytesValue(process.env[gbVar]);
|
||||||
|
return value ? value * 1024 * 1024 * 1024 : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let configuredSoftLimit = null;
|
||||||
|
let capacityOverrideDb = null;
|
||||||
|
let availableOverrideDb = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storageSettings = await db('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'general_storage_soft_limit_bytes',
|
||||||
|
'general_storage_capacity_override_bytes',
|
||||||
|
'general_storage_available_override_bytes'
|
||||||
|
])
|
||||||
|
.select('setting_key', 'setting_value');
|
||||||
|
|
||||||
|
storageSettings.forEach((setting) => {
|
||||||
|
let parsedValue = null;
|
||||||
|
if (setting.setting_value) {
|
||||||
|
try {
|
||||||
|
parsedValue = JSON.parse(setting.setting_value);
|
||||||
|
} catch (error) {
|
||||||
|
parsedValue = setting.setting_value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (setting.setting_key) {
|
||||||
|
case 'general_storage_soft_limit_bytes':
|
||||||
|
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||||
|
configuredSoftLimit = parsedValue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'general_storage_capacity_override_bytes':
|
||||||
|
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||||
|
capacityOverrideDb = parsedValue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'general_storage_available_override_bytes':
|
||||||
|
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
|
||||||
|
availableOverrideDb = parsedValue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Storage settings read error:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB');
|
||||||
|
const availableOverrideEnv = parseEnvOverride('STORAGE_AVAILABLE_OVERRIDE_BYTES', 'STORAGE_AVAILABLE_OVERRIDE_GB');
|
||||||
|
|
||||||
|
let capacityOverrideBytes = null;
|
||||||
|
let availableOverrideBytes = null;
|
||||||
|
let overrideSource = null;
|
||||||
|
|
||||||
|
if (capacityOverrideEnv != null || availableOverrideEnv != null) {
|
||||||
|
capacityOverrideBytes = capacityOverrideEnv;
|
||||||
|
availableOverrideBytes = availableOverrideEnv;
|
||||||
|
overrideSource = 'env';
|
||||||
|
} else if (capacityOverrideDb != null || availableOverrideDb != null) {
|
||||||
|
capacityOverrideBytes = capacityOverrideDb;
|
||||||
|
availableOverrideBytes = availableOverrideDb;
|
||||||
|
overrideSource = 'settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capacityOverrideBytes != null) {
|
||||||
|
diskTotal = capacityOverrideBytes;
|
||||||
|
if (availableOverrideBytes == null) {
|
||||||
|
diskAvailable = Math.max(capacityOverrideBytes - totalUsed, 0);
|
||||||
|
} else {
|
||||||
|
diskAvailable = Math.min(Math.max(availableOverrideBytes, 0), capacityOverrideBytes);
|
||||||
|
}
|
||||||
|
diskFree = diskAvailable;
|
||||||
|
} else if (availableOverrideBytes != null) {
|
||||||
|
diskAvailable = Math.max(availableOverrideBytes, 0);
|
||||||
|
diskFree = diskAvailable;
|
||||||
|
}
|
||||||
|
|
||||||
|
let recommendedSoftLimit = null;
|
||||||
|
if (diskTotal && diskAvailable) {
|
||||||
|
const projected = totalUsed + Math.floor(diskAvailable * 0.8);
|
||||||
|
recommendedSoftLimit = Math.min(diskTotal, Math.max(projected, Math.floor(diskTotal * 0.5)));
|
||||||
|
} else if (diskTotal) {
|
||||||
|
recommendedSoftLimit = Math.floor(diskTotal * 0.8);
|
||||||
|
} else if (diskAvailable) {
|
||||||
|
recommendedSoftLimit = Math.max(totalUsed, totalUsed + Math.floor(diskAvailable * 0.8));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recommendedSoftLimit && totalUsed > 0 && recommendedSoftLimit < totalUsed) {
|
||||||
|
recommendedSoftLimit = totalUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackSoftLimit = recommendedSoftLimit || diskTotal || DEFAULT_SOFT_LIMIT_BYTES;
|
||||||
|
if (!recommendedSoftLimit && fallbackSoftLimit) {
|
||||||
|
recommendedSoftLimit = fallbackSoftLimit;
|
||||||
|
}
|
||||||
|
const effectiveSoftLimit = configuredSoftLimit || fallbackSoftLimit || DEFAULT_SOFT_LIMIT_BYTES;
|
||||||
|
|
||||||
|
const diskMetricsReliable = Boolean(diskTotal);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
total_used: totalStorage.total || 0,
|
total_used: totalUsed,
|
||||||
archive_storage: archiveStorage,
|
archive_storage: archiveStorage,
|
||||||
storage_by_event: storageByEvent,
|
storage_by_event: storageByEvent,
|
||||||
storage_limit: 10 * 1024 * 1024 * 1024 // 10GB default
|
storage_limit: effectiveSoftLimit,
|
||||||
|
storage_soft_limit: effectiveSoftLimit,
|
||||||
|
configured_soft_limit: configuredSoftLimit,
|
||||||
|
recommended_soft_limit: recommendedSoftLimit,
|
||||||
|
soft_limit_configured: Boolean(configuredSoftLimit),
|
||||||
|
disk_total: diskTotal,
|
||||||
|
disk_free: diskFree,
|
||||||
|
disk_available: diskAvailable,
|
||||||
|
disk_total_raw: rawDiskTotal,
|
||||||
|
disk_free_raw: rawDiskFree,
|
||||||
|
disk_available_raw: rawDiskAvailable,
|
||||||
|
disk_metrics_reliable: diskMetricsReliable,
|
||||||
|
disk_override_source: overrideSource
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Storage info error:', error);
|
console.error('Storage info error:', error);
|
||||||
@@ -717,4 +950,79 @@ router.put('/security/rate-limit', adminAuth, [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
// Get default public site template
|
||||||
|
router.get('/public-site/default', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const defaults = await getDefaultPublicSitePayload();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
enabled: false,
|
||||||
|
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
|
||||||
|
css: '',
|
||||||
|
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
|
||||||
|
branding: defaults.branding,
|
||||||
|
meta: {
|
||||||
|
title: defaults.title,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load public site defaults:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to load defaults' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset public site template to defaults
|
||||||
|
router.post('/public-site/reset', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
key: 'general_public_site_html',
|
||||||
|
value: DEFAULT_PUBLIC_SITE_HTML.trim()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'general_public_site_custom_css',
|
||||||
|
value: ''
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { key, value } of entries) {
|
||||||
|
await db('app_settings')
|
||||||
|
.insert({
|
||||||
|
setting_key: key,
|
||||||
|
setting_value: JSON.stringify(value),
|
||||||
|
setting_type: 'general',
|
||||||
|
updated_at: new Date()
|
||||||
|
})
|
||||||
|
.onConflict('setting_key')
|
||||||
|
.merge({
|
||||||
|
setting_value: JSON.stringify(value),
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearPublicSiteCache();
|
||||||
|
|
||||||
|
const defaults = await getDefaultPublicSitePayload();
|
||||||
|
|
||||||
|
await logActivity('public_site_reset_to_default',
|
||||||
|
{
|
||||||
|
template_length: DEFAULT_PUBLIC_SITE_HTML.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Public site template reset to defaults',
|
||||||
|
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
|
||||||
|
css: '',
|
||||||
|
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
|
||||||
|
branding: defaults.branding
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to reset public site template:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to reset template' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get system version
|
// Get system version
|
||||||
@@ -208,10 +209,14 @@ router.get('/database', adminAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Table might not exist
|
// Table might not exist
|
||||||
|
logger.warn('Failed to retrieve table info', {
|
||||||
|
table,
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
tableInfo.push({
|
tableInfo.push({
|
||||||
name: table,
|
name: table,
|
||||||
rows: 0,
|
rows: 0,
|
||||||
error: error.message
|
error: 'Unable to retrieve table details'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,4 +231,4 @@ router.get('/database', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ const {
|
|||||||
} = require('../utils/authSecurity');
|
} = require('../utils/authSecurity');
|
||||||
const { endSession } = require('../middleware/sessionTimeout');
|
const { endSession } = require('../middleware/sessionTimeout');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const {
|
||||||
|
setAdminAuthCookie,
|
||||||
|
clearAdminAuthCookie,
|
||||||
|
setGalleryAuthCookies,
|
||||||
|
clearGalleryAuthCookies,
|
||||||
|
getAdminTokenFromRequest,
|
||||||
|
getGalleryTokenFromRequest,
|
||||||
|
} = require('../utils/tokenUtils');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Admin login with enhanced security
|
// Admin login with enhanced security
|
||||||
@@ -91,6 +99,8 @@ router.post('/admin/login', [
|
|||||||
expiresIn: '24h',
|
expiresIn: '24h',
|
||||||
issuer: 'picpeak-auth'
|
issuer: 'picpeak-auth'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setAdminAuthCookie(res, token);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
@@ -110,13 +120,14 @@ router.post('/admin/login', [
|
|||||||
// Logout endpoint
|
// Logout endpoint
|
||||||
router.post('/logout', async (req, res) => {
|
router.post('/logout', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const adminToken = getAdminTokenFromRequest(req);
|
||||||
|
const galleryToken = getGalleryTokenFromRequest(req);
|
||||||
|
const token = adminToken || galleryToken;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
// End the session
|
// End the session
|
||||||
endSession(token);
|
endSession(token);
|
||||||
|
|
||||||
// Log the logout
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
logger.info('User logged out', {
|
logger.info('User logged out', {
|
||||||
@@ -124,11 +135,23 @@ router.post('/logout', async (req, res) => {
|
|||||||
username: decoded.username,
|
username: decoded.username,
|
||||||
type: decoded.type
|
type: decoded.type
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (decoded.type === 'admin') {
|
||||||
|
clearAdminAuthCookie(res);
|
||||||
|
} else if (decoded.type === 'gallery') {
|
||||||
|
clearGalleryAuthCookies(res, decoded.eventSlug);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Token might be invalid, but still process logout
|
// Token might be invalid, but still process logout and clear cookies
|
||||||
|
clearAdminAuthCookie(res);
|
||||||
|
clearGalleryAuthCookies(res);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No token found, but ensure cookies are cleared
|
||||||
|
clearAdminAuthCookie(res);
|
||||||
|
clearGalleryAuthCookies(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ message: 'Logged out successfully' });
|
res.json({ message: 'Logged out successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Logout error:', error);
|
logger.error('Logout error:', error);
|
||||||
@@ -139,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);
|
||||||
@@ -150,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,
|
||||||
@@ -209,6 +245,8 @@ router.post('/gallery/verify', [
|
|||||||
expiresIn: '24h',
|
expiresIn: '24h',
|
||||||
issuer: 'picpeak-auth'
|
issuer: 'picpeak-auth'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setGalleryAuthCookies(res, token, event.slug);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
token,
|
token,
|
||||||
@@ -221,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) {
|
||||||
@@ -230,15 +269,97 @@ router.post('/gallery/verify', [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Share link authentication (token-based)
|
||||||
|
router.post('/gallery/share-login', [
|
||||||
|
body('slug').notEmpty().trim(),
|
||||||
|
body('token').notEmpty()
|
||||||
|
], async (req, res) => {
|
||||||
|
try {
|
||||||
|
const errors = validationResult(req);
|
||||||
|
if (!errors.isEmpty()) {
|
||||||
|
return res.status(400).json({ errors: errors.array() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { slug, token } = req.body;
|
||||||
|
const ipAddress = req.ip || req.connection.remoteAddress;
|
||||||
|
const userAgent = req.headers['user-agent'] || '';
|
||||||
|
|
||||||
|
const event = await db('events')
|
||||||
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Gallery not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
let expectedToken = event.share_link;
|
||||||
|
if (expectedToken && expectedToken.includes('/')) {
|
||||||
|
expectedToken = expectedToken.split('/').pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectedToken || token !== expectedToken) {
|
||||||
|
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const jwtToken = jwt.sign({
|
||||||
|
eventId: event.id,
|
||||||
|
eventSlug: event.slug,
|
||||||
|
type: 'gallery',
|
||||||
|
ip: ipAddress,
|
||||||
|
loginTime: Date.now()
|
||||||
|
}, process.env.JWT_SECRET, {
|
||||||
|
expiresIn: '24h',
|
||||||
|
issuer: 'picpeak-auth'
|
||||||
|
});
|
||||||
|
|
||||||
|
await trackSuccessfulLogin(`gallery:${slug}:share`, ipAddress, userAgent);
|
||||||
|
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||||
|
|
||||||
|
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
token: jwtToken,
|
||||||
|
event: {
|
||||||
|
id: event.id,
|
||||||
|
event_name: event.event_name,
|
||||||
|
event_type: event.event_type,
|
||||||
|
event_date: event.event_date,
|
||||||
|
welcome_message: event.welcome_message,
|
||||||
|
color_theme: event.color_theme,
|
||||||
|
expires_at: event.expires_at,
|
||||||
|
allow_user_uploads: event.allow_user_uploads,
|
||||||
|
upload_category_id: event.upload_category_id,
|
||||||
|
require_password: requiresPassword
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Share link authentication error:', error);
|
||||||
|
res.status(500).json({ error: 'Share link login failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gallery logout to clear cookies
|
||||||
|
router.post('/gallery/logout', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { slug } = req.body || {};
|
||||||
|
clearGalleryAuthCookies(res, slug);
|
||||||
|
res.json({ message: 'Logged out successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Gallery logout error:', error);
|
||||||
|
res.status(500).json({ error: 'Logout failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get current session info
|
// Get current session info
|
||||||
router.get('/session', async (req, res) => {
|
router.get('/session', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
const { slug } = req.query;
|
||||||
|
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: 'No token provided' });
|
return res.status(401).json({ error: 'No token provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
@@ -250,7 +371,9 @@ router.get('/session', async (req, res) => {
|
|||||||
valid: true,
|
valid: true,
|
||||||
type: decoded.type,
|
type: decoded.type,
|
||||||
expiresIn: Math.floor(remainingTime),
|
expiresIn: Math.floor(remainingTime),
|
||||||
user: decoded.username || decoded.eventSlug
|
user: decoded.username || decoded.eventSlug,
|
||||||
|
eventSlug: decoded.eventSlug,
|
||||||
|
adminUsername: decoded.username
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -263,4 +386,4 @@ router.get('/session', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
+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);
|
||||||
|
|||||||
+186
-85
@@ -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();
|
||||||
|
|
||||||
@@ -37,7 +36,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
|
|||||||
res.json({ valid: true });
|
res.json({ valid: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error verifying token:', error);
|
console.error('Error verifying token:', error);
|
||||||
res.status(500).json({ error: 'Failed to verify token', details: error.message });
|
res.status(500).json({ error: 'Failed to verify token' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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,16 +95,16 @@ 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) {
|
||||||
console.error('Error fetching gallery info:', error);
|
console.error('Error fetching gallery info:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch gallery info', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch gallery info' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -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')
|
||||||
@@ -107,38 +120,82 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
.select('photos.*')
|
.select('photos.*')
|
||||||
.orderBy('photos.uploaded_at', 'desc');
|
.orderBy('photos.uploaded_at', 'desc');
|
||||||
|
|
||||||
// Apply filtering if requested (global, based on aggregate counts)
|
// Apply filtering if requested (supports global stats + per-guest interactions)
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const f = String(filter).toLowerCase();
|
const filterTokens = new Set(
|
||||||
const parts = f.split(',').map(s => s.trim());
|
String(filter)
|
||||||
const include = new Set();
|
.toLowerCase()
|
||||||
|
.split(',')
|
||||||
|
.map(token => token.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
// Helper to include IDs for a predicate
|
if (filterTokens.size > 0) {
|
||||||
const includeBy = (predicate) => {
|
// Treat "saved" / "favorite" synonyms as favorites
|
||||||
photos.forEach(p => { if (predicate(p)) include.add(p.id); });
|
if (filterTokens.has('saved')) {
|
||||||
};
|
filterTokens.add('favorited');
|
||||||
|
}
|
||||||
|
if (filterTokens.has('favorite')) {
|
||||||
|
filterTokens.add('favorited');
|
||||||
|
}
|
||||||
|
|
||||||
if (parts.includes('liked')) {
|
const include = new Set();
|
||||||
includeBy(p => (p.like_count || 0) > 0);
|
|
||||||
}
|
|
||||||
if (parts.includes('favorited')) {
|
|
||||||
includeBy(p => (p.favorite_count || 0) > 0);
|
|
||||||
}
|
|
||||||
if (parts.includes('rated')) {
|
|
||||||
includeBy(p => (p.average_rating || 0) > 0);
|
|
||||||
}
|
|
||||||
if (parts.includes('commented')) {
|
|
||||||
// Query commented photo IDs
|
|
||||||
const commented = await db('photo_feedback')
|
|
||||||
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
|
|
||||||
.groupBy('photo_id')
|
|
||||||
.select('photo_id');
|
|
||||||
const commentedIds = new Set(commented.map(c => c.photo_id));
|
|
||||||
includeBy(p => commentedIds.has(p.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (include.size > 0) {
|
const includeBy = (predicate) => {
|
||||||
photos = photos.filter(p => include.has(p.id));
|
photos.forEach(photo => {
|
||||||
|
if (predicate(photo)) {
|
||||||
|
include.add(photo.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let guestFeedbackByType = null;
|
||||||
|
if (guest_id) {
|
||||||
|
const guestFeedbackRows = await db('photo_feedback')
|
||||||
|
.where({ event_id: req.event.id, guest_identifier: guest_id })
|
||||||
|
.select('photo_id', 'feedback_type');
|
||||||
|
|
||||||
|
guestFeedbackByType = guestFeedbackRows.reduce((acc, row) => {
|
||||||
|
if (!acc[row.feedback_type]) {
|
||||||
|
acc[row.feedback_type] = new Set();
|
||||||
|
}
|
||||||
|
acc[row.feedback_type].add(row.photo_id);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeGuestMatches = (type) => {
|
||||||
|
const ids = guestFeedbackByType?.[type];
|
||||||
|
if (ids && ids.size > 0) {
|
||||||
|
ids.forEach(id => include.add(id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (filterTokens.has('liked')) {
|
||||||
|
includeGuestMatches('like');
|
||||||
|
includeBy(photo => (photo.like_count || 0) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterTokens.has('favorited')) {
|
||||||
|
includeGuestMatches('favorite');
|
||||||
|
includeBy(photo => (photo.favorite_count || 0) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterTokens.has('rated')) {
|
||||||
|
includeGuestMatches('rating');
|
||||||
|
includeBy(photo => (photo.average_rating || 0) > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterTokens.has('commented')) {
|
||||||
|
includeGuestMatches('comment');
|
||||||
|
const commentedRows = await db('photo_feedback')
|
||||||
|
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
|
||||||
|
.groupBy('photo_id')
|
||||||
|
.select('photo_id');
|
||||||
|
commentedRows.forEach(row => include.add(row.photo_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
photos = photos.filter(photo => include.has(photo.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +296,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching photos:', error);
|
console.error('Error fetching photos:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch photos', details: error.message });
|
res.status(500).json({ error: 'Failed to fetch photos' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -273,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
|
||||||
@@ -301,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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -346,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
|
||||||
@@ -370,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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -389,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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -433,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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -547,7 +654,7 @@ router.get('/:slug/photo/:photoId',
|
|||||||
photoId: req.params.photoId,
|
photoId: req.params.photoId,
|
||||||
eventId: req.event?.id
|
eventId: req.event?.id
|
||||||
});
|
});
|
||||||
res.status(500).json({ error: 'Failed to serve photo', details: error.message });
|
res.status(500).json({ error: 'Failed to serve photo' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -713,12 +820,6 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
|||||||
// Process uploaded photos
|
// Process uploaded photos
|
||||||
const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId);
|
const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId);
|
||||||
|
|
||||||
// Clean up temp files
|
|
||||||
const fs = require('fs').promises;
|
|
||||||
for (const file of req.files) {
|
|
||||||
await fs.unlink(file.path).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Photos uploaded successfully',
|
message: 'Photos uploaded successfully',
|
||||||
count: results.length,
|
count: results.length,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,32 @@ const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
|||||||
// 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');
|
||||||
|
|
||||||
|
function normalizeFiles(files) {
|
||||||
|
if (!files) return [];
|
||||||
|
if (Array.isArray(files)) return files.filter(Boolean);
|
||||||
|
|
||||||
|
// Multer may expose files as an iterable object
|
||||||
|
if (typeof files[Symbol.iterator] === 'function') {
|
||||||
|
return Array.from(files).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof files === 'object') {
|
||||||
|
return Object.values(files)
|
||||||
|
.flatMap((value) => (Array.isArray(value) ? value : [value]))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) {
|
async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) {
|
||||||
const uploadedPhotos = [];
|
const uploadedPhotos = [];
|
||||||
|
const fileList = normalizeFiles(files);
|
||||||
|
|
||||||
|
if (fileList.length === 0) {
|
||||||
|
return uploadedPhotos;
|
||||||
|
}
|
||||||
|
|
||||||
// Get event details
|
// Get event details
|
||||||
const event = await db('events').where({ id: eventId }).first();
|
const event = await db('events').where({ id: eventId }).first();
|
||||||
if (!event) {
|
if (!event) {
|
||||||
@@ -17,7 +40,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process each file
|
// Process each file
|
||||||
for (const file of files) {
|
for (const file of fileList) {
|
||||||
const trx = await db.transaction();
|
const trx = await db.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -35,8 +58,9 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
.where({ event_id: eventId, type: photoType })
|
.where({ event_id: eventId, type: photoType })
|
||||||
.count('id as count')
|
.count('id as count')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
counter = (existingCount.count || 0) + 1;
|
const existingCountValue = Number(existingCount?.count ?? 0);
|
||||||
|
counter = existingCountValue + 1;
|
||||||
|
|
||||||
// Generate new filename
|
// Generate new filename
|
||||||
const extension = path.extname(file.originalname);
|
const extension = path.extname(file.originalname);
|
||||||
@@ -53,9 +77,24 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
await fs.mkdir(destPath, { recursive: true });
|
await fs.mkdir(destPath, { recursive: true });
|
||||||
|
|
||||||
const newPath = path.join(destPath, newFilename);
|
const newPath = path.join(destPath, newFilename);
|
||||||
|
const tempPath = file?.path || file?.filepath || file?.tempFilePath;
|
||||||
|
|
||||||
|
if (!tempPath) {
|
||||||
|
throw new Error('Uploaded file is missing a temporary path');
|
||||||
|
}
|
||||||
|
|
||||||
// Use copyFile and unlink instead of rename to avoid cross-device issues
|
// Use copyFile and unlink instead of rename to avoid cross-device issues
|
||||||
await fs.copyFile(file.path, newPath);
|
try {
|
||||||
await fs.unlink(file.path);
|
await fs.copyFile(tempPath, newPath);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await fs.unlink(tempPath);
|
||||||
|
} catch (unlinkErr) {
|
||||||
|
if (unlinkErr?.code !== 'ENOENT') {
|
||||||
|
console.warn(`Failed to clean up temp upload ${tempPath}:`, unlinkErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate thumbnail
|
// Generate thumbnail
|
||||||
const thumbnailPath = await generateThumbnail(newPath);
|
const thumbnailPath = await generateThumbnail(newPath);
|
||||||
@@ -66,15 +105,46 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
|
||||||
|
|
||||||
// Add to database with uploaded_by field
|
// Add to database with uploaded_by field
|
||||||
const [photoId] = await trx('photos').insert({
|
let insertResult;
|
||||||
event_id: eventId,
|
const clientName = trx?.client?.config?.client;
|
||||||
filename: newFilename,
|
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
|
||||||
path: relativePath,
|
|
||||||
thumbnail_path: relativeThumbPath,
|
if (supportsReturning) {
|
||||||
type: photoType,
|
insertResult = await trx('photos')
|
||||||
size_bytes: file.size
|
.insert({
|
||||||
});
|
event_id: eventId,
|
||||||
|
filename: newFilename,
|
||||||
|
path: relativePath,
|
||||||
|
thumbnail_path: relativeThumbPath,
|
||||||
|
type: photoType,
|
||||||
|
size_bytes: file.size,
|
||||||
|
uploaded_by: uploadedBy,
|
||||||
|
source_origin: 'managed'
|
||||||
|
})
|
||||||
|
.returning('id');
|
||||||
|
} else {
|
||||||
|
insertResult = await trx('photos').insert({
|
||||||
|
event_id: eventId,
|
||||||
|
filename: newFilename,
|
||||||
|
path: relativePath,
|
||||||
|
thumbnail_path: relativeThumbPath,
|
||||||
|
type: photoType,
|
||||||
|
size_bytes: file.size,
|
||||||
|
uploaded_by: uploadedBy,
|
||||||
|
source_origin: 'managed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertedId = Array.isArray(insertResult)
|
||||||
|
? (insertResult[0]?.id ?? insertResult[0])
|
||||||
|
: insertResult;
|
||||||
|
|
||||||
|
const photoId = typeof insertedId === 'object' ? insertedId.id : insertedId;
|
||||||
|
|
||||||
|
if (photoId === undefined || photoId === null) {
|
||||||
|
throw new Error('Failed to determine inserted photo ID');
|
||||||
|
}
|
||||||
|
|
||||||
// Commit transaction
|
// Commit transaction
|
||||||
await trx.commit();
|
await trx.commit();
|
||||||
|
|
||||||
@@ -96,4 +166,4 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
processUploadedPhotos
|
processUploadedPhotos
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { resolveExternalPath } = require('./externalMediaService');
|
const { resolveExternalPath } = require('./externalMediaService');
|
||||||
|
const { safePathJoin } = require('../utils/fileSecurityUtils');
|
||||||
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
@@ -11,8 +12,10 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
|
|||||||
function resolvePhotoFilePath(event, photo) {
|
function resolvePhotoFilePath(event, photo) {
|
||||||
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
|
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
|
||||||
|
|
||||||
const mode = (event.source_mode || photo.source_origin || 'managed');
|
const isExternal = photo.source_origin === 'external' ||
|
||||||
if (mode === 'reference' || photo.source_origin === 'external') {
|
(!!photo.external_relpath && (event.source_mode === 'reference' || event.source_mode === 'external'));
|
||||||
|
|
||||||
|
if (isExternal) {
|
||||||
if (!photo.external_relpath) {
|
if (!photo.external_relpath) {
|
||||||
throw new Error('Missing external_relpath for external photo');
|
throw new Error('Missing external_relpath for external photo');
|
||||||
}
|
}
|
||||||
@@ -33,10 +36,15 @@ function resolvePhotoFilePath(event, photo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
|
const eventsRoot = path.join(storagePath, 'events/active');
|
||||||
|
|
||||||
if (photo.path && photo.path.startsWith('events/active/')) {
|
if (photo.path && photo.path.startsWith('events/active/')) {
|
||||||
return path.join(storagePath, photo.path);
|
// Legacy paths already include prefix; normalize via safe join
|
||||||
|
return safePathJoin(storagePath, photo.path.replace(/^events\/active\/?/, 'events/active/'));
|
||||||
}
|
}
|
||||||
return path.join(storagePath, 'events/active', photo.path || '');
|
|
||||||
|
const relativeSegment = photo.path ? photo.path.replace(/^\/+/, '') : '';
|
||||||
|
return safePathJoin(eventsRoot, relativeSegment);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const sanitizeHtml = require('sanitize-html');
|
||||||
|
const { db } = require('../database/db');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||||
|
const {
|
||||||
|
DEFAULT_PUBLIC_SITE_TITLE,
|
||||||
|
DEFAULT_PUBLIC_SITE_HTML,
|
||||||
|
DEFAULT_PUBLIC_SITE_CSS,
|
||||||
|
} = require('../constants/publicSiteDefaults');
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = Number(process.env.PUBLIC_SITE_CACHE_TTL_MS || 60_000);
|
||||||
|
|
||||||
|
let cachedPayload = null;
|
||||||
|
let cacheExpiresAt = 0;
|
||||||
|
|
||||||
|
const ALLOWED_HTML_TAGS = [
|
||||||
|
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div',
|
||||||
|
'em', 'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'header', 'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span',
|
||||||
|
'strong', 'sup', 'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr',
|
||||||
|
'ul'
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMMON_ATTRIBUTES = ['class', 'id', 'role', 'aria-label', 'aria-hidden'];
|
||||||
|
|
||||||
|
function parseSettingValue(value) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch (error) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPublicSiteSettings() {
|
||||||
|
const rows = await db('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'general_public_site_enabled',
|
||||||
|
'general_public_site_html',
|
||||||
|
'general_public_site_custom_css'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
general_public_site_enabled: false,
|
||||||
|
general_public_site_html: DEFAULT_PUBLIC_SITE_HTML,
|
||||||
|
general_public_site_custom_css: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const parsed = parseSettingValue(row.setting_value);
|
||||||
|
map[row.setting_key] = parsed == null ? map[row.setting_key] : parsed;
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeBrandUrl(url) {
|
||||||
|
if (typeof url !== 'string' || !url.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (trimmed.startsWith('javascript:')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBrandingContext() {
|
||||||
|
const rows = await db('app_settings')
|
||||||
|
.whereIn('setting_key', [
|
||||||
|
'branding_company_name',
|
||||||
|
'branding_company_tagline',
|
||||||
|
'branding_support_email',
|
||||||
|
'branding_logo_url',
|
||||||
|
'branding_footer_text',
|
||||||
|
'theme_config'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
companyName: null,
|
||||||
|
companyTagline: null,
|
||||||
|
supportEmail: null,
|
||||||
|
logoUrl: null,
|
||||||
|
footerText: null,
|
||||||
|
colors: {
|
||||||
|
primary: '#16a34a',
|
||||||
|
accent: '#0f766e',
|
||||||
|
background: '#f4fbf6',
|
||||||
|
text: '#0f172a'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
const parsed = parseSettingValue(row.setting_value);
|
||||||
|
switch (row.setting_key) {
|
||||||
|
case 'branding_company_name':
|
||||||
|
context.companyName = parsed || context.companyName;
|
||||||
|
break;
|
||||||
|
case 'branding_company_tagline':
|
||||||
|
context.companyTagline = parsed || context.companyTagline;
|
||||||
|
break;
|
||||||
|
case 'branding_support_email':
|
||||||
|
context.supportEmail = parsed || context.supportEmail;
|
||||||
|
break;
|
||||||
|
case 'branding_logo_url':
|
||||||
|
context.logoUrl = sanitizeBrandUrl(parsed);
|
||||||
|
break;
|
||||||
|
case 'branding_footer_text':
|
||||||
|
context.footerText = parsed || context.footerText;
|
||||||
|
break;
|
||||||
|
case 'theme_config': {
|
||||||
|
try {
|
||||||
|
const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
|
||||||
|
if (themeConfig && typeof themeConfig === 'object') {
|
||||||
|
context.colors.primary = themeConfig.primaryColor || context.colors.primary;
|
||||||
|
context.colors.accent = themeConfig.accentColor || context.colors.accent;
|
||||||
|
context.colors.background = themeConfig.backgroundColor || context.colors.background;
|
||||||
|
context.colors.text = themeConfig.textColor || context.colors.text;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to parse theme configuration for public site', { error: error.message });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeHtmlPayload(html) {
|
||||||
|
const sanitized = sanitizeHtml(html || '', {
|
||||||
|
allowedTags: ALLOWED_HTML_TAGS,
|
||||||
|
allowedAttributes: {
|
||||||
|
'*': COMMON_ATTRIBUTES,
|
||||||
|
a: ['href', 'target', 'rel', ...COMMON_ATTRIBUTES],
|
||||||
|
img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding', ...COMMON_ATTRIBUTES],
|
||||||
|
button: ['type', ...COMMON_ATTRIBUTES]
|
||||||
|
},
|
||||||
|
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||||
|
allowedSchemesByTag: { img: ['http', 'https', 'data'] },
|
||||||
|
transformTags: {
|
||||||
|
a: (tagName, attribs) => {
|
||||||
|
const transformed = { ...attribs };
|
||||||
|
if (transformed.href && !/^https?:|^mailto:|^tel:/i.test(transformed.href)) {
|
||||||
|
// sanitize-html will remove disallowed schemes, but we guard as well
|
||||||
|
delete transformed.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transformed.target === '_blank') {
|
||||||
|
transformed.rel = transformed.rel ? `${transformed.rel} noopener noreferrer`.trim() : 'noopener noreferrer';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { tagName, attribs: transformed };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nonBooleanAttributes: ['target'],
|
||||||
|
parser: {
|
||||||
|
lowerCaseAttributeNames: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCachedPayload(raw) {
|
||||||
|
const sanitizedHtml = sanitizeHtmlPayload(raw.publicSite.general_public_site_html || DEFAULT_PUBLIC_SITE_HTML);
|
||||||
|
const sanitizedCss = sanitizeCss(raw.publicSite.general_public_site_custom_css || '');
|
||||||
|
const enabled = Boolean(raw.publicSite.general_public_site_enabled);
|
||||||
|
const title = raw.branding.companyName || DEFAULT_PUBLIC_SITE_TITLE;
|
||||||
|
const baseCss = sanitizeCss(DEFAULT_PUBLIC_SITE_CSS);
|
||||||
|
|
||||||
|
const substitutedHtml = applyBrandTokens(sanitizedHtml, raw.branding);
|
||||||
|
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha1')
|
||||||
|
.update(`${enabled}|${substitutedHtml}|${sanitizedCss}|${baseCss}|${JSON.stringify(raw.branding)}`)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
html: substitutedHtml,
|
||||||
|
css: sanitizedCss,
|
||||||
|
baseCss,
|
||||||
|
title,
|
||||||
|
branding: raw.branding,
|
||||||
|
etag: `W/"${hash}"`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPublicSitePayload({ bypassCache = false } = {}) {
|
||||||
|
if (!bypassCache && cachedPayload && Date.now() < cacheExpiresAt) {
|
||||||
|
return cachedPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [publicSite, branding] = await Promise.all([
|
||||||
|
fetchPublicSiteSettings(),
|
||||||
|
fetchBrandingContext()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const payload = buildCachedPayload({ publicSite, branding });
|
||||||
|
|
||||||
|
cachedPayload = payload;
|
||||||
|
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPublicSiteCache() {
|
||||||
|
cachedPayload = null;
|
||||||
|
cacheExpiresAt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDefaultPublicSitePayload() {
|
||||||
|
const branding = await fetchBrandingContext();
|
||||||
|
return buildCachedPayload({
|
||||||
|
publicSite: {
|
||||||
|
general_public_site_enabled: false,
|
||||||
|
general_public_site_html: DEFAULT_PUBLIC_SITE_HTML,
|
||||||
|
general_public_site_custom_css: ''
|
||||||
|
},
|
||||||
|
branding
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRawPublicSiteSettings() {
|
||||||
|
return fetchPublicSiteSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBrandTokens(html, branding) {
|
||||||
|
if (!html) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = {
|
||||||
|
company_name: branding.companyName || '',
|
||||||
|
company_tagline: branding.companyTagline || '',
|
||||||
|
support_email: branding.supportEmail || '',
|
||||||
|
brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png',
|
||||||
|
brand_primary_hex: branding.colors?.primary || '#2563eb',
|
||||||
|
brand_accent_hex: branding.colors?.accent || '#1d4ed8',
|
||||||
|
brand_background_hex: branding.colors?.background || '#f8fafc',
|
||||||
|
brand_text_hex: branding.colors?.text || '#0f172a'
|
||||||
|
};
|
||||||
|
|
||||||
|
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
|
||||||
|
(_, key) => tokens[key] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getPublicSitePayload,
|
||||||
|
clearPublicSiteCache,
|
||||||
|
getDefaultPublicSitePayload,
|
||||||
|
getRawPublicSiteSettings
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ const rateLimit = require('express-rate-limit');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||||
|
|
||||||
// Cache for rate limit settings
|
// Cache for rate limit settings
|
||||||
let settingsCache = null;
|
let settingsCache = null;
|
||||||
@@ -95,12 +96,9 @@ function clearSettingsCache() {
|
|||||||
*/
|
*/
|
||||||
function isAuthenticated(req) {
|
function isAuthenticated(req) {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers.authorization;
|
const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
|
||||||
return false;
|
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||||
}
|
|
||||||
|
|
||||||
const token = authHeader.substring(7);
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
|
||||||
// Check if token is valid
|
// Check if token is valid
|
||||||
@@ -280,4 +278,4 @@ module.exports = {
|
|||||||
createAuthRateLimiter,
|
createAuthRateLimiter,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
shouldSkipRateLimit
|
shouldSkipRateLimit
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const S3StorageAdapter = require('../s3Storage');
|
const S3StorageAdapter = require('../s3Storage');
|
||||||
const { S3Client } = require('@aws-sdk/client-s3');
|
const { S3Client, HeadBucketCommand, HeadObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||||
const { Upload } = require('@aws-sdk/lib-storage');
|
const { Upload } = require('@aws-sdk/lib-storage');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
@@ -24,6 +24,10 @@ describe('S3StorageAdapter', () => {
|
|||||||
send: mockSend
|
send: mockSend
|
||||||
};
|
};
|
||||||
S3Client.mockImplementation(() => mockS3Client);
|
S3Client.mockImplementation(() => mockS3Client);
|
||||||
|
|
||||||
|
HeadBucketCommand.mockImplementation((input) => ({ input }));
|
||||||
|
HeadObjectCommand.mockImplementation((input) => ({ input }));
|
||||||
|
ListObjectsV2Command.mockImplementation((input) => ({ input }));
|
||||||
|
|
||||||
// Create adapter instance
|
// Create adapter instance
|
||||||
s3Storage = new S3StorageAdapter({
|
s3Storage = new S3StorageAdapter({
|
||||||
@@ -70,11 +74,7 @@ describe('S3StorageAdapter', () => {
|
|||||||
const result = await s3Storage.testConnection();
|
const result = await s3Storage.testConnection();
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(HeadBucketCommand).toHaveBeenCalledWith({ Bucket: 'test-bucket' });
|
||||||
expect.objectContaining({
|
|
||||||
input: { Bucket: 'test-bucket' }
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error on connection failure', async () => {
|
it('should throw error on connection failure', async () => {
|
||||||
@@ -132,24 +132,25 @@ describe('S3StorageAdapter', () => {
|
|||||||
|
|
||||||
it('should track upload progress', async () => {
|
it('should track upload progress', async () => {
|
||||||
const onProgress = jest.fn();
|
const onProgress = jest.fn();
|
||||||
let progressCallback;
|
Upload.mockImplementation(() => {
|
||||||
|
const uploadInstance = {
|
||||||
mockUpload.on.mockImplementation((event, callback) => {
|
on: jest.fn((event, handler) => {
|
||||||
if (event === 'httpUploadProgress') {
|
if (event === 'httpUploadProgress') {
|
||||||
progressCallback = callback;
|
handler({ loaded: 512, total: 1024 });
|
||||||
}
|
}
|
||||||
return mockUpload;
|
return uploadInstance;
|
||||||
|
}),
|
||||||
|
done: mockDone
|
||||||
|
};
|
||||||
|
return uploadInstance;
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', {
|
const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', {
|
||||||
onProgress
|
onProgress
|
||||||
});
|
});
|
||||||
|
|
||||||
// Simulate progress
|
|
||||||
progressCallback({ loaded: 512, total: 1024 });
|
|
||||||
|
|
||||||
await uploadPromise;
|
await uploadPromise;
|
||||||
|
|
||||||
expect(onProgress).toHaveBeenCalledWith(512, 1024);
|
expect(onProgress).toHaveBeenCalledWith(512, 1024);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -177,11 +178,10 @@ describe('S3StorageAdapter', () => {
|
|||||||
const result = await s3Storage.exists('test-key');
|
const result = await s3Storage.exists('test-key');
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(mockSend).toHaveBeenCalledWith(
|
expect(HeadObjectCommand).toHaveBeenCalledWith({
|
||||||
expect.objectContaining({
|
Bucket: 'test-bucket',
|
||||||
input: { Bucket: 'test-bucket', Key: 'test-key' }
|
Key: 'test-key'
|
||||||
})
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if object does not exist', async () => {
|
it('should return false if object does not exist', async () => {
|
||||||
@@ -220,58 +220,51 @@ describe('S3StorageAdapter', () => {
|
|||||||
it('should retry on retryable errors', async () => {
|
it('should retry on retryable errors', async () => {
|
||||||
const retryableError = new Error('Connection reset');
|
const retryableError = new Error('Connection reset');
|
||||||
retryableError.code = 'ECONNRESET';
|
retryableError.code = 'ECONNRESET';
|
||||||
|
|
||||||
// First attempt fails, second succeeds
|
const operation = jest.fn()
|
||||||
mockSend
|
|
||||||
.mockRejectedValueOnce(retryableError)
|
.mockRejectedValueOnce(retryableError)
|
||||||
.mockResolvedValueOnce({});
|
.mockResolvedValueOnce('success');
|
||||||
|
|
||||||
// Mock setTimeout to speed up test
|
const originalRandom = Math.random;
|
||||||
jest.useFakeTimers();
|
const originalDelay = s3Storage.config.retryDelay;
|
||||||
|
Math.random = jest.fn(() => 0);
|
||||||
const promise = s3Storage.exists('test-key');
|
s3Storage.config.retryDelay = 0;
|
||||||
|
|
||||||
// Advance timers
|
const result = await s3Storage._retryOperation(operation);
|
||||||
jest.runAllTimers();
|
|
||||||
|
expect(result).toBe('success');
|
||||||
const result = await promise;
|
expect(operation).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
Math.random = originalRandom;
|
||||||
expect(mockSend).toHaveBeenCalledTimes(2);
|
s3Storage.config.retryDelay = originalDelay;
|
||||||
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not retry on non-retryable errors', async () => {
|
it('should not retry on non-retryable errors', async () => {
|
||||||
const nonRetryableError = new Error('Invalid credentials');
|
const nonRetryableError = new Error('Invalid credentials');
|
||||||
nonRetryableError.code = 'InvalidCredentials';
|
nonRetryableError.code = 'InvalidCredentials';
|
||||||
|
|
||||||
mockSend.mockRejectedValueOnce(nonRetryableError);
|
const operation = jest.fn().mockRejectedValueOnce(nonRetryableError);
|
||||||
|
|
||||||
await expect(s3Storage.exists('test-key')).rejects.toThrow('Invalid credentials');
|
await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Invalid credentials');
|
||||||
expect(mockSend).toHaveBeenCalledTimes(1);
|
expect(operation).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should stop retrying after max attempts', async () => {
|
it('should stop retrying after max attempts', async () => {
|
||||||
const retryableError = new Error('Service unavailable');
|
const retryableError = new Error('Service unavailable');
|
||||||
retryableError.code = 'ServiceUnavailable';
|
retryableError.code = 'ServiceUnavailable';
|
||||||
|
|
||||||
mockSend.mockRejectedValue(retryableError);
|
const operation = jest.fn().mockRejectedValue(retryableError);
|
||||||
|
|
||||||
// Mock setTimeout to speed up test
|
const originalRandom = Math.random;
|
||||||
jest.useFakeTimers();
|
const originalDelay = s3Storage.config.retryDelay;
|
||||||
|
Math.random = jest.fn(() => 0);
|
||||||
const promise = s3Storage.exists('test-key');
|
s3Storage.config.retryDelay = 0;
|
||||||
|
|
||||||
// Advance timers for all retries
|
await expect(s3Storage._retryOperation(operation)).rejects.toThrow('Service unavailable');
|
||||||
for (let i = 0; i < 4; i++) {
|
expect(operation).toHaveBeenCalledTimes(4); // initial + 3 retries
|
||||||
jest.runAllTimers();
|
|
||||||
}
|
Math.random = originalRandom;
|
||||||
|
s3Storage.config.retryDelay = originalDelay;
|
||||||
await expect(promise).rejects.toThrow('Service unavailable');
|
|
||||||
expect(mockSend).toHaveBeenCalledTimes(4); // Initial + 3 retries
|
|
||||||
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -308,4 +301,4 @@ describe('S3StorageAdapter', () => {
|
|||||||
expect(s3Storage._formatBytes(1536, 1)).toBe('1.5 KB');
|
expect(s3Storage._formatBytes(1536, 1)).toBe('1.5 KB');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
function sanitizeCss(css) {
|
||||||
|
if (!css || typeof css !== 'string') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
let sanitized = css;
|
||||||
|
|
||||||
|
const disallowedPatterns = [
|
||||||
|
/@import[^;]+;?/gi,
|
||||||
|
/@charset[^;]+;?/gi,
|
||||||
|
/expression\s*\([^)]*\)/gi,
|
||||||
|
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
|
||||||
|
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
|
||||||
|
];
|
||||||
|
|
||||||
|
disallowedPatterns.forEach((pattern) => {
|
||||||
|
sanitized = sanitized.replace(pattern, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
||||||
|
|
||||||
|
const MAX_LENGTH = 100 * 1024;
|
||||||
|
if (sanitized.length > MAX_LENGTH) {
|
||||||
|
sanitized = sanitized.slice(0, MAX_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
sanitizeCss,
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
const ADMIN_COOKIE_NAME = 'admin_token';
|
||||||
|
const GALLERY_COOKIE_NAME = 'gallery_token';
|
||||||
|
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
|
||||||
|
|
||||||
|
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
|
const secureCookie = (() => {
|
||||||
|
if (typeof process.env.COOKIE_SECURE === 'string') {
|
||||||
|
return process.env.COOKIE_SECURE.toLowerCase() === 'true';
|
||||||
|
}
|
||||||
|
// Default to false so native HTTP installs stay functional. Operators can
|
||||||
|
// opt-in via COOKIE_SECURE=true when serving behind HTTPS.
|
||||||
|
return false;
|
||||||
|
})();
|
||||||
|
const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
|
||||||
|
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||||
|
|
||||||
|
function buildCookieBaseOptions() {
|
||||||
|
const options = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: secureCookie,
|
||||||
|
sameSite: sameSiteDefault,
|
||||||
|
path: '/',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (cookieDomain) {
|
||||||
|
options.domain = cookieDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCookieOptionsWithExpiry(maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
||||||
|
return {
|
||||||
|
...buildCookieBaseOptions(),
|
||||||
|
maxAge: maxAgeMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeSlugForCookie(slug = '') {
|
||||||
|
return String(slug).replace(/[^A-Za-z0-9_-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdminAuthCookie(res, token) {
|
||||||
|
if (!token) return;
|
||||||
|
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAdminAuthCookie(res) {
|
||||||
|
res.clearCookie(ADMIN_COOKIE_NAME, buildCookieBaseOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGalleryAuthCookies(res, token, slug) {
|
||||||
|
if (!token) return;
|
||||||
|
const options = buildCookieOptionsWithExpiry();
|
||||||
|
res.cookie(GALLERY_COOKIE_NAME, token, options);
|
||||||
|
if (slug) {
|
||||||
|
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
||||||
|
res.cookie(cookieName, token, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGalleryAuthCookies(res, slug) {
|
||||||
|
const baseOptions = buildCookieBaseOptions();
|
||||||
|
res.clearCookie(GALLERY_COOKIE_NAME, baseOptions);
|
||||||
|
|
||||||
|
const cookies = res.req?.cookies || {};
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
||||||
|
res.clearCookie(cookieName, baseOptions);
|
||||||
|
} else {
|
||||||
|
Object.keys(cookies).forEach((name) => {
|
||||||
|
if (name.startsWith(GALLERY_COOKIE_PREFIX)) {
|
||||||
|
res.clearCookie(name, baseOptions);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAdminTokenFromRequest(req) {
|
||||||
|
const header = req.headers?.authorization;
|
||||||
|
if (header && header.startsWith('Bearer ')) {
|
||||||
|
return header.substring(7);
|
||||||
|
}
|
||||||
|
return req.cookies?.[ADMIN_COOKIE_NAME] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGalleryTokenFromRequest(req, slug) {
|
||||||
|
const header = req.headers?.authorization;
|
||||||
|
if (header && header.startsWith('Bearer ')) {
|
||||||
|
return header.substring(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.cookies) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slug) {
|
||||||
|
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
||||||
|
if (req.cookies[cookieName]) {
|
||||||
|
return req.cookies[cookieName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.cookies[GALLERY_COOKIE_NAME]) {
|
||||||
|
return req.cookies[GALLERY_COOKIE_NAME];
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixed = Object.keys(req.cookies).find((name) => name.startsWith(GALLERY_COOKIE_PREFIX));
|
||||||
|
if (prefixed) {
|
||||||
|
return req.cookies[prefixed];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ADMIN_COOKIE_NAME,
|
||||||
|
GALLERY_COOKIE_NAME,
|
||||||
|
GALLERY_COOKIE_PREFIX,
|
||||||
|
sanitizeSlugForCookie,
|
||||||
|
setAdminAuthCookie,
|
||||||
|
clearAdminAuthCookie,
|
||||||
|
setGalleryAuthCookies,
|
||||||
|
clearGalleryAuthCookies,
|
||||||
|
getAdminTokenFromRequest,
|
||||||
|
getGalleryTokenFromRequest,
|
||||||
|
};
|
||||||
+31
-5
@@ -3,19 +3,45 @@
|
|||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
host="$DB_HOST"
|
host="${DB_HOST:-postgres}"
|
||||||
port="${DB_PORT:-5432}"
|
port="${DB_PORT:-5432}"
|
||||||
user="${DB_USER:-picpeak}"
|
user="${DB_USER:-picpeak}"
|
||||||
|
target_db="${DB_NAME:-picpeak}"
|
||||||
|
default_db="${DB_CHECK_DB:-postgres}"
|
||||||
|
|
||||||
|
sanitize_identifier() {
|
||||||
|
printf '%s' "$1" | sed "s/'/''/g"
|
||||||
|
}
|
||||||
|
|
||||||
echo "Waiting for PostgreSQL at $host:$port..."
|
echo "Waiting for PostgreSQL at $host:$port..."
|
||||||
|
|
||||||
# Wait for PostgreSQL to be ready
|
# Wait for PostgreSQL server to accept connections (using the default database)
|
||||||
until PGPASSWORD=$DB_PASSWORD psql -h "$host" -p "$port" -U "$user" -d "${DB_NAME:-picpeak}" -c '\q' 2>/dev/null; do
|
until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c '\q' >/dev/null 2>&1; do
|
||||||
>&2 echo "PostgreSQL is unavailable - sleeping"
|
>&2 echo "PostgreSQL is unavailable - sleeping"
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
>&2 echo "PostgreSQL is up - executing command"
|
>&2 echo "PostgreSQL is up - verifying target database \"$target_db\""
|
||||||
|
|
||||||
|
# Ensure the target database exists (helps when volumes are reused or DB_NAME is customised)
|
||||||
|
db_exists=$(PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -tAc "SELECT 1 FROM pg_database WHERE datname = '$(sanitize_identifier "$target_db")'" 2>/dev/null || echo 0)
|
||||||
|
|
||||||
|
if [ "$db_exists" != "1" ]; then
|
||||||
|
>&2 echo "Database \"$target_db\" not found. Attempting to create..."
|
||||||
|
if ! PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c "CREATE DATABASE \"$target_db\";" >/dev/null 2>&1; then
|
||||||
|
>&2 echo "Failed to create database \"$target_db\". Please ensure it exists and is accessible."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
>&2 echo "Database \"$target_db\" created successfully."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait until the target database itself is ready to accept connections
|
||||||
|
until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$target_db" -c '\q' >/dev/null 2>&1; do
|
||||||
|
>&2 echo "Waiting for database \"$target_db\" to accept connections..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
>&2 echo "Target database \"$target_db\" is ready."
|
||||||
|
|
||||||
# Run migrations (use safe runner in production)
|
# Run migrations (use safe runner in production)
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
@@ -26,4 +52,4 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Execute the main command
|
# Execute the main command
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
- VITE_API_URL=${VITE_API_URL:-http://localhost:3001/api}
|
- VITE_API_URL=${VITE_API_URL:-/api}
|
||||||
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
||||||
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
||||||
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
|
|||||||
- Check the console output from when you ran migrations
|
- Check the console output from when you ran migrations
|
||||||
- File is created in the backend directory root
|
- File is created in the backend directory root
|
||||||
- File might have been deleted for security (as recommended)
|
- File might have been deleted for security (as recommended)
|
||||||
|
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
|
||||||
|
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -19,5 +19,28 @@ export default tseslint.config([
|
|||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
||||||
|
'react-hooks/rules-of-hooks': 'off',
|
||||||
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
|
'no-useless-escape': 'off',
|
||||||
|
'no-case-declarations': 'off',
|
||||||
|
'prefer-const': 'off',
|
||||||
|
'no-control-regex': 'off',
|
||||||
|
'no-useless-catch': 'off',
|
||||||
|
'react-refresh/only-export-components': 'off',
|
||||||
|
'no-empty': 'off',
|
||||||
|
'no-debugger': 'off',
|
||||||
|
'@typescript-eslint/no-unused-expressions': 'off',
|
||||||
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.d.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
+14
-1
@@ -101,6 +101,19 @@ server {
|
|||||||
proxy_cache_valid 404 1m;
|
proxy_cache_valid 404 1m;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Delegate root requests to backend for public landing page handling
|
||||||
|
location = / {
|
||||||
|
proxy_pass http://backend:3001/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
# SPA fallback
|
# SPA fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
@@ -110,4 +123,4 @@ server {
|
|||||||
location ~ /\. {
|
location ~ /\. {
|
||||||
deny all;
|
deny all;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1320
-52
File diff suppressed because it is too large
Load Diff
+15
-9
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.127",
|
"version": "1.1.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "cross-env ROLLUP_USE_NODE_JS=true vite build",
|
||||||
"build:check": "tsc -b && vite build",
|
"build:check": "tsc -b && cross-env ROLLUP_USE_NODE_JS=true vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run src/components/admin/__tests__/ThemeCustomizerEnhanced.test.tsx"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
@@ -23,14 +24,14 @@
|
|||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "^4.17.20",
|
||||||
"@types/react-google-recaptcha": "^2.1.9",
|
"@types/react-google-recaptcha": "^2.1.9",
|
||||||
"axios": "^1.3.2",
|
"axios": "^1.12.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"dompurify": "^3.2.6",
|
"dompurify": "^3.2.6",
|
||||||
"i18next": "^25.3.1",
|
"i18next": "^25.3.1",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"js-cookie": "^3.0.5",
|
"linkifyjs": "^4.3.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"lowlight": "^2.9.0",
|
"lowlight": "^2.9.0",
|
||||||
"lucide-react": "0.525.0",
|
"lucide-react": "0.525.0",
|
||||||
@@ -47,19 +48,24 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.29.0",
|
"@eslint/js": "^9.29.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.1.0",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.5.2",
|
"@vitejs/plugin-react": "^4.5.3",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
"eslint": "^9.29.0",
|
"eslint": "^9.29.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.2.0",
|
"globals": "^16.2.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.4.21",
|
"postcss": "^8.4.21",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.34.1",
|
"typescript-eslint": "^8.34.1",
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.1.12",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import {
|
|||||||
AnalyticsPage,
|
AnalyticsPage,
|
||||||
BrandingPage,
|
BrandingPage,
|
||||||
SettingsPage,
|
SettingsPage,
|
||||||
CMSPage,
|
|
||||||
BackupManagement
|
BackupManagement
|
||||||
} from './pages/admin';
|
} from './pages/admin';
|
||||||
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
|
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const MaintenanceMode: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await api.get('/public/settings');
|
const response = await api.get('/public/settings');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Return empty object if settings can't be fetched
|
// Return empty object if settings can't be fetched
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@@ -110,4 +110,4 @@ export const MaintenanceMode: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { MaintenanceMode } from './MaintenanceMode';
|
import { MaintenanceMode } from './MaintenanceMode';
|
||||||
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
|
import { useMaintenanceMode } from '../contexts/MaintenanceContext';
|
||||||
import { setMaintenanceModeCallback, api, getAuthToken } from '../config/api';
|
import { setMaintenanceModeCallback, api } from '../config/api';
|
||||||
|
|
||||||
interface MaintenanceWrapperProps {
|
interface MaintenanceWrapperProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -12,10 +12,38 @@ interface MaintenanceWrapperProps {
|
|||||||
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
|
export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children }) => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
|
const { isMaintenanceMode, setMaintenanceMode } = useMaintenanceMode();
|
||||||
|
const [hasAdminSession, setHasAdminSession] = useState(false);
|
||||||
|
|
||||||
// Check if current route is admin route
|
// Check if current route is admin route
|
||||||
const isAdminRoute = location.pathname.startsWith('/admin');
|
const isAdminRoute = location.pathname.startsWith('/admin');
|
||||||
const hasAdminAuth = !!getAuthToken(true);
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const checkAdminSession = async () => {
|
||||||
|
if (!isAdminRoute) {
|
||||||
|
setHasAdminSession(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get<{ valid: boolean; type: string }>('/auth/session');
|
||||||
|
if (isMounted) {
|
||||||
|
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (isMounted) {
|
||||||
|
setHasAdminSession(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAdminSession();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [isAdminRoute]);
|
||||||
|
|
||||||
// Register the maintenance mode callback
|
// Register the maintenance mode callback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -37,7 +65,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.response?.status === 503) {
|
if (error.response?.status === 503) {
|
||||||
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
|
// Only set maintenance mode for non-admin routes or unauthenticated admin routes
|
||||||
if (!isAdminRoute || !hasAdminAuth) {
|
if (!isAdminRoute || !hasAdminSession) {
|
||||||
setMaintenanceMode(true);
|
setMaintenanceMode(true);
|
||||||
return { maintenance: true };
|
return { maintenance: true };
|
||||||
}
|
}
|
||||||
@@ -47,13 +75,13 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
|
|||||||
},
|
},
|
||||||
staleTime: 30000, // Check every 30 seconds
|
staleTime: 30000, // Check every 30 seconds
|
||||||
retry: false, // Don't retry on failure
|
retry: false, // Don't retry on failure
|
||||||
enabled: (!isAdminRoute || !hasAdminAuth) && !isMaintenanceMode, // Don't check if already in maintenance
|
enabled: (!isAdminRoute || !hasAdminSession) && !isMaintenanceMode, // Don't check if already in maintenance
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show maintenance page if in maintenance mode and not on admin route with auth
|
// Show maintenance page if in maintenance mode and not on admin route with auth
|
||||||
if (isMaintenanceMode && (!isAdminRoute || !hasAdminAuth)) {
|
if (isMaintenanceMode && (!isAdminRoute || !hasAdminSession)) {
|
||||||
return <MaintenanceMode />;
|
return <MaintenanceMode />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let objectUrl: string | null = null;
|
||||||
|
|
||||||
const loadImage = async () => {
|
const loadImage = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
|
setImageSrc(null);
|
||||||
|
|
||||||
// Make authenticated request to get the image
|
// Make authenticated request to get the image
|
||||||
const response = await api.get(src, {
|
const response = await api.get(src, {
|
||||||
@@ -31,11 +33,11 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
|||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
// Create object URL from blob
|
// Create object URL from blob
|
||||||
const imageUrl = URL.createObjectURL(response.data);
|
objectUrl = URL.createObjectURL(response.data);
|
||||||
setImageSrc(imageUrl);
|
setImageSrc(objectUrl);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch {
|
||||||
// Image loading failed - handled by error state
|
// Image loading failed - handled by error state
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setError(true);
|
setError(true);
|
||||||
@@ -51,8 +53,8 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
|||||||
// Cleanup function
|
// Cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (imageSrc) {
|
if (objectUrl) {
|
||||||
URL.revokeObjectURL(imageSrc);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [src]);
|
}, [src]);
|
||||||
@@ -74,4 +76,4 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <img src={imageSrc || ''} alt={alt} {...props} />;
|
return <img src={imageSrc || ''} alt={alt} {...props} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,12 +55,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear old notifications mutation
|
// Clear notifications mutation
|
||||||
const clearOldMutation = useMutation({
|
const clearAllMutation = useMutation({
|
||||||
mutationFn: notificationsService.clearOldNotifications,
|
mutationFn: notificationsService.clearAllNotifications,
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||||
toast.success(t('admin.notificationToasts.clearedOld', { count: data.deletedCount }));
|
toast.success(t('admin.notificationToasts.clearedAll', { count: data.deletedCount }));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,12 +128,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => clearOldMutation.mutate()}
|
onClick={() => clearAllMutation.mutate()}
|
||||||
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
||||||
title={t('admin.clearOld')}
|
title={t('admin.clearAll')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
{t('admin.clearOld')}
|
{t('admin.clearAll')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
await photosService.deletePhoto(eventId, photo.id);
|
await photosService.deletePhoto(eventId, photo.id);
|
||||||
toast.success('Photo deleted successfully');
|
toast.success('Photo deleted successfully');
|
||||||
onPhotosDeleted();
|
onPhotosDeleted();
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to delete photo');
|
toast.error('Failed to delete photo');
|
||||||
setDeletingPhotos(prev => {
|
setDeletingPhotos(prev => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -90,7 +90,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
setSelectedPhotos(new Set());
|
setSelectedPhotos(new Set());
|
||||||
setIsSelectionMode(false);
|
setIsSelectionMode(false);
|
||||||
onPhotosDeleted();
|
onPhotosDeleted();
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to delete photos');
|
toast.error('Failed to delete photos');
|
||||||
setDeletingPhotos(new Set());
|
setDeletingPhotos(new Set());
|
||||||
} finally {
|
} finally {
|
||||||
@@ -103,7 +103,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
try {
|
try {
|
||||||
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
|
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
|
||||||
toast.success('Download started');
|
toast.success('Download started');
|
||||||
} catch (error) {
|
} catch {
|
||||||
toast.error('Failed to download photo');
|
toast.error('Failed to download photo');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -167,6 +167,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{photos.map((photo, index) => {
|
{photos.map((photo, index) => {
|
||||||
const isDeleting = deletingPhotos.has(photo.id);
|
const isDeleting = deletingPhotos.has(photo.id);
|
||||||
|
const commentCount = photo.comment_count ?? 0;
|
||||||
|
const averageRating = photo.average_rating ?? 0;
|
||||||
|
const likeCount = photo.like_count ?? 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
@@ -258,18 +261,18 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
|
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
|
||||||
{(photo.comment_count > 0 || photo.average_rating > 0 || photo.like_count > 0) && (
|
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
|
||||||
<div className="absolute bottom-2 right-2 flex items-center gap-1 z-10">
|
<div className="absolute bottom-2 right-2 flex items-center gap-1 z-10">
|
||||||
{photo.average_rating > 0 && (
|
{averageRating > 0 && (
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
|
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(averageRating).toFixed(1)}`}>
|
||||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
|
<span className="text-xs font-medium text-neutral-700">{Number(averageRating).toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{photo.comment_count > 0 && (
|
{commentCount > 0 && (
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
|
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${commentCount} comments`}>
|
||||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
|
<span className="text-xs font-medium text-neutral-700">{commentCount}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, ThumbsUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { AdminPhoto } from '../../services/photos.service';
|
import { AdminPhoto } from '../../services/photos.service';
|
||||||
import { photosService } from '../../services/photos.service';
|
import { photosService } from '../../services/photos.service';
|
||||||
import { feedbackService } from '../../services/feedback.service';
|
import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||||
|
|
||||||
|
type AdminFeedbackResponse = {
|
||||||
|
feedback: PhotoFeedback[];
|
||||||
|
summary?: FeedbackSummary;
|
||||||
|
};
|
||||||
|
|
||||||
interface AdminPhotoViewerProps {
|
interface AdminPhotoViewerProps {
|
||||||
photos: AdminPhoto[];
|
photos: AdminPhoto[];
|
||||||
initialIndex: number;
|
initialIndex: number;
|
||||||
@@ -34,9 +39,16 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const currentPhoto = photos[currentIndex];
|
const currentPhoto = photos[currentIndex];
|
||||||
|
const averageRating = currentPhoto?.average_rating ?? 0;
|
||||||
|
const likeCount = currentPhoto?.like_count ?? 0;
|
||||||
|
const favoriteCount = currentPhoto?.favorite_count ?? 0;
|
||||||
|
|
||||||
|
if (!currentPhoto) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch feedback for current photo
|
// Fetch feedback for current photo
|
||||||
const { data: feedbackData } = useQuery({
|
const { data: feedbackData } = useQuery<AdminFeedbackResponse>({
|
||||||
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
|
queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id],
|
||||||
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
||||||
photoId: currentPhoto?.id.toString(),
|
photoId: currentPhoto?.id.toString(),
|
||||||
@@ -45,6 +57,8 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
enabled: !!currentPhoto
|
enabled: !!currentPhoto
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const comments = (feedbackData?.feedback ?? []).filter((item): item is PhotoFeedback => item.feedback_type === 'comment');
|
||||||
|
|
||||||
const goToPrevious = () => {
|
const goToPrevious = () => {
|
||||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1));
|
||||||
};
|
};
|
||||||
@@ -304,41 +318,41 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
|
|
||||||
{/* Feedback Stats */}
|
{/* Feedback Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
{currentPhoto.average_rating > 0 && (
|
{averageRating > 0 && (
|
||||||
<div className="bg-neutral-800 rounded-lg p-3">
|
<div className="bg-neutral-800 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-1 text-yellow-400 mb-1">
|
<div className="flex items-center gap-1 text-yellow-400 mb-1">
|
||||||
<Star className="w-4 h-4" fill="currentColor" />
|
<Star className="w-4 h-4" fill="currentColor" />
|
||||||
<span className="text-white font-medium">{Number(currentPhoto.average_rating).toFixed(1)}</span>
|
<span className="text-white font-medium">{Number(averageRating).toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-400">Avg Rating</p>
|
<p className="text-xs text-neutral-400">Avg Rating</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentPhoto.like_count > 0 && (
|
{likeCount > 0 && (
|
||||||
<div className="bg-neutral-800 rounded-lg p-3">
|
<div className="bg-neutral-800 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-1 text-red-400 mb-1">
|
<div className="flex items-center gap-1 text-red-400 mb-1">
|
||||||
<Heart className="w-4 h-4" fill="currentColor" />
|
<Heart className="w-4 h-4" fill="currentColor" />
|
||||||
<span className="text-white font-medium">{currentPhoto.like_count}</span>
|
<span className="text-white font-medium">{likeCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-400">Likes</p>
|
<p className="text-xs text-neutral-400">Likes</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{currentPhoto.favorite_count > 0 && (
|
{favoriteCount > 0 && (
|
||||||
<div className="bg-neutral-800 rounded-lg p-3">
|
<div className="bg-neutral-800 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-1 text-blue-400 mb-1">
|
<div className="flex items-center gap-1 text-blue-400 mb-1">
|
||||||
<Star className="w-4 h-4" />
|
<Star className="w-4 h-4" />
|
||||||
<span className="text-white font-medium">{currentPhoto.favorite_count}</span>
|
<span className="text-white font-medium">{favoriteCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-400">Favorites</p>
|
<p className="text-xs text-neutral-400">Favorites</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{feedbackData.feedback && (
|
{comments.length > 0 && (
|
||||||
<div className="bg-neutral-800 rounded-lg p-3">
|
<div className="bg-neutral-800 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-1 text-green-400 mb-1">
|
<div className="flex items-center gap-1 text-green-400 mb-1">
|
||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
<span className="text-white font-medium">{feedbackData.feedback.filter(f => f.feedback_type === 'comment').length}</span>
|
<span className="text-white font-medium">{comments.length}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-400">Comments</p>
|
<p className="text-xs text-neutral-400">Comments</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,20 +360,18 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Comments List */}
|
{/* Comments List */}
|
||||||
{feedbackData.feedback && feedbackData.feedback.filter(f => f.feedback_type === 'comment').length > 0 && (
|
{comments.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setExpandedComments(!expandedComments)}
|
onClick={() => setExpandedComments(!expandedComments)}
|
||||||
className="text-xs text-primary-400 hover:text-primary-300 mb-2"
|
className="text-xs text-primary-400 hover:text-primary-300 mb-2"
|
||||||
>
|
>
|
||||||
{expandedComments ? 'Hide' : 'Show'} Comments ({feedbackData.feedback.filter(f => f.feedback_type === 'comment').length})
|
{expandedComments ? 'Hide' : 'Show'} Comments ({comments.length})
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expandedComments && (
|
{expandedComments && (
|
||||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||||
{feedbackData.feedback
|
{comments.map((comment) => (
|
||||||
.filter(f => f.feedback_type === 'comment')
|
|
||||||
.map((comment) => (
|
|
||||||
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
|
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@@ -459,7 +471,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No feedback message */}
|
{/* No feedback message */}
|
||||||
{(!feedbackData.feedback || feedbackData.feedback.length === 0) && (
|
{comments.length === 0 && (
|
||||||
<p className="text-neutral-400 text-sm">No feedback for this photo yet.</p>
|
<p className="text-neutral-400 text-sm">No feedback for this photo yet.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -475,4 +487,4 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -121,11 +121,20 @@ const StorageInfo: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const usagePercent = Math.round((storageInfo.total_used / storageInfo.storage_limit) * 100);
|
const limitInUse = storageInfo.storage_soft_limit || storageInfo.storage_limit || 1;
|
||||||
|
const usagePercent = limitInUse
|
||||||
|
? Math.round((storageInfo.total_used / limitInUse) * 100)
|
||||||
|
: 0;
|
||||||
|
const isOverSoftLimit = limitInUse && storageInfo.total_used >= limitInUse;
|
||||||
|
const progressBarClass = isOverSoftLimit ? 'bg-red-600' : 'bg-primary-600';
|
||||||
|
const containerClass = isOverSoftLimit
|
||||||
|
? 'bg-red-50 border border-red-200'
|
||||||
|
: 'bg-neutral-100';
|
||||||
|
const softLimitDisplay = settingsService.formatBytes(limitInUse);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 border-t border-neutral-200">
|
<div className="p-4 border-t border-neutral-200">
|
||||||
<div className="bg-neutral-100 rounded-lg p-3">
|
<div className={`${containerClass} rounded-lg p-3 transition-colors duration-300`}>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
|
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
|
||||||
<span className="font-medium text-neutral-900">
|
<span className="font-medium text-neutral-900">
|
||||||
@@ -134,14 +143,14 @@ const StorageInfo: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
|
<div className="mt-2 w-full bg-neutral-200 rounded-full h-2">
|
||||||
<div
|
<div
|
||||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
className={`${progressBarClass} h-2 rounded-full transition-all duration-300`}
|
||||||
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
style={{ width: `${Math.min(usagePercent, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-600 mt-1">
|
<p className="text-xs text-neutral-600 mt-1">
|
||||||
{t('admin.storagePercent', { percent: usagePercent, limit: settingsService.formatBytes(storageInfo.storage_limit) })}
|
{t('admin.storagePercent', { percent: usagePercent, limit: softLimitDisplay })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
export const BackupConfiguration: ComponentType<any>;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
export const BackupDashboard: ComponentType<any>;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
export const BackupHistory: ComponentType<any>;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { useEditor, EditorContent } from '@tiptap/react';
|
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||||
import StarterKit from '@tiptap/starter-kit';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
import Link from '@tiptap/extension-link';
|
import Link from '@tiptap/extension-link';
|
||||||
import HardBreak from '@tiptap/extension-hard-break';
|
import HardBreak from '@tiptap/extension-hard-break';
|
||||||
@@ -107,10 +107,11 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateCounts = useCallback((editor: any) => {
|
const updateCounts = useCallback((editorInstance: Editor) => {
|
||||||
const text = editor.state.doc.textContent;
|
const textContent = editorInstance.state.doc.textContent;
|
||||||
setCharCount(editor.storage.characterCount.characters());
|
setCharCount(editorInstance.storage.characterCount.characters());
|
||||||
setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length);
|
const words = textContent.trim().split(/\s+/).filter((word: string) => word.length > 0);
|
||||||
|
setWordCount(words.length);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Update editor content when prop changes
|
// Update editor content when prop changes
|
||||||
@@ -568,4 +569,4 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
CMSEditor.displayName = 'CMSEditor';
|
CMSEditor.displayName = 'CMSEditor';
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Eye,
|
EyeOff,
|
||||||
EyeOff,
|
Trash2,
|
||||||
Trash2,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
|
||||||
User
|
User
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
@@ -16,7 +13,7 @@ import { toast } from 'react-toastify';
|
|||||||
|
|
||||||
import { Card, Loading, Button } from '../common';
|
import { Card, Loading, Button } from '../common';
|
||||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||||
import { feedbackService } from '../../services/feedback.service';
|
import { feedbackService, type FeedbackResponse, type PhotoFeedback } from '../../services/feedback.service';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
|
|
||||||
interface FeedbackModerationPanelProps {
|
interface FeedbackModerationPanelProps {
|
||||||
@@ -38,7 +35,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
|||||||
const [showAll, setShowAll] = useState(false);
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
// Fetch pending feedback
|
// Fetch pending feedback
|
||||||
const { data: feedbackData, isLoading } = useQuery({
|
const { data: feedbackData, isLoading } = useQuery<FeedbackResponse>({
|
||||||
queryKey: ['event-feedback-moderation', eventId],
|
queryKey: ['event-feedback-moderation', eventId],
|
||||||
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
|
||||||
type: 'comment',
|
type: 'comment',
|
||||||
@@ -77,12 +74,12 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingComments = feedbackData?.feedback || [];
|
const pendingComments: PhotoFeedback[] = feedbackData?.feedback || [];
|
||||||
const hasPending = pendingComments.length > 0;
|
const hasPending = pendingComments.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<div className="p-6">
|
<div className={compact ? 'p-4' : 'p-6'}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900">
|
<h2 className="text-lg font-semibold text-neutral-900">
|
||||||
{t('feedback.pendingModeration', 'Pending Moderation')}
|
{t('feedback.pendingModeration', 'Pending Moderation')}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Camera } from 'lucide-react';
|
import { Camera } from 'lucide-react';
|
||||||
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
||||||
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
|
|
||||||
|
interface GalleryPreviewBranding {
|
||||||
|
company_name?: string;
|
||||||
|
company_tagline?: string;
|
||||||
|
logo_url?: string;
|
||||||
|
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
||||||
|
logo_position?: 'left' | 'center' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
interface GalleryPreviewProps {
|
interface GalleryPreviewProps {
|
||||||
theme: ThemeConfig;
|
theme: ThemeConfig;
|
||||||
|
branding?: GalleryPreviewBranding;
|
||||||
layoutType?: GalleryLayoutType;
|
layoutType?: GalleryLayoutType;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -56,6 +66,7 @@ const PreviewPhoto: React.FC<{
|
|||||||
|
|
||||||
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||||
theme,
|
theme,
|
||||||
|
branding,
|
||||||
layoutType,
|
layoutType,
|
||||||
className = ''
|
className = ''
|
||||||
}) => {
|
}) => {
|
||||||
@@ -63,6 +74,23 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
|||||||
|
|
||||||
// Use the provided layoutType or fallback to theme's gallery layout
|
// Use the provided layoutType or fallback to theme's gallery layout
|
||||||
const activeLayout = layoutType || theme.galleryLayout || 'grid';
|
const activeLayout = layoutType || theme.galleryLayout || 'grid';
|
||||||
|
|
||||||
|
const displayMode = branding?.logo_display_mode || 'logo_and_text';
|
||||||
|
const showLogo = displayMode === 'logo_only' || displayMode === 'logo_and_text';
|
||||||
|
const showText = displayMode === 'text_only' || displayMode === 'logo_and_text';
|
||||||
|
const brandName = branding?.company_name?.trim() || 'Your Studio';
|
||||||
|
const brandTagline = branding?.company_tagline?.trim() || '';
|
||||||
|
const resolvedLogoUrl = showLogo && branding?.logo_url
|
||||||
|
? (branding.logo_url.startsWith('http')
|
||||||
|
? branding.logo_url
|
||||||
|
: buildResourceUrl(branding.logo_url))
|
||||||
|
: null;
|
||||||
|
const logoPosition = branding?.logo_position || 'left';
|
||||||
|
const brandFlexClass = logoPosition === 'center'
|
||||||
|
? 'justify-center text-center'
|
||||||
|
: logoPosition === 'right'
|
||||||
|
? 'justify-end text-right flex-row-reverse'
|
||||||
|
: 'justify-start text-left';
|
||||||
|
|
||||||
const renderLayout = () => {
|
const renderLayout = () => {
|
||||||
const spacing = theme.gallerySettings?.spacing || 'normal';
|
const spacing = theme.gallerySettings?.spacing || 'normal';
|
||||||
@@ -163,14 +191,41 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
|||||||
>
|
>
|
||||||
{/* Preview Header */}
|
{/* Preview Header */}
|
||||||
<div
|
<div
|
||||||
className="px-4 py-3 border-b"
|
className="px-4 py-3 border-b space-y-2"
|
||||||
style={{
|
style={{
|
||||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 className="text-sm font-medium">
|
<div className={`flex items-center gap-3 ${brandFlexClass}`}>
|
||||||
Gallery Preview - <span className="capitalize">{activeLayout}</span> Layout
|
{showLogo && (
|
||||||
</h3>
|
resolvedLogoUrl ? (
|
||||||
|
<img
|
||||||
|
src={resolvedLogoUrl}
|
||||||
|
alt={brandName}
|
||||||
|
className="h-8 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-8 w-8 rounded-full bg-neutral-200 flex items-center justify-center">
|
||||||
|
<Camera className="w-4 h-4 text-neutral-500" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{showText && (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold leading-tight">{brandName}</p>
|
||||||
|
{brandTagline && (
|
||||||
|
<p className="text-xs text-neutral-500 leading-tight">{brandTagline}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!showLogo && !showText && (
|
||||||
|
<p className="text-sm font-semibold">{brandName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500 flex justify-between">
|
||||||
|
<span>Gallery preview</span>
|
||||||
|
<span className="capitalize">{activeLayout} layout</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Content */}
|
{/* Preview Content */}
|
||||||
@@ -181,4 +236,4 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
GalleryPreview.displayName = 'GalleryPreview';
|
GalleryPreview.displayName = 'GalleryPreview';
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Shield, Eye, Lock, AlertTriangle, Info } from 'lucide-react';
|
|
||||||
import { Button, Card, Toggle, Select, Input, Textarea } from '../common';
|
|
||||||
import { settingsService } from '../../services/settings.service';
|
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
|
|
||||||
interface ProtectionSettings {
|
|
||||||
default_protection_level: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
|
||||||
default_image_quality: number;
|
|
||||||
enable_devtools_protection: boolean;
|
|
||||||
max_image_requests_per_minute: number;
|
|
||||||
suspicious_activity_threshold: number;
|
|
||||||
enable_canvas_rendering: boolean;
|
|
||||||
default_fragmentation_level: number;
|
|
||||||
enable_overlay_protection: boolean;
|
|
||||||
protection_warning_message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ImageProtectionSettings: React.FC = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [settings, setSettings] = useState<ProtectionSettings>({
|
|
||||||
default_protection_level: 'standard',
|
|
||||||
default_image_quality: 85,
|
|
||||||
enable_devtools_protection: true,
|
|
||||||
max_image_requests_per_minute: 30,
|
|
||||||
suspicious_activity_threshold: 10,
|
|
||||||
enable_canvas_rendering: false,
|
|
||||||
default_fragmentation_level: 3,
|
|
||||||
enable_overlay_protection: true,
|
|
||||||
protection_warning_message: 'Images in this gallery are protected from unauthorized download.'
|
|
||||||
});
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadSettings();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadSettings = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const response = await settingsService.getSettings();
|
|
||||||
|
|
||||||
// Map settings from API response
|
|
||||||
const protectionSettings: ProtectionSettings = {
|
|
||||||
default_protection_level: response.default_protection_level || 'standard',
|
|
||||||
default_image_quality: parseInt(response.default_image_quality) || 85,
|
|
||||||
enable_devtools_protection: response.enable_devtools_protection !== false,
|
|
||||||
max_image_requests_per_minute: parseInt(response.max_image_requests_per_minute) || 30,
|
|
||||||
suspicious_activity_threshold: parseInt(response.suspicious_activity_threshold) || 10,
|
|
||||||
enable_canvas_rendering: response.enable_canvas_rendering === true,
|
|
||||||
default_fragmentation_level: parseInt(response.default_fragmentation_level) || 3,
|
|
||||||
enable_overlay_protection: response.enable_overlay_protection !== false,
|
|
||||||
protection_warning_message: response.protection_warning_message || settings.protection_warning_message
|
|
||||||
};
|
|
||||||
|
|
||||||
setSettings(protectionSettings);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load protection settings:', error);
|
|
||||||
toast.error('Failed to load protection settings');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveSettings = async () => {
|
|
||||||
try {
|
|
||||||
setIsSaving(true);
|
|
||||||
|
|
||||||
// Convert settings to API format
|
|
||||||
const apiSettings = Object.entries(settings).reduce((acc, [key, value]) => {
|
|
||||||
acc[key] = typeof value === 'boolean' ? value : value.toString();
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, string | boolean>);
|
|
||||||
|
|
||||||
await settingsService.updateSettings(apiSettings);
|
|
||||||
toast.success('Protection settings saved successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save protection settings:', error);
|
|
||||||
toast.error('Failed to save protection settings');
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSetting = (key: keyof ProtectionSettings, value: any) => {
|
|
||||||
setSettings(prev => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const protectionLevels = [
|
|
||||||
{ value: 'basic', label: 'Basic - Minimal protection, best performance' },
|
|
||||||
{ value: 'standard', label: 'Standard - Balanced protection and performance' },
|
|
||||||
{ value: 'enhanced', label: 'Enhanced - Strong protection with good performance' },
|
|
||||||
{ value: 'maximum', label: 'Maximum - Strongest protection, may impact performance' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const getProtectionLevelIcon = (level: string) => {
|
|
||||||
switch (level) {
|
|
||||||
case 'basic': return <Eye className="w-4 h-4 text-green-500" />;
|
|
||||||
case 'standard': return <Shield className="w-4 h-4 text-blue-500" />;
|
|
||||||
case 'enhanced': return <Lock className="w-4 h-4 text-orange-500" />;
|
|
||||||
case 'maximum': return <AlertTriangle className="w-4 h-4 text-red-500" />;
|
|
||||||
default: return <Shield className="w-4 h-4 text-gray-500" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getProtectionLevelDescription = (level: string) => {
|
|
||||||
switch (level) {
|
|
||||||
case 'basic':
|
|
||||||
return 'Prevents drag/drop and basic right-click. Good for public galleries.';
|
|
||||||
case 'standard':
|
|
||||||
return 'Adds keyboard shortcut blocking and user selection prevention.';
|
|
||||||
case 'enhanced':
|
|
||||||
return 'Includes DevTools detection, rate limiting, and overlay protection.';
|
|
||||||
case 'maximum':
|
|
||||||
return 'Canvas rendering, image fragmentation, and comprehensive monitoring.';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="animate-pulse space-y-4">
|
|
||||||
<div className="h-8 bg-gray-200 rounded"></div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="h-4 bg-gray-200 rounded"></div>
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<Shield className="w-6 h-6 text-blue-500" />
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Image Protection Settings</h2>
|
|
||||||
<p className="text-sm text-gray-600">Configure security measures for photo galleries</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Protection Level */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Default Protection Level
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={settings.default_protection_level}
|
|
||||||
onChange={(value) => updateSetting('default_protection_level', value as any)}
|
|
||||||
options={protectionLevels}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="mt-2 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
{getProtectionLevelIcon(settings.default_protection_level)}
|
|
||||||
<span className="font-medium text-sm capitalize">
|
|
||||||
{settings.default_protection_level} Protection
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
{getProtectionLevelDescription(settings.default_protection_level)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Image Quality */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Default Image Quality ({settings.default_image_quality}%)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="30"
|
|
||||||
max="100"
|
|
||||||
step="5"
|
|
||||||
value={settings.default_image_quality}
|
|
||||||
onChange={(e) => updateSetting('default_image_quality', parseInt(e.target.value))}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
|
||||||
<span>Lower quality = Better protection</span>
|
|
||||||
<span>Higher quality = Better image</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DevTools Protection */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
DevTools Protection
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Detect and respond to browser developer tools
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
checked={settings.enable_devtools_protection}
|
|
||||||
onChange={(checked) => updateSetting('enable_devtools_protection', checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Canvas Rendering */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
Canvas Rendering
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Render images on canvas instead of img tags (stronger protection)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
checked={settings.enable_canvas_rendering}
|
|
||||||
onChange={(checked) => updateSetting('enable_canvas_rendering', checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fragmentation Level */}
|
|
||||||
{settings.enable_canvas_rendering && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Image Fragmentation Level ({settings.default_fragmentation_level})
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="1"
|
|
||||||
max="5"
|
|
||||||
step="1"
|
|
||||||
value={settings.default_fragmentation_level}
|
|
||||||
onChange={(e) => updateSetting('default_fragmentation_level', parseInt(e.target.value))}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
|
||||||
<span>Low fragmentation</span>
|
|
||||||
<span>High fragmentation</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rate Limiting */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Max Requests Per Minute
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="5"
|
|
||||||
max="100"
|
|
||||||
value={settings.max_image_requests_per_minute}
|
|
||||||
onChange={(e) => updateSetting('max_image_requests_per_minute', parseInt(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Suspicious Activity Threshold
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="3"
|
|
||||||
max="50"
|
|
||||||
value={settings.suspicious_activity_threshold}
|
|
||||||
onChange={(e) => updateSetting('suspicious_activity_threshold', parseInt(e.target.value))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overlay Protection */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
Overlay Protection
|
|
||||||
</label>
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Add transparent overlays to prevent easy screenshot extraction
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
checked={settings.enable_overlay_protection}
|
|
||||||
onChange={(checked) => updateSetting('enable_overlay_protection', checked)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Warning Message */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Protection Warning Message
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={settings.protection_warning_message}
|
|
||||||
onChange={(e) => updateSetting('protection_warning_message', e.target.value)}
|
|
||||||
placeholder="Message shown when protection is triggered"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Warning Box */}
|
|
||||||
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Info className="w-5 h-5 text-amber-600 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-medium text-amber-800 mb-1">Important Notes</h4>
|
|
||||||
<ul className="text-sm text-amber-700 space-y-1">
|
|
||||||
<li>• Higher protection levels may impact page performance</li>
|
|
||||||
<li>• Canvas rendering disables browser image caching</li>
|
|
||||||
<li>• Maximum protection may cause accessibility issues</li>
|
|
||||||
<li>• Test thoroughly with your target browsers</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="mt-6 flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={loadSettings}
|
|
||||||
disabled={isSaving}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
onClick={saveSettings}
|
|
||||||
loading={isSaving}
|
|
||||||
>
|
|
||||||
Save Settings
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -94,7 +94,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
|
await api.post(`/admin/events/${eventId}/upload`, formData, {
|
||||||
onUploadProgress: (progressEvent) => {
|
onUploadProgress: (progressEvent) => {
|
||||||
if (progressEvent.total) {
|
if (progressEvent.total) {
|
||||||
// Calculate overall progress across all chunks
|
// Calculate overall progress across all chunks
|
||||||
@@ -276,4 +276,4 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
PhotoUpload.displayName = 'PhotoUpload';
|
PhotoUpload.displayName = 'PhotoUpload';
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
export const RestoreWizard: ComponentType<any>;
|
||||||
@@ -14,6 +14,8 @@ interface ThemeCustomizerEnhancedProps {
|
|||||||
isPreviewMode?: boolean;
|
isPreviewMode?: boolean;
|
||||||
showGalleryLayouts?: boolean;
|
showGalleryLayouts?: boolean;
|
||||||
hideActions?: boolean;
|
hideActions?: boolean;
|
||||||
|
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
|
||||||
|
isApplying?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||||
@@ -34,7 +36,9 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
onPresetChange,
|
onPresetChange,
|
||||||
isPreviewMode = false,
|
isPreviewMode = false,
|
||||||
showGalleryLayouts = true,
|
showGalleryLayouts = true,
|
||||||
hideActions = false
|
hideActions = false,
|
||||||
|
onApply,
|
||||||
|
isApplying = false
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||||
@@ -80,8 +84,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = async () => {
|
||||||
onChange({ ...localTheme, customCss });
|
const themeWithCss = { ...localTheme, customCss };
|
||||||
|
onChange(themeWithCss);
|
||||||
|
|
||||||
|
if (onApply) {
|
||||||
|
await onApply(themeWithCss, { presetName: selectedPreset });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
@@ -587,11 +596,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
variant="primary"
|
variant="primary"
|
||||||
leftIcon={<Palette className="w-4 h-4" />}
|
leftIcon={<Palette className="w-4 h-4" />}
|
||||||
onClick={handleApply}
|
onClick={handleApply}
|
||||||
|
disabled={isApplying}
|
||||||
>
|
>
|
||||||
{t('branding.applyTheme')}
|
{isApplying ? t('common.applying', 'Applying...') : t('branding.applyTheme')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
|||||||
rows={rows}
|
rows={rows}
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="absolute top-2 right-2 text-neutral-400">
|
<div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
|
||||||
<HelpCircle className="w-4 h-4" title="Line breaks will be preserved in emails" />
|
<HelpCircle className="w-4 h-4" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,4 +61,4 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
|
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ThemeCustomizerEnhanced } from '../ThemeCustomizerEnhanced';
|
||||||
|
import type { ThemeConfig } from '../../../types/theme.types';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (_key: string, fallback?: string) => fallback ?? _key
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeCustomizerEnhanced', () => {
|
||||||
|
const baseTheme: ThemeConfig = {
|
||||||
|
primaryColor: '#000000',
|
||||||
|
accentColor: '#ffffff',
|
||||||
|
backgroundColor: '#eeeeee',
|
||||||
|
textColor: '#111111',
|
||||||
|
galleryLayout: 'grid',
|
||||||
|
gallerySettings: {
|
||||||
|
spacing: 'normal'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
it('invokes onApply when Apply Theme is clicked', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const handleApply = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeCustomizerEnhanced
|
||||||
|
value={baseTheme}
|
||||||
|
onChange={handleChange}
|
||||||
|
presetName="default"
|
||||||
|
onApply={handleApply}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyButton = screen.getByRole('button', { name: /branding\.applyTheme/i });
|
||||||
|
await user.click(applyButton);
|
||||||
|
|
||||||
|
expect(handleChange).toHaveBeenCalled();
|
||||||
|
expect(handleApply).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleApply).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ primaryColor: '#000000' }),
|
||||||
|
expect.objectContaining({ presetName: 'default' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the Apply button while applying', () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThemeCustomizerEnhanced
|
||||||
|
value={baseTheme}
|
||||||
|
onChange={handleChange}
|
||||||
|
presetName="default"
|
||||||
|
isApplying={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyButton = screen.getByRole('button', { name: /applying/i });
|
||||||
|
expect(applyButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { getAuthToken } from '../../config/api';
|
|
||||||
import { buildResourceUrl } from '../../utils/url';
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
|
|
||||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
@@ -7,6 +6,23 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
|
|||||||
fallbackSrc?: string;
|
fallbackSrc?: string;
|
||||||
useWatermark?: boolean;
|
useWatermark?: boolean;
|
||||||
isGallery?: boolean;
|
isGallery?: boolean;
|
||||||
|
protectFromDownload?: boolean;
|
||||||
|
slug?: string;
|
||||||
|
photoId?: number;
|
||||||
|
requiresToken?: boolean;
|
||||||
|
secureUrlTemplate?: string;
|
||||||
|
downloadUrlTemplate?: string;
|
||||||
|
onProtectionViolation?: (violationType: string) => void;
|
||||||
|
watermarkText?: string;
|
||||||
|
overlayProtection?: boolean;
|
||||||
|
fragmentGrid?: boolean;
|
||||||
|
scrambleFragments?: boolean;
|
||||||
|
useCanvasRendering?: boolean;
|
||||||
|
blockKeyboardShortcuts?: boolean;
|
||||||
|
detectPrintScreen?: boolean;
|
||||||
|
detectDevTools?: boolean;
|
||||||
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
|
useEnhancedProtection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||||
@@ -15,8 +31,46 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
alt,
|
alt,
|
||||||
useWatermark = false,
|
useWatermark = false,
|
||||||
isGallery = false,
|
isGallery = false,
|
||||||
|
protectFromDownload,
|
||||||
|
slug,
|
||||||
|
photoId,
|
||||||
|
requiresToken,
|
||||||
|
secureUrlTemplate,
|
||||||
|
downloadUrlTemplate,
|
||||||
|
onProtectionViolation,
|
||||||
|
watermarkText,
|
||||||
|
overlayProtection,
|
||||||
|
fragmentGrid,
|
||||||
|
scrambleFragments,
|
||||||
|
useCanvasRendering,
|
||||||
|
blockKeyboardShortcuts,
|
||||||
|
detectPrintScreen,
|
||||||
|
detectDevTools,
|
||||||
|
protectionLevel,
|
||||||
|
useEnhancedProtection,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
|
const unusedProps = {
|
||||||
|
protectFromDownload,
|
||||||
|
slug,
|
||||||
|
photoId,
|
||||||
|
requiresToken,
|
||||||
|
secureUrlTemplate,
|
||||||
|
downloadUrlTemplate,
|
||||||
|
onProtectionViolation,
|
||||||
|
watermarkText,
|
||||||
|
overlayProtection,
|
||||||
|
fragmentGrid,
|
||||||
|
scrambleFragments,
|
||||||
|
useCanvasRendering,
|
||||||
|
blockKeyboardShortcuts,
|
||||||
|
detectPrintScreen,
|
||||||
|
detectDevTools,
|
||||||
|
protectionLevel,
|
||||||
|
useEnhancedProtection
|
||||||
|
};
|
||||||
|
void unusedProps;
|
||||||
|
|
||||||
const [imageSrc, setImageSrc] = useState<string>('');
|
const [imageSrc, setImageSrc] = useState<string>('');
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
@@ -25,33 +79,12 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
let objectUrl: string | null = null;
|
let objectUrl: string | null = null;
|
||||||
|
|
||||||
// Determine which token to use based on context
|
// Determine which token to use based on context
|
||||||
let token: string | undefined;
|
|
||||||
|
|
||||||
if (isGallery) {
|
|
||||||
// For gallery images, get the gallery-specific token
|
|
||||||
const pathParts = window.location.pathname.split('/');
|
|
||||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
|
||||||
const gallerySlug = pathParts[2];
|
|
||||||
token = localStorage.getItem(`gallery_token_${gallerySlug}`) || undefined;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For admin images, use the admin token
|
|
||||||
token = getAuthToken(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
setImageSrc(fallbackSrc || '');
|
setImageSrc(fallbackSrc || '');
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
// No auth token - use fallback
|
|
||||||
setImageSrc(fallbackSrc || '');
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
|
|
||||||
@@ -71,9 +104,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
|
|
||||||
// Fetch authenticated image
|
// Fetch authenticated image
|
||||||
const response = await fetch(fullImageUrl, {
|
const response = await fetch(fullImageUrl, {
|
||||||
headers: {
|
credentials: 'include'
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -119,4 +150,4 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <img src={imageSrc} alt={alt} {...props} />;
|
return <img src={imageSrc} alt={alt} {...props} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ export const PasswordGenerator: React.FC<PasswordGeneratorProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="sm"
|
||||||
onClick={() => selectPassword(password)}
|
onClick={() => selectPassword(password)}
|
||||||
>
|
>
|
||||||
{t('passwordGenerator.use')}
|
{t('passwordGenerator.use')}
|
||||||
@@ -206,4 +206,4 @@ export const PasswordGenerator: React.FC<PasswordGeneratorProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PasswordGenerator;
|
export default PasswordGenerator;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { ProtectionLevel } from '../../hooks/useImageProtection';
|
import { ProtectionLevel } from '../../hooks/useImageProtection';
|
||||||
|
|
||||||
|
type VendorStyle = CSSStyleDeclaration & {
|
||||||
|
webkitUserSelect?: string;
|
||||||
|
webkitTouchCallout?: string;
|
||||||
|
webkitUserDrag?: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasElement> {
|
interface ProtectedImageProps extends React.CanvasHTMLAttributes<HTMLCanvasElement> {
|
||||||
src: string;
|
src: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
@@ -31,7 +37,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||||
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
@@ -201,9 +206,6 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
|||||||
// Render normal image - ensure image is valid before drawing
|
// Render normal image - ensure image is valid before drawing
|
||||||
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
||||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
// Verify the image was drawn by checking a pixel
|
|
||||||
const pixelData = ctx.getImageData(10, 10, 1, 1).data;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,11 +290,12 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Apply CSS protection
|
// Apply CSS protection
|
||||||
canvas.style.userSelect = 'none';
|
const canvasStyle = canvas.style as VendorStyle;
|
||||||
canvas.style.webkitUserSelect = 'none';
|
canvasStyle.userSelect = 'none';
|
||||||
canvas.style.webkitTouchCallout = 'none';
|
canvasStyle.webkitUserSelect = 'none';
|
||||||
canvas.style.webkitUserDrag = 'none';
|
canvasStyle.webkitTouchCallout = 'none';
|
||||||
canvas.style.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
|
canvasStyle.webkitUserDrag = 'none';
|
||||||
|
canvasStyle.pointerEvents = protectionLevel === 'maximum' ? 'none' : 'auto';
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
canvas.removeEventListener('contextmenu', handleContextMenu);
|
canvas.removeEventListener('contextmenu', handleContextMenu);
|
||||||
@@ -402,4 +405,4 @@ export const ProtectedImage: React.FC<ProtectedImageProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const ProtectionWarning: React.FC<ProtectionWarningProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<style jsx>{`
|
<style>{`
|
||||||
@keyframes shrink {
|
@keyframes shrink {
|
||||||
from { width: 100%; }
|
from { width: 100%; }
|
||||||
to { width: 0%; }
|
to { width: 0%; }
|
||||||
@@ -110,4 +110,4 @@ export const ProtectionWarning: React.FC<ProtectionWarningProps> = ({
|
|||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Heart, Star, MessageSquare } from 'lucide-react';
|
import { Heart, Star, MessageSquare, Bookmark } from 'lucide-react';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export type FilterType = 'all' | 'liked' | 'rated' | 'commented';
|
export type FilterType = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
|
||||||
|
|
||||||
interface GalleryFilterProps {
|
interface GalleryFilterProps {
|
||||||
currentFilter: FilterType;
|
currentFilter: FilterType;
|
||||||
onFilterChange: (filter: FilterType) => void;
|
onFilterChange: (filter: FilterType) => void;
|
||||||
feedbackEnabled: boolean;
|
feedbackEnabled: boolean;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
|
favoriteCount?: number;
|
||||||
ratedCount?: number;
|
ratedCount?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
isMobile?: boolean;
|
isMobile?: boolean;
|
||||||
@@ -21,6 +22,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
onFilterChange,
|
onFilterChange,
|
||||||
feedbackEnabled,
|
feedbackEnabled,
|
||||||
likeCount = 0,
|
likeCount = 0,
|
||||||
|
favoriteCount = 0,
|
||||||
ratedCount = 0,
|
ratedCount = 0,
|
||||||
className = '',
|
className = '',
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
@@ -59,6 +61,15 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
>
|
>
|
||||||
<Heart className="w-3.5 h-3.5" />
|
<Heart className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('favorited')}
|
||||||
|
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||||
|
aria-label={t('gallery.favorited', 'Saved')}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -110,6 +121,16 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
<Heart className="w-3 h-3" />
|
<Heart className="w-3 h-3" />
|
||||||
<span>{likeCount > 0 ? likeCount : t('gallery.liked', 'Liked')}</span>
|
<span>{likeCount > 0 ? likeCount : t('gallery.liked', 'Liked')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('favorited')}
|
||||||
|
className="text-xs flex-1 min-w-[80px] flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3 h-3" />
|
||||||
|
<span>{favoriteCount > 0 ? favoriteCount : t('gallery.favorited', 'Saved')}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
@@ -152,6 +173,21 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('favorited')}
|
||||||
|
className="text-xs sm:text-sm flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
|
<span className="hidden sm:inline">{t('gallery.favorited', 'Saved')}</span>
|
||||||
|
{favoriteCount > 0 && (
|
||||||
|
<span className="bg-primary-100 text-primary-700 px-1.5 rounded">
|
||||||
|
{favoriteCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ interface GalleryLayoutProps {
|
|||||||
showDownloadAll?: boolean;
|
showDownloadAll?: boolean;
|
||||||
onDownloadAll?: () => void;
|
onDownloadAll?: () => void;
|
||||||
isDownloading?: boolean;
|
isDownloading?: boolean;
|
||||||
isExpired?: boolean;
|
|
||||||
headerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
menuButton?: React.ReactNode;
|
menuButton?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -49,7 +48,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
showDownloadAll = false,
|
showDownloadAll = false,
|
||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
isDownloading = false,
|
isDownloading = false,
|
||||||
isExpired = false,
|
|
||||||
headerExtra,
|
headerExtra,
|
||||||
menuButton,
|
menuButton,
|
||||||
children,
|
children,
|
||||||
@@ -63,22 +61,28 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||||
|
|
||||||
// Calculate logo size classes based on settings
|
// Calculate logo size classes based on settings
|
||||||
const getLogoSizeClass = (context: 'header' | 'hero') => {
|
const getLogoDimensions = (context: 'header' | 'hero'): { className: string; style?: React.CSSProperties } => {
|
||||||
const size = brandingSettings?.logo_size || 'medium';
|
const size = brandingSettings?.logo_size || 'medium';
|
||||||
const maxHeight = brandingSettings?.logo_max_height || 48;
|
const maxHeight = brandingSettings?.logo_max_height || 48;
|
||||||
|
|
||||||
if (size === 'custom') {
|
if (size === 'custom') {
|
||||||
return { maxHeight: `${maxHeight}px`, height: 'auto' };
|
return {
|
||||||
|
className: '',
|
||||||
|
style: { maxHeight: `${maxHeight}px`, height: 'auto' }
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeMap = {
|
const sizeMap: Record<'small' | 'medium' | 'large' | 'xlarge', string> = {
|
||||||
small: context === 'header' ? 'h-6 sm:h-8' : 'h-12 sm:h-14 lg:h-16',
|
small: context === 'header' ? 'h-6 sm:h-8' : 'h-12 sm:h-14 lg:h-16',
|
||||||
medium: context === 'header' ? 'h-8 sm:h-10 lg:h-12' : 'h-16 sm:h-20 lg:h-24',
|
medium: context === 'header' ? 'h-8 sm:h-10 lg:h-12' : 'h-16 sm:h-20 lg:h-24',
|
||||||
large: context === 'header' ? 'h-10 sm:h-12 lg:h-16' : 'h-20 sm:h-24 lg:h-32',
|
large: context === 'header' ? 'h-10 sm:h-12 lg:h-16' : 'h-20 sm:h-24 lg:h-32',
|
||||||
xlarge: context === 'header' ? 'h-12 sm:h-16 lg:h-20' : 'h-24 sm:h-32 lg:h-40'
|
xlarge: context === 'header' ? 'h-12 sm:h-16 lg:h-20' : 'h-24 sm:h-32 lg:h-40'
|
||||||
};
|
};
|
||||||
|
|
||||||
return sizeMap[size] || sizeMap.medium;
|
return {
|
||||||
|
className: sizeMap[size as keyof typeof sizeMap] || sizeMap.medium,
|
||||||
|
style: undefined
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine logo position classes
|
// Determine logo position classes
|
||||||
@@ -108,6 +112,9 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
const displayMode = brandingSettings?.logo_display_mode || 'logo_and_text';
|
const displayMode = brandingSettings?.logo_display_mode || 'logo_and_text';
|
||||||
return displayMode !== 'logo_only';
|
return displayMode !== 'logo_only';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const headerLogoSize = getLogoDimensions('header');
|
||||||
|
const heroLogoSize = getLogoDimensions('hero');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50">
|
<div className="min-h-screen bg-neutral-50">
|
||||||
@@ -183,8 +190,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
'/picpeak-logo-transparent.png'
|
'/picpeak-logo-transparent.png'
|
||||||
}
|
}
|
||||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||||
className={`${typeof getLogoSizeClass('header') === 'string' ? getLogoSizeClass('header') : ''} w-auto object-contain`}
|
className={`${headerLogoSize.className} w-auto object-contain`}
|
||||||
style={typeof getLogoSizeClass('header') === 'object' ? getLogoSizeClass('header') : undefined}
|
style={headerLogoSize.style}
|
||||||
/>
|
/>
|
||||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||||
<span className="hidden sm:inline text-lg font-semibold text-neutral-900">
|
<span className="hidden sm:inline text-lg font-semibold text-neutral-900">
|
||||||
@@ -353,11 +360,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
'/picpeak-logo-transparent.png'
|
'/picpeak-logo-transparent.png'
|
||||||
}
|
}
|
||||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||||
className={`${typeof getLogoSizeClass('hero') === 'string' ? getLogoSizeClass('hero') : ''} w-auto object-contain mx-auto`}
|
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
|
||||||
style={typeof getLogoSizeClass('hero') === 'object' ?
|
style={{
|
||||||
{ ...getLogoSizeClass('hero'), filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' } :
|
...(heroLogoSize.style || {}),
|
||||||
{ filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))' }
|
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||||
<div className="mt-3 text-xl sm:text-2xl font-semibold text-white/90" style={{ textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)' }}>
|
<div className="mt-3 text-xl sm:text-2xl font-semibold text-white/90" style={{ textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)' }}>
|
||||||
@@ -460,4 +467,4 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
GalleryLayout.displayName = 'GalleryLayout';
|
GalleryLayout.displayName = 'GalleryLayout';
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ interface GallerySidebarProps {
|
|||||||
onDownloadAll: () => void;
|
onDownloadAll: () => void;
|
||||||
onDownloadSelected: () => void;
|
onDownloadSelected: () => void;
|
||||||
isDownloading: boolean;
|
isDownloading: boolean;
|
||||||
isExpired?: boolean;
|
|
||||||
allowDownloads?: boolean;
|
allowDownloads?: boolean;
|
||||||
photoCounts?: Record<number, number>;
|
photoCounts?: Record<number, number>;
|
||||||
totalPhotos: number;
|
totalPhotos: number;
|
||||||
@@ -33,6 +32,7 @@ interface GallerySidebarProps {
|
|||||||
filterType?: FilterType;
|
filterType?: FilterType;
|
||||||
onFilterChange?: (filter: FilterType) => void;
|
onFilterChange?: (filter: FilterType) => void;
|
||||||
likeCount?: number;
|
likeCount?: number;
|
||||||
|
favoriteCount?: number;
|
||||||
ratedCount?: number;
|
ratedCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +52,6 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
onDownloadSelected,
|
onDownloadSelected,
|
||||||
isDownloading,
|
isDownloading,
|
||||||
isExpired = false,
|
|
||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
photoCounts = {},
|
photoCounts = {},
|
||||||
totalPhotos,
|
totalPhotos,
|
||||||
@@ -64,6 +63,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
filterType = 'all',
|
filterType = 'all',
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
likeCount = 0,
|
likeCount = 0,
|
||||||
|
favoriteCount = 0,
|
||||||
ratedCount = 0
|
ratedCount = 0
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -223,6 +223,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
}}
|
}}
|
||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
likeCount={likeCount}
|
likeCount={likeCount}
|
||||||
|
favoriteCount={favoriteCount}
|
||||||
ratedCount={ratedCount}
|
ratedCount={ratedCount}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
variant="compact"
|
variant="compact"
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
|||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
import { Upload, Menu } from 'lucide-react';
|
import { Upload, Menu } from 'lucide-react';
|
||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
import { feedbackService } from '../../services/feedback.service';
|
|
||||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
|
|
||||||
@@ -210,7 +209,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
// Invalid theme format - use default
|
// Invalid theme format - use default
|
||||||
// Fall back to global theme
|
// Fall back to global theme
|
||||||
if (settingsData.theme_config) {
|
if (settingsData.theme_config) {
|
||||||
@@ -271,6 +270,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
case 'liked':
|
case 'liked':
|
||||||
photos = photos.filter(photo => (photo.like_count || 0) > 0);
|
photos = photos.filter(photo => (photo.like_count || 0) > 0);
|
||||||
break;
|
break;
|
||||||
|
case 'favorited':
|
||||||
|
photos = photos.filter(photo => (photo.favorite_count || 0) > 0);
|
||||||
|
break;
|
||||||
case 'rated':
|
case 'rated':
|
||||||
photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
|
photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
|
||||||
break;
|
break;
|
||||||
@@ -315,6 +317,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
return photos;
|
return photos;
|
||||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]);
|
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]);
|
||||||
|
|
||||||
|
const likeCount = useMemo(
|
||||||
|
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
|
||||||
|
[data?.photos]
|
||||||
|
);
|
||||||
|
|
||||||
|
const favoriteCount = useMemo(
|
||||||
|
() => data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0,
|
||||||
|
[data?.photos]
|
||||||
|
);
|
||||||
|
|
||||||
|
const ratedCount = useMemo(
|
||||||
|
() => data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0,
|
||||||
|
[data?.photos]
|
||||||
|
);
|
||||||
|
|
||||||
// Check if downloads are allowed (both event setting and not expired)
|
// Check if downloads are allowed (both event setting and not expired)
|
||||||
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
|
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
|
||||||
|
|
||||||
@@ -462,7 +479,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
onDownloadAll={handleDownloadAll}
|
onDownloadAll={handleDownloadAll}
|
||||||
onDownloadSelected={handleDownloadSelected}
|
onDownloadSelected={handleDownloadSelected}
|
||||||
isDownloading={downloadAllMutation.isPending}
|
isDownloading={downloadAllMutation.isPending}
|
||||||
isExpired={isExpired}
|
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
photoCounts={photoCounts}
|
photoCounts={photoCounts}
|
||||||
totalPhotos={data?.photos.length || 0}
|
totalPhotos={data?.photos.length || 0}
|
||||||
@@ -473,8 +489,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
feedbackEnabled={feedbackEnabled}
|
feedbackEnabled={feedbackEnabled}
|
||||||
filterType={filterType}
|
filterType={filterType}
|
||||||
onFilterChange={setFilterType}
|
onFilterChange={setFilterType}
|
||||||
likeCount={data?.photos?.filter(p => p.like_count > 0).length || 0}
|
likeCount={likeCount}
|
||||||
ratedCount={data?.photos?.filter(p => (p.total_ratings || 0) > 0).length || 0}
|
favoriteCount={favoriteCount}
|
||||||
|
ratedCount={ratedCount}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -486,7 +503,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
showDownloadAll={!showSidebar && allowDownloads}
|
showDownloadAll={!showSidebar && allowDownloads}
|
||||||
onDownloadAll={handleDownloadAll}
|
onDownloadAll={handleDownloadAll}
|
||||||
isDownloading={downloadAllMutation.isPending}
|
isDownloading={downloadAllMutation.isPending}
|
||||||
isExpired={isExpired}
|
|
||||||
menuButton={showSidebar ? (
|
menuButton={showSidebar ? (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
value={guestName}
|
value={guestName}
|
||||||
onChange={(e) => setGuestName(e.target.value)}
|
onChange={(e) => setGuestName(e.target.value)}
|
||||||
error={errors.guest_name}
|
error={errors.guest_name}
|
||||||
size="sm"
|
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -155,7 +154,6 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
value={guestEmail}
|
value={guestEmail}
|
||||||
onChange={(e) => setGuestEmail(e.target.value)}
|
onChange={(e) => setGuestEmail(e.target.value)}
|
||||||
error={errors.guest_email}
|
error={errors.guest_email}
|
||||||
size="sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -247,4 +245,4 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,7 +73,10 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
|||||||
if (requireNameEmail && !savedIdentity) {
|
if (requireNameEmail && !savedIdentity) {
|
||||||
setShowIdentityModal(true);
|
setShowIdentityModal(true);
|
||||||
} else {
|
} else {
|
||||||
submitFavoriteMutation.mutate(savedIdentity || {});
|
const identityPayload = savedIdentity
|
||||||
|
? { guest_name: savedIdentity.name, guest_email: savedIdentity.email }
|
||||||
|
: {};
|
||||||
|
submitFavoriteMutation.mutate(identityPayload);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,4 +119,4 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { feedbackService } from '../../services/feedback.service';
|
import { feedbackService } from '../../services/feedback.service';
|
||||||
import { PhotoRating } from './PhotoRating';
|
import { PhotoRating } from './PhotoRating';
|
||||||
import { PhotoLikes } from './PhotoLikes';
|
import { PhotoLikes } from './PhotoLikes';
|
||||||
|
import { PhotoFavorites } from './PhotoFavorites';
|
||||||
import { PhotoComments } from './PhotoComments';
|
import { PhotoComments } from './PhotoComments';
|
||||||
import { Skeleton } from '../common';
|
import { Skeleton } from '../common';
|
||||||
import type { FeedbackSettings } from '../../services/feedback.service';
|
|
||||||
|
|
||||||
interface PhotoFeedbackProps {
|
interface PhotoFeedbackProps {
|
||||||
photoId: string;
|
photoId: string;
|
||||||
@@ -33,7 +33,7 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fetch feedback data for the photo
|
// Fetch feedback data for the photo
|
||||||
const { data: feedbackData, isLoading: feedbackLoading } = useQuery({
|
const { data: feedbackData } = useQuery({
|
||||||
queryKey: ['photo-feedback', gallerySlug, photoId],
|
queryKey: ['photo-feedback', gallerySlug, photoId],
|
||||||
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
|
queryFn: () => feedbackService.getPhotoFeedback(gallerySlug, photoId),
|
||||||
enabled: !!settings?.feedback_enabled,
|
enabled: !!settings?.feedback_enabled,
|
||||||
@@ -43,13 +43,17 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
const [currentRating, setCurrentRating] = useState(0);
|
const [currentRating, setCurrentRating] = useState(0);
|
||||||
const [isLiked, setIsLiked] = useState(false);
|
const [isLiked, setIsLiked] = useState(false);
|
||||||
const [likeCount, setLikeCount] = useState(0);
|
const [likeCount, setLikeCount] = useState(0);
|
||||||
|
const [isFavorited, setIsFavorited] = useState(false);
|
||||||
|
const [favoriteCount, setFavoriteCount] = useState(0);
|
||||||
|
|
||||||
// Update local state when data loads
|
// Update local state when data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (feedbackData) {
|
if (feedbackData) {
|
||||||
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
setCurrentRating(feedbackData.my_feedback.rating || 0);
|
||||||
setIsLiked(feedbackData.my_feedback.liked);
|
setIsLiked(Boolean(feedbackData.my_feedback.liked));
|
||||||
setLikeCount(feedbackData.summary.like_count);
|
setLikeCount(Number(feedbackData.summary.like_count) || 0);
|
||||||
|
setIsFavorited(Boolean(feedbackData.my_feedback.favorited));
|
||||||
|
setFavoriteCount(Number(feedbackData.summary.favorite_count) || 0);
|
||||||
}
|
}
|
||||||
}, [feedbackData]);
|
}, [feedbackData]);
|
||||||
|
|
||||||
@@ -65,6 +69,12 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
if (onFeedbackUpdate) onFeedbackUpdate();
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFavoriteChange = (favorited: boolean) => {
|
||||||
|
setIsFavorited(favorited);
|
||||||
|
setFavoriteCount(prev => favorited ? prev + 1 : Math.max(0, prev - 1));
|
||||||
|
if (onFeedbackUpdate) onFeedbackUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
if (settingsLoading) {
|
if (settingsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-3 ${className}`}>
|
<div className={`space-y-3 ${className}`}>
|
||||||
@@ -78,8 +88,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
const hasAnyFeedbackType = settings.allow_ratings || settings.allow_likes ||
|
||||||
settings.allow_comments;
|
settings.allow_comments || settings.allow_favorites;
|
||||||
|
|
||||||
if (!hasAnyFeedbackType) {
|
if (!hasAnyFeedbackType) {
|
||||||
return null;
|
return null;
|
||||||
@@ -102,8 +112,8 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
{settings.allow_likes && (
|
{(settings.allow_likes || settings.allow_favorites) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{settings.allow_likes && (
|
{settings.allow_likes && (
|
||||||
<PhotoLikes
|
<PhotoLikes
|
||||||
photoId={photoId}
|
photoId={photoId}
|
||||||
@@ -115,6 +125,18 @@ export const PhotoFeedback: React.FC<PhotoFeedbackProps> = ({
|
|||||||
onLikeChange={handleLikeChange}
|
onLikeChange={handleLikeChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{settings.allow_favorites && (
|
||||||
|
<PhotoFavorites
|
||||||
|
photoId={photoId}
|
||||||
|
gallerySlug={gallerySlug}
|
||||||
|
isFavorited={isFavorited}
|
||||||
|
favoriteCount={favoriteCount}
|
||||||
|
isEnabled={true}
|
||||||
|
requireNameEmail={settings.require_name_email || false}
|
||||||
|
onFavoriteChange={handleFavoriteChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Search, SortAsc, Grid, Heart, Star, MessageSquare } from 'lucide-react';
|
import { Search, SortAsc, Grid, Heart, Star, MessageSquare, Bookmark } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Input } from '../common';
|
import { Button, Input } from '../common';
|
||||||
import type { FilterType } from './GalleryFilter';
|
import type { FilterType } from './GalleryFilter';
|
||||||
@@ -50,7 +50,6 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showSortMenu, setShowSortMenu] = useState(false);
|
const [showSortMenu, setShowSortMenu] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Search and Sort */}
|
{/* Search and Sort */}
|
||||||
@@ -195,6 +194,15 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
>
|
>
|
||||||
<Heart className="w-3.5 h-3.5" />
|
<Heart className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('favorited')}
|
||||||
|
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||||
|
aria-label={t('gallery.favorited', 'Saved')}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -248,6 +256,15 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
|||||||
>
|
>
|
||||||
<Heart className="w-3.5 h-3.5" />
|
<Heart className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={currentFilter === 'favorited' ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onFilterChange('favorited')}
|
||||||
|
className="p-1 w-8 h-8 flex items-center justify-center"
|
||||||
|
aria-label={t('gallery.favorited', 'Saved')}
|
||||||
|
>
|
||||||
|
<Bookmark className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
variant={currentFilter === 'rated' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||||
analyticsService.trackGalleryEvent('bulk_download_selected', { gallery: slug, photo_count: ids.length });
|
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastify.error(t('gallery.downloadError'));
|
toastify.error(t('gallery.downloadError'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -191,6 +191,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({
|
|||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
slug={slug}
|
slug={slug}
|
||||||
|
feedbackEnabled={feedbackEnabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -222,6 +223,7 @@ interface PhotoThumbnailProps {
|
|||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
slug: string; // Add slug as required prop
|
slug: string; // Add slug as required prop
|
||||||
|
feedbackEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
||||||
@@ -233,7 +235,8 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
|||||||
allowDownloads = true,
|
allowDownloads = true,
|
||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
slug
|
slug,
|
||||||
|
feedbackEnabled = false
|
||||||
}) => {
|
}) => {
|
||||||
const { ref, inView } = useInView({
|
const { ref, inView } = useInView({
|
||||||
triggerOnce: true,
|
triggerOnce: true,
|
||||||
@@ -280,18 +283,18 @@ const PhotoThumbnail: React.FC<PhotoThumbnailProps> = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Feedback Indicators */}
|
{/* Feedback Indicators */}
|
||||||
{feedbackEnabled && (photo.has_feedback || photo.average_rating > 0 || photo.comment_count > 0) && (
|
{feedbackEnabled && (photo.has_feedback || (photo.average_rating ?? 0) > 0 || (photo.comment_count ?? 0) > 0) && (
|
||||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||||
{photo.comment_count > 0 && (
|
{(photo.comment_count ?? 0) > 0 && (
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count} comments`}>
|
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`${photo.comment_count ?? 0} comments`}>
|
||||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count}</span>
|
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{photo.average_rating > 0 && (
|
{(photo.average_rating ?? 0) > 0 && (
|
||||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating).toFixed(1)}`}>
|
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1" title={`Rating: ${Number(photo.average_rating ?? 0).toFixed(1)}`}>
|
||||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating).toFixed(1)}</span>
|
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await galleryService.downloadSelectedPhotos(slug, ids);
|
await galleryService.downloadSelectedPhotos(slug, ids);
|
||||||
analyticsService.trackGalleryEvent('bulk_download_selected', { gallery: slug, photo_count: ids.length });
|
analyticsService.trackGalleryEvent('bulk_download', { gallery: slug, photo_count: ids.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toastify.error(t('gallery.downloadError'));
|
toastify.error(t('gallery.downloadError'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -291,6 +291,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
protectionLevel={protectionLevel}
|
protectionLevel={protectionLevel}
|
||||||
useEnhancedProtection={useEnhancedProtection}
|
useEnhancedProtection={useEnhancedProtection}
|
||||||
initialShowFeedback={openFeedbackInitially}
|
initialShowFeedback={openFeedbackInitially}
|
||||||
|
onFeedbackChange={onFeedbackChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface PhotoLightboxProps {
|
|||||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||||
useEnhancedProtection?: boolean;
|
useEnhancedProtection?: boolean;
|
||||||
initialShowFeedback?: boolean;
|
initialShowFeedback?: boolean;
|
||||||
|
onFeedbackChange?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||||
@@ -30,6 +31,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
protectionLevel = 'standard',
|
protectionLevel = 'standard',
|
||||||
useEnhancedProtection = false,
|
useEnhancedProtection = false,
|
||||||
initialShowFeedback = false,
|
initialShowFeedback = false,
|
||||||
|
onFeedbackChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
@@ -432,12 +434,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
}}
|
}}
|
||||||
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
className="relative p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||||
aria-label="Toggle feedback"
|
aria-label="Toggle feedback"
|
||||||
title={`Photo feedback${currentPhoto.comment_count > 0 ? ` (${currentPhoto.comment_count} comments)` : ''}`}
|
title={`Photo feedback${(currentPhoto.comment_count ?? 0) > 0 ? ` (${currentPhoto.comment_count ?? 0} comments)` : ''}`}
|
||||||
>
|
>
|
||||||
<MessageSquare className="w-5 h-5 text-white" />
|
<MessageSquare className="w-5 h-5 text-white" />
|
||||||
{(currentPhoto.comment_count > 0 || currentPhoto.average_rating > 0) && (
|
{((currentPhoto.comment_count ?? 0) > 0 || (currentPhoto.average_rating ?? 0) > 0) && (
|
||||||
<span className="absolute -top-1 -right-1 bg-primary-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
<span className="absolute -top-1 -right-1 bg-primary-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
|
||||||
{currentPhoto.comment_count > 0 ? currentPhoto.comment_count : '★'}
|
{(currentPhoto.comment_count ?? 0) > 0 ? currentPhoto.comment_count ?? 0 : '★'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -529,10 +531,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 flex-1 overflow-y-auto">
|
<div className="p-4 flex-1 overflow-y-auto">
|
||||||
<PhotoFeedback
|
<PhotoFeedback
|
||||||
photoId={currentPhoto.id}
|
photoId={String(currentPhoto.id)}
|
||||||
gallerySlug={slug}
|
gallerySlug={slug}
|
||||||
showComments={true}
|
showComments={true}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
|
onFeedbackUpdate={() => {
|
||||||
|
if (onFeedbackChange) onFeedbackChange();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user