Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff9fb64e75 | |||
| 9cbbe74051 | |||
| 2e1c71c1ab | |||
| f6ca713a6e | |||
| 197cd8e1e0 | |||
| 681b440381 | |||
| 3daeac9e53 | |||
| 7febba2d9c | |||
| 0a3a53763c | |||
| 85a60a2dc7 | |||
| e74e73a3a0 | |||
| 52ab609597 | |||
| fafcfbf4e6 | |||
| 56f497c5f1 | |||
| b52cf1f741 | |||
| 7f7736282f | |||
| 67b0f32456 | |||
| 25b40c03b0 | |||
| 28793bba68 | |||
| 4ae91142f8 | |||
| c92879fbd3 | |||
| a0bb080586 | |||
| fc75bcdfc3 | |||
| 9877f63aed | |||
| 0c98c6b453 | |||
| 831ea6a3bc | |||
| 9c44a0ebfa | |||
| a840ad4594 | |||
| 08ac238d0a | |||
| 7d967a47ae | |||
| 9b7495e005 | |||
| e1ad4219a5 | |||
| cc4503ad28 | |||
| 424336340b | |||
| a8308a5c02 | |||
| 02a46e083d | |||
| 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.5.1"
|
||||
".": "2.6.3"
|
||||
}
|
||||
|
||||
@@ -5,6 +5,77 @@ 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.3](https://github.com/the-luap/picpeak/compare/v2.6.2...v2.6.3) (2026-04-07)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([2e1c71c](https://github.com/the-luap/picpeak/commit/2e1c71c1ab073e488ac93e35337a2d3955dfef3d))
|
||||
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([f6ca713](https://github.com/the-luap/picpeak/commit/f6ca713a6edc8ba371db790daba05ecb85ea4872))
|
||||
|
||||
## [2.6.2](https://github.com/the-luap/picpeak/compare/v2.6.1...v2.6.2) (2026-03-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([85a60a2](https://github.com/the-luap/picpeak/commit/85a60a2dc7526aa6b673e2a04e9fdfba7de7117f))
|
||||
* **security:** token invalidation on password change, session timeout enforcement ([0a3a537](https://github.com/the-luap/picpeak/commit/0a3a53763c9f3caef9fdceccf9fdbfdefe9bd8bf))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add configurable upload batch size for reverse proxy compatibility ([#208](https://github.com/the-luap/picpeak/issues/208)) ([02a46e0](https://github.com/the-luap/picpeak/commit/02a46e083d68cfdb355b5a4fe4a8da7d667050b9))
|
||||
* configurable upload batch size for reverse proxy compatibility ([4243363](https://github.com/the-luap/picpeak/commit/424336340bef8e1629490ade154f0ceebb2a71e1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c))
|
||||
* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68))
|
||||
|
||||
## [2.5.1](https://github.com/the-luap/picpeak/compare/v2.5.0...v2.5.1) (2026-02-22)
|
||||
|
||||
|
||||
@@ -49,6 +120,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
|
||||
@@ -96,6 +168,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)
|
||||
|
||||
|
||||
+90
-1
@@ -11,7 +11,7 @@ This guide covers multiple deployment options for PicPeak, from simple local set
|
||||
- [First Login](#-first-login)
|
||||
- [Release Channels](#-release-channels)
|
||||
- [Reverse Proxy Setup](#-reverse-proxy-setup)
|
||||
- [External Media Library](#external-media-library)
|
||||
- [External Media Library](#-external-media-library)
|
||||
- [Maintenance](#-maintenance)
|
||||
- [Troubleshooting](#-troubleshooting)
|
||||
|
||||
@@ -552,6 +552,95 @@ sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
|
||||
# Or use your reverse proxy's built-in ACME support
|
||||
```
|
||||
|
||||
## 📂 External Media Library
|
||||
|
||||
The External Media Library allows events to reference photos stored directly on your host filesystem instead of uploading them through the admin UI. This is useful for photographers who already have organized photo libraries and want to share them without re-uploading.
|
||||
|
||||
### How It Works
|
||||
|
||||
- **Managed mode** (default): Photos are uploaded through the admin UI and stored inside PicPeak's storage directory.
|
||||
- **Reference mode**: Photos remain on your host filesystem. PicPeak reads them directly and generates thumbnails on demand.
|
||||
|
||||
Each event can use either mode. Reference mode events point to a folder under the configured external media root.
|
||||
|
||||
### Configuration
|
||||
|
||||
Add the following to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Path where your photo library is stored on the host
|
||||
EXTERNAL_MEDIA_ROOT=/path/to/your/photos
|
||||
```
|
||||
|
||||
Then mount this path into the backend container in your `docker-compose.yml` or `docker-compose.production.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
- EXTERNAL_MEDIA_ROOT=/external-media
|
||||
volumes:
|
||||
- /path/to/your/photos:/external-media:ro # read-only is recommended
|
||||
```
|
||||
|
||||
> **Permissions**: Ensure the container user (`PUID`/`PGID`) has read access to the mounted directory. If thumbnails fail to generate, this is usually a permissions issue.
|
||||
|
||||
### Folder Structure
|
||||
|
||||
Organize your photos with subdirectories for each event. Within each event folder, use `individual/` and `collages/` subdirectories to classify photos:
|
||||
|
||||
```
|
||||
/path/to/your/photos/
|
||||
├── wedding-smith-2026/
|
||||
│ ├── individual/
|
||||
│ │ ├── IMG_0001.jpg
|
||||
│ │ ├── IMG_0002.jpg
|
||||
│ │ └── ...
|
||||
│ └── collages/
|
||||
│ ├── group-photo.jpg
|
||||
│ └── ...
|
||||
├── corporate-event/
|
||||
│ ├── individual/
|
||||
│ │ └── ...
|
||||
│ └── collages/
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
Supported file formats: `.jpg`, `.jpeg`, `.png`, `.webp`
|
||||
|
||||
### Usage
|
||||
|
||||
1. **Create an event** in the admin panel as usual (name, date, email, etc.).
|
||||
|
||||
2. **Switch source mode** to "Reference external folder" in the event details under Source Mode.
|
||||
|
||||
3. **Browse and select** the external folder using the folder picker that appears. Navigate to the event's directory.
|
||||
|
||||
4. **Import photos** by clicking "Import from External Folder" in the Photos tab. PicPeak will:
|
||||
- Recursively scan the selected folder
|
||||
- Classify photos by subfolder name (`individual/` or `collages/`)
|
||||
- Deduplicate by filename (keeps the largest file if duplicates exist)
|
||||
- Extract image dimensions for gallery layout
|
||||
- Register the photos in the database
|
||||
|
||||
5. **Thumbnails** are generated on demand when a guest first views the gallery. There is no upfront processing delay.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **Images only** — video files are not supported for external media.
|
||||
- **Read-only** — PicPeak does not modify or delete files in the external media directory.
|
||||
- **No automatic sync** — If you add new photos to the external folder, you need to re-import from the admin UI.
|
||||
- **Backup caveat** — External media originals are excluded from PicPeak's built-in backup system. Only thumbnails and database records are backed up. You are responsible for backing up the source files separately.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| Folder picker shows empty directory | Check that the volume is mounted correctly and `EXTERNAL_MEDIA_ROOT` matches the container path |
|
||||
| "Permission denied" errors | Ensure `PUID`/`PGID` in `.env` match the owner of the external media files on the host |
|
||||
| Thumbnails not generating | Verify the backend container can read the files: `docker exec picpeak-backend ls /external-media/your-folder/` |
|
||||
| Import finds 0 photos | Only `.jpg`, `.jpeg`, `.png`, `.webp` files are supported. Check file extensions. |
|
||||
|
||||
## 🔧 Maintenance
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
+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
+839
-933
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.5.1",
|
||||
"version": "2.6.3",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -19,7 +19,7 @@
|
||||
"@ffmpeg-installer/ffmpeg": "^1.1.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^5.3.1",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.14.0",
|
||||
"bcrypt": "6.0.0",
|
||||
"chokidar": "4.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +495,7 @@ app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'
|
||||
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
||||
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
|
||||
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
||||
app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions'));
|
||||
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
|
||||
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||
|
||||
@@ -85,18 +85,28 @@ async function sessionTimeoutMiddleware(req, res, next) {
|
||||
const now = Date.now();
|
||||
const lastActivity = sessions.get(token);
|
||||
const timeout = await getSessionTimeout();
|
||||
|
||||
// If session exists, check if it's expired
|
||||
|
||||
if (lastActivity) {
|
||||
// Existing session — check if idle too long
|
||||
if (now - lastActivity > timeout) {
|
||||
sessions.delete(token);
|
||||
return res.status(401).json({
|
||||
error: 'Session expired',
|
||||
code: 'SESSION_TIMEOUT'
|
||||
return res.status(401).json({
|
||||
error: 'Session expired',
|
||||
code: 'SESSION_TIMEOUT'
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// First request with this token — check if token was issued longer ago than the timeout
|
||||
// This prevents old/stolen tokens from bypassing session timeout after server restart
|
||||
const tokenIssuedAt = (decoded.iat || 0) * 1000; // iat is in seconds
|
||||
if (now - tokenIssuedAt > timeout) {
|
||||
return res.status(401).json({
|
||||
error: 'Session expired',
|
||||
code: 'SESSION_TIMEOUT'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update last activity
|
||||
sessions.set(token, now);
|
||||
|
||||
|
||||
@@ -122,13 +122,15 @@ router.post('/change-password', [
|
||||
// Hash new password with more rounds
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password and clear must_change_password flag
|
||||
// Update password, set password_changed_at to invalidate existing tokens, and clear must_change_password flag
|
||||
const now = new Date();
|
||||
await db('admin_users')
|
||||
.where('id', userId)
|
||||
.update({
|
||||
password_hash: newPasswordHash,
|
||||
password_changed_at: now,
|
||||
must_change_password: false,
|
||||
updated_at: new Date()
|
||||
updated_at: now
|
||||
});
|
||||
|
||||
// Log activity
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Module-level progress state
|
||||
let repairProgress = {
|
||||
isRunning: false,
|
||||
lastResult: null
|
||||
};
|
||||
|
||||
// Repair photo dimensions (background job)
|
||||
router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
if (repairProgress.isRunning) {
|
||||
return res.status(409).json({ error: 'Repair is already running' });
|
||||
}
|
||||
|
||||
const photos = await db('photos')
|
||||
.where(function () {
|
||||
this.whereNull('width').orWhereNull('height');
|
||||
})
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.select('id', 'path', 'filename');
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ message: 'No photos need dimension repair', count: 0 });
|
||||
}
|
||||
|
||||
// Return immediately
|
||||
res.json({
|
||||
message: `Started repairing dimensions for ${photos.length} photos`,
|
||||
count: photos.length
|
||||
});
|
||||
|
||||
// Process in background
|
||||
repairProgress.isRunning = true;
|
||||
repairProgress.lastResult = null;
|
||||
|
||||
setImmediate(async () => {
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (err) {
|
||||
logger.error('Sharp not available for dimension repair:', err.message);
|
||||
repairProgress.isRunning = false;
|
||||
repairProgress.lastResult = { success: 0, failed: 0, error: 'Sharp not available' };
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
if (!photo.path) {
|
||||
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const fullPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
} catch (err) {
|
||||
logger.warn(`File not found for photo ${photo.id}: ${fullPath}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = await sharp(fullPath).metadata();
|
||||
|
||||
if (metadata.width && metadata.height) {
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
successCount++;
|
||||
|
||||
if (successCount % 50 === 0) {
|
||||
logger.info(`Dimension repair progress: ${successCount} updated...`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Could not extract dimensions for photo ${photo.id}`);
|
||||
errorCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error repairing dimensions for photo ${photo.id}:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
repairProgress.isRunning = false;
|
||||
repairProgress.lastResult = { success: successCount, failed: errorCount };
|
||||
logger.info(`Dimension repair complete: ${successCount} success, ${errorCount} errors`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error starting dimension repair:', error);
|
||||
res.status(500).json({ error: 'Failed to start dimension repair' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get dimension repair status
|
||||
router.get('/repair-dimensions/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const totalPhotos = await db('photos')
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const withDimensions = await db('photos')
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.whereNotNull('width')
|
||||
.whereNotNull('height')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const total = Number(totalPhotos.count);
|
||||
const withDims = Number(withDimensions.count);
|
||||
|
||||
res.json({
|
||||
total,
|
||||
withDimensions: withDims,
|
||||
withoutDimensions: total - withDims,
|
||||
isRunning: repairProgress.isRunning,
|
||||
lastResult: repairProgress.lastResult
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching dimension repair status:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dimension repair status' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -6,10 +6,11 @@ const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
|
||||
const { processUploadedVideo, isVideoMimeType } = require('../services/videoProcessor');
|
||||
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');
|
||||
@@ -47,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
|
||||
@@ -111,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();
|
||||
@@ -263,6 +272,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
console.log(`Could not extract EXIF date for ${file.originalname}`);
|
||||
}
|
||||
|
||||
// Determine media type
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Prepare photo data for batch insert
|
||||
const photoData = {
|
||||
event_id: parseInt(eventId),
|
||||
@@ -273,7 +286,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
type: photoType,
|
||||
category_id: parsedCategoryId, // Save the selected category
|
||||
size_bytes: tempStats.size, // Use actual file size from stat
|
||||
captured_at: capturedAt // EXIF capture date (if available)
|
||||
captured_at: capturedAt, // EXIF capture date (if available)
|
||||
media_type: mediaType,
|
||||
mime_type: file.mimetype
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
@@ -317,26 +332,65 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`);
|
||||
}
|
||||
|
||||
// Generate thumbnail with final path
|
||||
// Generate thumbnail and extract metadata
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
const isVideoFile = isVideoMimeType(operation.photoData.mime_type);
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ thumbnail_path: thumbnailPath });
|
||||
try {
|
||||
if (isVideoFile) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
const thumbnailDir = path.join(getStoragePath(), 'thumbnails');
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
const videoThumbnailPath = path.join(thumbnailDir, `thumb_${operation.filename.replace(/\.[^.]+$/, '.jpg')}`);
|
||||
|
||||
const result = await processUploadedVideo(operation.finalPath, videoThumbnailPath);
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
|
||||
if (photoId && result.metadata) {
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({
|
||||
thumbnail_path: thumbnailPath,
|
||||
duration: result.metadata.duration,
|
||||
video_codec: result.metadata.videoCodec,
|
||||
audio_codec: result.metadata.audioCodec,
|
||||
width: result.metadata.width,
|
||||
height: result.metadata.height
|
||||
});
|
||||
}
|
||||
} else {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path and image dimensions
|
||||
if (photoId) {
|
||||
const updateData = {};
|
||||
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
|
||||
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(operation.finalPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
updateData.width = metadata.width;
|
||||
updateData.height = metadata.height;
|
||||
}
|
||||
} catch (metadataError) {
|
||||
console.warn(`Could not extract image dimensions for ${operation.filename}:`, metadataError.message);
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update(updateData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
console.error(`Thumbnail/metadata processing failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Queue watermark generation in background (non-blocking)
|
||||
// This pre-generates watermarked versions for fast serving in lightbox
|
||||
if (insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
// Queue watermark generation in background (non-blocking, images only)
|
||||
if (photoId && !isVideoFile) {
|
||||
watermarkGeneratorService.generateForPhoto(photoId)
|
||||
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
|
||||
}
|
||||
@@ -801,6 +855,11 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
category_id: photo.category_id || photo.type,
|
||||
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||
category_slug: photo.pc_slug || photo.type,
|
||||
media_type: photo.media_type || 'image',
|
||||
mime_type: photo.mime_type || null,
|
||||
width: photo.width || null,
|
||||
height: photo.height || null,
|
||||
duration: photo.duration || null,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
|
||||
@@ -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
+168
-120
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "3.3.0-beta.0",
|
||||
"version": "2.6.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "3.3.0-beta.0",
|
||||
"version": "2.6.2",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.14.0",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
@@ -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,14 +3020,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
|
||||
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@@ -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": {
|
||||
@@ -5709,10 +5751,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
@@ -6045,9 +6090,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 +6106,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 +6360,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.5.1",
|
||||
"version": "2.6.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -24,7 +24,7 @@
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/react-google-recaptcha": "^2.1.9",
|
||||
"axios": "^1.12.2",
|
||||
"axios": "1.14.0",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "4.1.0",
|
||||
"dompurify": "^3.2.6",
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -215,6 +215,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
useCanvasRendering,
|
||||
isSelectionMode,
|
||||
onPhotoSelect: handlePhotoSelect,
|
||||
onSelectAll: selectAll,
|
||||
onDeselectAll: deselectAll,
|
||||
eventName,
|
||||
eventLogo,
|
||||
eventDate,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface BaseGalleryLayoutProps {
|
||||
selectedPhotos?: Set<number>;
|
||||
isSelectionMode?: boolean;
|
||||
onPhotoSelect?: (photoId: number) => void;
|
||||
onSelectAll?: () => void;
|
||||
onDeselectAll?: () => void;
|
||||
eventName?: string;
|
||||
eventLogo?: string | null;
|
||||
eventDate?: string | null;
|
||||
|
||||
@@ -165,6 +165,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
selectedPhotos = new Set(),
|
||||
isSelectionMode = false,
|
||||
onPhotoSelect,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
eventName,
|
||||
eventDate,
|
||||
allowDownloads = true,
|
||||
@@ -287,17 +289,11 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (selectedPhotos.size === filteredPhotos.length) {
|
||||
// Deselect all
|
||||
filteredPhotos.forEach(p => onPhotoSelect?.(p.id));
|
||||
onDeselectAll?.();
|
||||
} else {
|
||||
// Select all
|
||||
filteredPhotos.forEach(p => {
|
||||
if (!selectedPhotos.has(p.id)) {
|
||||
onPhotoSelect?.(p.id);
|
||||
}
|
||||
});
|
||||
onSelectAll?.();
|
||||
}
|
||||
}, [selectedPhotos, filteredPhotos, onPhotoSelect]);
|
||||
}, [selectedPhotos, filteredPhotos, onSelectAll, onDeselectAll]);
|
||||
|
||||
const handleDownloadSelected = useCallback(async () => {
|
||||
if (selectedPhotos.size === 0) return;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
Clock,
|
||||
HardDrive,
|
||||
Activity,
|
||||
Ruler,
|
||||
} from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../components/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../../config/api';
|
||||
import { settingsService } from '../../../services/settings.service';
|
||||
import { useStatusTab } from '../hooks/useStatusTab';
|
||||
import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings';
|
||||
@@ -53,6 +56,27 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { storageInfo, systemStatus } = useStatusTab(isActive);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: dimensionStatus } = useQuery({
|
||||
queryKey: ['photo-dimension-status'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/admin/photos/repair-dimensions/status');
|
||||
return res.data;
|
||||
},
|
||||
enabled: isActive,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const repairMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/admin/photos/repair-dimensions');
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-dimension-status'] });
|
||||
},
|
||||
});
|
||||
|
||||
// Sync soft limit from storage info
|
||||
useEffect(() => {
|
||||
@@ -528,6 +552,61 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Photo Dimensions */}
|
||||
{dimensionStatus && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<Ruler className="w-5 h-5" />
|
||||
{t('settings.photoDimensions.title')}
|
||||
</h2>
|
||||
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('settings.photoDimensions.description')}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{dimensionStatus.total}</p>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.totalPhotos')}</p>
|
||||
</div>
|
||||
<div className="bg-neutral-50 dark:bg-neutral-800 rounded-lg p-3 text-center">
|
||||
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{dimensionStatus.withDimensions}</p>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.withDimensions')}</p>
|
||||
</div>
|
||||
<div className={`rounded-lg p-3 text-center ${Number(dimensionStatus.withoutDimensions) > 0 ? 'bg-amber-50 dark:bg-amber-900/30' : 'bg-neutral-50 dark:bg-neutral-800'}`}>
|
||||
<p className={`text-2xl font-bold ${Number(dimensionStatus.withoutDimensions) > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-neutral-900 dark:text-neutral-100'}`}>{dimensionStatus.withoutDimensions}</p>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400">{t('settings.photoDimensions.missingDimensions')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dimensionStatus.lastResult && (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('settings.photoDimensions.resultSuccess', {
|
||||
success: dimensionStatus.lastResult.success,
|
||||
failed: dimensionStatus.lastResult.failed,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => repairMutation.mutate()}
|
||||
isLoading={repairMutation.isPending || dimensionStatus.isRunning}
|
||||
disabled={Number(dimensionStatus.withoutDimensions) === 0 || dimensionStatus.isRunning}
|
||||
leftIcon={<Ruler className="w-4 h-4" />}
|
||||
>
|
||||
{dimensionStatus.isRunning
|
||||
? t('settings.photoDimensions.repairing')
|
||||
: Number(dimensionStatus.withoutDimensions) === 0
|
||||
? t('settings.photoDimensions.noneToRepair')
|
||||
: t('settings.photoDimensions.repairButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Update Notification Settings */}
|
||||
<UpdateNotificationSettings />
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -1250,6 +1279,19 @@
|
||||
"failed": "Fehlgeschlagen",
|
||||
"lastUpdate": "Letzte Aktualisierung"
|
||||
},
|
||||
"photoDimensions": {
|
||||
"title": "Foto-Abmessungen",
|
||||
"totalPhotos": "Fotos gesamt",
|
||||
"withDimensions": "Mit Abmessungen",
|
||||
"missingDimensions": "Fehlende Abmessungen",
|
||||
"repairButton": "Abmessungen reparieren",
|
||||
"repairing": "Repariere...",
|
||||
"alreadyRunning": "Reparatur läuft bereits",
|
||||
"started": "Reparatur von {{count}} Fotos gestartet",
|
||||
"noneToRepair": "Alle Fotos haben bereits Abmessungen",
|
||||
"resultSuccess": "Letzte Reparatur: {{success}} aktualisiert, {{failed}} fehlgeschlagen",
|
||||
"description": "Fehlende Breite/Höhe für Fotos ergänzen, die vor der Dimensionsverfolgung hochgeladen wurden. Erforderlich für Masonry- und Mosaik-Layouts."
|
||||
},
|
||||
"updateNotifications": {
|
||||
"title": "Update-Benachrichtigungen",
|
||||
"description": "Erhalten Sie E-Mail-Benachrichtigungen, wenn neue Versionen von PicPeak verfügbar sind.",
|
||||
|
||||
@@ -799,6 +799,19 @@
|
||||
"failed": "Failed",
|
||||
"lastUpdate": "Last update"
|
||||
},
|
||||
"photoDimensions": {
|
||||
"title": "Photo Dimensions",
|
||||
"totalPhotos": "Total Photos",
|
||||
"withDimensions": "With Dimensions",
|
||||
"missingDimensions": "Missing Dimensions",
|
||||
"repairButton": "Repair Dimensions",
|
||||
"repairing": "Repairing...",
|
||||
"alreadyRunning": "Repair is already running",
|
||||
"started": "Started repairing {{count}} photos",
|
||||
"noneToRepair": "All photos already have dimensions",
|
||||
"resultSuccess": "Last repair: {{success}} updated, {{failed}} failed",
|
||||
"description": "Backfill missing width/height for photos uploaded before dimension tracking was added. Required for Masonry and Mosaic layouts."
|
||||
},
|
||||
"updateNotifications": {
|
||||
"title": "Update Notifications",
|
||||
"description": "Receive email notifications when new versions of PicPeak are available.",
|
||||
@@ -898,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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -149,7 +149,11 @@ export const userManagementService = {
|
||||
* Update an admin user
|
||||
*/
|
||||
async updateUser(id: number, data: UpdateUserData): Promise<AdminUser> {
|
||||
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, data);
|
||||
// Convert camelCase to snake_case for backend API
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (data.roleId !== undefined) payload.role_id = data.roleId;
|
||||
if (data.isActive !== undefined) payload.is_active = data.isActive;
|
||||
const response = await api.put<UpdateUserResponse>(`/admin/users/${id}`, payload);
|
||||
return transformUser(response.data.user);
|
||||
},
|
||||
|
||||
|
||||
@@ -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(',');
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,115 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
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 { token } = await res.json();
|
||||
return token;
|
||||
}
|
||||
|
||||
test.describe('Admin video upload (#203)', () => {
|
||||
test('Videos uploaded via admin have correct media_type and mime_type', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
// Create event
|
||||
const eventName = `PW Video ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
const createRes = await page.request.post('/api/admin/events', {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 90,
|
||||
allow_downloads: true,
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
const event = await createRes.json();
|
||||
expect(event.id).toBeTruthy();
|
||||
|
||||
// Upload a video via admin endpoint
|
||||
const videoPath = path.join(process.cwd(), 'test-assets', 'test-video.mp4');
|
||||
expect(fs.existsSync(videoPath)).toBeTruthy();
|
||||
const buffer = fs.readFileSync(videoPath);
|
||||
|
||||
const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: {
|
||||
photos: { name: 'test-video.mp4', mimeType: 'video/mp4', buffer },
|
||||
category_id: 'individual',
|
||||
},
|
||||
});
|
||||
expect(uploadRes.ok()).toBeTruthy();
|
||||
const uploadBody = await uploadRes.json();
|
||||
expect(uploadBody.successCount).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Wait for background processing
|
||||
await page.waitForTimeout(5000);
|
||||
|
||||
// Fetch photos for this event via admin API
|
||||
const photosRes = await page.request.get(`/api/admin/photos/${event.id}/photos`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(photosRes.ok()).toBeTruthy();
|
||||
const photosBody = await photosRes.json();
|
||||
const photos = photosBody.photos || photosBody;
|
||||
expect(photos.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Find our video — the critical fix: media_type and mime_type must be set
|
||||
const video = photos.find((p: any) => p.media_type === 'video');
|
||||
expect(video).toBeTruthy();
|
||||
expect(video.media_type).toBe('video');
|
||||
expect(video.mime_type).toBe('video/mp4');
|
||||
// width/height/duration depend on ffprobe being available in the environment;
|
||||
// if present they should be positive, but we don't fail on missing ffprobe
|
||||
if (video.width !== null) {
|
||||
expect(video.width).toBeGreaterThan(0);
|
||||
expect(video.height).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// Also upload an image and verify it gets media_type = 'image' with dimensions
|
||||
const imgBuffer = fs.readFileSync(path.join(process.cwd(), 'test-assets', 'img1.png'));
|
||||
const imgUploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: {
|
||||
photos: { name: 'img1.png', mimeType: 'image/png', buffer: imgBuffer },
|
||||
category_id: 'individual',
|
||||
},
|
||||
});
|
||||
expect(imgUploadRes.ok()).toBeTruthy();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Re-fetch and verify image has correct media_type and dimensions
|
||||
const photosRes2 = await page.request.get(`/api/admin/photos/${event.id}/photos`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(photosRes2.ok()).toBeTruthy();
|
||||
const photosBody2 = await photosRes2.json();
|
||||
const photos2 = photosBody2.photos || photosBody2;
|
||||
|
||||
const image = photos2.find((p: any) => p.media_type === 'image');
|
||||
expect(image).toBeTruthy();
|
||||
expect(image.media_type).toBe('image');
|
||||
expect(image.width).toBeGreaterThan(0);
|
||||
expect(image.height).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||
const GALLERY_PASSWORD = process.env.GALLERY_PASSWORD || 'PlaywrightGallery123!';
|
||||
|
||||
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 { token } = await res.json();
|
||||
return token;
|
||||
}
|
||||
|
||||
async function createGalleryPremiumEvent(page: Page) {
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
const eventName = `PW SelectAll ${Date.now()}`;
|
||||
const eventDate = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
const createRes = await page.request.post('/api/admin/events', {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
event_type: 'wedding',
|
||||
event_name: eventName,
|
||||
event_date: eventDate,
|
||||
customer_name: 'Playwright Host',
|
||||
customer_email: 'host@example.com',
|
||||
admin_email: ADMIN_EMAIL,
|
||||
password: GALLERY_PASSWORD,
|
||||
expiration_days: 90,
|
||||
allow_downloads: true,
|
||||
gallery_theme: 'gallery-premium',
|
||||
},
|
||||
});
|
||||
expect(createRes.ok()).toBeTruthy();
|
||||
const event = await createRes.json();
|
||||
|
||||
// Upload 3 images so we can verify select-all picks all of them
|
||||
const imagePaths = ['img1.png', 'img2.png', 'img1.png'].map((f) =>
|
||||
path.join(process.cwd(), 'test-assets', f)
|
||||
);
|
||||
|
||||
for (const imagePath of imagePaths) {
|
||||
const buffer = fs.readFileSync(imagePath);
|
||||
const uploadRes = await page.request.post(`/api/admin/events/${event.id}/upload`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: {
|
||||
photos: { name: path.basename(imagePath), mimeType: 'image/png', buffer },
|
||||
category_id: 'individual',
|
||||
},
|
||||
});
|
||||
expect(uploadRes.ok()).toBeTruthy();
|
||||
}
|
||||
|
||||
return { shareLink: event.share_link, slug: event.slug, token };
|
||||
}
|
||||
|
||||
test.describe('Gallery-Premium Select All (#220)', () => {
|
||||
test('Select All button selects all photos in one click', async ({ page, browserName }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
const { shareLink } = await createGalleryPremiumEvent(page);
|
||||
|
||||
// Navigate to gallery
|
||||
await page.goto(shareLink);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
// Handle password if needed
|
||||
const passwordField = page.getByPlaceholder(/gallery password/i).first();
|
||||
if (await passwordField.count()) {
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
try {
|
||||
await page.getByRole('button', { name: /View Gallery/i }).click({ timeout: 5000 });
|
||||
} catch {
|
||||
// Token may auto-auth
|
||||
}
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// Wait for photos to render
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Look for download button to enter selection mode
|
||||
const downloadBtn = page.getByRole('button', { name: /Download/i }).first();
|
||||
await expect(downloadBtn).toBeVisible({ timeout: 15000 });
|
||||
await downloadBtn.click();
|
||||
|
||||
// Now look for "Select All" button
|
||||
const selectAllBtn = page.getByRole('button', { name: /Select All/i }).first();
|
||||
await expect(selectAllBtn).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click Select All once
|
||||
await selectAllBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify all photos are selected - check for checkmarks or selected state
|
||||
// The selection count or "Deselect All" text should appear
|
||||
const deselectAllBtn = page.getByRole('button', { name: /Deselect All/i }).first();
|
||||
await expect(deselectAllBtn).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify: clicking Deselect All should clear selection
|
||||
await deselectAllBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Select All should be visible again
|
||||
await expect(selectAllBtn).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
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 { token } = await res.json();
|
||||
return token;
|
||||
}
|
||||
|
||||
test.describe('Photo Dimensions Repair (#180)', () => {
|
||||
test('Status endpoint returns dimension counts', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
const statusRes = await page.request.get('/api/admin/photos/repair-dimensions/status', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(statusRes.ok()).toBeTruthy();
|
||||
|
||||
const status = await statusRes.json();
|
||||
expect(status).toHaveProperty('total');
|
||||
expect(status).toHaveProperty('withDimensions');
|
||||
expect(status).toHaveProperty('withoutDimensions');
|
||||
expect(status).toHaveProperty('isRunning');
|
||||
expect(typeof status.total).toBe('number');
|
||||
expect(typeof status.isRunning).toBe('boolean');
|
||||
});
|
||||
|
||||
test('Repair endpoint runs and returns immediately', async ({ page }, testInfo) => {
|
||||
if (testInfo.project.name === 'mobile-chrome') {
|
||||
test.skip();
|
||||
}
|
||||
|
||||
const token = await getAdminToken(page);
|
||||
|
||||
const repairRes = await page.request.post('/api/admin/photos/repair-dimensions', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(repairRes.ok()).toBeTruthy();
|
||||
|
||||
const body = await repairRes.json();
|
||||
expect(body).toHaveProperty('message');
|
||||
expect(body).toHaveProperty('count');
|
||||
|
||||
// Wait for background job to complete
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Check status after repair
|
||||
const statusRes = await page.request.get('/api/admin/photos/repair-dimensions/status', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(statusRes.ok()).toBeTruthy();
|
||||
const status = await statusRes.json();
|
||||
expect(status.isRunning).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user