Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56f497c5f1 | |||
| b52cf1f741 | |||
| 7f7736282f | |||
| 67b0f32456 | |||
| 25b40c03b0 | |||
| 28793bba68 | |||
| 9877f63aed | |||
| 0c98c6b453 | |||
| 831ea6a3bc | |||
| 9c44a0ebfa | |||
| 08ac238d0a | |||
| 7d967a47ae | |||
| 9b7495e005 | |||
| a8308a5c02 | |||
| 98fd6dd8e1 | |||
| 3a30fea862 | |||
| 7d6d2f5688 | |||
| b5074e4e46 | |||
| a1d941f049 | |||
| 80171713e0 | |||
| c0301dcbf4 | |||
| cbecb9323c | |||
| 4272618b3f | |||
| fe07a148f1 | |||
| 0ec3787150 | |||
| 3e0c4fd73e | |||
| 5ea4ef3cf3 | |||
| cd00bc13d4 |
@@ -9,7 +9,7 @@ assignees: ''
|
||||
|
||||
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
|
||||
|
||||
Instead, please email security@example.com with the details.
|
||||
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
|
||||
|
||||
For minor security improvements or questions, you can use this template:
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.15.1-beta.0"
|
||||
".": "3.17.1-beta.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.6.0"
|
||||
".": "2.6.1"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,47 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.6.1](https://github.com/the-luap/picpeak/compare/v2.6.0...v2.6.1) (2026-03-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update security policy with private reporting channels ([7f77362](https://github.com/the-luap/picpeak/commit/7f7736282f534adf4b9d5331d841a1f0bff7341c))
|
||||
* update security policy with proper contact email and private reporting ([67b0f32](https://github.com/the-luap/picpeak/commit/67b0f32456d0216e4c685a104c680fa5a5fd578f)), closes [#223](https://github.com/the-luap/picpeak/issues/223)
|
||||
|
||||
## [3.17.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.0-beta.0...v3.17.1-beta.0) (2026-03-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* respect optional email settings in event creation ([831ea6a](https://github.com/the-luap/picpeak/commit/831ea6a3bccfae4ec00ce1f619967b91b85150ce))
|
||||
* respect optional email settings in event creation ([#217](https://github.com/the-luap/picpeak/issues/217)) ([9c44a0e](https://github.com/the-luap/picpeak/commit/9c44a0ebfa527fa133512eb7f2f03335a2377aaa))
|
||||
|
||||
## [3.17.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.16.0-beta.0...v3.17.0-beta.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* configurable upload batch size for reverse proxy compatibility ([9b7495e](https://github.com/the-luap/picpeak/commit/9b7495e0054975e66c9b5006c24a9fae63969de4))
|
||||
|
||||
## [3.16.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.3-beta.0...v3.16.0-beta.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add thumbnail settings UI to admin panel ([3a30fea](https://github.com/the-luap/picpeak/commit/3a30fea862034d64fbc7188fc25292594a9319e2))
|
||||
* add thumbnail settings UI to admin settings page ([#206](https://github.com/the-luap/picpeak/issues/206)) ([7d6d2f5](https://github.com/the-luap/picpeak/commit/7d6d2f56883a4402f0d97c95b0432a8a783c8024))
|
||||
|
||||
## [3.15.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.2-beta.0...v3.15.3-beta.0) (2026-03-02)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* issue [#203](https://github.com/the-luap/picpeak/issues/203) file type validation + security CVE fixes ([8017171](https://github.com/the-luap/picpeak/commit/80171713e0ffedda56f7cffb403b25a8d55634d1))
|
||||
* respect allowed_file_types setting for upload validation ([#203](https://github.com/the-luap/picpeak/issues/203)) ([fe07a14](https://github.com/the-luap/picpeak/commit/fe07a148f1d998c0be00377c1f8b4eca3908305c))
|
||||
* **security:** resolve all npm audit vulnerabilities ([4272618](https://github.com/the-luap/picpeak/commit/4272618b3f7fcb06aaca14fb724a6a7733251f24))
|
||||
* **security:** resolve Docker image CVEs for code scanning alerts ([cbecb93](https://github.com/the-luap/picpeak/commit/cbecb9323cf4b80c800326de14f6df73f60147c1))
|
||||
|
||||
## [2.6.0](https://github.com/the-luap/picpeak/compare/v2.5.1...v2.6.0) (2026-03-11)
|
||||
|
||||
|
||||
@@ -63,6 +104,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
|
||||
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
|
||||
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
|
||||
## [3.15.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.1-beta.0...v3.15.2-beta.0) (2026-02-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
@@ -110,6 +152,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
|
||||
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([5ea4ef3](https://github.com/the-luap/picpeak/commit/5ea4ef3cf36b06f9e6c9108f80bfe2e9a6470898))
|
||||
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([cd00bc1](https://github.com/the-luap/picpeak/commit/cd00bc13d4e02a86a0f1742ed1f11f064614b8da))
|
||||
|
||||
## [3.15.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.0-beta.0...v3.15.1-beta.0) (2026-02-21)
|
||||
|
||||
|
||||
+6
-6
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Currently supported versions:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.x.x | :white_check_mark: |
|
||||
| < 1.0 | :x: |
|
||||
| 2.x.x | :white_check_mark: |
|
||||
| < 2.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
@@ -15,9 +15,9 @@ We take the security of PicPeak seriously. If you have discovered a security vul
|
||||
|
||||
### 1. **Do NOT create a public GitHub issue**
|
||||
|
||||
### 2. Report the vulnerability by:
|
||||
- Opening a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- Mark it clearly as "SECURITY" in the title
|
||||
### 2. Report the vulnerability privately by:
|
||||
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
|
||||
- **Alternative:** Email us at **info@picpeak.app** with the details
|
||||
- Include:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
|
||||
|
||||
## Contact
|
||||
|
||||
- Security issues: [Create a security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
|
||||
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
|
||||
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
|
||||
|
||||
Thank you for helping keep PicPeak and its users safe!
|
||||
+3
-8
@@ -11,10 +11,6 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Backend Service"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
# Pin to npm 10.x which supports --omit=dev flag
|
||||
RUN npm install -g npm@10
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
@@ -31,12 +27,11 @@ FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
# Pin to npm 10.x which supports --omit=dev flag
|
||||
RUN npm install -g npm@10
|
||||
# Upgrade npm to latest to fix tar, minimatch, brace-expansion CVEs in npm's own deps
|
||||
RUN npm install -g npm@latest
|
||||
|
||||
# Install dumb-init for proper signal handling and postgresql-client for database checks
|
||||
RUN apk add --no-cache dumb-init postgresql-client
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
exports.up = async function(knex) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('host_email', 255).nullable().alter();
|
||||
table.string('admin_email', 255).nullable().alter();
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('host_email', 255).notNullable().defaultTo('').alter();
|
||||
table.string('admin_email', 255).notNullable().defaultTo('').alter();
|
||||
});
|
||||
};
|
||||
Generated
+826
-936
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -66,7 +66,11 @@
|
||||
"tar-fs": "2.1.4"
|
||||
},
|
||||
"glob": "^11.1.0",
|
||||
"body-parser": "^2.2.1",
|
||||
"js-yaml": "^4.1.1"
|
||||
"js-yaml": "^4.1.1",
|
||||
"fast-xml-parser": ">=5.3.8",
|
||||
"qs": ">=6.14.2",
|
||||
"tar": ">=7.5.8",
|
||||
"brace-expansion": ">=5.0.0",
|
||||
"minimatch": ">=9.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,10 +162,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
return true;
|
||||
}),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').optional().isDate(),
|
||||
body('event_date').optional({ values: 'falsy' }).isDate(),
|
||||
body('customer_name').optional().trim(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('admin_email').optional().isEmail().normalizeEmail(),
|
||||
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
|
||||
body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
|
||||
body('require_password').optional().isBoolean(),
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
const input = req.body.require_password;
|
||||
@@ -392,9 +392,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
event_name,
|
||||
event_date: event_date || null,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
admin_email,
|
||||
host_name: customerName || null,
|
||||
host_email: customerEmail || null,
|
||||
admin_email: admin_email || null,
|
||||
password_hash,
|
||||
welcome_message,
|
||||
color_theme,
|
||||
@@ -446,28 +446,30 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
// Queue creation email
|
||||
// Queue creation email (only if there is a recipient)
|
||||
// Language detection is handled by email processor
|
||||
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify({
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
// scheduled_at will use default value
|
||||
});
|
||||
|
||||
if (customerEmail) {
|
||||
await db('email_queue').insert({
|
||||
event_id: eventId,
|
||||
recipient_email: customerEmail,
|
||||
email_type: 'gallery_created',
|
||||
email_data: JSON.stringify({
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
|
||||
event_name,
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
status: 'pending',
|
||||
created_at: new Date()
|
||||
// scheduled_at will use default value
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: eventId,
|
||||
|
||||
@@ -10,7 +10,7 @@ const { processUploadedVideo, isVideoMimeType } = require('../services/videoProc
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const chunkedUpload = require('../services/chunkedUploadService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
@@ -48,47 +48,55 @@ const storage = multer.diskStorage({
|
||||
}
|
||||
});
|
||||
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
const { validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Create a multer instance that uses dynamically resolved allowed MIME types.
|
||||
// The allowed types are fetched from the database once per request (before multer
|
||||
// processes files) and attached to req.allowedMimeTypes so that the fileFilter
|
||||
// callback can read them synchronously.
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos
|
||||
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
|
||||
// Set a reasonable field size limit to prevent memory issues
|
||||
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
|
||||
// Add part size limits to prevent incomplete uploads
|
||||
parts: 10000, // Maximum number of parts (fields + files)
|
||||
headerPairs: 2000 // Maximum number of header key-value pairs
|
||||
parts: 10000,
|
||||
headerPairs: 2000
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// Accept images and videos with proper validation
|
||||
const allowedMimeTypes = [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
];
|
||||
// req.allowedMimeTypes is populated by the middleware that runs before multer
|
||||
const allowedMimeTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG, WebP images and MP4, WebM, MOV, AVI videos are allowed'));
|
||||
cb(new Error('Invalid file type. Check allowed file types in system settings.'));
|
||||
}
|
||||
},
|
||||
// Add abort on limit to stop processing when limits are exceeded
|
||||
abortOnLimit: true
|
||||
});
|
||||
|
||||
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
|
||||
// Middleware to resolve allowed MIME types from settings before multer runs
|
||||
const resolveAllowedTypes = async (req, res, next) => {
|
||||
try {
|
||||
req.allowedMimeTypes = await getAllowedMimeTypes();
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve allowed MIME types:', error);
|
||||
req.allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Create content validator middleware
|
||||
const validateUploadContent = createFileUploadValidator({
|
||||
allowedTypes: [
|
||||
'image/jpeg', 'image/png', 'image/webp',
|
||||
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
|
||||
],
|
||||
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
|
||||
validateContent: true
|
||||
});
|
||||
// Dynamic content validator middleware that reads allowed types from req
|
||||
const validateUploadContent = async (req, res, next) => {
|
||||
const allowedTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const validator = createFileUploadValidator({
|
||||
allowedTypes,
|
||||
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
|
||||
validateContent: true
|
||||
});
|
||||
return validator(req, res, next);
|
||||
};
|
||||
|
||||
// Request timeout middleware for uploads
|
||||
const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
@@ -112,7 +120,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
|
||||
|
||||
// Upload photos for an event
|
||||
// Max file count is configurable via general settings
|
||||
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
|
||||
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
|
||||
let maxFilesPerUpload;
|
||||
try {
|
||||
maxFilesPerUpload = await getMaxFilesPerUpload();
|
||||
|
||||
@@ -10,27 +10,33 @@ const logger = require('../utils/logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Parse JSON-encoded setting values
|
||||
function parseSettingValue(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
try { return JSON.parse(value); } catch (e) { return value; }
|
||||
}
|
||||
|
||||
// Get thumbnail settings
|
||||
router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const settings = await db('app_settings')
|
||||
.whereIn('key', [
|
||||
.whereIn('setting_key', [
|
||||
'thumbnail_width',
|
||||
'thumbnail_height',
|
||||
'thumbnail_fit',
|
||||
'thumbnail_quality',
|
||||
'thumbnail_format'
|
||||
])
|
||||
.select('key', 'value', 'description');
|
||||
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const settingsMap = {};
|
||||
settings.forEach(s => {
|
||||
settingsMap[s.key] = {
|
||||
value: s.value,
|
||||
description: s.description
|
||||
const parsed = parseSettingValue(s.setting_value);
|
||||
settingsMap[s.setting_key] = {
|
||||
value: String(parsed ?? '')
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
res.json({
|
||||
settings: settingsMap,
|
||||
fitOptions: ['cover', 'contain', 'fill', 'inside', 'outside'],
|
||||
@@ -66,17 +72,17 @@ router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req,
|
||||
|
||||
// Update settings
|
||||
const updates = [];
|
||||
if (width) updates.push({ key: 'thumbnail_width', value: width.toString() });
|
||||
if (height) updates.push({ key: 'thumbnail_height', value: height.toString() });
|
||||
if (fit) updates.push({ key: 'thumbnail_fit', value: fit });
|
||||
if (quality) updates.push({ key: 'thumbnail_quality', value: quality.toString() });
|
||||
if (format) updates.push({ key: 'thumbnail_format', value: format });
|
||||
|
||||
if (width) updates.push({ setting_key: 'thumbnail_width', setting_value: width });
|
||||
if (height) updates.push({ setting_key: 'thumbnail_height', setting_value: height });
|
||||
if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) });
|
||||
if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality });
|
||||
if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) });
|
||||
|
||||
for (const update of updates) {
|
||||
await db('app_settings')
|
||||
.where('key', update.key)
|
||||
.where('setting_key', update.setting_key)
|
||||
.update({
|
||||
value: update.value,
|
||||
setting_value: update.setting_value,
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1169,6 +1169,17 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
||||
|
||||
// Import multer and photo processing
|
||||
const multer = require('multer');
|
||||
const { getAllowedMimeTypes } = require('../services/uploadSettings');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Resolve allowed MIME types from settings
|
||||
let allowedMimeTypes;
|
||||
try {
|
||||
allowedMimeTypes = await getAllowedMimeTypes();
|
||||
} catch {
|
||||
allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
dest: tempUploadDir,
|
||||
limits: {
|
||||
@@ -1176,8 +1187,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
||||
files: 10 // Max 10 files at once
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type'));
|
||||
|
||||
@@ -78,6 +78,8 @@ router.get('/', async (req, res) => {
|
||||
event_require_admin_email: settingsObject.event_require_admin_email !== false,
|
||||
event_require_event_date: settingsObject.event_require_event_date !== false,
|
||||
event_require_expiration: settingsObject.event_require_expiration !== false,
|
||||
// Upload settings (safe to expose - needed for client-side validation)
|
||||
allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp',
|
||||
// SEO meta tag flags (safe to expose - these are intended for crawlers)
|
||||
seo_meta_noindex: settingsObject.seo_meta_noindex === true,
|
||||
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
|
||||
|
||||
@@ -7,6 +7,25 @@ const CACHE_TTL_MS = 60_000;
|
||||
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
let cacheExpiresAt = 0;
|
||||
|
||||
// Map of file extension to MIME type(s)
|
||||
const EXTENSION_TO_MIME = {
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'webp': 'image/webp',
|
||||
'gif': 'image/gif',
|
||||
'mp4': 'video/mp4',
|
||||
'm4v': 'video/mp4',
|
||||
'webm': 'video/webm',
|
||||
'mov': 'video/quicktime',
|
||||
'avi': 'video/x-msvideo',
|
||||
};
|
||||
|
||||
const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp';
|
||||
|
||||
let cachedAllowedTypes = null;
|
||||
let allowedTypesCacheExpiresAt = 0;
|
||||
|
||||
const parseSettingValue = (setting) => {
|
||||
if (!setting || setting.setting_value == null) {
|
||||
return null;
|
||||
@@ -79,9 +98,76 @@ const clearMaxFilesPerUploadCache = () => {
|
||||
cacheExpiresAt = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a comma-separated list of file extensions into an array of MIME types.
|
||||
* Unknown extensions are silently ignored.
|
||||
*/
|
||||
const extensionsToMimeTypes = (extString) => {
|
||||
if (!extString || typeof extString !== 'string') {
|
||||
return extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
|
||||
}
|
||||
|
||||
const mimeSet = new Set();
|
||||
extString.split(',').forEach(ext => {
|
||||
const cleaned = ext.trim().toLowerCase().replace(/^\./, '');
|
||||
const mime = EXTENSION_TO_MIME[cleaned];
|
||||
if (mime) {
|
||||
mimeSet.add(mime);
|
||||
}
|
||||
});
|
||||
|
||||
if (mimeSet.size === 0) {
|
||||
return extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
|
||||
}
|
||||
|
||||
return Array.from(mimeSet);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the allowed MIME types for uploads from the database setting.
|
||||
* Returns an array of MIME type strings, e.g. ['image/jpeg', 'image/png', 'video/mp4'].
|
||||
*/
|
||||
const getAllowedMimeTypes = async () => {
|
||||
if (Date.now() < allowedTypesCacheExpiresAt && cachedAllowedTypes) {
|
||||
return cachedAllowedTypes;
|
||||
}
|
||||
|
||||
try {
|
||||
const setting = await db('app_settings')
|
||||
.where({ setting_key: 'general_allowed_file_types' })
|
||||
.first();
|
||||
|
||||
let rawValue = setting?.setting_value;
|
||||
if (typeof rawValue === 'string') {
|
||||
try { rawValue = JSON.parse(rawValue); } catch { /* keep string */ }
|
||||
}
|
||||
|
||||
const mimeTypes = extensionsToMimeTypes(rawValue);
|
||||
cachedAllowedTypes = mimeTypes;
|
||||
allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return mimeTypes;
|
||||
} catch (error) {
|
||||
console.error('Failed to read allowed file types setting:', error.message);
|
||||
const fallback = extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
|
||||
cachedAllowedTypes = fallback;
|
||||
allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS;
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const clearAllowedTypesCache = () => {
|
||||
allowedTypesCacheExpiresAt = 0;
|
||||
cachedAllowedTypes = null;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMaxFilesPerUpload,
|
||||
clearMaxFilesPerUploadCache,
|
||||
getAllowedMimeTypes,
|
||||
clearAllowedTypesCache,
|
||||
extensionsToMimeTypes,
|
||||
EXTENSION_TO_MIME,
|
||||
DEFAULT_MAX_FILES_PER_UPLOAD,
|
||||
MAX_ALLOWED_FILES_PER_UPLOAD
|
||||
MAX_ALLOWED_FILES_PER_UPLOAD,
|
||||
DEFAULT_ALLOWED_FILE_TYPES
|
||||
};
|
||||
|
||||
+3
-8
@@ -12,9 +12,6 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
|
||||
LABEL org.opencontainers.image.description="PicPeak Frontend Application"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
|
||||
RUN npm install -g npm@latest
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -30,12 +27,10 @@ COPY . .
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage (use Alpine with patched libpng/c-ares)
|
||||
FROM nginx:1.27-alpine
|
||||
# Production stage (Alpine 3.23 with OpenSSL 3.5.5, patched libexpat)
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities
|
||||
# This ensures libpng >= 1.6.51 (fixes CVE-2025-64720, CVE-2025-65018, CVE-2025-64505, CVE-2025-64506)
|
||||
# and c-ares >= 1.34.5 (fixes CVE-2025-31498)
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install runtime dependencies
|
||||
|
||||
@@ -22,10 +22,10 @@ ENV VITE_API_URL=$VITE_API_URL
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
# Production stage (Alpine 3.23 with OpenSSL 3.5.5, patched libexpat)
|
||||
FROM nginx:1.28-alpine
|
||||
|
||||
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
|
||||
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
|
||||
RUN apk upgrade --no-cache
|
||||
|
||||
# Install runtime dependencies
|
||||
|
||||
Generated
+159
-114
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "3.3.0-beta.0",
|
||||
"version": "2.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "3.3.0-beta.0",
|
||||
"version": "2.5.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
@@ -1303,9 +1303,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
|
||||
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1317,9 +1317,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1331,9 +1331,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1345,9 +1345,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1359,9 +1359,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1373,9 +1373,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
|
||||
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1387,9 +1387,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1401,9 +1401,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
|
||||
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1415,9 +1415,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1429,9 +1429,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1443,9 +1443,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1457,9 +1471,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1471,9 +1499,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1485,9 +1513,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1499,9 +1527,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1513,9 +1541,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1526,9 +1554,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
|
||||
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1539,10 +1567,24 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
|
||||
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1554,9 +1596,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1568,9 +1610,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1582,9 +1624,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
|
||||
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1596,9 +1638,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
|
||||
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2584,13 +2626,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
||||
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -2825,9 +2867,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2978,13 +3020,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.13.6",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
|
||||
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -4866,9 +4908,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
@@ -4966,9 +5008,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -6045,9 +6087,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.53.3",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz",
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -6061,28 +6103,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.53.3",
|
||||
"@rollup/rollup-android-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-arm64": "4.53.3",
|
||||
"@rollup/rollup-darwin-x64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-arm64": "4.53.3",
|
||||
"@rollup/rollup-freebsd-x64": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.53.3",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-linux-x64-musl": "4.53.3",
|
||||
"@rollup/rollup-openharmony-arm64": "4.53.3",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.53.3",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.53.3",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -6312,9 +6357,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/swiper": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.0.tgz",
|
||||
"integrity": "sha512-BD4CpAOOyEvZ2f0CDx362ea+vmOwukVcmbsQx+0BhRIaBUz8wvcCd//E7RFmvBZCrfyqXCHUVqmgUwts6ywlxw==",
|
||||
"version": "12.1.2",
|
||||
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.2.tgz",
|
||||
"integrity": "sha512-4gILrI3vXZqoZh71I1PALqukCFgk+gpOwe1tOvz5uE9kHtl2gTDzmYflYCwWvR4LOvCrJi6UEEU+gnuW5BtkgQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "patreon",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "2.6.0",
|
||||
"version": "2.6.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Upload, X, Image, Loader2 } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { clsx } from 'clsx';
|
||||
@@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
eventId: number;
|
||||
@@ -47,12 +48,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
|
||||
}, [settings]);
|
||||
|
||||
const allowedMimeTypes = useMemo(
|
||||
() => extensionsToMimeTypes(settings?.general_allowed_file_types),
|
||||
[settings?.general_allowed_file_types]
|
||||
);
|
||||
|
||||
const acceptString = useMemo(
|
||||
() => extensionsToAcceptString(settings?.general_allowed_file_types),
|
||||
[settings?.general_allowed_file_types]
|
||||
);
|
||||
|
||||
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const imageFiles = files.filter(file =>
|
||||
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
|
||||
const imageFiles = files.filter(file =>
|
||||
allowedMimeTypes.includes(file.type)
|
||||
);
|
||||
|
||||
// Check total file count with existing files
|
||||
@@ -254,7 +265,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
|
||||
accept={acceptString}
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Upload, X, CheckCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button } from '../common';
|
||||
import { api } from '../../config/api';
|
||||
import { publicSettingsService } from '../../services/publicSettings.service';
|
||||
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
|
||||
|
||||
interface UserPhotoUploadProps {
|
||||
eventId: number;
|
||||
@@ -23,13 +26,28 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
|
||||
|
||||
const { data: publicSettings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: () => publicSettingsService.getPublicSettings(),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const allowedMimeTypes = useMemo(
|
||||
() => extensionsToMimeTypes(publicSettings?.allowed_file_types),
|
||||
[publicSettings?.allowed_file_types]
|
||||
);
|
||||
|
||||
const acceptString = useMemo(
|
||||
() => extensionsToAcceptString(publicSettings?.allowed_file_types),
|
||||
[publicSettings?.allowed_file_types]
|
||||
);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
|
||||
|
||||
// Validate file types
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
const validFiles = selectedFiles.filter(file => {
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
if (!allowedMimeTypes.includes(file.type)) {
|
||||
toast.error(`Invalid file type: ${file.name}`);
|
||||
return false;
|
||||
}
|
||||
@@ -143,7 +161,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
accept="image/jpeg,image/png,image/webp,video/mp4,video/webm,video/quicktime,video/x-msvideo"
|
||||
accept={acceptString}
|
||||
onChange={handleFileSelect}
|
||||
disabled={uploading}
|
||||
/>
|
||||
|
||||
@@ -14,3 +14,4 @@ export { AnalyticsTab } from './tabs/AnalyticsTab';
|
||||
export { ModerationTab } from './tabs/ModerationTab';
|
||||
export { StylingTab } from './tabs/StylingTab';
|
||||
export { SEOTab } from './tabs/SEOTab';
|
||||
export { ThumbnailsTab } from './tabs/ThumbnailsTab';
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Save, Image, RefreshCw, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { api } from '../../../config/api';
|
||||
|
||||
interface ThumbnailSettings {
|
||||
width: number;
|
||||
height: number;
|
||||
quality: number;
|
||||
fit: string;
|
||||
format: string;
|
||||
}
|
||||
|
||||
const defaultSettings: ThumbnailSettings = {
|
||||
width: 300,
|
||||
height: 300,
|
||||
quality: 85,
|
||||
fit: 'cover',
|
||||
format: 'jpeg',
|
||||
};
|
||||
|
||||
interface FetchedSettings {
|
||||
settings: Record<string, { value: string; description: string }>;
|
||||
fitOptions: string[];
|
||||
formatOptions: string[];
|
||||
}
|
||||
|
||||
export const ThumbnailsTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [settings, setSettings] = useState<ThumbnailSettings>(defaultSettings);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
const { data: fetchedData, isLoading, error } = useQuery<FetchedSettings>({
|
||||
queryKey: ['thumbnail-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await api.get('/admin/thumbnails/settings');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (fetchedData?.settings) {
|
||||
const s = fetchedData.settings;
|
||||
setSettings({
|
||||
width: parseInt(s.thumbnail_width?.value) || defaultSettings.width,
|
||||
height: parseInt(s.thumbnail_height?.value) || defaultSettings.height,
|
||||
quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality,
|
||||
fit: s.thumbnail_fit?.value || defaultSettings.fit,
|
||||
format: s.thumbnail_format?.value || defaultSettings.format,
|
||||
});
|
||||
}
|
||||
}, [fetchedData]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (newSettings: ThumbnailSettings) => {
|
||||
const response = await api.put('/admin/thumbnails/settings', newSettings);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['thumbnail-settings'] });
|
||||
toast.success(t('settings.thumbnails.saveSuccess', 'Thumbnail settings saved'));
|
||||
setIsDirty(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.thumbnails.saveError', 'Failed to save thumbnail settings'));
|
||||
},
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await api.post('/admin/thumbnails/regenerate');
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message || t('settings.thumbnails.regenerateStarted', 'Thumbnail regeneration started'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('settings.thumbnails.regenerateError', 'Failed to start thumbnail regeneration'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = <K extends keyof ThumbnailSettings>(
|
||||
key: K,
|
||||
value: ThumbnailSettings[K]
|
||||
) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
saveMutation.mutate(settings);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (fetchedData?.settings) {
|
||||
const s = fetchedData.settings;
|
||||
setSettings({
|
||||
width: parseInt(s.thumbnail_width?.value) || defaultSettings.width,
|
||||
height: parseInt(s.thumbnail_height?.value) || defaultSettings.height,
|
||||
quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality,
|
||||
fit: s.thumbnail_fit?.value || defaultSettings.fit,
|
||||
format: s.thumbnail_format?.value || defaultSettings.format,
|
||||
});
|
||||
setIsDirty(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<Loading size="lg" text={t('common.loading')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card padding="md">
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<p>{t('settings.thumbnails.loadError', 'Failed to load thumbnail settings')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const fitOptions = fetchedData?.fitOptions || ['cover', 'contain', 'fill', 'inside', 'outside'];
|
||||
const formatOptions = fetchedData?.formatOptions || ['jpeg', 'png', 'webp'];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Dimensions & Quality */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<Image className="w-5 h-5 text-primary-600" />
|
||||
{t('settings.thumbnails.dimensionsTitle', 'Thumbnail Dimensions & Quality')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('settings.thumbnails.dimensionsHelp', 'Configure the size and quality of auto-generated thumbnails. Higher values produce better-looking previews but increase storage and load times.')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.thumbnails.width', 'Width (px)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="50"
|
||||
max="1000"
|
||||
value={settings.width}
|
||||
onChange={(e) => handleChange('width', parseInt(e.target.value) || 300)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.thumbnails.widthHelp', '50-1000 pixels')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.thumbnails.height', 'Height (px)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="50"
|
||||
max="1000"
|
||||
value={settings.height}
|
||||
onChange={(e) => handleChange('height', parseInt(e.target.value) || 300)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.thumbnails.heightHelp', '50-1000 pixels')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.thumbnails.quality', 'Quality')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={settings.quality}
|
||||
onChange={(e) => handleChange('quality', parseInt(e.target.value) || 85)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.thumbnails.qualityHelp', '1-100, higher = better quality but larger files')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.thumbnails.format', 'Format')}
|
||||
</label>
|
||||
<select
|
||||
value={settings.format}
|
||||
onChange={(e) => handleChange('format', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{formatOptions.map((fmt) => (
|
||||
<option key={fmt} value={fmt}>{fmt.toUpperCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('settings.thumbnails.fit', 'Fit Mode')}
|
||||
</label>
|
||||
<select
|
||||
value={settings.fit}
|
||||
onChange={(e) => handleChange('fit', e.target.value)}
|
||||
className="w-full sm:w-64 px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{fitOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{t(`settings.thumbnails.fit_${opt}`, opt.charAt(0).toUpperCase() + opt.slice(1))}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('settings.thumbnails.fitHelp', 'How images are resized to fit the thumbnail dimensions. "Cover" crops to fill, "Contain" fits within bounds.')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Regenerate */}
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<RefreshCw className="w-5 h-5 text-primary-600" />
|
||||
{t('settings.thumbnails.regenerateTitle', 'Regenerate Thumbnails')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('settings.thumbnails.regenerateHelp', 'After changing thumbnail settings, regenerate all existing thumbnails to apply the new configuration. This runs in the background and may take a while for large galleries.')}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => regenerateMutation.mutate()}
|
||||
isLoading={regenerateMutation.isPending}
|
||||
leftIcon={regenerateMutation.isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <RefreshCw className="w-5 h-5" />}
|
||||
>
|
||||
{t('settings.thumbnails.regenerateButton', 'Regenerate All Thumbnails')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{/* Info Box */}
|
||||
<Card padding="md" className="bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium mb-1">{t('settings.thumbnails.infoTitle', 'About Thumbnails')}</p>
|
||||
<p>
|
||||
{t('settings.thumbnails.infoText', 'Thumbnails are smaller preview images generated from your originals. Increasing the size or quality improves how photos look in the gallery grid but uses more storage and bandwidth. After changing settings, use "Regenerate All Thumbnails" to update existing photos.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isLoading={saveMutation.isPending}
|
||||
leftIcon={<Save className="w-5 h-5" />}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
{t('common.saveChanges', 'Save Changes')}
|
||||
</Button>
|
||||
|
||||
{isDirty && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
leftIcon={<RefreshCw className="w-5 h-5" />}
|
||||
>
|
||||
{t('common.resetChanges', 'Reset Changes')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1218,6 +1218,35 @@
|
||||
"hidePreview": "robots.txt-Vorschau ausblenden",
|
||||
"saveSettings": "SEO-Einstellungen speichern"
|
||||
},
|
||||
"thumbnails": {
|
||||
"title": "Vorschaubilder",
|
||||
"dimensionsTitle": "Abmessungen & Qualität der Vorschaubilder",
|
||||
"dimensionsHelp": "Konfigurieren Sie Größe und Qualität der automatisch generierten Vorschaubilder. Höhere Werte erzeugen bessere Vorschauen, erhöhen aber den Speicherbedarf und die Ladezeiten.",
|
||||
"width": "Breite (px)",
|
||||
"widthHelp": "50-1000 Pixel",
|
||||
"height": "Höhe (px)",
|
||||
"heightHelp": "50-1000 Pixel",
|
||||
"quality": "Qualität",
|
||||
"qualityHelp": "1-100, höher = bessere Qualität, aber größere Dateien",
|
||||
"format": "Format",
|
||||
"fit": "Anpassungsmodus",
|
||||
"fitHelp": "Wie Bilder an die Vorschaubildgröße angepasst werden. \"Füllen\" beschneidet das Bild, \"Einpassen\" passt es innerhalb der Grenzen an.",
|
||||
"fit_cover": "Füllen (zuschneiden)",
|
||||
"fit_contain": "Einpassen (innerhalb)",
|
||||
"fit_fill": "Strecken",
|
||||
"fit_inside": "Innen (verkleinern)",
|
||||
"fit_outside": "Außen (vergrößern)",
|
||||
"regenerateTitle": "Vorschaubilder neu generieren",
|
||||
"regenerateHelp": "Nach dem Ändern der Einstellungen können Sie alle vorhandenen Vorschaubilder neu generieren. Dies läuft im Hintergrund und kann bei großen Galerien eine Weile dauern.",
|
||||
"regenerateButton": "Alle Vorschaubilder neu generieren",
|
||||
"regenerateStarted": "Neugenerierung der Vorschaubilder gestartet",
|
||||
"regenerateError": "Neugenerierung der Vorschaubilder konnte nicht gestartet werden",
|
||||
"saveSuccess": "Vorschaubild-Einstellungen gespeichert",
|
||||
"saveError": "Vorschaubild-Einstellungen konnten nicht gespeichert werden",
|
||||
"loadError": "Vorschaubild-Einstellungen konnten nicht geladen werden",
|
||||
"infoTitle": "Über Vorschaubilder",
|
||||
"infoText": "Vorschaubilder sind kleinere Vorschauversionen Ihrer Originalbilder. Das Erhöhen der Größe oder Qualität verbessert die Darstellung in der Galerieübersicht, benötigt aber mehr Speicherplatz und Bandbreite. Nach dem Ändern der Einstellungen verwenden Sie \"Alle Vorschaubilder neu generieren\", um bestehende Fotos zu aktualisieren."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorien",
|
||||
"about": "Über Fotokategorien",
|
||||
|
||||
@@ -911,6 +911,35 @@
|
||||
"styling": {
|
||||
"title": "Custom CSS"
|
||||
},
|
||||
"thumbnails": {
|
||||
"title": "Thumbnails",
|
||||
"dimensionsTitle": "Thumbnail Dimensions & Quality",
|
||||
"dimensionsHelp": "Configure the size and quality of auto-generated thumbnails. Higher values produce better-looking previews but increase storage and load times.",
|
||||
"width": "Width (px)",
|
||||
"widthHelp": "50-1000 pixels",
|
||||
"height": "Height (px)",
|
||||
"heightHelp": "50-1000 pixels",
|
||||
"quality": "Quality",
|
||||
"qualityHelp": "1-100, higher = better quality but larger files",
|
||||
"format": "Format",
|
||||
"fit": "Fit Mode",
|
||||
"fitHelp": "How images are resized to fit the thumbnail dimensions. \"Cover\" crops to fill, \"Contain\" fits within bounds.",
|
||||
"fit_cover": "Cover (crop to fill)",
|
||||
"fit_contain": "Contain (fit within)",
|
||||
"fit_fill": "Fill (stretch)",
|
||||
"fit_inside": "Inside (shrink to fit)",
|
||||
"fit_outside": "Outside (expand to cover)",
|
||||
"regenerateTitle": "Regenerate Thumbnails",
|
||||
"regenerateHelp": "After changing thumbnail settings, regenerate all existing thumbnails to apply the new configuration. This runs in the background and may take a while for large galleries.",
|
||||
"regenerateButton": "Regenerate All Thumbnails",
|
||||
"regenerateStarted": "Thumbnail regeneration started",
|
||||
"regenerateError": "Failed to start thumbnail regeneration",
|
||||
"saveSuccess": "Thumbnail settings saved",
|
||||
"saveError": "Failed to save thumbnail settings",
|
||||
"loadError": "Failed to load thumbnail settings",
|
||||
"infoTitle": "About Thumbnails",
|
||||
"infoText": "Thumbnails are smaller preview images generated from your originals. Increasing the size or quality improves how photos look in the gallery grid but uses more storage and bandwidth. After changing settings, use \"Regenerate All Thumbnails\" to update existing photos."
|
||||
},
|
||||
"seo": {
|
||||
"title": "SEO & Robots",
|
||||
"indexingTitle": "Search Engine Indexing",
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
ModerationTab,
|
||||
StylingTab,
|
||||
SEOTab,
|
||||
ThumbnailsTab,
|
||||
} from '../../features/settings';
|
||||
|
||||
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling';
|
||||
type TabType = 'general' | 'events' | 'status' | 'security' | 'imageSecurity' | 'thumbnails' | 'categories' | 'seo' | 'analytics' | 'moderation' | 'styling';
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('general');
|
||||
@@ -74,6 +75,7 @@ export const SettingsPage: React.FC = () => {
|
||||
{ key: 'status', label: t('settings.systemStatus.title') },
|
||||
{ key: 'security', label: t('settings.security.title') },
|
||||
{ key: 'imageSecurity', label: t('settings.imageSecurity.title', 'Image Protection') },
|
||||
{ key: 'thumbnails', label: t('settings.thumbnails.title', 'Thumbnails') },
|
||||
{ key: 'seo', label: t('settings.seo.title', 'SEO & Robots') },
|
||||
{ key: 'categories', label: t('settings.categories.title') },
|
||||
{ key: 'analytics', label: t('settings.analytics.title') },
|
||||
@@ -168,6 +170,8 @@ export const SettingsPage: React.FC = () => {
|
||||
|
||||
{activeTab === 'imageSecurity' && <ImageSecurityTab />}
|
||||
|
||||
{activeTab === 'thumbnails' && <ThumbnailsTab />}
|
||||
|
||||
{activeTab === 'categories' && <CategoriesTab />}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface PublicSettings {
|
||||
umami_url: string | null;
|
||||
umami_website_id: string | null;
|
||||
umami_share_url: string | null;
|
||||
// Upload settings
|
||||
allowed_file_types?: string;
|
||||
// Event field requirements
|
||||
event_require_customer_name?: boolean;
|
||||
event_require_customer_email?: boolean;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Maps file extensions to MIME types for upload validation.
|
||||
*/
|
||||
const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
mp4: 'video/mp4',
|
||||
m4v: 'video/mp4',
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
avi: 'video/x-msvideo',
|
||||
};
|
||||
|
||||
const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp';
|
||||
|
||||
/**
|
||||
* Convert a comma-separated extension string (e.g. "jpg,png,mp4") to an
|
||||
* array of unique MIME types.
|
||||
*/
|
||||
export function extensionsToMimeTypes(extString?: string | null): string[] {
|
||||
const input = extString?.trim() || DEFAULT_ALLOWED;
|
||||
const mimeSet = new Set<string>();
|
||||
|
||||
input.split(',').forEach(ext => {
|
||||
const cleaned = ext.trim().toLowerCase().replace(/^\./, '');
|
||||
const mime = EXTENSION_TO_MIME[cleaned];
|
||||
if (mime) {
|
||||
mimeSet.add(mime);
|
||||
}
|
||||
});
|
||||
|
||||
if (mimeSet.size === 0) {
|
||||
return extensionsToMimeTypes(DEFAULT_ALLOWED);
|
||||
}
|
||||
|
||||
return Array.from(mimeSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a comma-separated extension string to an HTML `accept` attribute
|
||||
* value, e.g. "image/jpeg,image/png,video/mp4".
|
||||
*/
|
||||
export function extensionsToAcceptString(extString?: string | null): string {
|
||||
return extensionsToMimeTypes(extString).join(',');
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
|
||||
async function getAdminToken(page: Page): Promise<string> {
|
||||
const res = await page.request.post('/api/auth/admin/login', {
|
||||
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const body = await res.json();
|
||||
expect(body.token).toBeTruthy();
|
||||
return body.token;
|
||||
}
|
||||
|
||||
async function updateEventSettings(
|
||||
page: Page,
|
||||
token: string,
|
||||
settings: Record<string, boolean>
|
||||
) {
|
||||
const res = await page.request.put('/api/admin/settings/general', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: settings,
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
test.describe('Optional email fields in event creation (#217)', () => {
|
||||
test('event creation succeeds with empty emails when set to optional', async ({ page }) => {
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
// Disable email requirements
|
||||
await updateEventSettings(page, token, {
|
||||
event_require_customer_email: false,
|
||||
event_require_admin_email: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create event with empty email fields
|
||||
const eventRes = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: `E2E Optional Emails ${Date.now()}`,
|
||||
event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
|
||||
customer_name: 'Test Host',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
password: 'TestPass123!',
|
||||
expiration_days: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await eventRes.json();
|
||||
expect(eventRes.ok(), `Expected 200 but got ${eventRes.status()}: ${JSON.stringify(body)}`).toBeTruthy();
|
||||
expect(body.id).toBeTruthy();
|
||||
|
||||
// Cleanup: delete the created event
|
||||
await page.request.delete(`/api/admin/events/${body.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} finally {
|
||||
// Revert settings to required
|
||||
await updateEventSettings(page, token, {
|
||||
event_require_customer_email: true,
|
||||
event_require_admin_email: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('event creation still fails with empty emails when set to required', async ({ page }) => {
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
// Ensure email requirements are enabled
|
||||
await updateEventSettings(page, token, {
|
||||
event_require_customer_email: true,
|
||||
event_require_admin_email: true,
|
||||
});
|
||||
|
||||
const eventRes = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: `E2E Required Emails ${Date.now()}`,
|
||||
event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
|
||||
customer_name: 'Test Host',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
password: 'TestPass123!',
|
||||
expiration_days: 30,
|
||||
},
|
||||
});
|
||||
|
||||
expect(eventRes.status()).toBe(400);
|
||||
const body = await eventRes.json();
|
||||
const paths = body.errors.map((e: { path: string }) => e.path);
|
||||
expect(paths).toContain('customer_email');
|
||||
expect(paths).toContain('admin_email');
|
||||
});
|
||||
|
||||
test('event creation succeeds with missing email fields when optional', async ({ page }) => {
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
// Disable email requirements
|
||||
await updateEventSettings(page, token, {
|
||||
event_require_customer_email: false,
|
||||
event_require_admin_email: false,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create event without email fields at all (undefined, not empty string)
|
||||
const eventRes = await page.request.post('/api/admin/events', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: `E2E Missing Emails ${Date.now()}`,
|
||||
event_date: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10),
|
||||
customer_name: 'Test Host',
|
||||
password: 'TestPass123!',
|
||||
expiration_days: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const body = await eventRes.json();
|
||||
expect(eventRes.ok(), `Expected 200 but got ${eventRes.status()}: ${JSON.stringify(body)}`).toBeTruthy();
|
||||
expect(body.id).toBeTruthy();
|
||||
|
||||
// Cleanup
|
||||
await page.request.delete(`/api/admin/events/${body.id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} finally {
|
||||
await updateEventSettings(page, token, {
|
||||
event_require_customer_email: true,
|
||||
event_require_admin_email: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user