Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Nothaft 6af5241c15 docs: align stable security and backport policy 2026-09-07 23:13:05 +02:00
29 changed files with 196 additions and 1036 deletions
+1 -1
View File
@@ -1 +1 @@
{".":"3.46.11"}
{".":"3.46.10"}
-7
View File
@@ -5,13 +5,6 @@ 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).
## [3.46.11](https://github.com/PicPeak/picpeak/compare/v3.46.10...v3.46.11) (2026-09-08)
### Documentation
* align stable security and backport policy ([#1352](https://github.com/PicPeak/picpeak/issues/1352)) ([143c403](https://github.com/PicPeak/picpeak/commit/143c4035ec38683634d0e3d493032e2965f4a46f))
## [3.46.10](https://github.com/PicPeak/picpeak/compare/v3.46.9...v3.46.10) (2026-09-07)
@@ -1,122 +0,0 @@
/**
* PUT /api/admin/database-backup/config must reject a
* database_backup_destination_path that resolves inside a publicly served
* directory (GHSA-jw8m-43r2-jqrm class, #1365).
*
* Before #1365, database_backup_destination_path was silently ignored by
* databaseBackupService.backup() (a destructuring bug always fell back to
* the hardcoded /backup/database), so this setting being freely writable by
* any backup.create holder — the built-in `admin` role has it without
* settings.edit or backup.restore — was harmless. Making the setting
* actually take effect reopens the exact exfiltration path GHSA-jw8m fixed
* for the per-request override, through the persisted setting instead.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-config-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-config-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('database backup destination-path config guard (GHSA-jw8m class, #1365)', () => {
let db; let cleanup; let app; let adminToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'admin' }).first();
const r = await db('admin_users').insert({
username: 'limited-admin',
email: 'limited-admin-config@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
adminToken = jwt.sign(
{ id, username: 'limited-admin', type: 'admin', role: 'admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
app = express();
app.use(express.json());
app.use('/api/admin/database-backup', require('../../src/routes/adminDatabaseBackup'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a destination inside the public uploads/logos mount', async () => {
const res = await request(app)
.put('/api/admin/database-backup/config')
.set('Authorization', `Bearer ${adminToken}`)
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'uploads', 'logos') });
expect(res.status).toBe(400);
// The seeded default must survive untouched — the rejected value never lands.
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
expect(JSON.parse(row.setting_value)).toBe('/backup/database');
});
it('rejects a destination inside the public fonts mount', async () => {
const res = await request(app)
.put('/api/admin/database-backup/config')
.set('Authorization', `Bearer ${adminToken}`)
.send({ database_backup_destination_path: path.join(process.env.STORAGE_PATH, 'fonts') });
expect(res.status).toBe(400);
});
it('accepts a destination outside any public mount', async () => {
const safePath = path.join(process.env.STORAGE_PATH, 'db-backups');
const res = await request(app)
.put('/api/admin/database-backup/config')
.set('Authorization', `Bearer ${adminToken}`)
.send({ database_backup_destination_path: safePath });
expect(res.status).toBe(200);
const row = await db('app_settings').where({ setting_key: 'database_backup_destination_path' }).first();
expect(JSON.parse(row.setting_value)).toBe(safePath);
});
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
// the future, deleting every completed backup on the next scheduled run
// — a backup.create holder achieving what backup.delete gates on /cleanup.
it.each([-1, 0])('rejects database_backup_retention_days=%s', async (bad) => {
const res = await request(app)
.put('/api/admin/database-backup/config')
.set('Authorization', `Bearer ${adminToken}`)
.send({ database_backup_retention_days: bad });
expect(res.status).toBe(400);
});
it('accepts a positive database_backup_retention_days', async () => {
const res = await request(app)
.put('/api/admin/database-backup/config')
.set('Authorization', `Bearer ${adminToken}`)
.send({ database_backup_retention_days: 90 });
expect(res.status).toBe(200);
const row = await db('app_settings').where({ setting_key: 'database_backup_retention_days' }).first();
expect(JSON.parse(row.setting_value)).toBe(90);
});
});
@@ -25,18 +25,14 @@ process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
fs.mkdirSync(process.env.STORAGE_PATH, { recursive: true });
process.env.JWT_SECRET = process.env.JWT_SECRET || 'crm-route-test-secret';
const express = require('express');
const cookieParser = require('cookie-parser');
const request = require('supertest');
const { bootCrmDb, seedMinimal, createPublicToken, buildRouteApp } = require('../integration/helpers/crmDb');
const tokenGuards = require('../../src/utils/publicTokenGuards');
const { errorHandler } = require('../../src/middleware/errorHandler');
describe('publicContracts routes', () => {
let db;
let cleanup;
let app;
let appWithErrorHandler;
let customerId;
let contractId;
@@ -55,17 +51,6 @@ describe('publicContracts routes', () => {
contractId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
// A second app instance wired to the REAL production error handler
// (buildRouteApp's is a simplified stand-in that only reads
// err.statusCode/err.status, which a bare MulterError doesn't set).
// Used below to verify the actual 4xx contract end-to-end, not just
// that multer aborted the request.
appWithErrorHandler = express();
appWithErrorHandler.use(express.json());
appWithErrorHandler.use(cookieParser());
appWithErrorHandler.use('/api/public/contracts', require('../../src/routes/publicContracts'));
appWithErrorHandler.use(errorHandler);
}, 120000);
afterAll(async () => {
@@ -146,40 +131,6 @@ describe('publicContracts routes', () => {
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(404);
});
// CVE-2026-82333 regression (#1374 follow-up): multer 2.3.0 added an
// opt-in `fieldArrayIndexLimit` that must be set to actually close the
// field-parser DoS — the version bump alone does nothing. This route is
// unauthenticated (token-in-URL only), so it's the sharpest place to
// prove a crafted request with an oversized array-index field name
// (`evil[999999999]`) is rejected rather than accepted or left to hang.
it('rejects a multipart request with an oversized array-index field name', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(app)
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
.field('evil[999999999]', 'x')
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
// multer aborts the request before the handler runs; buildRouteApp's
// generic error handler falls back to 500 for a bare MulterError
// (see appWithErrorHandler test below for the real 4xx contract), so
// here we only assert the upload was NOT accepted/processed.
expect(res.status).toBeGreaterThanOrEqual(400);
expect(res.body.error).not.toBe(undefined);
});
it('maps the oversized array-index rejection to a 400 through the real error handler', async () => {
const token = await createPublicToken(db, 'contract_action_tokens', {
contract_id: contractId,
});
const res = await request(appWithErrorHandler)
.post(`/api/public/contracts/${token}/upload-signed-pdf`)
.field('evil[999999999]', 'x')
.attach('file', Buffer.from('%PDF-1.4 fake'), 'signed.pdf');
expect(res.status).toBe(400);
expect(res.body.code).toBe('VALIDATION_ERROR');
});
});
describe('GET /:token/pdf', () => {
@@ -1,62 +0,0 @@
/**
* generateVideoPlaceholder() must not touch the database when the caller
* already supplies width/height (videoProcessor.js's thumbnail-generation
* fallback does exactly this).
*
* Why it matters: processUploadedPhotos() (chunked video upload) holds a
* per-file SQLite transaction open across thumbnail generation. SQLite's
* knex pool defaults to a single connection, so any second, un-transacted
* db() query made while that transaction is open blocks until
* acquireConnectionTimeout (60s in production) — verified directly against
* an isolated SQLite db (codex review of #1371/#1372). Passing explicit
* dimensions must skip getThumbnailSettings()'s db() call entirely, not
* just tolerate its failure.
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const mockDbSpy = jest.fn(() => {
throw new Error('db() must not be called when width/height are supplied');
});
jest.mock('../../src/database/db', () => ({ db: (...args) => mockDbSpy(...args) }));
const storageModule = require('../../src/services/storage');
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
describe('generateVideoPlaceholder skips the settings DB lookup given explicit dimensions', () => {
let storage;
let root;
let imageProcessor;
beforeAll(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-vidplaceholder-'));
storage = new LocalFsStorage({ root });
await storage.init();
storageModule.setStorageForTesting(storage);
imageProcessor = require('../../src/services/imageProcessor');
}, 30000);
afterAll(async () => {
storageModule.resetStorage();
await fs.rm(root, { recursive: true, force: true }).catch(() => {});
});
afterEach(() => mockDbSpy.mockClear());
it('never calls db() when width/height are provided', async () => {
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4', { width: 300, height: 300 });
expect(key).toBe('thumbnails/thumb_demo.jpg');
expect(await storage.exists(key)).toBe(true);
expect(mockDbSpy).not.toHaveBeenCalled();
});
it('falls through to defaults (not a throw) when db() fails and no dimensions were given', async () => {
const key = await imageProcessor.generateVideoPlaceholder('demo2.mp4');
expect(key).toBe('thumbnails/thumb_demo2.jpg');
expect(mockDbSpy).toHaveBeenCalled();
});
});
+135 -135
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.46.11",
"version": "3.46.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.46.11",
"version": "3.46.8",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -37,7 +37,7 @@
"knex": "^2.4.2",
"mailparser": "^3.9.9",
"mime-types": "^3.0.1",
"multer": "2.3.0",
"multer": "2.2.0",
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
@@ -50,7 +50,7 @@
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "2.17.7",
"sharp": "0.35.4",
"sharp": "0.35.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
@@ -1689,9 +1689,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
@@ -1707,13 +1707,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
@@ -1729,20 +1729,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
@@ -1752,9 +1752,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
@@ -1768,9 +1768,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
@@ -1784,9 +1784,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
@@ -1800,9 +1800,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
@@ -1816,9 +1816,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
@@ -1832,9 +1832,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
@@ -1848,9 +1848,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
@@ -1864,9 +1864,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
@@ -1880,9 +1880,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
@@ -1896,9 +1896,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
@@ -1912,9 +1912,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
@@ -1930,13 +1930,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
@@ -1952,13 +1952,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
@@ -1974,13 +1974,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
@@ -1996,13 +1996,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
@@ -2018,13 +2018,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
@@ -2040,13 +2040,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
@@ -2062,13 +2062,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
@@ -2084,17 +2084,17 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.3"
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
@@ -2104,16 +2104,16 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
@@ -2123,9 +2123,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
@@ -2142,9 +2142,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
@@ -2161,9 +2161,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
@@ -7964,9 +7964,9 @@
}
},
"node_modules/joi": {
"version": "17.13.7",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.7.tgz",
"integrity": "sha512-MF80Dm5Y2veNy8QWVx9Bj3ui4mo7+VPSPsR1M+oaHXV0Gx6zGX9a2F+OZG3Blby9tOlzU9Rs5FUimlEhbKtfnQ==",
"version": "17.13.4",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz",
"integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^9.3.0",
@@ -7991,9 +7991,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
@@ -9067,9 +9067,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -9270,9 +9270,9 @@
}
},
"node_modules/nodemailer": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.1.tgz",
"integrity": "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -11061,9 +11061,9 @@
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
@@ -11077,31 +11077,31 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.46.11",
"version": "3.46.10",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
@@ -46,7 +46,7 @@
"knex": "^2.4.2",
"mailparser": "^3.9.9",
"mime-types": "^3.0.1",
"multer": "2.3.0",
"multer": "2.2.0",
"node-cron": "^3.0.2",
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
@@ -59,7 +59,7 @@
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "2.17.7",
"sharp": "0.35.4",
"sharp": "0.35.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
+4 -14
View File
@@ -120,14 +120,7 @@ const createPhotoUploader = (options = {}) => {
files: options.maxFiles || 2000,
fieldSize: 10 * 1024 * 1024,
parts: 10000,
headerPairs: 2000,
// CVE-2026-82333: no preset in this factory is currently wired up to
// a route (nothing imports createPhotoUploader et al. — routes build
// their own multer instances directly), but every preset gets the
// limit anyway so it can't be adopted later without it. None of the
// uploaders this factory builds have a legitimate use for
// array-indexed field names.
fieldArrayIndexLimit: 0
headerPairs: 2000
},
fileFilter: createFileFilter(ALLOWED_TYPES.media, {
validateMagicNumbers: true
@@ -153,8 +146,7 @@ const createLogoUploader = (options = {}) => {
}
}),
limits: {
fileSize: options.maxSize || SIZE_LIMITS.medium,
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
fileSize: options.maxSize || SIZE_LIMITS.medium
},
fileFilter: createFileFilter(ALLOWED_TYPES.logos, {
skipMagicValidation: ['image/svg+xml']
@@ -180,8 +172,7 @@ const createFaviconUploader = (options = {}) => {
}
}),
limits: {
fileSize: options.maxSize || SIZE_LIMITS.small,
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
fileSize: options.maxSize || SIZE_LIMITS.small
},
fileFilter: createFileFilter(ALLOWED_TYPES.favicons, {
skipMagicValidation: ['image/x-icon', 'image/vnd.microsoft.icon']
@@ -203,8 +194,7 @@ const createGalleryUploader = (destDir, options = {}) => {
dest: destDir,
limits: {
fileSize: options.maxSize || SIZE_LIMITS.large,
files: options.maxFiles || 10,
fieldArrayIndexLimit: 0 // CVE-2026-82333 — see createPhotoUploader comment
files: options.maxFiles || 10
},
fileFilter: createFileFilter(ALLOWED_TYPES.photos)
};
-10
View File
@@ -92,16 +92,6 @@ const handleKnownErrors = (err) => {
return new ValidationError('Unexpected file field');
}
// CVE-2026-82333: multer 2.3.0's fieldArrayIndexLimit rejects multipart
// field names with an oversized bracket array index (e.g. `a[99999999]`)
// before the DoS-prone field parser runs. Without this mapping the
// resulting MulterError has no .statusCode/.status and falls through to
// a 500 here, so map it to a proper 400 like the other multer limits.
if (err.code === 'LIMIT_FIELD_ARRAY_INDEX') {
const { ValidationError } = require('../utils/errors');
return new ValidationError('Field name array index too large');
}
return err;
};
+1 -5
View File
@@ -191,11 +191,7 @@ const picpeakUpload = multer({
destination: (req, file, cb) => cb(null, os.tmpdir()),
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
}),
// CVE-2026-82333: this route only ever consumes a single unnamed file
// field (`backup`) — no legitimate bracket-indexed field name (e.g.
// `a[0]`) exists in its form. fieldArrayIndexLimit: 0 rejects any field
// name using array-index syntax at all, closing multer's field-parser DoS.
limits: { fileSize: 5 * 1024 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5 GB — .picpeak with photos can be large
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
});
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
+1 -3
View File
@@ -109,9 +109,7 @@ const pdfLogoStorage = multer.diskStorage({
const pdfLogoUpload = multer({
storage: pdfLogoStorage,
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 },
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['image/png', 'image/jpeg', 'image/svg+xml'];
if (allowed.includes(file.mimetype)) cb(null, true);
+1 -3
View File
@@ -31,9 +31,7 @@ const pageLogoStorage = multer.diskStorage({
const pageLogoUpload = multer({
storage: pageLogoStorage,
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 },
limits: { fileSize: 5 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowed)) cb(null, true);
+1 -3
View File
@@ -72,9 +72,7 @@ const signedPdfStorage = multer.diskStorage({
const signedPdfUpload = multer({
storage: signedPdfStorage,
// CVE-2026-82333: single unnamed `file` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 10 MB
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
fileFilter: (req, file, cb) => {
const allowed = ['application/pdf'];
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
+2 -23
View File
@@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { databaseBackupService, isUnderPubliclyServableRoot } = require('../services/databaseBackup');
const { databaseBackupService } = require('../services/databaseBackup');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
@@ -60,28 +60,7 @@ router.put('/config', requirePermission('backup.create'), async (req, res) => {
'database_backup_email_on_failure',
'database_backup_email_on_success'
];
// A backup.create holder (the built-in `admin` role has it without
// settings.edit or backup.restore) could otherwise point backups at a
// public static mount and fetch the dump unauthenticated — see
// isUnderPubliclyServableRoot's comment (GHSA-jw8m-43r2-jqrm class).
if (
typeof req.body.database_backup_destination_path === 'string'
&& isUnderPubliclyServableRoot(req.body.database_backup_destination_path)
) {
return res.status(400).json({ error: 'Destination path must not be inside a publicly served directory' });
}
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
// the future, deleting every completed backup on the next scheduled run
// — a backup.create holder achieving what backup.delete gates on /cleanup.
if (
req.body.database_backup_retention_days !== undefined
&& (!Number.isFinite(req.body.database_backup_retention_days) || req.body.database_backup_retention_days < 1)
) {
return res.status(400).json({ error: 'database_backup_retention_days must be a positive number' });
}
const updates = [];
for (const [key, value] of Object.entries(req.body)) {
+1 -3
View File
@@ -30,9 +30,7 @@ const eventLogoStorage = multer.diskStorage({
const eventLogoUpload = multer({
storage: eventLogoStorage,
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5MB
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
+1 -4
View File
@@ -41,10 +41,7 @@ function diskUpload(subdir) {
},
filename: (_req, file, cb) => cb(null, `${subdir.split('/').pop()}-${Date.now()}${path.extname(file.originalname) || ''}`),
}),
// CVE-2026-82333: both callers (`inboundUpload` → 'file', `proofUpload`
// → 'proof') take a single unnamed field — no legitimate array-indexed
// field names, so reject any bracket-index field name.
limits: { fileSize: 15 * 1024 * 1024, fieldArrayIndexLimit: 0 },
limits: { fileSize: 15 * 1024 * 1024 },
fileFilter: (_req, file, cb) => (ALLOWED_MIME.includes(file.mimetype) ? cb(null, true) : cb(new Error('Only PDF, JPEG or PNG files are allowed'))),
});
}
+1 -3
View File
@@ -67,9 +67,7 @@ const importedInvoiceStorage = multer.diskStorage({
});
const importedInvoiceUpload = multer({
storage: importedInvoiceStorage,
// CVE-2026-82333: single unnamed `pdf` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 },
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'application/pdf') cb(null, true);
else cb(new Error('Only PDF files are allowed for imported invoices'));
+1 -6
View File
@@ -72,12 +72,7 @@ const upload = multer({
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
parts: 10000,
headerPairs: 2000,
// CVE-2026-82333: files arrive as repeated `photos` parts via
// .array('photos', N) — not bracket-indexed field names like
// `photos[0]` — so no legitimate field name uses array-index syntax
// at all. Reject any that do.
fieldArrayIndexLimit: 0
headerPairs: 2000
},
fileFilter: (req, file, cb) => {
// req.allowedMimeTypes is populated by the middleware that runs before multer
+2 -7
View File
@@ -50,10 +50,7 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({
storage,
// CVE-2026-82333: single unnamed field (`logo` or `watermarkLogo`) per
// route — no legitimate array-indexed field names, so reject any
// bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5MB
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
// Note: SVG files are excluded from magic number validation for logos
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
@@ -81,9 +78,7 @@ const faviconStorage = multer.diskStorage({
const faviconUpload = multer({
storage: faviconStorage,
// CVE-2026-82333: single unnamed `favicon` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 2 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 2MB — roomy enough for a 512×512+ square PNG
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — roomy enough for a 512×512+ square PNG
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
const name = file.originalname.toLowerCase();
+1 -6
View File
@@ -2342,12 +2342,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
dest: tempUploadDir,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB per file (separate concern from #613)
files: maxFilesPerUpload,
// CVE-2026-82333: files arrive as repeated `photos` parts via
// .array(), not bracket-indexed field names like `photos[0]` — no
// legitimate field name uses array-index syntax at all. Reject any
// that do.
fieldArrayIndexLimit: 0
files: maxFilesPerUpload
},
fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
+1 -4
View File
@@ -62,10 +62,7 @@ const signedPdfStorage = multer.diskStorage({
const signedPdfUpload = multer({
storage: signedPdfStorage,
// CVE-2026-82333: single unnamed `file` field only, and this route is
// unauthenticated (token-only) — no legitimate array-indexed field
// names, so reject any bracket-index field name.
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 10 MB
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, ['application/pdf'])) return cb(null, true);
return cb(new Error('Only PDF files are allowed'));
+1 -3
View File
@@ -57,9 +57,7 @@ const photoStorage = multer.diskStorage({
});
const photoUpload = multer({
storage: photoStorage,
// CVE-2026-82333: single unnamed `photo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 100 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 100MB per file for v1
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB per file for v1
fileFilter: (_req, file, cb) => {
if (/^image\//.test(file.mimetype)) cb(null, true);
else cb(new Error('Only image uploads are accepted on this endpoint'));
@@ -1,3 +1,4 @@
const { DatabaseBackupService } = require('../databaseBackup');
const { db } = require('../../database/db');
const fs = require('fs').promises;
const path = require('path');
@@ -8,10 +9,6 @@ jest.mock('../../database/db');
jest.mock('../../utils/logger');
jest.mock('../emailProcessor');
jest.mock('child_process');
jest.mock('node-cron', () => ({ schedule: jest.fn(() => ({ stop: jest.fn() })) }));
const { DatabaseBackupService, startScheduledBackups, databaseBackupService, isUnderPubliclyServableRoot } = require('../databaseBackup');
const cron = require('node-cron');
describe('DatabaseBackupService', () => {
let service;
@@ -191,213 +188,6 @@ describe('DatabaseBackupService', () => {
});
});
describe('backup() destination path resolution (#1365)', () => {
// getBackupConfig() returns database_backup_*-prefixed keys.
// Regression: backup() used to destructure the unprefixed names
// (`destinationPath`, ...) straight off that object, which never
// matched, so the configured path was silently ignored and every
// run tried to create the hardcoded /backup/database default.
it('creates the directory from database_backup_destination_path when configured', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify('/data/db-backups') }
])
});
const stop = new Error('stop after mkdir — nothing past it matters for this test');
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
await expect(service.backup({})).rejects.toThrow(stop.message);
expect(mkdirSpy).toHaveBeenCalledWith('/data/db-backups', { recursive: true });
mkdirSpy.mockRestore();
});
it('falls back to /backup/database only when nothing is configured', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([])
});
const stop = new Error('stop after mkdir');
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
await expect(service.backup({})).rejects.toThrow(stop.message);
expect(mkdirSpy).toHaveBeenCalledWith('/backup/database', { recursive: true });
mkdirSpy.mockRestore();
});
});
describe('isUnderPubliclyServableRoot (GHSA-jw8m class, #1365)', () => {
const originalStoragePath = process.env.STORAGE_PATH;
const storage = '/tmp/picpeak-test-storage';
beforeEach(() => {
process.env.STORAGE_PATH = storage;
});
afterAll(() => {
if (originalStoragePath === undefined) {
delete process.env.STORAGE_PATH;
} else {
process.env.STORAGE_PATH = originalStoragePath;
}
});
it.each([
path.join(storage, 'uploads', 'logos'),
path.join(storage, 'uploads', 'logos', 'sub'),
path.join(storage, 'uploads', 'favicons'),
path.join(storage, 'fonts'),
path.join(storage, 'fonts', 'inter'),
// Bundled fallback fonts — nodejs-owned per the Dockerfile's
// COPY --chown, and served at the same public /fonts route.
path.resolve(__dirname, '../../../assets/fonts'),
// Case-insensitive-but-preserving filesystems (APFS, NTFS, Docker
// Desktop bind mounts of either) resolve this to the same directory
// as uploads/logos even though path.resolve() never folds case.
path.join(storage, 'UPLOADS', 'Logos')
])('flags %s as publicly servable', (candidate) => {
expect(isUnderPubliclyServableRoot(candidate)).toBe(true);
});
it.each([
path.join(storage, 'backups'),
path.join(storage, 'uploads', 'contracts', 'signed'),
path.join(storage, 'uploads', 'transfers', '123'),
'/data/db-backups'
])('does not flag %s', (candidate) => {
expect(isUnderPubliclyServableRoot(candidate)).toBe(false);
});
it('backup() refuses a destination inside a publicly servable root without ever calling mkdir', async () => {
const publicPath = path.join(storage, 'uploads', 'logos');
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify(publicPath) }
])
});
const mkdirSpy = jest.spyOn(fs, 'mkdir');
await expect(service.backup({})).rejects.toThrow('publicly served directory');
expect(mkdirSpy).not.toHaveBeenCalled();
mkdirSpy.mockRestore();
});
it('flags FRONTEND_DIR — the all-in-one image serves its built SPA unauthenticated', () => {
const originalFrontendDir = process.env.FRONTEND_DIR;
process.env.FRONTEND_DIR = '/app/frontend/dist';
try {
expect(isUnderPubliclyServableRoot('/app/frontend/dist')).toBe(true);
expect(isUnderPubliclyServableRoot(path.join('/app/frontend/dist', 'assets'))).toBe(true);
} finally {
if (originalFrontendDir === undefined) delete process.env.FRONTEND_DIR;
else process.env.FRONTEND_DIR = originalFrontendDir;
}
});
it('resolves a symlinked alias of a public root to the same real directory (all-in-one /app/storage -> /data/storage)', async () => {
const os = require('os');
const realRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-real-'));
const linkRoot = path.join(os.tmpdir(), `picpeak-link-${process.pid}-${Date.now()}`);
await fs.mkdir(path.join(realRoot, 'uploads', 'logos'), { recursive: true });
await fs.symlink(realRoot, linkRoot, 'dir');
try {
// STORAGE_PATH (what the guard's roots are built from) is the real
// path; the attacker-supplied destination goes through the symlink
// — exactly the all-in-one image's /app/storage -> /data/storage.
process.env.STORAGE_PATH = realRoot;
const aliased = path.join(linkRoot, 'uploads', 'logos');
expect(isUnderPubliclyServableRoot(aliased)).toBe(true);
} finally {
await fs.unlink(linkRoot);
await fs.rm(realRoot, { recursive: true, force: true });
}
});
});
describe('startScheduledBackups (#1365)', () => {
// Same key-mismatch bug as backup(): getBackupConfig() returns
// database_backup_*-prefixed keys, but this read `config.enabled` /
// `config.schedule` / `config.retentionDays` — always undefined, so
// the scheduler silently treated every install as disabled.
it('does not start the schedule while database_backup_enabled is false', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'false' }
])
});
await startScheduledBackups();
expect(cron.schedule).not.toHaveBeenCalled();
});
it('starts the schedule with the configured cron when database_backup_enabled is true', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_schedule', setting_value: JSON.stringify('0 4 * * *') }
])
});
await startScheduledBackups();
expect(cron.schedule).toHaveBeenCalledWith('0 4 * * *', expect.any(Function));
});
it('re-reads retention on every tick instead of the value captured at schedule start (#1365)', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(30) }
])
});
await startScheduledBackups();
const tick = cron.schedule.mock.calls[0][1];
// A /config update between schedule-start and this tick raised
// retention to 365 — the closed-over 30 must not be what runs.
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(365) }
])
});
jest.spyOn(databaseBackupService, 'backup').mockResolvedValue({ success: true });
const cleanupSpy = jest.spyOn(databaseBackupService, 'cleanupOldBackups').mockResolvedValue(undefined);
await tick();
expect(cleanupSpy).toHaveBeenCalledWith(365);
jest.restoreAllMocks();
});
});
describe('cleanupOldBackups destructive-retention guard (#1365)', () => {
it.each([-1, 0, NaN, Infinity])('refuses retentionDays=%s without touching the database', async (bad) => {
const dbSpy = jest.fn();
db.mockImplementation(dbSpy);
await service.cleanupOldBackups(bad);
expect(dbSpy).not.toHaveBeenCalled();
});
});
describe('cleanupOldBackups', () => {
it('should delete old backup files and records', async () => {
const oldBackups = [
@@ -1,125 +0,0 @@
jest.mock('../../utils/logger');
jest.mock('fluent-ffmpeg');
jest.mock('../storage', () => ({
getStorage: jest.fn()
}));
jest.mock('../imageProcessor', () => ({
generateVideoPlaceholder: jest.fn(),
DEFAULT_THUMBNAIL_WIDTH: 300,
DEFAULT_THUMBNAIL_HEIGHT: 300
}));
const ffmpeg = require('fluent-ffmpeg');
const { getStorage } = require('../storage');
const { generateVideoPlaceholder } = require('../imageProcessor');
const {
extractVideoMetadata,
processUploadedVideo
} = require('../videoProcessor');
describe('extractVideoMetadata (#1370)', () => {
afterEach(() => jest.clearAllMocks());
it('returns null duration rather than 0 when ffprobe has none, so "unknown" and "a real 0s clip" stay distinguishable', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
cb(null, {
streams: [{ codec_type: 'video', width: 1920, height: 1080, codec_name: 'hevc' }],
format: {} // no duration field at all
});
});
const metadata = await extractVideoMetadata('/tmp/video.mp4');
expect(metadata.duration).toBeNull();
expect(metadata.width).toBe(1920);
expect(metadata.videoCodec).toBe('hevc');
});
it('floors a real duration', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
cb(null, { streams: [], format: { duration: 12.9 } });
});
const metadata = await extractVideoMetadata('/tmp/video.mp4');
expect(metadata.duration).toBe(12);
});
});
describe('processUploadedVideo degrades gracefully instead of rejecting the whole video (#1370)', () => {
let storage;
beforeEach(() => {
storage = { putFromFile: jest.fn().mockResolvedValue(undefined), exists: jest.fn().mockResolvedValue(true) };
getStorage.mockReturnValue(storage);
generateVideoPlaceholder.mockResolvedValue('thumbnails/thumb_placeholder.jpg');
});
afterEach(() => jest.clearAllMocks());
it('keeps the thumbnail when only metadata extraction fails', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('moov atom not found')));
ffmpeg.mockImplementation(() => ({
screenshots: jest.fn(function screenshots({ filename, folder }) {
require('fs').writeFileSync(require('path').join(folder, filename), 'jpeg-bytes');
return this;
}),
on(event, handler) {
if (event === 'end') setImmediate(handler);
return this;
}
}));
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_video.jpg');
expect(result.success).toBe(true);
expect(result.metadata).toBeNull();
expect(result.thumbnailKey).toBe('thumbnails/thumb_video.jpg');
// A real thumbnail already succeeded — never touch the placeholder path.
expect(generateVideoPlaceholder).not.toHaveBeenCalled();
});
it('falls back to the SVG placeholder when thumbnail generation fails, so the gallery never falls back to rendering the raw video as an <img> (codex review)', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
cb(null, {
streams: [{ codec_type: 'video', width: 1080, height: 1920, codec_name: 'h264' }],
format: { duration: 5.4 }
});
});
ffmpeg.mockImplementation(() => ({
screenshots() { return this; },
on(event, handler) {
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
return this;
}
}));
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_wedding_001.jpg');
expect(result.success).toBe(true);
expect(result.metadata).toEqual(expect.objectContaining({ duration: 5, videoCodec: 'h264' }));
// thumbnailKey is always thumbnails/thumb_<name>.jpg — strip the prefix
// back to a filename so generateVideoPlaceholder recomputes the same key.
// Explicit width/height so generateVideoPlaceholder skips its DB-backed
// settings lookup — this can run inside an open per-file SQLite
// transaction (chunked video upload), where that lookup deadlocks.
expect(generateVideoPlaceholder).toHaveBeenCalledWith('wedding_001.jpg', { width: 300, height: 300 });
expect(result.thumbnailKey).toBe('thumbnails/thumb_placeholder.jpg');
expect(storage.putFromFile).not.toHaveBeenCalled();
});
it('throws when metadata, thumbnail generation, AND the placeholder all fail, so the caller surfaces a retryable failure instead of completing with nothing to show (codex review)', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('Invalid data found when processing input')));
ffmpeg.mockImplementation(() => ({
screenshots() { return this; },
on(event, handler) {
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
return this;
}
}));
generateVideoPlaceholder.mockRejectedValue(new Error('sharp render failed'));
await expect(processUploadedVideo('/tmp/corrupt.mp4', 'thumbnails/thumb_corrupt.jpg'))
.rejects.toThrow('Unable to generate any thumbnail');
});
});
+1 -1
View File
@@ -1043,7 +1043,7 @@ function buildManifestFiles(backedUpFiles, allFiles) {
async function saveManifestToLocal(manifest, manifestFileName, config) {
const manifestDir = config.backup_manifest_path
|| path.join(config.backup_destination_path || path.join(getStoragePath(), 'backups'), 'manifests');
|| path.join(config.backup_destination_path || '/backup', 'manifests');
await fs.mkdir(manifestDir, { recursive: true });
const manifestPath = path.join(manifestDir, manifestFileName);
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
+13 -116
View File
@@ -4,7 +4,7 @@ const crypto = require('crypto');
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream, realpathSync } = require('fs');
const { createReadStream, createWriteStream } = require('fs');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
@@ -16,76 +16,6 @@ const packageJson = require('../../package.json');
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
const PROGRESS_INTERVAL = 100; // Report progress every 100 rows
function getStoragePath() {
return process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
}
// Public, unauthenticated static mounts (server.js) that must never become a
// backup destination — a dump landing there is downloadable by anyone who
// learns or guesses the filename, GHSA-jw8m-43r2-jqrm's exact class. Before
// #1365, `database_backup_destination_path` was silently ignored (a
// destructuring bug always fell back to the hardcoded /backup/database), so
// this setting being freely writable by any backup.create holder — the
// built-in `admin` role has it without settings.edit or backup.restore — was
// harmless. Making the setting actually take effect reopens that exact
// exfiltration path unless it's rejected here too.
function getPubliclyServableRoots() {
const storage = getStoragePath();
return [
path.join(storage, 'uploads', 'logos'),
path.join(storage, 'uploads', 'favicons'),
path.join(storage, 'fonts'),
// Bundled fallback fonts (server.js mounts both at /fonts, storage wins
// on overlap but express.static falls through to this one on a miss).
// COPY --chown=nodejs:nodejs in the Dockerfile makes this nodejs-owned
// and therefore writable at runtime, not just a read-only image layer.
path.resolve(__dirname, '../../assets/fonts'),
// The all-in-one image's built frontend bundle (Dockerfile.aio ships it
// nodejs-owned) — server.js serves it unauthenticated as the SPA itself.
process.env.FRONTEND_DIR || path.resolve(__dirname, '../../../frontend/dist')
];
}
// Resolves symlinks in whatever prefix of candidatePath currently exists,
// then re-appends any not-yet-created remainder literally. A plain
// fs.realpathSync would throw ENOENT for the common case where the backup
// destination doesn't exist yet; a plain path.resolve() would miss the
// all-in-one image's `/app/storage -> /data/storage` symlink (Dockerfile.aio),
// which lets `/app/storage/uploads/logos` alias the real public logos
// directory under a name that never lexically matches it.
function resolveRealish(candidatePath) {
let current = path.resolve(candidatePath);
const remainder = [];
for (;;) {
try {
const real = realpathSync(current);
return remainder.length ? path.join(real, ...remainder) : real;
} catch (error) {
if (error.code !== 'ENOENT') {
return path.resolve(candidatePath);
}
const parent = path.dirname(current);
if (parent === current) {
return path.resolve(candidatePath);
}
remainder.unshift(path.basename(current));
current = parent;
}
}
}
function isUnderPubliclyServableRoot(candidatePath) {
// Lowercased comparison: on a case-insensitive-but-preserving filesystem
// (default macOS APFS, NTFS, and Docker Desktop's bind-mount passthrough
// of either) `STORAGE_PATH/UPLOADS/logos` and `.../uploads/logos` name the
// same directory on disk even though path.resolve() never folds case.
const resolved = resolveRealish(candidatePath).toLowerCase();
return getPubliclyServableRoots().some((root) => {
const resolvedRoot = resolveRealish(root).toLowerCase();
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep);
});
}
/**
* Database Backup Service
* Supports both SQLite and PostgreSQL with proper escaping,
@@ -366,33 +296,15 @@ class DatabaseBackupService {
let backupRun = null;
try {
// Get configuration. getBackupConfig() returns the raw
// database_backup_*-prefixed setting keys, not the unprefixed
// names used internally below — map them explicitly rather than
// spreading `config` straight into the destructure, which silently
// matched nothing and always fell through to the hardcoded
// defaults (notably `/backup/database`, regardless of what was
// configured).
// Get configuration
const config = await this.getBackupConfig();
const {
destinationPath = '/backup/database',
compress = true,
validateIntegrity = true,
includeChecksums = true
} = {
destinationPath: config.database_backup_destination_path,
compress: config.database_backup_compress,
validateIntegrity: config.database_backup_validate_integrity,
includeChecksums: config.database_backup_include_checksums,
...options
};
} = { ...config, ...options };
if (isUnderPubliclyServableRoot(destinationPath)) {
throw new Error(
`Refusing to write a database backup to a publicly served directory: ${destinationPath}`
);
}
// Create backup directory
await fs.mkdir(destinationPath, { recursive: true });
@@ -511,7 +423,7 @@ class DatabaseBackupService {
logger.info(`Database backup completed: ${finalFile} (${(finalStats.size / 1024 / 1024).toFixed(2)} MB) in ${durationSeconds}s`);
// Send success notification if configured
if (config.database_backup_email_on_success) {
if (config.emailOnSuccess) {
await this.sendBackupNotification('success', {
duration: durationSeconds,
size: finalStats.size,
@@ -545,7 +457,7 @@ class DatabaseBackupService {
// Send failure notification
const config = await this.getBackupConfig();
if (config.database_backup_email_on_failure) {
if (config.emailOnFailure) {
await this.sendBackupNotification('failure', {
error: error.message
});
@@ -626,19 +538,10 @@ class DatabaseBackupService {
* Clean up old backups
*/
async cleanupOldBackups(retentionDays = 30) {
// A zero/negative/non-finite value pushes the cutoff to today or the
// future, matching (and deleting) every completed backup — including
// the one a scheduled run just created. Defense in depth: PUT /config
// already rejects such values, but this is also reachable with
// whatever database_backup_retention_days happens to be persisted.
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
logger.error(`Refusing to clean up backups with invalid retentionDays: ${retentionDays}`);
return;
}
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
// Get old backup records
const oldBackups = await db('database_backup_runs')
.where('completed_at', '<', cutoffDate)
@@ -783,30 +686,25 @@ async function startScheduledBackups() {
try {
const config = await databaseBackupService.getBackupConfig();
if (!config.database_backup_enabled) {
if (!config.enabled) {
logger.info('Database backup service is disabled');
return;
}
// Stop existing schedule
if (backupSchedule) {
backupSchedule.stop();
}
// Default schedule: 3 AM daily (offset from file backups at 2 AM)
const schedule = config.database_backup_schedule || '0 3 * * *';
const schedule = config.schedule || '0 3 * * *';
backupSchedule = cron.schedule(schedule, async () => {
logger.info('Starting scheduled database backup');
try {
await databaseBackupService.backup();
// Re-read retention on every tick rather than closing over the value
// from schedule start — a retention-only /config update doesn't
// restart the schedule (only enabled/schedule changes do), so the
// closed-over value would otherwise run stale until next restart.
const latestConfig = await databaseBackupService.getBackupConfig();
await databaseBackupService.cleanupOldBackups(latestConfig.database_backup_retention_days || 30);
await databaseBackupService.cleanupOldBackups(config.retentionDays || 30);
} catch (error) {
logger.error('Scheduled database backup failed:', error);
}
@@ -833,6 +731,5 @@ module.exports = {
databaseBackupService,
startScheduledBackups,
stopScheduledBackups,
isUnderPubliclyServableRoot,
DatabaseBackupService // Export class for testing
};
+3 -11
View File
@@ -369,15 +369,9 @@ async function generateVideoPlaceholder(originalFilename, options = {}) {
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
const storage = getStorage();
// Skip the settings lookup when the caller already supplies dimensions.
// This can run from inside an open per-file SQLite transaction (chunked
// video upload's fallback path in videoProcessor.js) — a second,
// un-transacted db() query for settings there deadlocks against SQLite's
// single-connection pool until acquireConnectionTimeout (60s), reproduced
// directly against an isolated SQLite db (codex review of #1371/#1372).
const settings = (options.width && options.height) ? {} : await getThumbnailSettings();
const width = options.width || settings.width || DEFAULT_THUMBNAIL_WIDTH;
const height = options.height || settings.height || DEFAULT_THUMBNAIL_HEIGHT;
const settings = await getThumbnailSettings();
const width = settings.width || DEFAULT_THUMBNAIL_WIDTH;
const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT;
if (options.regenerate) {
await storage.delete(thumbnailRelKey).catch(() => {});
@@ -870,6 +864,4 @@ module.exports = {
ensurePreviewImage,
extractCaptureDate,
withLocalCopy,
DEFAULT_THUMBNAIL_WIDTH,
DEFAULT_THUMBNAIL_HEIGHT,
};
+19 -95
View File
@@ -32,10 +32,7 @@ async function extractVideoMetadata(videoPath) {
const audioStream = metadata.streams.find(s => s.codec_type === 'audio');
const result = {
// null (not 0) when ffprobe genuinely has no duration — a real
// 0-second clip and "unknown" must stay distinguishable, since
// downstream code treats `duration != null` as "trust this value".
duration: metadata.format.duration != null ? Math.floor(metadata.format.duration) : null,
duration: Math.floor(metadata.format.duration || 0),
width: videoStream?.width || null,
height: videoStream?.height || null,
videoCodec: videoStream?.codec_name || null,
@@ -133,108 +130,35 @@ async function getVideoDuration(videoPath) {
* Process an uploaded video: extract metadata and produce a thumbnail through
* the storage backend.
*
* Metadata extraction and thumbnail generation are independent, best-effort
* steps mirroring how the image pipeline treats thumbnail/dimension/EXIF
* failures (log a warning, keep the upload). This used to gate everything
* behind isValidVideo(), which rejects the whole video if ffprobe can't read
* even one of duration/width/height common on some iPhone/Lightroom-
* exported MP4s (#1370). Callers (photoProcessor.js's processPhoto and
* processUploadedPhotos) already catch that throw and fall back to a static
* placeholder thumbnail plus a metadata-only retry (codex review of #845),
* but that fallback never got a REAL thumbnail even when
* generateVideoThumbnail() would have succeeded on its own thumbnailing
* doesn't need valid duration/width/height, it just seeks and grabs a frame.
* Trying both steps independently means a real thumbnail (and whatever
* metadata ffprobe *can* read) survives far more often. metadata is still
* allowed to come back null (ffprobe failed) a video with no thumbnail
* would fall back to rendering the raw video as an <img> in the gallery
* grid (`photo.thumbnail_url || photo.url`), so this only resolves when a
* real thumbnail or the SVG placeholder produced *something*; if both fail
* (storage backend down, disk full not a quirk of one file) it throws
* instead, so the caller surfaces a retryable failure rather than silently
* completing with nothing to show.
*
* @param {string} videoPath - Local path to the source video (ffmpeg requires fs).
* @param {string} thumbnailKey - Relative storage key for the thumbnail.
* @returns {Promise<{success: boolean, metadata: Object|null, thumbnailKey: string}>}
* @returns {Promise<{success: boolean, metadata: Object, thumbnailKey: string}>}
*/
async function processUploadedVideo(videoPath, thumbnailKey, options = {}) {
let metadata = null;
try {
metadata = await extractVideoMetadata(videoPath);
} catch (error) {
logger.error('Video metadata extraction failed — continuing without duration/codec/dimensions', {
error: error.message,
videoPath
});
}
const isValid = await isValidVideo(videoPath);
if (!isValid) {
throw new Error('Invalid video file');
}
let generatedThumbnailKey = null;
try {
const metadata = await extractVideoMetadata(videoPath);
await generateVideoThumbnail(videoPath, thumbnailKey, options);
const storage = getStorage();
if (await storage.exists(thumbnailKey)) {
generatedThumbnailKey = thumbnailKey;
const exists = await storage.exists(thumbnailKey);
if (!exists) {
throw new Error('Thumbnail generation failed (not in storage)');
}
return {
success: true,
metadata,
thumbnailKey
};
} catch (error) {
logger.error('Video thumbnail generation failed — continuing without a thumbnail', {
error: error.message,
videoPath
});
logger.error('Error processing video', { error: error.message, videoPath });
throw error;
}
// Never return "success" with no thumbnail at all: the gallery grid
// (GridGalleryLayout/JustifiedGalleryLayout) falls back to
// `photo.thumbnail_url || photo.url` when there's no thumbnail, which
// makes AuthenticatedImage download the full ORIGINAL VIDEO and try to
// render it as an <img> — a broken tile and a multi-GB fetch just from
// opening the gallery (codex review, #1371/#1372). Fall back to the same
// ffmpeg-free SVG placeholder the callers already generate for a total
// processing failure, so a bare thumbnail-generation failure degrades to
// that placeholder too, not to "no thumbnail". thumbnailKey is always
// `thumbnails/thumb_<name>.jpg` (see callers) — strip the prefix back to
// a filename so generateVideoPlaceholder recomputes this exact same key.
if (!generatedThumbnailKey) {
try {
const {
generateVideoPlaceholder,
DEFAULT_THUMBNAIL_WIDTH,
DEFAULT_THUMBNAIL_HEIGHT
} = require('./imageProcessor');
const placeholderFilename = path.basename(thumbnailKey).replace(/^thumb_/, '');
// Explicit width/height make generateVideoPlaceholder skip its
// configured-thumbnail-size DB lookup (see its own comment) — this
// call can run from inside processUploadedPhotos' open per-file
// SQLite transaction, where that lookup would otherwise deadlock.
const placeholderKey = await generateVideoPlaceholder(placeholderFilename, {
width: DEFAULT_THUMBNAIL_WIDTH,
height: DEFAULT_THUMBNAIL_HEIGHT
});
if (placeholderKey) {
generatedThumbnailKey = placeholderKey;
}
} catch (error) {
logger.error('Video placeholder generation also failed', { error: error.message, videoPath });
}
}
// A real thumbnail AND the ffmpeg-free SVG placeholder both failing points
// at something systemic (storage backend down, disk full) rather than a
// quirk of this one file — that's worth surfacing as a retryable failure
// rather than silently completing with no thumbnail at all, which would
// make the gallery fall back to rendering the raw video as an <img>
// (codex review, #1371/#1372). Metadata (if any was extracted) is lost
// here, same trade-off the callers' own pre-existing total-failure
// handling already makes.
if (!generatedThumbnailKey) {
throw new Error('Unable to generate any thumbnail (real or placeholder) for this video');
}
return {
success: true,
metadata,
thumbnailKey: generatedThumbnailKey
};
}
/**
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.46.11",
"version": "3.46.10",
"type": "module",
"scripts": {
"dev": "vite",