From b83f4272b584f937fea1f47656182e514b12d980 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 8 Jan 2026 11:29:38 +0100 Subject: [PATCH 1/4] fix: JSON serialize favicon and logo URLs for PostgreSQL storage Fixes #84 The favicon and logo upload endpoints were storing URL paths directly without JSON.stringify(), causing PostgreSQL JSON validation errors ("Token '/' is invalid") since paths like "/uploads/favicons/..." are not valid JSON. Applied JSON.stringify() to: - branding_logo_url setting (lines 358, 364) - branding_favicon_url setting (lines 894, 900) --- backend/src/routes/adminSettings.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 35eb5e57..60087f09 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -355,13 +355,13 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl await db('app_settings') .insert({ setting_key: 'branding_logo_url', - setting_value: publicPath, + setting_value: JSON.stringify(publicPath), setting_type: 'branding', updated_at: new Date() }) .onConflict('setting_key') .merge({ - setting_value: publicPath, + setting_value: JSON.stringify(publicPath), updated_at: new Date() }); @@ -891,13 +891,13 @@ router.post('/favicon', adminAuth, requirePermission('settings.edit'), faviconUp await db('app_settings') .insert({ setting_key: 'branding_favicon_url', - setting_value: faviconUrl, + setting_value: JSON.stringify(faviconUrl), setting_type: 'branding', updated_at: new Date() }) .onConflict('setting_key') .merge({ - setting_value: faviconUrl, + setting_value: JSON.stringify(faviconUrl), updated_at: new Date() }); From 4872ef71f8b3c6aad9c95b6f5d3631efd87eecd3 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 8 Jan 2026 11:33:20 +0100 Subject: [PATCH 2/4] ci: only build ARM64 images for tagged releases QEMU emulation of ARM64 on x86 GitHub runners is too slow and unreliable for npm operations, causing builds to hang or crash with "Illegal instruction" errors. Changed platform detection logic to: - Tagged releases (v*.*.*): Build both amd64 and arm64 - All other builds (branches, PRs): Build amd64 only This ensures fast CI feedback during development while still providing multi-arch images for production releases. --- .github/workflows/docker-build.yml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 23dc13c0..faa24054 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -45,14 +45,14 @@ jobs: - name: Determine build platforms id: platforms run: | - # For PRs, build only amd64 to avoid QEMU emulation issues with Sharp - # For main/develop/tags, build multi-arch - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo "platforms=linux/amd64" >> $GITHUB_OUTPUT - echo "skip_qemu=true" >> $GITHUB_OUTPUT - else + # Only build ARM64 for tagged releases (v*.*.*) + # QEMU emulation is too slow/unreliable for npm operations on regular builds + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT echo "skip_qemu=false" >> $GITHUB_OUTPUT + else + echo "platforms=linux/amd64" >> $GITHUB_OUTPUT + echo "skip_qemu=true" >> $GITHUB_OUTPUT fi - name: Set up QEMU @@ -142,14 +142,14 @@ jobs: - name: Determine build platforms id: platforms run: | - # For PRs, build only amd64 to avoid QEMU emulation issues - # For main/develop/tags, build multi-arch - if [[ "${{ github.event_name }}" == "pull_request" ]]; then - echo "platforms=linux/amd64" >> $GITHUB_OUTPUT - echo "skip_qemu=true" >> $GITHUB_OUTPUT - else + # Only build ARM64 for tagged releases (v*.*.*) + # QEMU emulation is too slow/unreliable for npm operations on regular builds + if [[ "${{ github.ref }}" == refs/tags/v* ]]; then echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT echo "skip_qemu=false" >> $GITHUB_OUTPUT + else + echo "platforms=linux/amd64" >> $GITHUB_OUTPUT + echo "skip_qemu=true" >> $GITHUB_OUTPUT fi - name: Set up QEMU From 0d5ce48dccf0c61f210725ffae15dafc5e9f7cab Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 8 Jan 2026 11:44:29 +0100 Subject: [PATCH 3/4] fix: handle legacy non-JSON logo paths when replacing logo When uploading a new logo, the code tries to delete the old logo file. This failed when the old path was stored as a raw path (legacy format) instead of JSON-serialized. Added check to handle both formats. --- backend/src/routes/adminSettings.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 60087f09..e655e6e3 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -326,8 +326,12 @@ router.post('/logo', adminAuth, requirePermission('settings.edit'), upload.singl .first(); if (oldLogoSetting && oldLogoSetting.setting_value) { - const oldPath = JSON.parse(oldLogoSetting.setting_value); try { + // Handle both JSON-serialized and legacy raw path values + let oldPath = oldLogoSetting.setting_value; + if (oldPath.startsWith('"')) { + oldPath = JSON.parse(oldPath); + } await fs.unlink(oldPath); } catch (error) { console.error('Failed to delete old logo:', error); From 1931d73b60d3419203cc8b420841abbfc9e14d2d Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 8 Jan 2026 13:56:02 +0100 Subject: [PATCH 4/4] fix: resolve branding display issues and invitation parsing errors Fixes #84 - Logo and favicon not displaying on branding page and galleries Fixes #85 - Invitations showing undefined expiresAt causing parseISO errors Changes: - Fix nginx.conf: Add ^~ modifier to /uploads location to prioritize proxy over static file matching - Fix vite.config.ts: Add /uploads proxy for development environment - Fix BrandingPage.tsx: Include logo_url from branding settings instead of expecting it from theme - Fix adminUsers.js: Add transformInvitation() to convert snake_case DB fields to camelCase API response - Fix publicSettings.js: Add branding_hide_powered_by to public settings API response - Update README.md: Mark Multiple Administrators feature as implemented - Bump version to 2.2.1 --- README.md | 2 +- backend/package.json | 2 +- backend/src/routes/adminUsers.js | 16 +++++++++++++++- backend/src/routes/publicSettings.js | 1 + frontend/nginx.conf | 3 ++- frontend/package.json | 2 +- frontend/src/pages/admin/BrandingPage.tsx | 17 +++++++++-------- frontend/vite.config.ts | 4 ++++ 8 files changed, 34 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4e802ae9..30b07bbd 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ These features are currently in beta testing and may have limited functionality | **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open | | **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented | | **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented | -| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned | +| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented | | **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented | **Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned diff --git a/backend/package.json b/backend/package.json index 4240c43f..3f3a7906 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "1.1.15", + "version": "2.2.1", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "scripts": { diff --git a/backend/src/routes/adminUsers.js b/backend/src/routes/adminUsers.js index 82d09abc..38d5ded3 100644 --- a/backend/src/routes/adminUsers.js +++ b/backend/src/routes/adminUsers.js @@ -45,6 +45,20 @@ function transformRole(role) { }; } +/** + * Transform invitation object from snake_case (DB) to camelCase (API) + */ +function transformInvitation(invitation) { + return { + id: invitation.id, + email: invitation.email, + expiresAt: invitation.expires_at, + createdAt: invitation.created_at, + roleName: invitation.role_name, + invitedBy: invitation.invited_by + }; +} + /** * GET /me/permissions * Get current user's permissions @@ -81,7 +95,7 @@ router.get('/roles', adminAuth, requirePermission('users.view'), handleAsync(asy */ router.get('/invitations', adminAuth, requirePermission('users.view'), handleAsync(async (req, res) => { const invitations = await userManagementService.getPendingInvitations(); - res.json({ invitations }); + res.json({ invitations: invitations.map(transformInvitation) }); })); /** diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 2e41f182..b6e9ac56 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -58,6 +58,7 @@ router.get('/', async (req, res) => { branding_logo_display_header: settingsObject.branding_logo_display_header !== false, branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false, branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text', + branding_hide_powered_by: settingsObject.branding_hide_powered_by === true, theme_config: settingsObject.theme_config || null, default_language: settingsObject.general_default_language || 'en', enable_analytics: settingsObject.general_enable_analytics !== false, diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 2a9b5c54..977eb899 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -88,7 +88,8 @@ server { } # Uploads serving proxy (logos, favicons, watermarks) - location /uploads { + # ^~ modifier stops regex matching, ensuring uploads are proxied not served locally + location ^~ /uploads { proxy_pass http://backend:3001; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/frontend/package.json b/frontend/package.json index 569c4976..647168bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "1.1.15", + "version": "2.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index 74fd32cf..4108ac90 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -81,9 +81,8 @@ export const BrandingPage: React.FC = () => { useEffect(() => { if (settings) { const formatted = settingsService.formatBrandingSettings(settings); - // Don't set logo_url here - it will be synced from theme - const { logo_url, ...brandingWithoutLogo } = formatted; - setBrandingSettings(prev => ({ ...prev, ...brandingWithoutLogo })); + // Include logo_url from branding settings + setBrandingSettings(prev => ({ ...prev, ...formatted })); } }, [settings]); @@ -91,15 +90,17 @@ export const BrandingPage: React.FC = () => { useEffect(() => { if (themeSettings) { const formatted = settingsService.formatThemeSettings(themeSettings) as ThemeConfig; - + if (formatted && Object.keys(formatted).length > 0) { // Use the theme's logo URL as stored in the theme config setCurrentTheme(formatted); setTheme(formatted); - - // Always sync the logo URL from theme to branding settings - theme is source of truth - setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl || '' })); - + + // Only sync logo URL from theme if it exists there (logo is stored in branding settings) + if (formatted.logoUrl) { + setBrandingSettings(prev => ({ ...prev, logo_url: formatted.logoUrl })); + } + // Try to identify which preset this matches for (const [key, preset] of Object.entries(GALLERY_THEME_PRESETS)) { if (JSON.stringify(preset.config) === JSON.stringify(formatted)) { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 15c9255b..80aae11a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -36,6 +36,10 @@ const config: VitestUserConfig = { target: 'http://localhost:7101', changeOrigin: true, }, + '/uploads': { + target: 'http://localhost:7101', + changeOrigin: true, + }, }, } }