feat: add Apple Liquid Glass templates, image security settings, and automated releases

## New Features
- Apple Liquid Glass CSS template with iOS 26-inspired design
- Liquid Glass Dark theme with neon accents
- Image Security settings tab with per-event protection levels
- Release Please automation for versioning and changelog

## Improvements
- Update CSS template migration with final working templates
- Add search placeholder visibility fix for glass themes
- Update README roadmap (Download Protection, Gallery Templates, Filtering & Export now implemented)

## Infrastructure
- Add release-please.yml workflow for automated releases
- Add release-please-config.json and manifest
- Update docker-build.yml with Release Please integration comments
- Add comprehensive CHANGELOG.md

## Cleanup
- Add working/planning docs to .gitignore (CLAUDE.md, test-*.md, feature-*.md, etc.)
- Remove internal planning documents from git tracking (kept locally)

## Files Added
- .github/workflows/release-please.yml
- .release-please-manifest.json
- release-please-config.json
- CHANGELOG.md
- frontend/src/features/settings/tabs/ImageSecurityTab.tsx
This commit is contained in:
Paul Nothaft
2026-01-03 23:35:23 +01:00
parent f3c2cee362
commit 6033461be1
44 changed files with 1978 additions and 9443 deletions
+8 -1
View File
@@ -533,7 +533,7 @@ router.put('/:id', adminAuth, [
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
body('customer_name').optional().trim().notEmpty(),
body('customer_name').optional({ nullable: true, checkFalsy: true }).trim(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('upload_category_id').optional().custom((value) => {
// Accept null, undefined, or integer values
@@ -554,6 +554,13 @@ router.put('/:id', adminAuth, [
body('source_mode').optional().isIn(['managed', 'reference']),
body('external_path').optional({ nullable: true }).isString().trim(),
body('require_password').optional().isBoolean(),
// Download protection settings
body('protection_level').optional().isIn(['basic', 'standard', 'enhanced', 'maximum']),
body('enable_devtools_protection').optional().isBoolean(),
body('use_canvas_rendering').optional().isBoolean(),
body('overlay_protection').optional().isBoolean(),
body('image_quality').optional().isInt({ min: 1, max: 100 }),
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
body('password').optional().isString().custom((value, { req }) => {
if (value === undefined || value === null || value === '') {
return true;
+5 -3
View File
@@ -31,13 +31,15 @@ router.get('/settings', adminAuth, async (req, res) => {
const config = {};
settings.forEach(setting => {
config[setting.setting_key] = JSON.parse(setting.setting_value);
// PostgreSQL JSON columns are already parsed by the driver
// Just use the value directly - no need to JSON.parse
config[setting.setting_key] = setting.setting_value;
});
res.json(config);
} catch (error) {
logger.error('Error getting image security settings', { error: error.message });
res.status(500).json({ error: 'Failed to get security settings' });
logger.error('Error getting image security settings', { error: error.message, stack: error.stack });
res.status(500).json({ error: 'Failed to get security settings', details: error.message });
}
});
+46 -17
View File
@@ -110,7 +110,9 @@ router.get('/:slug/info', async (req, res) => {
'watermark_downloads',
'watermark_text',
'require_password',
'color_theme'
'color_theme',
'enable_devtools_protection',
'use_canvas_rendering'
)
.first();
@@ -154,7 +156,9 @@ router.get('/:slug/info', async (req, res) => {
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
watermark_text: event.watermark_text
watermark_text: event.watermark_text,
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1'
});
} catch (error) {
console.error('Error fetching gallery info:', error);
@@ -315,6 +319,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
disable_right_click: req.event.disable_right_click === true,
watermark_downloads: req.event.watermark_downloads === true,
watermark_text: req.event.watermark_text,
enable_devtools_protection: req.event.enable_devtools_protection === true,
use_canvas_rendering: req.event.use_canvas_rendering === true,
...protectionSettings
},
categories: categories,
@@ -397,19 +403,27 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
return res.status(404).json({ error: 'Photo file not found' });
}
// Get watermark settings
// Get watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) {
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
if (shouldApplyWatermark) {
// Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
// Use event watermark text if available, otherwise fall back to global settings
const effectiveSettings = {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
};
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': `attachment; filename="${photo.filename}"`,
'Content-Length': watermarkedBuffer.length
});
res.send(watermarkedBuffer);
} else {
// Send original file
@@ -468,9 +482,16 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
archive.pipe(res);
// Get watermark settings
// Get watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
const effectiveSettings = shouldApplyWatermark ? {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
// Add photos to archive
for (const photo of photos) {
let filePath;
@@ -485,7 +506,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
});
continue;
}
// Determine the file name in the archive
let archiveName;
if (hasMultipleTypes) {
@@ -496,10 +517,10 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
// No folders, just the filename
archiveName = photo.filename;
}
if (watermarkSettings && watermarkSettings.enabled) {
if (shouldApplyWatermark && effectiveSettings) {
try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
archive.append(watermarkedBuffer, { name: archiveName });
} catch (watermarkError) {
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
@@ -586,15 +607,23 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
});
archive.pipe(res);
// Check watermark settings similar to download-all
// Check watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
const effectiveSettings = shouldApplyWatermark ? {
...watermarkSettings,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
} : null;
for (const photo of photos) {
try {
const filePath = resolvePhotoFilePath(req.event, photo);
const name = photo.filename || `photo-${photo.id}.jpg`;
if (watermarkSettings && watermarkSettings.enabled) {
if (shouldApplyWatermark && effectiveSettings) {
try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
archive.append(watermarkedBuffer, { name });
} catch (watermarkError) {
logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {