61f1d13210
Adds an opt-in lightbox preview tier so guests open photos against an
aspect-preserved ~1920px JPEG (~200–500 KB) instead of the full original
(often 5–12 MB). Originals are still served on Download.
Backend:
- imageProcessor: generatePreviewImage / isPreviewValid / ensurePreviewImage
using fit:'inside' + withoutEnlargement (longEdge 1920, q85, mozjpeg)
- migration 104: photos.preview_path + lightbox_preview_enabled setting
(off by default, JSON-stringified for SQLite/Postgres parity)
- GET /api/gallery/:slug/preview/:photoId — gallery-auth, lazy generation,
ETag based on mtime+photoId+watermarkHash
- preview_url surfaced in the photo response only when the toggle is on
- admin /thumbnails/regenerate-previews mirrors regenerate-thumbnails,
skipping videos
- backup walk + archive cleanup + photo-delete now include previews/
Frontend:
- PhotoLightbox uses photo.preview_url ?? photo.url (null-safe fallback)
- ThumbnailsTab gets a Lightbox Preview Tier card: opt-in toggle +
Regenerate All Previews button (gated until the toggle is on)
- en/de locale strings; nl/pt/ru/fr fall back to en
Tested end-to-end: 11 MB / 4000×3000 source → 985 KB / 1920×1440 preview,
381 ms first call, 7 ms cached, ~91% byte reduction.
201 lines
7.8 KiB
JavaScript
201 lines
7.8 KiB
JavaScript
const path = require('path');
|
||
const fs = require('fs').promises;
|
||
const fsSync = require('fs');
|
||
const os = require('os');
|
||
const crypto = require('crypto');
|
||
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
|
||
const sharp = require('sharp');
|
||
|
||
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
||
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
|
||
const storageModule = require('../../src/services/storage');
|
||
|
||
// Stub out the DB so getThumbnailSettings falls into its catch and uses defaults.
|
||
jest.mock('../../src/database/db', () => ({
|
||
db: () => {
|
||
throw new Error('db disabled in this test');
|
||
},
|
||
}));
|
||
|
||
const TEST_S3 = {
|
||
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
|
||
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
|
||
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
|
||
region: 'us-east-1',
|
||
};
|
||
|
||
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
|
||
|
||
function backendCases() {
|
||
const cases = [
|
||
{
|
||
name: 'LocalFsStorage',
|
||
async setup() {
|
||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-'));
|
||
const storage = new LocalFsStorage({ root });
|
||
await storage.init();
|
||
return { storage, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
|
||
},
|
||
},
|
||
];
|
||
if (!skipS3) {
|
||
cases.push({
|
||
name: 'S3StorageBackend (MinIO)',
|
||
async setup() {
|
||
const bucket = `picpeak-imgproc-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
|
||
const s3Client = new S3Client({
|
||
endpoint: TEST_S3.endpoint,
|
||
region: TEST_S3.region,
|
||
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
|
||
forcePathStyle: true,
|
||
});
|
||
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
|
||
const storage = new S3StorageBackend({
|
||
bucket,
|
||
region: TEST_S3.region,
|
||
endpoint: TEST_S3.endpoint,
|
||
accessKeyId: TEST_S3.accessKeyId,
|
||
secretAccessKey: TEST_S3.secretAccessKey,
|
||
forcePathStyle: true,
|
||
sslEnabled: false,
|
||
});
|
||
await storage.init();
|
||
return {
|
||
storage,
|
||
async cleanup() {
|
||
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
|
||
if (list.Contents?.length) {
|
||
await s3Client.send(new DeleteObjectsCommand({
|
||
Bucket: bucket,
|
||
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
|
||
}));
|
||
}
|
||
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
||
},
|
||
};
|
||
},
|
||
});
|
||
}
|
||
return cases;
|
||
}
|
||
|
||
async function makeSourceJpeg(targetDir, name) {
|
||
const localPath = path.join(targetDir, name);
|
||
// 800x600 random RGB image so sharp has something realistic to thumbnail.
|
||
const width = 800;
|
||
const height = 600;
|
||
const buf = Buffer.alloc(width * height * 3);
|
||
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
|
||
await sharp(buf, { raw: { width, height, channels: 3 } })
|
||
.jpeg({ quality: 90 })
|
||
.toFile(localPath);
|
||
return localPath;
|
||
}
|
||
|
||
describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
|
||
let storage;
|
||
let cleanup;
|
||
let tmpDir;
|
||
let imageProcessor;
|
||
|
||
beforeAll(async () => {
|
||
({ storage, cleanup } = await setup());
|
||
storageModule.setStorageForTesting(storage);
|
||
// Require AFTER setStorageForTesting so the module sees our injection.
|
||
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
||
imageProcessor = require('../../src/services/imageProcessor');
|
||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-src-'));
|
||
}, 30000);
|
||
|
||
afterAll(async () => {
|
||
storageModule.resetStorage();
|
||
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
||
if (cleanup) await cleanup();
|
||
});
|
||
|
||
test('generateThumbnail writes through storage and returns a relative key', async () => {
|
||
const src = await makeSourceJpeg(tmpDir, 'sample.jpg');
|
||
const key = await imageProcessor.generateThumbnail(src);
|
||
expect(key).toBe('thumbnails/thumb_sample.jpg');
|
||
|
||
expect(await storage.exists(key)).toBe(true);
|
||
const stat = await storage.stat(key);
|
||
expect(stat.size).toBeGreaterThan(100);
|
||
|
||
// Verify the bytes are a valid JPEG by re-parsing with sharp on local mode.
|
||
if (storage.kind() === 'local') {
|
||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||
expect(meta.format).toBe('jpeg');
|
||
expect(meta.width).toBeLessThanOrEqual(300);
|
||
}
|
||
});
|
||
|
||
test('generateHeroImage writes through storage and returns a relative key', async () => {
|
||
const src = await makeSourceJpeg(tmpDir, 'hero-source.jpg');
|
||
const key = await imageProcessor.generateHeroImage(src);
|
||
expect(key).toBe('heroes/hero_hero-source.jpg');
|
||
expect(await storage.exists(key)).toBe(true);
|
||
});
|
||
|
||
test('generatePreviewImage writes to /previews and skips enlargement of small originals', async () => {
|
||
const src = await makeSourceJpeg(tmpDir, 'preview-source.jpg');
|
||
const key = await imageProcessor.generatePreviewImage(src);
|
||
expect(key).toBe('previews/preview_preview-source.jpg');
|
||
expect(await storage.exists(key)).toBe(true);
|
||
|
||
if (storage.kind() === 'local') {
|
||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||
expect(meta.format).toBe('jpeg');
|
||
// Source is 800x600 and default longEdge is 1920 with
|
||
// withoutEnlargement: true → preview must NOT be upscaled.
|
||
expect(meta.width).toBe(800);
|
||
expect(meta.height).toBe(600);
|
||
}
|
||
});
|
||
|
||
test('generatePreviewImage shrinks oversized images to fit longEdge while preserving aspect', async () => {
|
||
const src = await makeSourceJpeg(tmpDir, 'preview-shrink.jpg');
|
||
const key = await imageProcessor.generatePreviewImage(src, { longEdge: 400 });
|
||
expect(await storage.exists(key)).toBe(true);
|
||
if (storage.kind() === 'local') {
|
||
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
|
||
// 800x600 → fit:'inside' inside 400×400 → 400×300.
|
||
expect(meta.width).toBe(400);
|
||
expect(meta.height).toBe(300);
|
||
}
|
||
});
|
||
|
||
test('isPreviewValid returns true for a real preview and false for a missing key', async () => {
|
||
const src = await makeSourceJpeg(tmpDir, 'preview-valid.jpg');
|
||
const key = await imageProcessor.generatePreviewImage(src);
|
||
expect(await imageProcessor.isPreviewValid(key)).toBe(true);
|
||
expect(await imageProcessor.isPreviewValid('previews/does-not-exist.jpg')).toBe(false);
|
||
});
|
||
|
||
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
|
||
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
|
||
const key = await imageProcessor.generateThumbnail(src);
|
||
expect(await imageProcessor.isThumbnailValid(key)).toBe(true);
|
||
expect(await imageProcessor.isThumbnailValid('thumbnails/does-not-exist.jpg')).toBe(false);
|
||
});
|
||
|
||
test('generateVideoPlaceholder writes a thumbnail entirely from buffer', async () => {
|
||
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4');
|
||
expect(key).toBe('thumbnails/thumb_demo.jpg');
|
||
expect(await storage.exists(key)).toBe(true);
|
||
});
|
||
|
||
test('withLocalCopy yields a usable local path on both backends', async () => {
|
||
const sourceKey = 'fixture/withlocal.jpg';
|
||
const src = await makeSourceJpeg(tmpDir, 'withlocal.jpg');
|
||
const buf = await fs.readFile(src);
|
||
await storage.put(sourceKey, buf, { contentType: 'image/jpeg' });
|
||
|
||
const seenSize = await imageProcessor.withLocalCopy(sourceKey, async (localPath) => {
|
||
const meta = await sharp(localPath).metadata();
|
||
return meta.width;
|
||
});
|
||
expect(seenSize).toBe(800);
|
||
});
|
||
});
|