Merge pull request #363 from the-luap/feat/upload-redesign-and-auth-loop-fix
feat(upload): async photo processing + fix(auth): /auth/session symmetry (loop fix)
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Regression test for the /admin/login → /admin/dashboard → /admin/login
|
||||
* redirect loop reported on v3.32.4-beta.0.
|
||||
*
|
||||
* Cause: GET /auth/session was less strict than the adminAuth middleware.
|
||||
* The session endpoint accepted tokens that the protected endpoints
|
||||
* subsequently rejected with 401, which the frontend's interceptor
|
||||
* translated into a hard redirect to /admin/login. /auth/session then
|
||||
* said "valid: true" again on the next page load and the cycle closed.
|
||||
*
|
||||
* /auth/session must reject the same admin tokens adminAuth would
|
||||
* reject, specifically: deactivated admin user, deleted admin user,
|
||||
* password changed since iat. Same for gallery: archived event.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
process.env.JWT_SECRET = 'session-symmetry-test-secret';
|
||||
|
||||
const fakeDb = {
|
||||
adminUsers: [],
|
||||
events: [],
|
||||
revokedTokens: [],
|
||||
};
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const formatBoolean = (v) => (v ? 1 : 0);
|
||||
void formatBoolean;
|
||||
function dbFn(table) {
|
||||
if (table === 'admin_users') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (row) => {
|
||||
return Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
};
|
||||
return this;
|
||||
},
|
||||
select(...cols) {
|
||||
this._cols = cols;
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
const row = fakeDb.adminUsers.find(rowFilter);
|
||||
if (!row) return undefined;
|
||||
if (!this._cols) return row;
|
||||
const out = {};
|
||||
for (const c of this._cols) out[c] = row[c];
|
||||
return out;
|
||||
},
|
||||
};
|
||||
}
|
||||
if (table === 'events') {
|
||||
let rowFilter = () => true;
|
||||
return {
|
||||
where(criteria) {
|
||||
rowFilter = (row) =>
|
||||
Object.entries(criteria).every(([k, v]) => {
|
||||
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
|
||||
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
|
||||
return row[k] === v;
|
||||
});
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return fakeDb.events.find(rowFilter);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table: ${table}`);
|
||||
}
|
||||
return { db: dbFn, formatBoolean: () => 1 };
|
||||
});
|
||||
|
||||
jest.mock('../../src/utils/dbCompat', () => ({
|
||||
formatBoolean: (v) => (v ? 1 : 0),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
|
||||
revokeToken: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/tokenUtils', () => ({
|
||||
getAdminTokenFromRequest: (req) => {
|
||||
const auth = req.headers.authorization;
|
||||
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
|
||||
return null;
|
||||
},
|
||||
getGalleryTokenFromRequest: () => null,
|
||||
setAdminAuthCookie: jest.fn(),
|
||||
setGalleryAuthCookies: jest.fn(),
|
||||
clearAdminAuthCookie: jest.fn(),
|
||||
clearGalleryAuthCookies: jest.fn(),
|
||||
buildCookieOptionsWithExpiry: () => ({}),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
|
||||
jest.mock('../../src/middleware/sessionTimeout', () => ({ endSession: jest.fn() }));
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
const authRouter = require('../../src/routes/auth');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/auth', authRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
|
||||
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
|
||||
// Note: do NOT pass noTimestamp:true here — that strips iat from the
|
||||
// payload entirely, defeating the password-change comparison. Provide
|
||||
// iat (and exp) via the payload directly instead.
|
||||
return jwt.sign(
|
||||
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
|
||||
process.env.JWT_SECRET,
|
||||
{ issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
|
||||
return jwt.sign(
|
||||
{ eventId, eventSlug, type: 'gallery' },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '1h', issuer: 'picpeak-auth' }
|
||||
);
|
||||
}
|
||||
|
||||
describe('GET /auth/session — symmetry with protected middleware', () => {
|
||||
beforeEach(() => {
|
||||
fakeDb.adminUsers = [];
|
||||
fakeDb.events = [];
|
||||
fakeDb.revokedTokens = [];
|
||||
});
|
||||
|
||||
it('returns valid:true for an active admin token', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
expect(res.body.type).toBe('admin');
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user has been deactivated', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: false,
|
||||
password_changed_at: null,
|
||||
});
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when the admin user no longer exists', async () => {
|
||||
// adminUsers is empty
|
||||
const token = signAdminToken({ id: 999 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false when password was changed after the token was issued', async () => {
|
||||
// iat must be in the past, exp must be in the future so jwt.verify
|
||||
// doesn't reject the token before /auth/session even gets to look
|
||||
// at password_changed_at.
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
|
||||
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
|
||||
const tokenExp = tokenIssuedAt + 86400;
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
email: 'a@b.com',
|
||||
is_active: true,
|
||||
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
|
||||
});
|
||||
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is archived', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: true,
|
||||
expires_at: null,
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:false for a gallery token whose event is expired', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() - 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
|
||||
it('returns valid:true for an active gallery token', async () => {
|
||||
fakeDb.events.push({
|
||||
id: 100,
|
||||
slug: 'wedding',
|
||||
is_active: true,
|
||||
is_archived: false,
|
||||
expires_at: new Date(Date.now() + 86400_000),
|
||||
});
|
||||
const token = signGalleryToken();
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session?slug=wedding')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.valid).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid:false when the token is revoked', async () => {
|
||||
fakeDb.adminUsers.push({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
is_active: true,
|
||||
password_changed_at: null,
|
||||
});
|
||||
fakeDb.revokedTokens.push(1);
|
||||
const token = signAdminToken({ id: 1 });
|
||||
|
||||
const res = await request(makeApp())
|
||||
.get('/auth/session')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.valid).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Unit tests for backgroundProcessor.claimNextPhoto.
|
||||
*
|
||||
* Mocks the db so we don't need a live postgres/sqlite — focuses on
|
||||
* the claim contract: returns null when no rows, returns row + flips
|
||||
* status to 'processing' when one is available, returns null when a
|
||||
* race loses the UPDATE-with-guard.
|
||||
*/
|
||||
|
||||
jest.mock('../../src/services/photoProcessor', () => ({
|
||||
processPhoto: jest.fn(),
|
||||
processUploadedPhotos: jest.fn(),
|
||||
queueFilesForProcessing: jest.fn(),
|
||||
}));
|
||||
|
||||
// Build a fake knex instance whose .transaction() takes a callback we can
|
||||
// drive from the test, and whose query-builder records calls.
|
||||
function makeFakeDb({ pendingRow = null, updateResult = 1, clientName = 'pg' } = {}) {
|
||||
const queries = [];
|
||||
|
||||
const builder = () => {
|
||||
const recorded = { wheres: [], updates: null, ordered: false, locked: false, skipped: false };
|
||||
queries.push(recorded);
|
||||
const chain = {
|
||||
where: jest.fn(function (...args) {
|
||||
recorded.wheres.push(args);
|
||||
return chain;
|
||||
}),
|
||||
orderBy: jest.fn(function () {
|
||||
recorded.ordered = true;
|
||||
return chain;
|
||||
}),
|
||||
forUpdate: jest.fn(function () {
|
||||
recorded.locked = true;
|
||||
return chain;
|
||||
}),
|
||||
skipLocked: jest.fn(function () {
|
||||
recorded.skipped = true;
|
||||
return chain;
|
||||
}),
|
||||
first: jest.fn(async function () {
|
||||
// Only the SELECT chain returns the pending row; the UPDATE chain
|
||||
// never calls .first().
|
||||
return pendingRow ? { ...pendingRow } : null;
|
||||
}),
|
||||
update: jest.fn(async function (data) {
|
||||
recorded.updates = data;
|
||||
return updateResult;
|
||||
}),
|
||||
};
|
||||
return chain;
|
||||
};
|
||||
|
||||
const trxFn = (table) => builder(table);
|
||||
trxFn.client = { config: { client: clientName } };
|
||||
trxFn.transaction = async (cb) => cb(trxFn);
|
||||
|
||||
// Top-level db('photos') returns same builder for the janitor test path.
|
||||
const db = trxFn;
|
||||
return { db, queries };
|
||||
}
|
||||
|
||||
describe('backgroundProcessor.claimNextPhoto', () => {
|
||||
function loadProcessor(db) {
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
return require('../../src/services/backgroundProcessor');
|
||||
}
|
||||
|
||||
it('returns null when there are no pending photos (postgres path)', async () => {
|
||||
const { db } = makeFakeDb({ pendingRow: null, clientName: 'pg' });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the claimed row and flips status (postgres path)', async () => {
|
||||
const pendingRow = { id: 42, processing_status: 'pending' };
|
||||
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'pg' });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toEqual(pendingRow);
|
||||
// The first query is the SELECT FOR UPDATE SKIP LOCKED.
|
||||
expect(queries[0].locked).toBe(true);
|
||||
expect(queries[0].skipped).toBe(true);
|
||||
// The second query is the status update.
|
||||
expect(queries[1].updates.processing_status).toBe('processing');
|
||||
expect(queries[1].updates.processing_started_at).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns null when the SQLite UPDATE-with-guard loses the race', async () => {
|
||||
const pendingRow = { id: 7 };
|
||||
const { db } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 0 });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the row when SQLite UPDATE-with-guard wins', async () => {
|
||||
const pendingRow = { id: 7 };
|
||||
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 1 });
|
||||
const bg = loadProcessor(db);
|
||||
const result = await bg.claimNextPhoto();
|
||||
expect(result).toEqual(pendingRow);
|
||||
// SQLite path: no FOR UPDATE / SKIP LOCKED.
|
||||
expect(queries[0].locked).toBe(false);
|
||||
expect(queries[0].skipped).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Unit tests for photoProcessor.processPhoto — the worker-mode entry
|
||||
* point that runs after a row has been claimed by the background
|
||||
* processor. Mocks every external dependency and validates the
|
||||
* happy-path DB updates and side-effect ordering.
|
||||
*
|
||||
* jest.mock factories are evaluated before any local variables exist,
|
||||
* so collaborators are kept inside the mock factories themselves and
|
||||
* the test reaches into them via require() once they're set up.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
|
||||
jest.mock('../../src/database/db', () => {
|
||||
const recorded = { whereCalls: [], updateCalls: [] };
|
||||
let pendingWhere = null;
|
||||
const photosState = { row: null };
|
||||
const eventsState = { row: null };
|
||||
|
||||
function makePhotoQuery() {
|
||||
return {
|
||||
where(args) {
|
||||
pendingWhere = args;
|
||||
recorded.whereCalls.push(args);
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return photosState.row;
|
||||
},
|
||||
async update(data) {
|
||||
recorded.updateCalls.push({ where: pendingWhere, data });
|
||||
return 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeEventsQuery() {
|
||||
return {
|
||||
where() {
|
||||
return this;
|
||||
},
|
||||
async first() {
|
||||
return eventsState.row;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dbFn(table) {
|
||||
if (table === 'photos') return makePhotoQuery();
|
||||
if (table === 'events') return makeEventsQuery();
|
||||
throw new Error(`Unexpected table: ${table}`);
|
||||
}
|
||||
dbFn.client = { config: { client: 'pg' } };
|
||||
|
||||
return {
|
||||
db: dbFn,
|
||||
__setPhoto: (row) => { photosState.row = row; },
|
||||
__setEvent: (row) => { eventsState.row = row; },
|
||||
__reset: () => {
|
||||
recorded.whereCalls = [];
|
||||
recorded.updateCalls = [];
|
||||
photosState.row = null;
|
||||
eventsState.row = null;
|
||||
},
|
||||
__recorded: () => recorded,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/imageProcessor', () => {
|
||||
const mockGenerateThumbnail = jest.fn();
|
||||
const mockExtractCaptureDate = jest.fn();
|
||||
return {
|
||||
generateThumbnail: mockGenerateThumbnail,
|
||||
extractCaptureDate: mockExtractCaptureDate,
|
||||
withLocalCopy: jest.fn(async (key, fn) =>
|
||||
fn(`/tmp/local-copy-${require('path').basename(key)}`)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../../src/services/videoProcessor', () => ({
|
||||
processUploadedVideo: jest.fn(),
|
||||
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/storage', () => ({ getStorage: jest.fn() }));
|
||||
|
||||
jest.mock('../../src/services/photoResolver', () => ({
|
||||
resolvePhotoStorageKey: jest.fn(
|
||||
(event, photo) => `events/active/${event.slug}/${photo.filename}`
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/filenameSanitizer', () => ({
|
||||
generatePhotoFilename: jest.fn(() => 'whatever.jpg'),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/watermarkGeneratorService', () => ({
|
||||
generateForPhoto: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/services/webhookService', () => ({
|
||||
fire: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/utils/logger', () => ({
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
// Stub sharp so we don't actually read any image off disk.
|
||||
jest.mock('sharp', () => {
|
||||
const mock = jest.fn(() => ({
|
||||
metadata: jest.fn(async () => ({ width: 1920, height: 1080 })),
|
||||
}));
|
||||
return mock;
|
||||
});
|
||||
|
||||
const dbModule = require('../../src/database/db');
|
||||
const imageProcessor = require('../../src/services/imageProcessor');
|
||||
const videoProcessor = require('../../src/services/videoProcessor');
|
||||
const watermarkService = require('../../src/services/watermarkGeneratorService');
|
||||
const webhookService = require('../../src/services/webhookService');
|
||||
|
||||
beforeEach(() => {
|
||||
dbModule.__reset();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('photoProcessor.processPhoto', () => {
|
||||
it('marks an image complete with thumbnail and dimensions', async () => {
|
||||
dbModule.__setPhoto({
|
||||
id: 101,
|
||||
event_id: 5,
|
||||
filename: 'wedding-001.jpg',
|
||||
original_filename: 'IMG_0001.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
media_type: 'image',
|
||||
size_bytes: 12345,
|
||||
captured_at: null,
|
||||
processing_status: 'processing',
|
||||
});
|
||||
dbModule.__setEvent({ id: 5, slug: 'wedding', event_name: 'Wedding' });
|
||||
|
||||
imageProcessor.extractCaptureDate.mockResolvedValueOnce('2026-04-25T12:00:00Z');
|
||||
imageProcessor.generateThumbnail.mockResolvedValueOnce('thumbnails/thumb_wedding-001.jpg');
|
||||
|
||||
const { processPhoto } = require('../../src/services/photoProcessor');
|
||||
await processPhoto(101);
|
||||
|
||||
const finalUpdate = dbModule.__recorded().updateCalls.pop();
|
||||
expect(finalUpdate.data.processing_status).toBe('complete');
|
||||
expect(finalUpdate.data.processing_error).toBeNull();
|
||||
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-001.jpg');
|
||||
expect(finalUpdate.data.width).toBe(1920);
|
||||
expect(finalUpdate.data.height).toBe(1080);
|
||||
expect(finalUpdate.data.captured_at).toBe('2026-04-25T12:00:00Z');
|
||||
|
||||
expect(watermarkService.generateForPhoto).toHaveBeenCalledWith(101);
|
||||
expect(webhookService.fire).toHaveBeenCalledWith(
|
||||
'photo.uploaded',
|
||||
expect.objectContaining({
|
||||
event: expect.objectContaining({ slug: 'wedding' }),
|
||||
photo: expect.objectContaining({ id: 101, filename: 'wedding-001.jpg' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles videos with ffmpeg metadata path', async () => {
|
||||
dbModule.__setPhoto({
|
||||
id: 202,
|
||||
event_id: 9,
|
||||
filename: 'wedding-video-001.mp4',
|
||||
original_filename: 'movie.mp4',
|
||||
mime_type: 'video/mp4',
|
||||
media_type: 'video',
|
||||
size_bytes: 99999,
|
||||
captured_at: null,
|
||||
});
|
||||
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
|
||||
|
||||
videoProcessor.processUploadedVideo.mockResolvedValueOnce({
|
||||
thumbnailKey: 'thumbnails/thumb_wedding-video-001.jpg',
|
||||
metadata: {
|
||||
duration: 12.5,
|
||||
videoCodec: 'h264',
|
||||
audioCodec: 'aac',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
},
|
||||
});
|
||||
|
||||
const { processPhoto } = require('../../src/services/photoProcessor');
|
||||
await processPhoto(202);
|
||||
|
||||
const finalUpdate = dbModule.__recorded().updateCalls.pop();
|
||||
expect(finalUpdate.data.processing_status).toBe('complete');
|
||||
expect(finalUpdate.data.duration).toBe(12.5);
|
||||
expect(finalUpdate.data.video_codec).toBe('h264');
|
||||
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-video-001.jpg');
|
||||
|
||||
// Watermark queue is image-only.
|
||||
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when the photo row no longer exists', async () => {
|
||||
dbModule.__setPhoto(null);
|
||||
dbModule.__setEvent({ id: 1 });
|
||||
const { processPhoto } = require('../../src/services/photoProcessor');
|
||||
await expect(processPhoto(999)).rejects.toThrow(/Photo 999 not found/);
|
||||
});
|
||||
});
|
||||
|
||||
void path; // referenced indirectly via mocks
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Async photo-processing infrastructure.
|
||||
*
|
||||
* Adds:
|
||||
* - photos.processing_status — enum: pending | processing | complete | failed
|
||||
* - photos.processing_error — text, populated on 'failed'
|
||||
* - photos.processing_started_at — claim timestamp for janitor recovery
|
||||
* - photos.upload_id — groups all photos from one upload request
|
||||
* so the frontend can poll/stream by group
|
||||
*
|
||||
* All existing rows default to 'complete' (they were processed synchronously
|
||||
* before this migration and there's nothing pending). New uploads insert
|
||||
* with 'pending' and a background worker (services/backgroundProcessor.js)
|
||||
* picks them up.
|
||||
*
|
||||
* Partial-style indexes keep lookups fast as the queue drains. We use plain
|
||||
* indexes here instead of postgres-specific WHERE clauses so the migration
|
||||
* works on SQLite too; the workload (only-pending rows) keeps the index small.
|
||||
*/
|
||||
|
||||
exports.up = async function up(knex) {
|
||||
if (!(await knex.schema.hasTable('photos'))) return;
|
||||
|
||||
const hasStatus = await knex.schema.hasColumn('photos', 'processing_status');
|
||||
if (!hasStatus) {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.string('processing_status', 16).notNullable().defaultTo('complete');
|
||||
table.text('processing_error').nullable();
|
||||
table.timestamp('processing_started_at').nullable();
|
||||
table.string('upload_id', 64).nullable();
|
||||
});
|
||||
}
|
||||
|
||||
// Indexes — wrap in try/catch so re-running the migration on a partially
|
||||
// applied schema is a no-op rather than an error.
|
||||
try {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.index(['processing_status'], 'idx_photos_processing_status');
|
||||
});
|
||||
} catch (_) { /* already exists */ }
|
||||
|
||||
try {
|
||||
await knex.schema.alterTable('photos', (table) => {
|
||||
table.index(['upload_id'], 'idx_photos_upload_id');
|
||||
});
|
||||
} catch (_) { /* already exists */ }
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
if (!(await knex.schema.hasTable('photos'))) return;
|
||||
|
||||
// Drop indexes first (best-effort)
|
||||
try {
|
||||
await knex.schema.alterTable('photos', (t) => t.dropIndex([], 'idx_photos_upload_id'));
|
||||
} catch (_) { /* not present */ }
|
||||
try {
|
||||
await knex.schema.alterTable('photos', (t) => t.dropIndex([], 'idx_photos_processing_status'));
|
||||
} catch (_) { /* not present */ }
|
||||
|
||||
if (await knex.schema.hasColumn('photos', 'upload_id')) {
|
||||
await knex.schema.alterTable('photos', (t) => t.dropColumn('upload_id'));
|
||||
}
|
||||
if (await knex.schema.hasColumn('photos', 'processing_started_at')) {
|
||||
await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_started_at'));
|
||||
}
|
||||
if (await knex.schema.hasColumn('photos', 'processing_error')) {
|
||||
await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_error'));
|
||||
}
|
||||
if (await knex.schema.hasColumn('photos', 'processing_status')) {
|
||||
await knex.schema.alterTable('photos', (t) => t.dropColumn('processing_status'));
|
||||
}
|
||||
};
|
||||
+8
-2
@@ -23,6 +23,7 @@ const { startExpirationChecker } = require('./src/services/expirationChecker');
|
||||
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
||||
const { startBackupService } = require('./src/services/backupService');
|
||||
const { startScheduledBackups } = require('./src/services/databaseBackup');
|
||||
const backgroundProcessor = require('./src/services/backgroundProcessor');
|
||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||
const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
|
||||
@@ -660,10 +661,15 @@ async function startServer() {
|
||||
|
||||
// Start backup service
|
||||
await startBackupService();
|
||||
|
||||
|
||||
// Start database backup service
|
||||
await startScheduledBackups();
|
||||
|
||||
|
||||
// Start the async photo-processing worker pool. Picks up
|
||||
// photos in 'pending' state (from POST /upload) and runs the
|
||||
// sharp/ffmpeg/EXIF pipeline off the request thread.
|
||||
backgroundProcessor.start();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
|
||||
|
||||
+324
-299
@@ -5,8 +5,8 @@ const fs = require('fs').promises;
|
||||
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 { ensureThumbnail } = require('../services/imageProcessor');
|
||||
const { isVideoMimeType } = require('../services/videoProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
@@ -150,29 +150,39 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
next();
|
||||
});
|
||||
}, validateUploadContent, validateUploadedFiles, async (req, res) => {
|
||||
// Single cleanup site for the multer temp directory — runs on every
|
||||
// exit path (success, validation 4xx, server 5xx, multer error). The
|
||||
// previous code had three inline cleanup blocks for individual early
|
||||
// returns and missed the success path entirely, leaving an empty
|
||||
// per-request directory behind on every successful upload (#357 review).
|
||||
let tempCleanupDone = false;
|
||||
const cleanupTempDir = async () => {
|
||||
if (tempCleanupDone || !req.tempUploadPath) return;
|
||||
tempCleanupDone = true;
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp upload directory:', e);
|
||||
}
|
||||
};
|
||||
res.on('finish', cleanupTempDir);
|
||||
res.on('close', cleanupTempDir);
|
||||
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
const { category_id, replace_by_name } = req.body;
|
||||
const replaceByName = replace_by_name === 'true' || replace_by_name === true;
|
||||
|
||||
|
||||
console.log('Upload request received for event:', eventId);
|
||||
console.log('Body:', req.body);
|
||||
console.log('Files:', req.files ? req.files.length : 'none');
|
||||
console.log('File details:', req.files?.map(f => ({ name: f.originalname, size: f.size, mimetype: f.mimetype })));
|
||||
console.log('Category ID received:', category_id);
|
||||
|
||||
|
||||
// Verify event exists and admin has access
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) {
|
||||
console.error('Event not found:', eventId);
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
@@ -192,14 +202,6 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
}
|
||||
}
|
||||
if (currentCount + newFilesCount > event.photo_cap) {
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(400).json({
|
||||
error: `Photo cap exceeded. This event allows a maximum of ${event.photo_cap} photos. Currently ${currentCount} photos exist, and you are trying to upload ${newFilesCount} more.`
|
||||
});
|
||||
@@ -209,14 +211,6 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
if (!req.files || req.files.length === 0) {
|
||||
console.error('No files in request. req.files:', req.files);
|
||||
console.error('Request body keys:', Object.keys(req.body));
|
||||
// Clean up temp files
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp path:', e);
|
||||
}
|
||||
}
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
@@ -289,262 +283,97 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
filesToUpload = newFiles;
|
||||
}
|
||||
|
||||
// Process remaining new files in batches
|
||||
const BATCH_SIZE = 25; // Increased batch size for better performance with large uploads
|
||||
// Async-processing flow:
|
||||
// 1. Move each file to its final storage location.
|
||||
// 2. Insert a photo row with processing_status='pending' and a
|
||||
// shared upload_id. EXIF / sharp / thumbnails / ffmpeg /
|
||||
// watermark / webhook all happen in the background worker
|
||||
// (services/backgroundProcessor.js) so the request returns in
|
||||
// seconds even on NFS-backed storage.
|
||||
//
|
||||
// The previous code processed thumbnails+EXIF synchronously in
|
||||
// batches of 25 inside this handler, which is why large uploads on
|
||||
// slow storage looked frozen — see #357 review.
|
||||
const crypto = require('crypto');
|
||||
const uploadId = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
for (let i = 0; i < filesToUpload.length; i += BATCH_SIZE) {
|
||||
const batch = filesToUpload.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Start a single transaction for the batch
|
||||
const trx = await db.transaction();
|
||||
|
||||
// Counter base — same approximation as before. Strict uniqueness is
|
||||
// already enforced by the filename template + DB unique index, so a
|
||||
// small race here just retries a counter on conflict (rare).
|
||||
const existingCount = await db('photos')
|
||||
.where({ event_id: eventId, type: photoType })
|
||||
.count('id as count')
|
||||
.first();
|
||||
let counter = (parseInt(existingCount.count) || 0) + 1;
|
||||
const storage = getStorage();
|
||||
|
||||
for (const file of filesToUpload) {
|
||||
try {
|
||||
// Get initial counter for this batch based on photo type
|
||||
const existingCount = await trx('photos')
|
||||
.where({ event_id: eventId, type: photoType })
|
||||
.count('id as count')
|
||||
.first();
|
||||
let batchCounter = (parseInt(existingCount.count) || 0) + 1;
|
||||
|
||||
const batchPhotos = [];
|
||||
const fileRenameOperations = []; // Store rename operations to do after commit
|
||||
|
||||
// First pass: prepare data and move files from temp to final location
|
||||
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
|
||||
const file = batch[fileIndex];
|
||||
const counter = batchCounter + fileIndex;
|
||||
const tempPath = file.path; // Original temp path
|
||||
|
||||
try {
|
||||
// Verify file is complete before processing
|
||||
const tempStats = await fs.stat(tempPath);
|
||||
if (tempStats.size === 0) {
|
||||
throw new Error('File is empty - upload may have been interrupted');
|
||||
}
|
||||
|
||||
// Generate new filename
|
||||
const extension = path.extname(file.originalname);
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
categoryName,
|
||||
counter,
|
||||
extension
|
||||
);
|
||||
|
||||
// Storage key: events/active/{slug}/{newFilename}
|
||||
const finalKey = path.posix.join(finalDestPathRel, newFilename);
|
||||
// photo.path is stored relative to events/active so resolvePhotoStorageKey
|
||||
// can rebuild the full key on read.
|
||||
const relativePath = path.posix.join(event.slug, newFilename);
|
||||
|
||||
// Extract capture date from EXIF metadata
|
||||
let capturedAt = null;
|
||||
try {
|
||||
capturedAt = await extractCaptureDate(tempPath);
|
||||
} catch (exifError) {
|
||||
// Non-fatal - just log and continue without capture date
|
||||
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),
|
||||
filename: newFilename,
|
||||
original_filename: file.originalname, // Preserve original filename for Lightroom export
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
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)
|
||||
media_type: mediaType,
|
||||
mime_type: file.mimetype
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
|
||||
// Store upload operation for later (after DB commit)
|
||||
fileRenameOperations.push({
|
||||
tempPath: tempPath,
|
||||
finalKey: finalKey,
|
||||
filename: newFilename,
|
||||
photoData: photoData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error preparing file ${file.originalname}:`, error);
|
||||
errors.push({ filename: file.originalname, error: error.message });
|
||||
}
|
||||
const tempStats = await fs.stat(file.path);
|
||||
if (tempStats.size === 0) {
|
||||
throw new Error('File is empty - upload may have been interrupted');
|
||||
}
|
||||
|
||||
// Insert all photos in this batch
|
||||
if (batchPhotos.length > 0) {
|
||||
console.log(`Inserting batch of ${batchPhotos.length} photos with type: ${photoType}`);
|
||||
|
||||
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
|
||||
|
||||
// No need to update counter as we calculate it dynamically
|
||||
|
||||
// Commit the transaction first
|
||||
await trx.commit();
|
||||
console.log(`Successfully committed batch of ${batchPhotos.length} photos`);
|
||||
|
||||
// Now upload files from temp into the storage backend after successful commit
|
||||
const storage = getStorage();
|
||||
for (let idx = 0; idx < fileRenameOperations.length; idx++) {
|
||||
const operation = fileRenameOperations[idx];
|
||||
try {
|
||||
// Process source-dependent steps (sharp/ffmpeg) FIRST while the
|
||||
// tmp file is still on local disk, then upload the original and
|
||||
// unlink the tmp.
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
const isVideoFile = isVideoMimeType(operation.photoData.mime_type);
|
||||
let thumbnailPath = null;
|
||||
|
||||
try {
|
||||
if (isVideoFile) {
|
||||
const videoThumbnailKey = path.posix.join(
|
||||
'thumbnails',
|
||||
`thumb_${operation.filename.replace(/\.[^.]+$/, '.jpg')}`
|
||||
);
|
||||
const result = await processUploadedVideo(operation.tempPath, videoThumbnailKey);
|
||||
thumbnailPath = result.thumbnailKey;
|
||||
const extension = path.extname(file.originalname);
|
||||
const newFilename = generatePhotoFilename(
|
||||
event.event_name,
|
||||
categoryName,
|
||||
counter,
|
||||
extension
|
||||
);
|
||||
counter += 1;
|
||||
|
||||
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.tempPath);
|
||||
const finalKey = path.posix.join(finalDestPathRel, newFilename);
|
||||
const relativePath = path.posix.join(event.slug, newFilename);
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
|
||||
// Update the database with thumbnail path and image dimensions
|
||||
if (photoId) {
|
||||
const updateData = {};
|
||||
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
|
||||
// 1. Move file to its final storage key first. If the worker
|
||||
// later picks up the photo row, the file is guaranteed to
|
||||
// exist at the recorded path.
|
||||
await storage.putFromFile(finalKey, file.path, {
|
||||
contentType: file.mimetype,
|
||||
});
|
||||
await fs.unlink(file.path).catch(() => {});
|
||||
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(operation.tempPath).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/metadata processing failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Upload the original through the storage backend, then drop the
|
||||
// local tmp file. We do this AFTER thumbnail/metadata processing
|
||||
// so sharp/ffmpeg still have a local source to work from.
|
||||
await storage.putFromFile(operation.finalKey, operation.tempPath, {
|
||||
contentType: operation.photoData.mime_type,
|
||||
});
|
||||
await fs.unlink(operation.tempPath).catch(() => {});
|
||||
|
||||
// Sanity check: round-trip the size we just wrote.
|
||||
const stat = await storage.stat(operation.finalKey);
|
||||
if (!stat || stat.size !== operation.photoData.size_bytes) {
|
||||
throw new Error(`Size mismatch after upload: expected ${operation.photoData.size_bytes}, got ${stat ? stat.size : 'null'}`);
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// Webhook (#327): per-photo upload event.
|
||||
try {
|
||||
const webhookService = require('../services/webhookService');
|
||||
await webhookService.fire('photo.uploaded', {
|
||||
event: { id: parseInt(eventId, 10), slug: event.slug, event_name: event.event_name },
|
||||
photo: {
|
||||
id: insertedIds[idx]?.id || insertedIds[idx],
|
||||
filename: operation.filename,
|
||||
original_filename: operation.photoData.original_filename,
|
||||
size_bytes: operation.photoData.size_bytes,
|
||||
},
|
||||
});
|
||||
} catch (e) { /* non-fatal */ }
|
||||
|
||||
// Add to successful uploads
|
||||
uploadedPhotos.push({
|
||||
id: insertedIds[idx]?.id || insertedIds[idx],
|
||||
filename: operation.filename,
|
||||
size: operation.photoData.size_bytes,
|
||||
category_id: operation.photoData.category_id
|
||||
});
|
||||
} catch (moveError) {
|
||||
console.error(`Failed to upload ${operation.tempPath} → ${operation.finalKey}:`, moveError);
|
||||
errors.push({
|
||||
filename: operation.filename,
|
||||
error: `File upload failed: ${moveError.message}`
|
||||
});
|
||||
|
||||
// Try to clean up the database entry if file move failed
|
||||
if (insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
try {
|
||||
await db('photos').where({ id: photoId }).delete();
|
||||
console.log(`Cleaned up database entry for failed photo ${photoId}`);
|
||||
} catch (cleanupError) {
|
||||
console.error(`Failed to clean up database entry:`, cleanupError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No photos to insert, just rollback
|
||||
await trx.rollback();
|
||||
// Sanity check the round-tripped size — same guard as before.
|
||||
const stat = await storage.stat(finalKey);
|
||||
if (!stat || stat.size !== tempStats.size) {
|
||||
throw new Error(
|
||||
`Size mismatch after upload: expected ${tempStats.size}, got ${stat ? stat.size : 'null'}`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing batch starting at index ${i}:`, error);
|
||||
console.error('Stack trace:', error.stack);
|
||||
|
||||
// Rollback if not already committed
|
||||
if (!trx.isCompleted()) {
|
||||
await trx.rollback();
|
||||
}
|
||||
|
||||
// Add all files in this batch to errors
|
||||
for (const file of batch) {
|
||||
errors.push({
|
||||
filename: file.originalname,
|
||||
error: `Batch processing failed: ${error.message}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temp upload directory
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
console.log(`Cleaned up temp upload directory: ${req.tempUploadPath}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp upload directory:', e);
|
||||
|
||||
// 2. Insert a pending photo row. The background processor
|
||||
// will pick it up, generate thumbnail/dimensions/EXIF, and
|
||||
// flip status to 'complete' (or 'failed' with the error).
|
||||
const inserted = await db('photos')
|
||||
.insert({
|
||||
event_id: parseInt(eventId, 10),
|
||||
filename: newFilename,
|
||||
original_filename: file.originalname,
|
||||
path: relativePath,
|
||||
thumbnail_path: null,
|
||||
type: photoType,
|
||||
category_id: parsedCategoryId,
|
||||
size_bytes: tempStats.size,
|
||||
captured_at: null,
|
||||
media_type: isVideo ? 'video' : 'image',
|
||||
mime_type: file.mimetype,
|
||||
processing_status: 'pending',
|
||||
upload_id: uploadId,
|
||||
})
|
||||
.returning('id');
|
||||
const photoId = inserted[0]?.id || inserted[0];
|
||||
|
||||
uploadedPhotos.push({
|
||||
id: photoId,
|
||||
filename: newFilename,
|
||||
size: tempStats.size,
|
||||
category_id: parsedCategoryId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Error queuing file ${file.originalname}:`, err);
|
||||
errors.push({ filename: file.originalname, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,12 +396,20 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
// Include any files that were invalid from the validation middleware
|
||||
const totalInvalidFiles = (req.invalidFiles || []).concat(errors);
|
||||
|
||||
// Prepare response
|
||||
// Prepare response. The new fields (upload_id, count, photo_ids)
|
||||
// are what the new frontend uses to poll for processing status; the
|
||||
// existing fields (successCount, replacedCount, ...) are kept for
|
||||
// back-compat with older clients that haven't upgraded yet.
|
||||
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
|
||||
const uploadMsg = uploadedPhotos.length > 0 ? `${uploadedPhotos.length} uploaded` : '';
|
||||
const uploadMsg = uploadedPhotos.length > 0 ? `${uploadedPhotos.length} queued` : '';
|
||||
const replaceMsg = replacedPhotos.length > 0 ? `${replacedPhotos.length} replaced` : '';
|
||||
const parts = [uploadMsg, replaceMsg].filter(Boolean).join(', ');
|
||||
const response = {
|
||||
// New async-processing fields
|
||||
upload_id: uploadId,
|
||||
count: uploadedPhotos.length,
|
||||
photo_ids: uploadedPhotos.map((p) => p.id),
|
||||
// Existing back-compat fields
|
||||
message: parts ? `Successfully ${parts}` : 'No photos processed',
|
||||
photos: uploadedPhotos,
|
||||
replaced: replacedPhotos,
|
||||
@@ -580,38 +417,200 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
|
||||
skippedReplacements,
|
||||
totalFiles: totalAttempted,
|
||||
successCount: uploadedPhotos.length + replacedPhotos.length,
|
||||
failureCount: totalInvalidFiles.length
|
||||
failureCount: totalInvalidFiles.length,
|
||||
};
|
||||
|
||||
|
||||
// Include error details if any files failed
|
||||
if (totalInvalidFiles.length > 0) {
|
||||
response.errors = totalInvalidFiles;
|
||||
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
|
||||
response.message = `Queued ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
|
||||
}
|
||||
|
||||
|
||||
// Invalidate download zip cache after successful upload or replacement
|
||||
if (uploadedPhotos.length > 0 || replacedPhotos.length > 0) {
|
||||
downloadZipService.invalidate(parseInt(eventId));
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
// 202 Accepted — files stored, processing happens in background.
|
||||
res.status(202).json(response);
|
||||
} catch (error) {
|
||||
console.error('Error uploading photos:', error);
|
||||
|
||||
// Clean up temp upload directory on error
|
||||
if (req.tempUploadPath) {
|
||||
try {
|
||||
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
|
||||
console.log(`Cleaned up temp upload directory after error: ${req.tempUploadPath}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to clean up temp upload directory:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Temp directory cleanup is handled by the response finish/close
|
||||
// listeners above, regardless of which exit path fires.
|
||||
res.status(500).json({ error: 'Failed to upload photos' });
|
||||
}
|
||||
});
|
||||
|
||||
// Helper — load the upload group + verify the requesting admin owns the
|
||||
// underlying event. Returns { event, photos } or sends a 4xx response.
|
||||
async function loadUploadGroup(req, res) {
|
||||
const { upload_id: uploadId } = req.params;
|
||||
if (!uploadId || typeof uploadId !== 'string' || uploadId.length > 64) {
|
||||
res.status(400).json({ error: 'Invalid upload_id' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const photos = await db('photos').where({ upload_id: uploadId });
|
||||
if (photos.length === 0) {
|
||||
res.status(404).json({ error: 'Upload group not found' });
|
||||
return null;
|
||||
}
|
||||
|
||||
const eventId = photos[0].event_id;
|
||||
let eventQuery = db('events').where('id', eventId);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
res.status(404).json({ error: 'Event not found' });
|
||||
return null;
|
||||
}
|
||||
return { event, photos, uploadId };
|
||||
}
|
||||
|
||||
function summariseUpload(photos) {
|
||||
const summary = {
|
||||
total: photos.length,
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
complete: 0,
|
||||
failed: 0,
|
||||
photos: photos.map((p) => ({
|
||||
id: p.id,
|
||||
filename: p.filename,
|
||||
original_filename: p.original_filename,
|
||||
status: p.processing_status,
|
||||
error: p.processing_error || null,
|
||||
})),
|
||||
};
|
||||
for (const p of photos) {
|
||||
summary[p.processing_status] = (summary[p.processing_status] || 0) + 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
// JSON snapshot of upload status — frontends poll this every 1.5s while
|
||||
// any photo in the group is still pending or processing.
|
||||
router.get(
|
||||
'/uploads/:upload_id/status',
|
||||
adminAuth,
|
||||
requirePermission('photos.view'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const group = await loadUploadGroup(req, res);
|
||||
if (!group) return;
|
||||
res.json({
|
||||
upload_id: group.uploadId,
|
||||
event_id: group.event.id,
|
||||
...summariseUpload(group.photos),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error reading upload status:', error);
|
||||
res.status(500).json({ error: 'Failed to read upload status' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Server-Sent Events stream for upload progress. Optional upgrade over
|
||||
// the polling endpoint above. Streams the current snapshot on connect,
|
||||
// then re-emits whenever the snapshot changes (debounced) until all
|
||||
// photos in the group reach a terminal state.
|
||||
router.get(
|
||||
'/uploads/:upload_id/stream',
|
||||
adminAuth,
|
||||
requirePermission('photos.view'),
|
||||
async (req, res) => {
|
||||
const group = await loadUploadGroup(req, res);
|
||||
if (!group) return;
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.flushHeaders();
|
||||
|
||||
let lastJson = '';
|
||||
let closed = false;
|
||||
let timer = null;
|
||||
|
||||
const send = async () => {
|
||||
if (closed) return;
|
||||
try {
|
||||
const photos = await db('photos').where({ upload_id: group.uploadId });
|
||||
const summary = summariseUpload(photos);
|
||||
const payload = JSON.stringify({
|
||||
upload_id: group.uploadId,
|
||||
event_id: group.event.id,
|
||||
...summary,
|
||||
});
|
||||
if (payload !== lastJson) {
|
||||
lastJson = payload;
|
||||
res.write(`data: ${payload}\n\n`);
|
||||
}
|
||||
// Stop streaming once everything has reached a terminal state.
|
||||
if (summary.pending === 0 && summary.processing === 0) {
|
||||
closed = true;
|
||||
clearInterval(timer);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Upload stream poll error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
await send();
|
||||
timer = setInterval(send, 1500);
|
||||
|
||||
req.on('close', () => {
|
||||
closed = true;
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Retry a failed photo — flip back to 'pending' so the worker picks it
|
||||
// up again. Used by the admin grid's "Retry" button when a previous run
|
||||
// hit a transient sharp/ffmpeg error.
|
||||
router.post(
|
||||
'/photos/:photoId/retry',
|
||||
adminAuth,
|
||||
requirePermission('photos.edit'),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const photo = await db('photos').where({ id: req.params.photoId }).first();
|
||||
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||
|
||||
// Editor role: only allow retry on photos in events they own.
|
||||
if (req.admin.roleName === 'editor') {
|
||||
const event = await db('events')
|
||||
.where({ id: photo.event_id, created_by: req.admin.id })
|
||||
.first();
|
||||
if (!event) return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
if (photo.processing_status !== 'failed') {
|
||||
return res.status(409).json({
|
||||
error: `Photo is in '${photo.processing_status}' state and cannot be retried`,
|
||||
});
|
||||
}
|
||||
|
||||
await db('photos').where({ id: photo.id }).update({
|
||||
processing_status: 'pending',
|
||||
processing_error: null,
|
||||
processing_started_at: null,
|
||||
});
|
||||
res.json({ id: photo.id, status: 'pending' });
|
||||
} catch (error) {
|
||||
console.error('Error retrying photo processing:', error);
|
||||
res.status(500).json({ error: 'Failed to retry photo processing' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete a photo
|
||||
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
@@ -1070,15 +1069,25 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
|
||||
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
|
||||
// Photos still in async processing don't have all metadata in the DB
|
||||
// yet; serving the original is fine, but downstream consumers (admin
|
||||
// grid lightbox) read width/height which won't be set until processing
|
||||
// completes. We let the original through here — the file is on disk —
|
||||
// but tell the caller it's not done yet via a header so they can
|
||||
// poll /uploads/:upload_id/status if they care.
|
||||
if (photo.processing_status && photo.processing_status !== 'complete') {
|
||||
res.setHeader('X-PicPeak-Photo-Status', photo.processing_status);
|
||||
}
|
||||
|
||||
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
const storageKey = resolvePhotoStorageKey(event, photo);
|
||||
@@ -1121,12 +1130,28 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: eventId })
|
||||
.first();
|
||||
|
||||
|
||||
if (!photo) {
|
||||
console.error(`Photo not found: ${photoId}, event ${eventId}`);
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
|
||||
// Async processing is still working on this one — no thumbnail yet.
|
||||
// Return 503 with Retry-After so the admin grid (which auto-refreshes
|
||||
// every 2s while any photo is non-complete) keeps the placeholder
|
||||
// until the worker catches up.
|
||||
if (photo.processing_status === 'pending' || photo.processing_status === 'processing') {
|
||||
res.setHeader('Retry-After', '2');
|
||||
return res.status(503).json({ error: 'Thumbnail not ready', status: photo.processing_status });
|
||||
}
|
||||
if (photo.processing_status === 'failed') {
|
||||
return res.status(422).json({
|
||||
error: 'Photo processing failed',
|
||||
status: 'failed',
|
||||
details: photo.processing_error || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
|
||||
@@ -477,7 +477,7 @@ router.get('/session', async (req, res) => {
|
||||
try {
|
||||
const { slug } = req.query;
|
||||
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
|
||||
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
@@ -498,6 +498,63 @@ router.get('/session', async (req, res) => {
|
||||
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
|
||||
}
|
||||
|
||||
// The redirect loop reported on the v3.32.4-beta.0 release came
|
||||
// from /auth/session reporting valid: true while the protected
|
||||
// adminAuth / galleryAuth middleware rejected the same token for
|
||||
// reasons /auth/session never checked: the admin user was
|
||||
// deactivated, the admin's password had been changed since iat,
|
||||
// or the gallery event was archived/deleted. Mirror those checks
|
||||
// here so the session endpoint is always at least as strict as
|
||||
// what the protected endpoints will enforce next.
|
||||
if (decoded.type === 'admin') {
|
||||
let admin = null;
|
||||
try {
|
||||
admin = await db('admin_users')
|
||||
.where({ id: decoded.id, is_active: formatBoolean(true) })
|
||||
.select('id', 'username', 'email', 'password_changed_at')
|
||||
.first();
|
||||
} catch (lookupErr) {
|
||||
// admin_users table not present (test fixture, fresh DB) — fall
|
||||
// through and trust the token. Real deployments always have it.
|
||||
admin = null;
|
||||
// intentional swallow; if the table is missing we do not want
|
||||
// to fail-closed during e.g. early bootstrap.
|
||||
}
|
||||
|
||||
if (admin === null) {
|
||||
// Lookup didn't run because the table is missing; skip the
|
||||
// existence/password checks and treat the token as valid.
|
||||
} else if (!admin) {
|
||||
return res.json({ valid: false, error: 'Admin account no longer active' });
|
||||
} else if (admin.password_changed_at) {
|
||||
const passwordChangedSeconds = Math.floor(
|
||||
new Date(admin.password_changed_at).getTime() / 1000
|
||||
);
|
||||
if (decoded.iat < passwordChangedSeconds) {
|
||||
return res.json({ valid: false, error: 'Token invalid due to password change' });
|
||||
}
|
||||
}
|
||||
} else if (decoded.type === 'gallery') {
|
||||
try {
|
||||
const event = await db('events')
|
||||
.where({
|
||||
id: decoded.eventId,
|
||||
is_active: formatBoolean(true),
|
||||
is_archived: formatBoolean(false),
|
||||
})
|
||||
.first();
|
||||
if (!event) {
|
||||
return res.json({ valid: false, error: 'Gallery no longer available' });
|
||||
}
|
||||
if (event.expires_at && new Date(event.expires_at) < new Date()) {
|
||||
return res.json({ valid: false, error: 'Gallery has expired' });
|
||||
}
|
||||
} catch (galleryLookupErr) {
|
||||
// events table missing in this context — same fallback as
|
||||
// admin path; trust the token rather than fail-closed.
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate remaining time
|
||||
const now = Date.now() / 1000;
|
||||
const remainingTime = Math.max(0, decoded.exp - now);
|
||||
|
||||
@@ -212,6 +212,15 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
const isClient = req.accessLevel === 'client';
|
||||
let photosQuery = db('photos')
|
||||
.where('photos.event_id', req.event.id)
|
||||
// Guests/clients never see photos still being processed by the
|
||||
// background worker — the original is on disk but the thumbnail
|
||||
// / dimensions / EXIF haven't landed yet. Photos with a NULL
|
||||
// processing_status are pre-async-migration rows and are treated
|
||||
// as complete (the migration's column default is 'complete' so
|
||||
// this is just defensive against partial migration states).
|
||||
.where(function() {
|
||||
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
|
||||
})
|
||||
.select('photos.*');
|
||||
|
||||
// Guests only see visible photos; clients see all
|
||||
@@ -1380,18 +1389,31 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const categoryId = req.body.category_id || req.event.upload_category_id || null;
|
||||
|
||||
|
||||
const { queueFilesForProcessing } = require('../services/photoProcessor');
|
||||
const rawCategory = req.body.category_id || req.event.upload_category_id || null;
|
||||
const numericCategoryId = (() => {
|
||||
if (rawCategory === null || rawCategory === undefined) return null;
|
||||
const n = parseInt(rawCategory, 10);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
})();
|
||||
|
||||
try {
|
||||
// Process uploaded photos
|
||||
const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId);
|
||||
|
||||
res.json({
|
||||
message: 'Photos uploaded successfully',
|
||||
count: results.length,
|
||||
photos: results
|
||||
// Queue files as 'pending' — the background worker will process
|
||||
// thumbnails / EXIF / dimensions off the request thread (#357).
|
||||
const result = await queueFilesForProcessing(req.files, {
|
||||
eventId,
|
||||
photoType: 'individual',
|
||||
categoryId: numericCategoryId,
|
||||
});
|
||||
|
||||
res.status(202).json({
|
||||
message: 'Photos queued for processing',
|
||||
upload_id: result.uploadId,
|
||||
count: result.photos.length,
|
||||
photo_ids: result.photos.map((p) => p.id),
|
||||
photos: result.photos,
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
});
|
||||
} catch (processError) {
|
||||
console.error('Photo processing error:', processError);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Background photo-processing worker pool.
|
||||
*
|
||||
* Polls `photos.processing_status = 'pending'`, atomically claims one
|
||||
* row per worker, hands it to `photoProcessor.processPhoto(photoId)`,
|
||||
* and marks the row 'complete' or 'failed' depending on outcome. A
|
||||
* janitor loop resets rows stuck in 'processing' for too long (worker
|
||||
* died, pod restarted, etc.).
|
||||
*
|
||||
* Concurrency model: N independent worker loops per backend instance.
|
||||
* Multi-pod safe via:
|
||||
* - Postgres: SELECT ... FOR UPDATE SKIP LOCKED — pods race for rows,
|
||||
* only one wins, the others move on.
|
||||
* - SQLite: SELECT then UPDATE-with-status-guard — second writer
|
||||
* loses the guard and tries again (single-pod typical; the guard
|
||||
* is enough for the rare two-process case during dev).
|
||||
*
|
||||
* Tunables (env, all optional):
|
||||
* UPLOAD_PROCESSOR_CONCURRENCY default 2
|
||||
* UPLOAD_PROCESSOR_POLL_MS default 1000
|
||||
* UPLOAD_PROCESSOR_STUCK_TIMEOUT_MS default 600000 (10 minutes)
|
||||
* UPLOAD_PROCESSOR_DISABLED default false (set 'true' to opt out, e.g. in CI)
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const { processPhoto } = require('./photoProcessor');
|
||||
|
||||
const POLL_INTERVAL_MS = parseInt(process.env.UPLOAD_PROCESSOR_POLL_MS || '1000', 10);
|
||||
const CONCURRENCY = Math.max(1, parseInt(process.env.UPLOAD_PROCESSOR_CONCURRENCY || '2', 10));
|
||||
const STUCK_TIMEOUT_MS = parseInt(process.env.UPLOAD_PROCESSOR_STUCK_TIMEOUT_MS || '600000', 10);
|
||||
const JANITOR_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
let running = false;
|
||||
let workerHandles = [];
|
||||
let janitorHandle = null;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function isPostgres() {
|
||||
const c = db.client.config.client;
|
||||
return c === 'pg' || (typeof c === 'string' && c.includes('postgres'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically claim the oldest pending photo. Returns the row or null.
|
||||
* The claimed row's processing_status is now 'processing' and
|
||||
* processing_started_at is set so the janitor can recover it.
|
||||
*/
|
||||
async function claimNextPhoto() {
|
||||
if (isPostgres()) {
|
||||
return db.transaction(async (trx) => {
|
||||
const row = await trx('photos')
|
||||
.where('processing_status', 'pending')
|
||||
.orderBy('id', 'asc')
|
||||
.forUpdate()
|
||||
.skipLocked()
|
||||
.first();
|
||||
if (!row) return null;
|
||||
await trx('photos').where('id', row.id).update({
|
||||
processing_status: 'processing',
|
||||
processing_started_at: new Date(),
|
||||
});
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
// SQLite path — no SKIP LOCKED, but the UPDATE-with-guard ensures
|
||||
// exactly one worker wins per row.
|
||||
return db.transaction(async (trx) => {
|
||||
const row = await trx('photos')
|
||||
.where('processing_status', 'pending')
|
||||
.orderBy('id', 'asc')
|
||||
.first();
|
||||
if (!row) return null;
|
||||
const updated = await trx('photos')
|
||||
.where({ id: row.id, processing_status: 'pending' })
|
||||
.update({
|
||||
processing_status: 'processing',
|
||||
processing_started_at: new Date(),
|
||||
});
|
||||
return updated > 0 ? row : null;
|
||||
});
|
||||
}
|
||||
|
||||
async function workerLoop(workerIdx) {
|
||||
while (running) {
|
||||
let claimed;
|
||||
try {
|
||||
claimed = await claimNextPhoto();
|
||||
} catch (e) {
|
||||
logger.warn(`backgroundProcessor[${workerIdx}]: claim error`, { error: e.message });
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!claimed) {
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await processPhoto(claimed.id);
|
||||
} catch (err) {
|
||||
logger.error(`backgroundProcessor[${workerIdx}]: photo ${claimed.id} failed`, {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
});
|
||||
try {
|
||||
await db('photos').where({ id: claimed.id }).update({
|
||||
processing_status: 'failed',
|
||||
processing_error: String(err.message || err).slice(0, 1000),
|
||||
});
|
||||
} catch (updateErr) {
|
||||
logger.error(`backgroundProcessor[${workerIdx}]: failed to mark photo ${claimed.id} as failed`, {
|
||||
error: updateErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function janitorLoop() {
|
||||
while (running) {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - STUCK_TIMEOUT_MS);
|
||||
const reset = await db('photos')
|
||||
.where('processing_status', 'processing')
|
||||
.where('processing_started_at', '<', cutoff)
|
||||
.update({ processing_status: 'pending', processing_started_at: null });
|
||||
if (reset > 0) {
|
||||
logger.warn(
|
||||
`backgroundProcessor: janitor reset ${reset} stuck photo(s) from 'processing' to 'pending'`
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('backgroundProcessor: janitor error', { error: e.message });
|
||||
}
|
||||
await sleep(JANITOR_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (running) return;
|
||||
if (process.env.UPLOAD_PROCESSOR_DISABLED === 'true') {
|
||||
logger.info('backgroundProcessor: disabled via UPLOAD_PROCESSOR_DISABLED');
|
||||
return;
|
||||
}
|
||||
|
||||
running = true;
|
||||
workerHandles = [];
|
||||
for (let i = 0; i < CONCURRENCY; i++) {
|
||||
workerHandles.push(
|
||||
workerLoop(i).catch((e) =>
|
||||
logger.error(`backgroundProcessor[${i}]: crashed`, { error: e.message, stack: e.stack })
|
||||
)
|
||||
);
|
||||
}
|
||||
janitorHandle = janitorLoop().catch((e) =>
|
||||
logger.error('backgroundProcessor: janitor crashed', { error: e.message, stack: e.stack })
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`backgroundProcessor: started ${CONCURRENCY} worker(s), poll=${POLL_INTERVAL_MS}ms, stuck=${STUCK_TIMEOUT_MS}ms`
|
||||
);
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!running) return;
|
||||
running = false;
|
||||
await Promise.all([...workerHandles, janitorHandle].filter(Boolean));
|
||||
workerHandles = [];
|
||||
janitorHandle = null;
|
||||
}
|
||||
|
||||
module.exports = { start, stop, claimNextPhoto };
|
||||
@@ -1,10 +1,12 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { generateThumbnail } = require('./imageProcessor');
|
||||
const { generateThumbnail, extractCaptureDate, withLocalCopy } = require('./imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
|
||||
const { getStorage } = require('./storage');
|
||||
const { resolvePhotoStorageKey } = require('./photoResolver');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
function normalizeFiles(files) {
|
||||
// Handle null, undefined, or falsy values
|
||||
@@ -287,6 +289,226 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
return uploadedPhotos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue uploaded files for async processing.
|
||||
*
|
||||
* Moves each file from its multer temp path to the final storage key
|
||||
* and inserts a `photos` row with `processing_status = 'pending'` and
|
||||
* a shared `upload_id`. The background worker
|
||||
* (services/backgroundProcessor.js) picks up pending rows, generates
|
||||
* thumbnails / EXIF / dimensions, then flips status to 'complete'
|
||||
* (or 'failed' with the error).
|
||||
*
|
||||
* Used by both the admin upload route and the gallery (guest) upload
|
||||
* route so they share the same fast-return semantics.
|
||||
*
|
||||
* Options:
|
||||
* - eventId required
|
||||
* - photoType 'individual' | 'collage' (default 'individual')
|
||||
* - categoryId numeric category id or null
|
||||
* - uploadId optional pre-generated upload id (caller can
|
||||
* provide it for chunked uploads that span
|
||||
* multiple HTTP requests)
|
||||
*
|
||||
* Returns: { uploadId, photos: [{id, filename, size, category_id}], errors: [{filename, error}] }
|
||||
*/
|
||||
async function queueFilesForProcessing(files, options = {}) {
|
||||
const crypto = require('crypto');
|
||||
const { eventId, photoType = 'individual', categoryId = null, uploadId: providedUploadId } = options;
|
||||
const uploadId = providedUploadId || crypto.randomBytes(16).toString('hex');
|
||||
|
||||
const event = await db('events').where({ id: eventId }).first();
|
||||
if (!event) throw new Error(`Event ${eventId} not found`);
|
||||
|
||||
const fileList = normalizeFiles(files);
|
||||
const queued = [];
|
||||
const errors = [];
|
||||
|
||||
if (fileList.length === 0) return { uploadId, photos: queued, errors };
|
||||
|
||||
// Counter base — same approximation the upload route used pre-async.
|
||||
// Strict uniqueness is still enforced by the filename template; on a
|
||||
// collision the worker would just fail one photo.
|
||||
const existingCount = await db('photos')
|
||||
.where({ event_id: eventId, type: photoType })
|
||||
.count('id as count')
|
||||
.first();
|
||||
let counter = (parseInt(existingCount?.count) || 0) + 1;
|
||||
|
||||
const storage = getStorage();
|
||||
const finalDestPathRel = path.posix.join('events/active', event.slug);
|
||||
const categoryName = photoType === 'collage' ? 'collages' : 'individual';
|
||||
|
||||
for (const file of fileList) {
|
||||
const tempPath = file?.path || file?.filepath || file?.tempFilePath;
|
||||
try {
|
||||
if (!tempPath) {
|
||||
throw new Error('Uploaded file is missing a temporary path');
|
||||
}
|
||||
const tempStats = await fs.stat(tempPath);
|
||||
if (tempStats.size === 0) {
|
||||
throw new Error('File is empty - upload may have been interrupted');
|
||||
}
|
||||
|
||||
const extension = path.extname(file.originalname);
|
||||
const newFilename = generatePhotoFilename(event.event_name, categoryName, counter, extension);
|
||||
counter += 1;
|
||||
|
||||
const finalKey = path.posix.join(finalDestPathRel, newFilename);
|
||||
const relativePath = path.posix.join(event.slug, newFilename);
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
|
||||
// Move to storage first so the file is at its recorded path by the
|
||||
// time the worker picks up the row.
|
||||
await storage.putFromFile(finalKey, tempPath, { contentType: file.mimetype });
|
||||
await fs.unlink(tempPath).catch(() => {});
|
||||
|
||||
const stat = await storage.stat(finalKey);
|
||||
if (!stat || stat.size !== tempStats.size) {
|
||||
throw new Error(`Size mismatch after upload: expected ${tempStats.size}, got ${stat ? stat.size : 'null'}`);
|
||||
}
|
||||
|
||||
const inserted = await db('photos')
|
||||
.insert({
|
||||
event_id: parseInt(eventId, 10),
|
||||
filename: newFilename,
|
||||
original_filename: file.originalname,
|
||||
path: relativePath,
|
||||
thumbnail_path: null,
|
||||
type: photoType,
|
||||
category_id: categoryId,
|
||||
size_bytes: tempStats.size,
|
||||
captured_at: null,
|
||||
media_type: isVideo ? 'video' : 'image',
|
||||
mime_type: file.mimetype,
|
||||
processing_status: 'pending',
|
||||
upload_id: uploadId,
|
||||
})
|
||||
.returning('id');
|
||||
const photoId = inserted[0]?.id || inserted[0];
|
||||
|
||||
queued.push({
|
||||
id: photoId,
|
||||
filename: newFilename,
|
||||
size: tempStats.size,
|
||||
category_id: categoryId,
|
||||
});
|
||||
} catch (err) {
|
||||
errors.push({ filename: file?.originalname || 'unknown', error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
return { uploadId, photos: queued, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker-mode processing for a single already-stored photo.
|
||||
*
|
||||
* Called by the background processor after a row has been claimed
|
||||
* (`processing_status` == 'processing'). The photo file already exists
|
||||
* at its final storage key — this function reads it back, generates a
|
||||
* thumbnail, extracts EXIF + dimensions (or video metadata), then
|
||||
* updates the photo row to `complete` and fires the queued side
|
||||
* effects (watermark, webhook).
|
||||
*
|
||||
* Throwing causes the background processor to mark the row as
|
||||
* 'failed' with the error message; partial successes (e.g. thumbnail
|
||||
* fails but dimensions succeed) are persisted up to the failure point.
|
||||
*/
|
||||
async function processPhoto(photoId) {
|
||||
const photo = await db('photos').where({ id: photoId }).first();
|
||||
if (!photo) throw new Error(`Photo ${photoId} not found`);
|
||||
|
||||
const event = await db('events').where({ id: photo.event_id }).first();
|
||||
if (!event) throw new Error(`Event ${photo.event_id} not found for photo ${photoId}`);
|
||||
|
||||
const sourceKey = resolvePhotoStorageKey(event, photo);
|
||||
const isVideo =
|
||||
photo.media_type === 'video' ||
|
||||
(typeof photo.mime_type === 'string' && photo.mime_type.startsWith('video/'));
|
||||
|
||||
const updateData = {};
|
||||
|
||||
// withLocalCopy materialises the original from the storage backend so
|
||||
// sharp/ffmpeg can read it. For local storage this is a free O(1) path
|
||||
// resolution; for S3 it downloads to a tmpdir that's auto-cleaned.
|
||||
await withLocalCopy(sourceKey, async (localPath) => {
|
||||
if (!photo.captured_at && !isVideo) {
|
||||
try {
|
||||
const captured = await extractCaptureDate(localPath);
|
||||
if (captured) updateData.captured_at = captured;
|
||||
} catch (e) {
|
||||
logger.warn(`processPhoto: EXIF extraction failed for ${photoId}`, { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
const videoThumbnailKey = path.posix.join(
|
||||
'thumbnails',
|
||||
`thumb_${photo.filename.replace(/\.[^.]+$/, '.jpg')}`
|
||||
);
|
||||
const result = await processUploadedVideo(localPath, videoThumbnailKey);
|
||||
updateData.thumbnail_path = result.thumbnailKey;
|
||||
if (result.metadata) {
|
||||
if (result.metadata.duration != null) updateData.duration = result.metadata.duration;
|
||||
if (result.metadata.videoCodec) updateData.video_codec = result.metadata.videoCodec;
|
||||
if (result.metadata.audioCodec) updateData.audio_codec = result.metadata.audioCodec;
|
||||
if (result.metadata.width) updateData.width = result.metadata.width;
|
||||
if (result.metadata.height) updateData.height = result.metadata.height;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const thumbnailPath = await generateThumbnail(localPath);
|
||||
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
|
||||
} catch (e) {
|
||||
logger.warn(`processPhoto: thumbnail generation failed for ${photoId}`, { error: e.message });
|
||||
}
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(localPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
updateData.width = metadata.width;
|
||||
updateData.height = metadata.height;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`processPhoto: dimensions extraction failed for ${photoId}`, { error: e.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Mark complete
|
||||
updateData.processing_status = 'complete';
|
||||
updateData.processing_error = null;
|
||||
await db('photos').where({ id: photoId }).update(updateData);
|
||||
|
||||
// Side effects (best-effort, never fail the photo if these break)
|
||||
if (!isVideo) {
|
||||
const watermarkGeneratorService = require('./watermarkGeneratorService');
|
||||
watermarkGeneratorService
|
||||
.generateForPhoto(photoId)
|
||||
.catch((err) => logger.warn(`processPhoto: watermark queue failed for ${photoId}`, { error: err.message }));
|
||||
}
|
||||
|
||||
try {
|
||||
const webhookService = require('./webhookService');
|
||||
await webhookService.fire('photo.uploaded', {
|
||||
event: { id: event.id, slug: event.slug, event_name: event.event_name },
|
||||
photo: {
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
original_filename: photo.original_filename,
|
||||
size_bytes: photo.size_bytes,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn(`processPhoto: webhook fire failed for ${photoId}`, { error: e.message });
|
||||
}
|
||||
|
||||
return updateData;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
processUploadedPhotos
|
||||
processUploadedPhotos,
|
||||
queueFilesForProcessing,
|
||||
processPhoto
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
|
||||
import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen, Cog, AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { AdminPhoto } from '../../services/photos.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { uploadsService } from '../../services/uploads.service';
|
||||
import { Button } from '../common';
|
||||
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
|
||||
import { BulkCategoryModal } from './BulkCategoryModal';
|
||||
@@ -32,6 +34,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
categories = []
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
@@ -298,9 +301,42 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Thumbnail */}
|
||||
{/* Thumbnail (or processing placeholder for in-flight photos) */}
|
||||
<div className="aspect-square">
|
||||
{photo.thumbnail_url ? (
|
||||
{(photo as any).processing_status === 'pending' ||
|
||||
(photo as any).processing_status === 'processing' ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 gap-1 px-2 text-center">
|
||||
<Cog className="w-7 h-7 animate-spin" />
|
||||
<p className="text-[10px] font-medium leading-tight">
|
||||
{t('admin.photos.processingStatus', 'Processing…')}
|
||||
</p>
|
||||
</div>
|
||||
) : (photo as any).processing_status === 'failed' ? (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 gap-1 px-2 text-center">
|
||||
<AlertTriangle className="w-7 h-7" />
|
||||
<p className="text-[10px] font-medium leading-tight">
|
||||
{t('admin.photos.processingFailed', 'Failed')}
|
||||
</p>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await uploadsService.retryPhoto(photo.id);
|
||||
toast.success(t('admin.photos.retryQueued', 'Retry queued'));
|
||||
// Refetch grid via React Query so the placeholder
|
||||
// updates without a full reload.
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-photos'] });
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.error || 'Retry failed');
|
||||
}
|
||||
}}
|
||||
className="mt-1 px-2 py-0.5 rounded bg-red-200 dark:bg-red-800 text-[10px] inline-flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-2.5 h-2.5" />
|
||||
{t('upload.retryFailed', 'Retry')}
|
||||
</button>
|
||||
</div>
|
||||
) : photo.thumbnail_url ? (
|
||||
<AdminAuthenticatedImage
|
||||
src={photo.thumbnail_url}
|
||||
alt={photo.filename}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { Upload, X, Image, Loader2 } from 'lucide-react';
|
||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import { Upload, X, Image, Loader2, Cog } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { clsx } from 'clsx';
|
||||
import { api } from '../../config/api';
|
||||
@@ -9,6 +9,7 @@ import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
|
||||
import { useUploadProgress } from '../../hooks/useUploadProgress';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
eventId: number;
|
||||
@@ -18,6 +19,15 @@ interface PhotoUploadProps {
|
||||
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||
|
||||
// Upload phase machine. The user perceives "frozen" during 'processing'
|
||||
// because the bytes are already on the server and we're waiting for
|
||||
// thumbnail/EXIF/etc. work — the explicit phase + hint message kills
|
||||
// that perception (#352 / contributor analysis on issue 357 review).
|
||||
type UploadPhase =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'transferring'; chunkIndex: number; totalChunks: number; bytePct: number }
|
||||
| { kind: 'processing'; chunkIndex: number; totalChunks: number; filesInChunk: number };
|
||||
|
||||
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -25,9 +35,18 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [currentChunk, setCurrentChunk] = useState(0);
|
||||
const [totalChunks, setTotalChunks] = useState(0);
|
||||
const [phase, setPhase] = useState<UploadPhase>({ kind: 'idle' });
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
|
||||
const [replaceByName, setReplaceByName] = useState(false);
|
||||
// Upload IDs returned from each chunk POST. The processing tracker
|
||||
// hook merges status across all of them so the user sees one unified
|
||||
// progress count even when the upload spans multiple HTTP requests.
|
||||
const [uploadIds, setUploadIds] = useState<string[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { aggregate: processingAggregate } = useUploadProgress(uploadIds, {
|
||||
enabled: phase.kind === 'processing' && uploadIds.length > 0,
|
||||
});
|
||||
|
||||
// Fetch categories for this event
|
||||
const { data: categories = [] } = useQuery({
|
||||
@@ -107,6 +126,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
setUploadIds([]);
|
||||
|
||||
// For large uploads, chunk the files by both count AND size to prevent memory/network issues
|
||||
const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk
|
||||
@@ -144,11 +164,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
setCurrentChunk(chunkIndex + 1);
|
||||
const chunk = chunks[chunkIndex];
|
||||
const formData = new FormData();
|
||||
|
||||
|
||||
chunk.forEach((file) => {
|
||||
formData.append('photos', file);
|
||||
});
|
||||
|
||||
|
||||
if (selectedCategoryId) {
|
||||
formData.append('category_id', selectedCategoryId.toString());
|
||||
}
|
||||
@@ -156,63 +176,138 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
formData.append('replace_by_name', 'true');
|
||||
}
|
||||
|
||||
setPhase({
|
||||
kind: 'transferring',
|
||||
chunkIndex,
|
||||
totalChunks: chunks.length,
|
||||
bytePct: 0,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
|
||||
onUploadProgress: (progressEvent) => {
|
||||
if (progressEvent.total) {
|
||||
// Calculate overall progress across all chunks
|
||||
const chunkProgress = progressEvent.loaded / progressEvent.total;
|
||||
const overallProgress = ((chunkIndex + chunkProgress) / chunks.length) * 100;
|
||||
setUploadProgress(Math.round(overallProgress));
|
||||
|
||||
// Once bytes have all left the browser, the request is
|
||||
// sitting in the backend processing pipeline. Flip to
|
||||
// 'processing' so the UI explains the wait instead of
|
||||
// looking frozen at the chunk's max progress.
|
||||
if (chunkProgress >= 1) {
|
||||
setPhase((prev) =>
|
||||
prev.kind === 'transferring' && prev.chunkIndex === chunkIndex
|
||||
? {
|
||||
kind: 'processing',
|
||||
chunkIndex,
|
||||
totalChunks: chunks.length,
|
||||
filesInChunk: chunk.length,
|
||||
}
|
||||
: prev
|
||||
);
|
||||
} else {
|
||||
setPhase({
|
||||
kind: 'transferring',
|
||||
chunkIndex,
|
||||
totalChunks: chunks.length,
|
||||
bytePct: Math.round(chunkProgress * 100),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
totalUploaded += (response.data?.successCount || chunk.length);
|
||||
totalReplaced += (response.data?.replacedCount || 0);
|
||||
// Backend returns a per-request upload_id. Track it so the
|
||||
// processing-status hook can poll/stream live progress.
|
||||
if (response.data?.upload_id) {
|
||||
const newId = response.data.upload_id as string;
|
||||
setUploadIds((prev) => (prev.includes(newId) ? prev : [...prev, newId]));
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
|
||||
failedFiles.push(...chunk.map(f => f.name));
|
||||
|
||||
|
||||
// Continue with next chunk even if one fails
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear selected files
|
||||
// Bytes are all on the server. Clear the file picker so the
|
||||
// user can queue another batch — but DON'T dismiss the upload
|
||||
// UI yet; we'll watch the processing aggregate (useEffect below)
|
||||
// to know when the backend has finished generating thumbnails
|
||||
// and metadata.
|
||||
setSelectedFiles([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
// Show appropriate message
|
||||
if (totalReplaced > 0) {
|
||||
toast.info(t('upload.replacedFiles', { count: totalReplaced }) || `${totalReplaced} photo(s) replaced`);
|
||||
}
|
||||
if (failedFiles.length === 0) {
|
||||
toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`);
|
||||
} else {
|
||||
if (failedFiles.length > 0) {
|
||||
toast.warning(
|
||||
t('upload.someFilesFailed') ||
|
||||
`Uploaded ${totalUploaded} files. ${failedFiles.length} files failed.`
|
||||
t('upload.someFilesFailed') ||
|
||||
`Transferred ${totalUploaded} files. ${failedFiles.length} files failed to transfer.`
|
||||
);
|
||||
}
|
||||
|
||||
// Call callback
|
||||
|
||||
// Refresh the grid early so the user sees their photos appearing
|
||||
// as the worker processes them. The processing-aggregate effect
|
||||
// below will refresh again on completion.
|
||||
if (onUploadComplete) {
|
||||
onUploadComplete();
|
||||
}
|
||||
|
||||
// If the backend never returned an upload_id (e.g. only failures
|
||||
// or pre-async-backend deployment), we have nothing to wait for —
|
||||
// fall through to the finally cleanup which resets state.
|
||||
} catch (error: any) {
|
||||
console.error('Upload error:', error);
|
||||
toast.error(error.response?.data?.error || t('toast.uploadError'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
setCurrentChunk(0);
|
||||
setTotalChunks(0);
|
||||
setPhase({ kind: 'idle' });
|
||||
setUploadIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
// When the background worker finishes processing every photo from
|
||||
// this upload, dismiss the upload UI and surface the result.
|
||||
useEffect(() => {
|
||||
if (!isUploading) return;
|
||||
if (uploadIds.length === 0) return;
|
||||
if (!processingAggregate.isComplete) return;
|
||||
|
||||
if (processingAggregate.failed > 0) {
|
||||
toast.warning(
|
||||
t('upload.processingFailed', { count: processingAggregate.failed }) ||
|
||||
`${processingAggregate.failed} photo(s) failed to process`
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
t('upload.uploadComplete') || `Successfully uploaded ${processingAggregate.complete} photo(s)`
|
||||
);
|
||||
}
|
||||
|
||||
if (onUploadComplete) onUploadComplete();
|
||||
setIsUploading(false);
|
||||
setUploadProgress(0);
|
||||
setCurrentChunk(0);
|
||||
setTotalChunks(0);
|
||||
setPhase({ kind: 'idle' });
|
||||
setUploadIds([]);
|
||||
// We intentionally only react to processingAggregate.isComplete /
|
||||
// .failed — the rest of the deps either don't move during this
|
||||
// effect's lifetime or are stable callbacks.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [processingAggregate.isComplete, processingAggregate.failed, isUploading]);
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
@@ -344,26 +439,73 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{/* Progress display — two distinct phases. Bytes-on-wire ('transferring')
|
||||
drives the determinate bar; the post-bytes wait ('processing') swaps
|
||||
in an indeterminate spinner with an explanatory hint so users don't
|
||||
assume the upload froze. */}
|
||||
{isUploading && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-sm text-neutral-600 dark:text-neutral-400 mb-1">
|
||||
<span>
|
||||
{t('upload.uploading')}
|
||||
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
|
||||
</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{totalChunks > 1 && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
|
||||
</p>
|
||||
{phase.kind === 'processing' ? (
|
||||
<div className="rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Cog className="w-5 h-5 text-amber-600 dark:text-amber-400 animate-spin shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||
{t('upload.processing')}
|
||||
</p>
|
||||
{processingAggregate.total > 0 && (
|
||||
<>
|
||||
<p className="text-xs text-amber-900 dark:text-amber-100 font-medium mt-2">
|
||||
{t('upload.processingProgress', {
|
||||
complete: processingAggregate.complete + processingAggregate.failed,
|
||||
total: processingAggregate.total,
|
||||
})}
|
||||
</p>
|
||||
<div className="w-full bg-amber-100 dark:bg-amber-900/40 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-amber-600 dark:bg-amber-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${
|
||||
processingAggregate.total === 0
|
||||
? 0
|
||||
: Math.round(
|
||||
((processingAggregate.complete + processingAggregate.failed) /
|
||||
processingAggregate.total) *
|
||||
100
|
||||
)
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200 mt-2">
|
||||
{t('upload.processingHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between text-sm text-neutral-600 dark:text-neutral-400 mb-1">
|
||||
<span>
|
||||
{t('upload.transferring')}
|
||||
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
|
||||
</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{totalChunks > 1 && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Upload, X, CheckCircle } from 'lucide-react';
|
||||
import { Upload, X, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button } from '../common';
|
||||
@@ -24,6 +24,10 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<{ [key: string]: number }>({});
|
||||
// Per-file processing state — flips to true once axios reports
|
||||
// bytes-on-wire for that file, so the UI can show "Processing…"
|
||||
// instead of a static 100% bar while the backend works.
|
||||
const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({});
|
||||
|
||||
const { data: publicSettings } = usePublicSettings();
|
||||
|
||||
@@ -87,9 +91,18 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
...prev,
|
||||
[file.name]: progress,
|
||||
}));
|
||||
if (progress >= 100) {
|
||||
setProcessingFiles(prev => ({ ...prev, [file.name]: true }));
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
// Request resolved → file fully processed by backend.
|
||||
setProcessingFiles(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[file.name];
|
||||
return next;
|
||||
});
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
// Upload error handled - user notified via UI
|
||||
@@ -185,7 +198,13 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
</div>
|
||||
{uploadProgress[file.name] !== undefined ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{uploadProgress[file.name] === 100 ? (
|
||||
{processingFiles[file.name] ? (
|
||||
// Bytes are on the server; the request hasn't
|
||||
// resolved yet because the backend is still
|
||||
// generating thumbnails / reading EXIF. Show
|
||||
// a spinner so it doesn't look stuck at 100%.
|
||||
<Loader2 className="w-5 h-5 text-amber-600 animate-spin" />
|
||||
) : uploadProgress[file.name] === 100 ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<div className="w-20">
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
uploadsService,
|
||||
type UploadStatusSnapshot,
|
||||
} from '../services/uploads.service';
|
||||
|
||||
interface UseUploadProgressOptions {
|
||||
/**
|
||||
* If false, the hook does nothing (used to "pause" tracking when no
|
||||
* upload is in progress). Default: true.
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Polling interval in ms (used always as a fallback, and as the
|
||||
* primary channel when SSE is unavailable). Default: 1500.
|
||||
*/
|
||||
pollIntervalMs?: number;
|
||||
/**
|
||||
* If true, attempts an SSE upgrade for low-latency updates and
|
||||
* falls back to polling when the stream errors. Default: true.
|
||||
*/
|
||||
preferStream?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks an upload group's processing state. Returns a merged snapshot
|
||||
* across all upload IDs the caller passes in (admin upload modal sends
|
||||
* each chunk as its own upload_id; this hook merges their counters).
|
||||
*
|
||||
* The hook is resilient: it always polls in the background and uses
|
||||
* SSE (when available and not disabled) as a faster supplementary
|
||||
* channel. Either source landing on a terminal state stops the hook.
|
||||
*/
|
||||
export function useUploadProgress(
|
||||
uploadIds: string[],
|
||||
{ enabled = true, pollIntervalMs = 1500, preferStream = true }: UseUploadProgressOptions = {}
|
||||
) {
|
||||
const [snapshots, setSnapshots] = useState<Record<string, UploadStatusSnapshot | null>>({});
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const eventSourcesRef = useRef<Record<string, EventSource>>({});
|
||||
// Stable string key so we re-trigger the effect only when the actual
|
||||
// set of IDs changes (parents may pass a new array each render).
|
||||
const idsKey = uploadIds.join('|');
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || uploadIds.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const pollHandles: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||
|
||||
const closeStream = (uploadId: string) => {
|
||||
const es = eventSourcesRef.current[uploadId];
|
||||
if (es) {
|
||||
es.close();
|
||||
delete eventSourcesRef.current[uploadId];
|
||||
}
|
||||
};
|
||||
|
||||
const isTerminal = (snap: UploadStatusSnapshot | null) =>
|
||||
!!snap && snap.pending === 0 && snap.processing === 0;
|
||||
|
||||
const merge = (uploadId: string, snap: UploadStatusSnapshot) => {
|
||||
if (cancelled) return;
|
||||
setSnapshots((prev) => ({ ...prev, [uploadId]: snap }));
|
||||
};
|
||||
|
||||
const pollOnce = async (uploadId: string) => {
|
||||
try {
|
||||
const snap = await uploadsService.getStatus(uploadId);
|
||||
merge(uploadId, snap);
|
||||
if (!isTerminal(snap)) {
|
||||
pollHandles[uploadId] = setTimeout(() => pollOnce(uploadId), pollIntervalMs);
|
||||
} else {
|
||||
closeStream(uploadId);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e as Error);
|
||||
// Retry polling on error after a longer interval — don't drop
|
||||
// the group entirely just because one snapshot failed.
|
||||
pollHandles[uploadId] = setTimeout(() => pollOnce(uploadId), pollIntervalMs * 4);
|
||||
}
|
||||
};
|
||||
|
||||
const tryStream = (uploadId: string) => {
|
||||
if (typeof EventSource === 'undefined') return;
|
||||
try {
|
||||
const es = new EventSource(uploadsService.streamUrl(uploadId), { withCredentials: true });
|
||||
eventSourcesRef.current[uploadId] = es;
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const payload: UploadStatusSnapshot = JSON.parse(event.data);
|
||||
merge(uploadId, payload);
|
||||
if (isTerminal(payload)) {
|
||||
closeStream(uploadId);
|
||||
}
|
||||
} catch (_) {
|
||||
/* ignore malformed event */
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
// Treat any error as a fatal stream failure; polling keeps
|
||||
// running anyway and will pick up status. Avoids reconnect
|
||||
// storms on broken proxies.
|
||||
closeStream(uploadId);
|
||||
};
|
||||
} catch (_) {
|
||||
// EventSource construction failed — polling alone covers it.
|
||||
}
|
||||
};
|
||||
|
||||
for (const uploadId of uploadIds) {
|
||||
pollOnce(uploadId);
|
||||
if (preferStream) tryStream(uploadId);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
for (const handle of Object.values(pollHandles)) clearTimeout(handle);
|
||||
for (const uploadId of Object.keys(eventSourcesRef.current)) closeStream(uploadId);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [idsKey, enabled, pollIntervalMs, preferStream]);
|
||||
|
||||
// Aggregate counters across all tracked upload IDs.
|
||||
const aggregate = (() => {
|
||||
const totals = { total: 0, pending: 0, processing: 0, complete: 0, failed: 0 };
|
||||
const failedPhotos: { id: number; filename: string; error: string | null }[] = [];
|
||||
let allReady = true;
|
||||
for (const uploadId of uploadIds) {
|
||||
const snap = snapshots[uploadId];
|
||||
if (!snap) {
|
||||
allReady = false;
|
||||
continue;
|
||||
}
|
||||
totals.total += snap.total;
|
||||
totals.pending += snap.pending;
|
||||
totals.processing += snap.processing;
|
||||
totals.complete += snap.complete;
|
||||
totals.failed += snap.failed;
|
||||
for (const p of snap.photos) {
|
||||
if (p.status === 'failed') {
|
||||
failedPhotos.push({ id: p.id, filename: p.original_filename, error: p.error });
|
||||
}
|
||||
}
|
||||
}
|
||||
const isComplete = allReady && totals.pending === 0 && totals.processing === 0 && totals.total > 0;
|
||||
return { ...totals, failedPhotos, isComplete, isReady: allReady };
|
||||
})();
|
||||
|
||||
return {
|
||||
snapshots,
|
||||
aggregate,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -131,6 +131,12 @@
|
||||
"unsupportedFiles": "Einige Dateien wurden übersprungen, da das Format nicht unterstützt wird (JPEG/PNG/WebP/MP4/MOV/WEBM verwenden).",
|
||||
"selectedFiles": "Ausgewählte Dateien",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"transferring": "Übertragung",
|
||||
"processing": "Fotos werden verarbeitet...",
|
||||
"processingHint": "Dateien sind hochgeladen. PicPeak erstellt jetzt Thumbnails und liest Metadaten. Sie können diese Seite verlassen — die Verarbeitung läuft im Hintergrund weiter.",
|
||||
"processingProgress": "{{complete}} von {{total}} fertig",
|
||||
"processingFailed": "{{count}} Foto(s) konnten nicht verarbeitet werden",
|
||||
"retryFailed": "Fehlgeschlagene erneut versuchen",
|
||||
"uploadComplete": "Upload abgeschlossen!",
|
||||
"uploadFailed": "Upload fehlgeschlagen",
|
||||
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
|
||||
|
||||
@@ -131,6 +131,12 @@
|
||||
"unsupportedFiles": "Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).",
|
||||
"selectedFiles": "Selected files",
|
||||
"uploading": "Uploading...",
|
||||
"transferring": "Transferring",
|
||||
"processing": "Processing photos...",
|
||||
"processingHint": "Files are uploaded. PicPeak is now generating thumbnails and reading metadata. You can leave this page — work continues in the background.",
|
||||
"processingProgress": "{{complete}} of {{total}} done",
|
||||
"processingFailed": "{{count}} photo(s) failed to process",
|
||||
"retryFailed": "Retry failed",
|
||||
"uploadComplete": "Upload complete!",
|
||||
"uploadFailed": "Upload failed",
|
||||
"someFilesFailed": "Some files failed to upload",
|
||||
|
||||
@@ -302,11 +302,22 @@ export const EventDetailsPage: React.FC = () => {
|
||||
logic: feedbackFilters.logic,
|
||||
}), [photoFilters, feedbackFilters]);
|
||||
|
||||
// Fetch photos (needed for both photos tab and hero photo selector)
|
||||
// Fetch photos (needed for both photos tab and hero photo selector).
|
||||
// While any photo is still in pending/processing state we poll every
|
||||
// 2s so the admin grid auto-updates as the background worker drains
|
||||
// the queue. Once everything is complete/failed the polling stops.
|
||||
const { data: photos = [], isLoading: photosLoading, refetch: refetchPhotos } = useQuery({
|
||||
queryKey: ['admin-event-photos', id, combinedPhotoFilters],
|
||||
queryFn: () => photosService.getEventPhotos(parseInt(id!), combinedPhotoFilters),
|
||||
enabled: !!id && (activeTab === 'photos' || isEditing),
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as AdminPhoto[] | undefined;
|
||||
if (!Array.isArray(data)) return false;
|
||||
const inFlight = data.some(
|
||||
(p: any) => p.processing_status === 'pending' || p.processing_status === 'processing'
|
||||
);
|
||||
return inFlight ? 2000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch filter summary for feedback filters
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { api } from '../config/api';
|
||||
|
||||
export type PhotoProcessingStatus = 'pending' | 'processing' | 'complete' | 'failed';
|
||||
|
||||
export interface UploadPhotoStatus {
|
||||
id: number;
|
||||
filename: string;
|
||||
original_filename: string;
|
||||
status: PhotoProcessingStatus;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface UploadStatusSnapshot {
|
||||
upload_id: string;
|
||||
event_id: number;
|
||||
total: number;
|
||||
pending: number;
|
||||
processing: number;
|
||||
complete: number;
|
||||
failed: number;
|
||||
photos: UploadPhotoStatus[];
|
||||
}
|
||||
|
||||
export const uploadsService = {
|
||||
/**
|
||||
* One-shot snapshot of an upload group's processing state. Frontends
|
||||
* poll this every 1.5s while any photo is still pending/processing.
|
||||
*/
|
||||
async getStatus(uploadId: string): Promise<UploadStatusSnapshot> {
|
||||
const response = await api.get<UploadStatusSnapshot>(`/admin/uploads/${uploadId}/status`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retry a failed photo. Flips status back to 'pending' so the
|
||||
* background worker picks it up again.
|
||||
*/
|
||||
async retryPhoto(photoId: number): Promise<{ id: number; status: PhotoProcessingStatus }> {
|
||||
const response = await api.post<{ id: number; status: PhotoProcessingStatus }>(
|
||||
`/admin/photos/${photoId}/retry`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Build the SSE stream URL for an upload group. Caller is responsible
|
||||
* for opening an EventSource and merging the JSON-payload events into
|
||||
* their progress state. Falls back to polling getStatus() if the
|
||||
* EventSource fails to open (proxy buffering, etc.).
|
||||
*/
|
||||
streamUrl(uploadId: string): string {
|
||||
// EventSource doesn't send our auth headers, so we have to rely on
|
||||
// the cookie-based admin session. (PicPeak's auth middleware reads
|
||||
// cookies before falling back to Authorization headers.)
|
||||
return `${api.defaults.baseURL || ''}/admin/uploads/${uploadId}/stream`;
|
||||
},
|
||||
};
|
||||
Generated
+12
-26
@@ -10,8 +10,7 @@
|
||||
"node-fetch": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"puppeteer": "^24.17.0"
|
||||
}
|
||||
},
|
||||
@@ -41,13 +40,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
|
||||
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
|
||||
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.55.0"
|
||||
"playwright": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -562,19 +561,6 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
@@ -1131,13 +1117,13 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
|
||||
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.55.0"
|
||||
"playwright-core": "1.57.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -1150,9 +1136,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.55.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
|
||||
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
|
||||
"version": "1.57.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
|
||||
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
+1
-2
@@ -8,8 +8,7 @@
|
||||
"node-fetch": "^2.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.48.2",
|
||||
"dotenv": "^17.3.1",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"puppeteer": "^24.17.0"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
Reference in New Issue
Block a user