From 3cdc0ea7152e63cd72124a91394741a6e6904af3 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 12 Jan 2026 13:20:39 +0100 Subject: [PATCH 1/5] fix: prevent unnecessary image recompression and fix SQLite migration #95 - Skip image processing for basic/standard protection levels when no fingerprinting or watermarking is enabled - Preserve original image format (PNG/WebP/JPEG) instead of always converting to JPEG - Fix SQLite migration failure for fresh installations by adding multilingual columns to email_templates table before inserting admin email templates Fixes #95 --- .../core/059_add_admin_email_templates.js | 75 +++++++++++++++++++ backend/src/routes/protectedImages.js | 60 +++++++++------ backend/src/services/secureImageService.js | 71 ++++++++++++++++-- 3 files changed, 175 insertions(+), 31 deletions(-) diff --git a/backend/migrations/core/059_add_admin_email_templates.js b/backend/migrations/core/059_add_admin_email_templates.js index bd25b1db..6f2034f1 100644 --- a/backend/migrations/core/059_add_admin_email_templates.js +++ b/backend/migrations/core/059_add_admin_email_templates.js @@ -3,6 +3,81 @@ * These templates support the RBAC (Role-Based Access Control) feature */ exports.up = async function(knex) { + // First, ensure the email_templates table has multilingual columns + // This is needed for fresh installations where legacy migrations don't run + const columnInfo = await knex('email_templates').columnInfo(); + + if (!columnInfo.subject_en) { + // Need to add multilingual columns + console.log('Adding multilingual columns to email_templates table...'); + + // Check if we're using SQLite or PostgreSQL + const client = knex.client.config.client; + const isSqlite = client === 'sqlite3' || client === 'better-sqlite3'; + + if (isSqlite) { + // SQLite doesn't support column rename directly in all versions + // We need to recreate the table with new structure + + // Get existing data + const existingData = await knex('email_templates').select('*'); + + // Drop the old table + await knex.schema.dropTable('email_templates'); + + // Create new table with multilingual columns + await knex.schema.createTable('email_templates', (table) => { + table.increments('id').primary(); + table.string('template_key').unique().notNullable(); + table.string('subject_en'); + table.string('subject_de'); + table.text('body_html_en'); + table.text('body_html_de'); + table.text('body_text_en'); + table.text('body_text_de'); + table.json('variables'); + table.datetime('updated_at').defaultTo(knex.fn.now()); + }); + + // Re-insert existing data with column mapping + for (const row of existingData) { + await knex('email_templates').insert({ + template_key: row.template_key, + subject_en: row.subject, + subject_de: row.subject, // Copy to German as default + body_html_en: row.body_html, + body_html_de: row.body_html, + body_text_en: row.body_text, + body_text_de: row.body_text, + variables: row.variables, + updated_at: row.updated_at + }); + } + + console.log('Migrated email_templates table to multilingual structure'); + } else { + // PostgreSQL supports ALTER TABLE for column operations + await knex.schema.alterTable('email_templates', (table) => { + table.renameColumn('subject', 'subject_en'); + table.renameColumn('body_html', 'body_html_en'); + table.renameColumn('body_text', 'body_text_en'); + }); + + await knex.schema.alterTable('email_templates', (table) => { + table.string('subject_de'); + table.text('body_html_de'); + table.text('body_text_de'); + }); + + // Copy English values to German as defaults + await knex('email_templates').update({ + subject_de: knex.raw('subject_en'), + body_html_de: knex.raw('body_html_en'), + body_text_de: knex.raw('body_text_en') + }); + } + } + // Check which templates already exist const existingTemplates = await knex('email_templates') .select('template_key') diff --git a/backend/src/routes/protectedImages.js b/backend/src/routes/protectedImages.js index 7f51283e..d99d230f 100644 --- a/backend/src/routes/protectedImages.js +++ b/backend/src/routes/protectedImages.js @@ -90,38 +90,50 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) = }, 'view'); // Get protection settings from event + const eventProtectionLevel = req.event.protection_level || protectionLevel; const protectionSettings = { - protectionLevel: req.event.protection_level || protectionLevel, + protectionLevel: eventProtectionLevel, quality: req.event.image_quality || 85, addFingerprint: req.event.add_fingerprint !== false, - fragmentImage: protectionLevel === 'maximum' + fragmentImage: eventProtectionLevel === 'maximum' }; - + // Build full path to photo const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path); - - // Process image with protection - const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings); - - // Apply watermark if enabled + + // For basic/standard protection without special features, serve original file + // This avoids unnecessary recompression + const needsProcessing = eventProtectionLevel === 'enhanced' || + eventProtectionLevel === 'maximum' || + protectionSettings.addFingerprint; + let finalImage; - if (processedImage.type === 'fragmented') { - // Return fragmented image data for canvas reconstruction - return res.json({ - type: 'fragmented', - fragments: processedImage.fragments.map(f => ({ - index: f.index, - row: f.row, - col: f.col, - data: f.buffer.toString('base64'), - position: f.position - })), - dimensions: processedImage.originalDimensions, - fragmentDimensions: processedImage.fragmentDimensions - }); + + if (!needsProcessing) { + // Serve original file without processing + const fs = require('fs').promises; + finalImage = await fs.readFile(photoPath); } else { - const watermarkSettings = await watermarkService.getWatermarkSettings(); - finalImage = await watermarkService.applyWatermark(photoPath, watermarkSettings); + // Process image with protection measures + const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings); + + if (processedImage.type === 'fragmented') { + // Return fragmented image data for canvas reconstruction + return res.json({ + type: 'fragmented', + fragments: processedImage.fragments.map(f => ({ + index: f.index, + row: f.row, + col: f.col, + data: f.buffer.toString('base64'), + position: f.position + })), + dimensions: processedImage.originalDimensions, + fragmentDimensions: processedImage.fragmentDimensions + }); + } + + finalImage = processedImage; } // Set security headers diff --git a/backend/src/services/secureImageService.js b/backend/src/services/secureImageService.js index 5f818e8a..67338761 100644 --- a/backend/src/services/secureImageService.js +++ b/backend/src/services/secureImageService.js @@ -157,6 +157,8 @@ class SecureImageService { /** * Process image with protection measures + * For basic/standard protection without fingerprinting, returns original file + * For enhanced/maximum protection, applies quality reduction and fingerprinting */ async processProtectedImage(imagePath, options = {}) { const { @@ -169,11 +171,58 @@ class SecureImageService { } = options; try { + // For basic protection level, always return original file without processing + if (protectionLevel === 'basic') { + return await fs.readFile(imagePath); + } + + // For standard protection without fingerprinting, return original file + // This avoids unnecessary recompression when no protection features are needed + if (protectionLevel === 'standard' && !addFingerprint && !fragmentImage) { + return await fs.readFile(imagePath); + } + + // Get metadata to check if processing is actually needed + const metadata = await sharp(imagePath).metadata(); + + // For standard protection with fingerprint only (no resize needed, no quality change), + // we can add fingerprint without full recompression by preserving format + const needsResize = metadata.width > maxWidth || metadata.height > maxHeight; + const needsQualityReduction = protectionLevel === 'enhanced' || protectionLevel === 'maximum'; + + // If standard protection and only fingerprinting is needed, and image doesn't need resize, + // just add metadata without recompressing + if (protectionLevel === 'standard' && addFingerprint && !needsResize) { + let image = sharp(imagePath); + + // Add fingerprint to metadata without changing image quality + const fingerprint = crypto.randomBytes(16).toString('hex'); + + // Preserve original format with high quality + const format = metadata.format || 'jpeg'; + if (format === 'png') { + image = image.png({ compressionLevel: 6 }); + } else if (format === 'webp') { + image = image.webp({ quality: 95 }); + } else { + image = image.jpeg({ quality: 100, mozjpeg: true }); + } + + image = image.withMetadata({ + exif: { + [sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}` + } + }); + + return await image.toBuffer(); + } + + // For enhanced/maximum protection or when resize is needed, do full processing let image = sharp(imagePath); - const metadata = await image.metadata(); + let effectiveQuality = quality; // Resize if too large - if (metadata.width > maxWidth || metadata.height > maxHeight) { + if (needsResize) { image = image.resize(maxWidth, maxHeight, { fit: 'inside', withoutEnlargement: true @@ -182,18 +231,26 @@ class SecureImageService { // Apply quality reduction for protection if (protectionLevel === 'enhanced') { - quality = Math.min(quality, 70); + effectiveQuality = Math.min(quality, 70); } else if (protectionLevel === 'maximum') { - quality = Math.min(quality, 60); + effectiveQuality = Math.min(quality, 60); } - // Convert to appropriate format - image = image.jpeg({ quality, progressive: true }); + // Preserve original format when possible, apply quality settings + const format = metadata.format || 'jpeg'; + if (format === 'png' && !needsQualityReduction) { + image = image.png({ compressionLevel: 6 }); + } else if (format === 'webp') { + image = image.webp({ quality: effectiveQuality }); + } else { + // JPEG or when quality reduction is needed (convert to JPEG) + image = image.jpeg({ quality: effectiveQuality, progressive: true }); + } // Add invisible watermark/fingerprint if (addFingerprint) { const fingerprint = crypto.randomBytes(16).toString('hex'); - + // Embed fingerprint in metadata image = image.withMetadata({ exif: { From bd8b885f7f060160eb852870d143f25ce628f3db Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 12 Jan 2026 13:23:24 +0100 Subject: [PATCH 2/5] fix: display new password after admin password reset - show-admin-credentials.js --reset now displays the generated password instead of just saying "[NEWLY RESET - stored in database]" - Also sets must_change_password flag to force password change on login - Updated DEPLOYMENT_GUIDE.md and SIMPLE_SETUP.md to clarify that the new password is displayed in console output after reset --- DEPLOYMENT_GUIDE.md | 186 +++++----------------- SIMPLE_SETUP.md | 2 + backend/scripts/show-admin-credentials.js | 10 +- 3 files changed, 50 insertions(+), 148 deletions(-) diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index 9d13a68d..3e0cbece 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -2,9 +2,23 @@ This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations. -## 🎯 Quick Start - Simple Setup (Recommended for Beginners) +## 📋 Table of Contents -For the easiest installation without Docker or complex configurations, use our **unified setup script**: +- [Quick Start](#-quick-start) +- [Prerequisites](#prerequisites) +- [Configuration](#-configuration) +- [Deployment](#-deployment) +- [First Login](#-first-login) +- [Reverse Proxy Setup](#-reverse-proxy-setup) +- [External Media Library](#external-media-library) +- [Maintenance](#-maintenance) +- [Troubleshooting](#-troubleshooting) + +## 🚀 Quick Start + +### Option 1: Automated Setup Script (Easiest) + +For the simplest installation, use our unified setup script: ```bash curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \ @@ -12,27 +26,11 @@ chmod +x picpeak-setup.sh && \ sudo ./picpeak-setup.sh ``` -This automated script handles everything including: -- Choice between Docker or Native installation -- OS detection and dependency installation -- Database setup and service configuration -- SSL/HTTPS setup (optional) - -Perfect for: -- Small to medium deployments -- Local or VPS installations -- Users new to server management -- Quick testing and evaluation +This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL. 👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.** ---- - -## 🐳 Docker Compose Deployment - -### Option 1: Using Pre-built Images (Recommended) - -PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building: +### Option 2: Docker with Pre-built Images (Recommended) ```bash # Clone repository for configuration files @@ -43,35 +41,33 @@ cd picpeak cp .env.example .env nano .env # Edit with your values -# Use pre-built images deployment +# Create required directories +mkdir -p events/active events/archived data logs backup storage +chmod -R 755 events data logs backup storage + +# Deploy using pre-built images docker compose -f docker-compose.production.yml up -d + +# Check logs +docker compose -f docker-compose.production.yml logs -f ``` -The production compose file uses: -- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest` -- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest` +Available image tags: `latest` (stable), `main`, `develop`, `v1.0.0` (version tags) -Available tags: -- `latest` - Latest stable release -- `main` - Latest main branch build -- `develop` - Development branch (may be unstable) -- `v1.0.0` - Specific version tags +### Option 3: Build from Source -### Option 2: Building from Source +```bash +git clone https://github.com/the-luap/picpeak.git +cd picpeak +cp .env.example .env +nano .env # Edit with your values -If you need to customize the application or the pre-built images aren't available, you can build locally: +mkdir -p events/active events/archived data logs backup storage +chmod -R 755 events data logs backup storage -## 📋 Table of Contents - -- [Prerequisites](#prerequisites) -- [Quick Start](#quick-start) -- [Configuration](#configuration) -- [Deployment](#deployment) -- [First Login](#first-login) -- [Reverse Proxy Setup](#reverse-proxy-setup) -- [Maintenance](#maintenance) -- [Troubleshooting](#troubleshooting) - - [External Media Library](#external-media-library) +docker compose build +docker compose up -d +``` ## Prerequisites @@ -80,106 +76,6 @@ If you need to customize the application or the pre-built images aren't availabl - SMTP server credentials for emails - At least 2GB RAM and 20GB storage -## 🚀 Quick Start - -### Method 1: Using Pre-built Images (Fastest) - -1. **Clone the repository for configs** - ```bash - git clone https://github.com/the-luap/picpeak.git - cd picpeak - ``` - -2. **Set up environment** - ```bash - cp .env.example .env - nano .env # Edit with your values - ``` - -3. **Create required directories** - ```bash - mkdir -p events/active events/archived data logs backup storage - chmod -R 755 events data logs backup storage - ``` - -4. **Deploy using pre-built images** - ```bash - docker compose -f docker-compose.production.yml up -d - ``` - -5. **Check logs** - ```bash - docker compose -f docker-compose.production.yml logs -f - ``` - -## External Media Library - -PicPeak can reference an existing, read‑only media library mounted into the backend container. This avoids copying originals into PicPeak storage. - -- Map your host library path to the container as read‑only in `docker-compose.production.yml`: - - Add volume under `backend`: `- ${EXTERNAL_MEDIA}:/external-media:ro` - - Add backend env: `EXTERNAL_MEDIA_ROOT=/external-media` -- In `.env`, set: - - `EXTERNAL_MEDIA=/mnt/photos` (example host path) - - `EXTERNAL_MEDIA_ROOT=/external-media` - -Usage: -- In Admin → Events, set “Source Mode” to “Reference (external folder)”, select a folder under `/external-media`, then import to index and generate thumbnails. Originals stay in your library. - -Backups and Archives: -- Backups only include data under `STORAGE_PATH` and exclude external originals. The backup manifest includes `metadata.external_references = { excluded: true, events: N, photos: M }` and the Admin UI surfaces a warning. -- Archiving reference events creates a manifest‑only ZIP and deletes thumbnails for that event. External originals are never moved or deleted. - -Local (npm) setup (no Docker): - -1. Create or choose a folder that contains your external originals, e.g. `/Users/you/Pictures/picpeak-external` (macOS/Linux) or `C:\\Pictures\\picpeak-external` (Windows). -2. In `backend/.env` (or your shell), set: - - `EXTERNAL_MEDIA_ROOT=/absolute/path/to/picpeak-external` - - Ensure `STORAGE_PATH` points to your PicPeak storage (defaults to `./storage`). -3. Start services from source: - - Backend: `cd backend && npm install && npm run migrate && JWT_SECRET=... npm start` - - Frontend: `cd frontend && npm install && npm run dev` (or build + serve) -4. In Admin → Events: - - Create an event, set “Source Mode” to “Reference (external folder)”. - - Use the folder picker to browse under your `EXTERNAL_MEDIA_ROOT` and select the subfolder to reference. - - Click “Import from selected folder” to index files and generate thumbnails on demand. - -Notes: -- PicPeak only reads from `EXTERNAL_MEDIA_ROOT`; it never modifies or deletes your originals there. -- Thumbnails are generated under `STORAGE_PATH/thumbnails` and are included in backups; originals in `EXTERNAL_MEDIA_ROOT` are excluded. -- On Windows, use absolute paths (e.g., `C:\\Photos\\Library`) for `EXTERNAL_MEDIA_ROOT`. - -### Method 2: Building from Source - -1. **Clone the repository** - ```bash - git clone https://github.com/the-luap/picpeak.git - cd picpeak - ``` - -2. **Set up environment** - ```bash - cp .env.example .env - nano .env # Edit with your values - ``` - -3. **Create required directories** - ```bash - mkdir -p events/active events/archived data logs backup storage - chmod -R 755 events data logs backup storage - ``` - -4. **Build and deploy** - ```bash - docker compose build - docker compose up -d - ``` - -5. **Check logs** - ```bash - docker compose logs -f - ``` - ## 🔧 Configuration ### Essential Environment Variables @@ -358,14 +254,16 @@ docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt # Show current admin username and email (password is hidden) docker exec picpeak-backend node scripts/show-admin-credentials.js -# Reset the admin password to a new random password +# Reset the admin password to a new random password (displays new password in console) docker exec picpeak-backend node scripts/show-admin-credentials.js --reset ``` +> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again! + #### Important Security Notes - **Login requires the email address**, not username -- The admin password is only displayed once during initial setup +- When resetting password, the new password is displayed once in the console - save it immediately - **Password change is MANDATORY** on first login - the system will force you to change it - If you lose the password before first login, use the `--reset` option to generate a new one - New password requirements: minimum 12 characters, mixed case, numbers, and special characters diff --git a/SIMPLE_SETUP.md b/SIMPLE_SETUP.md index c5f80d74..b2b9ac56 100644 --- a/SIMPLE_SETUP.md +++ b/SIMPLE_SETUP.md @@ -451,6 +451,8 @@ cd /opt/picpeak/app/backend sudo -u picpeak node scripts/reset-admin-password.js ``` +> **Note:** The new password will be displayed in the console output and saved to `ADMIN_PASSWORD_RESET.txt`. Save it immediately! + ### Getting Help 1. **Check logs:** diff --git a/backend/scripts/show-admin-credentials.js b/backend/scripts/show-admin-credentials.js index 1215ff14..312b58b5 100644 --- a/backend/scripts/show-admin-credentials.js +++ b/backend/scripts/show-admin-credentials.js @@ -36,12 +36,14 @@ async function showAdminCredentials(resetPassword = false) { .where('id', admin.id) .update({ password_hash: passwordHash, + must_change_password: true, updated_at: new Date() }); - - // Password logging removed for security - check logs or database if needed - console.log('Password: [NEWLY RESET - stored in database]'); - console.log('\n⚠️ IMPORTANT: New password has been set in database!'); + + console.log(`Password: ${newPassword}`); + console.log('\n⚠️ IMPORTANT:'); + console.log('1. Save this password securely - it will not be shown again'); + console.log('2. You will be required to change it on next login'); } else { console.log('Password: [hidden - use --reset flag to generate new password]'); } From 0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 12 Jan 2026 13:24:21 +0100 Subject: [PATCH 3/5] fix: watermark upload JSON parsing and image quality preservation - Fix JSON parsing error when uploading watermark logo by handling both JSON-stringified and raw string paths - Ensure publicPath is JSON.stringify'd consistently when saving - Preserve original image format (PNG/WebP/JPEG) when applying watermarks - Use maximum quality (100) to prevent unnecessary recompression --- backend/src/routes/adminSettings.js | 22 +++++++++++++------ backend/src/services/watermarkService.js | 27 +++++++++++++++++------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index e655e6e3..b92c5ec7 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -392,11 +392,21 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e .first(); if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) { - const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value); + let oldPath; try { - await fs.unlink(oldPath); - } catch (error) { - console.error('Failed to delete old watermark logo:', error); + // Try to parse as JSON first (for JSON-stringified paths) + oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value); + } catch (e) { + // If it's not valid JSON, use the raw value + oldPath = oldWatermarkLogoSetting.setting_value; + } + + if (oldPath && typeof oldPath === 'string') { + try { + await fs.unlink(oldPath); + } catch (error) { + console.error('Failed to delete old watermark logo:', error); + } } } @@ -421,13 +431,13 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e await db('app_settings') .insert({ setting_key: 'branding_watermark_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() }); diff --git a/backend/src/services/watermarkService.js b/backend/src/services/watermarkService.js index 8c5de36c..2ef44a6c 100644 --- a/backend/src/services/watermarkService.js +++ b/backend/src/services/watermarkService.js @@ -177,14 +177,25 @@ class WatermarkService { settings.position ); - // Apply watermark - const watermarkedBuffer = await image - .composite([{ - input: watermarkBuffer, - top: position.top, - left: position.left - }]) - .toBuffer(); + // Apply watermark with high quality output to preserve original image quality + let watermarkedImage = image.composite([{ + input: watermarkBuffer, + top: position.top, + left: position.left + }]); + + // Preserve original format with high quality settings + const format = metadata.format || 'jpeg'; + let watermarkedBuffer; + + if (format === 'png') { + watermarkedBuffer = await watermarkedImage.png({ quality: 100, compressionLevel: 6 }).toBuffer(); + } else if (format === 'webp') { + watermarkedBuffer = await watermarkedImage.webp({ quality: 95, lossless: false }).toBuffer(); + } else { + // Default to JPEG with maximum quality (100) to prevent recompression + watermarkedBuffer = await watermarkedImage.jpeg({ quality: 100, mozjpeg: true }).toBuffer(); + } // Cache the result this.cache.set(cacheKey, { From e3c3c4c951c52de99bd0afd95b08d119153997b4 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 15 Jan 2026 11:22:13 +0100 Subject: [PATCH 4/5] fix: gallery thumbnails not loading (404 errors) #96 The gallery thumbnail endpoint was returning 404 when thumbnail_path was null or the file didn't exist, unlike the admin endpoint which generates thumbnails on demand using ensureThumbnail(). - Import ensureThumbnail from imageProcessor - Use ensureThumbnail() in gallery thumbnail route to generate thumbnails on demand if they don't exist - This matches the admin endpoint behavior Fixes #96 --- backend/src/routes/gallery.js | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 4cc63c0f..4b5b7a40 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -12,6 +12,7 @@ const { resolvePhotoFilePath } = require('../services/photoResolver'); const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); const { handleAsync } = require('../utils/routeHelpers'); const { NotFoundError } = require('../utils/errors'); +const { ensureThumbnail } = require('../services/imageProcessor'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); @@ -787,30 +788,30 @@ router.get('/:slug/photo/:photoId', ); // Serve thumbnail -router.get('/:slug/thumbnail/:photoId', - verifyGalleryAccess, +router.get('/:slug/thumbnail/:photoId', + verifyGalleryAccess, async (req, res) => { try { const { photoId } = req.params; - + const photo = await db('photos') .where({ id: photoId, event_id: req.event.id }) .first(); - - if (!photo || !photo.thumbnail_path) { - return res.status(404).json({ error: 'Thumbnail not found' }); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); } - - const thumbPath = path.join(getStoragePath(), photo.thumbnail_path); - - // Check if file exists - const fs = require('fs').promises; - try { - await fs.access(thumbPath); - } catch (error) { - return res.status(404).json({ error: 'Thumbnail file not found' }); + + // Ensure thumbnail exists and is valid, regenerate if needed + const thumbnailPath = await ensureThumbnail(photo); + + if (!thumbnailPath) { + logger.error(`Failed to generate thumbnail for photo ${photoId}`); + return res.status(404).json({ error: 'Thumbnail generation failed' }); } + const thumbPath = path.join(getStoragePath(), thumbnailPath); + // Log thumbnail access await secureImageService.logImageAccess( photoId, @@ -818,7 +819,7 @@ router.get('/:slug/thumbnail/:photoId', req.clientInfo, 'thumbnail' ); - + // Set appropriate headers with enhanced security res.set({ 'Content-Type': 'image/jpeg', @@ -827,7 +828,7 @@ router.get('/:slug/thumbnail/:photoId', 'X-Content-Type-Options': 'nosniff', 'X-Protected-Thumbnail': 'true' }); - + // Send file res.sendFile(path.resolve(thumbPath)); } catch (error) { From 617e778a48e0f0c24fcb8441d00ed2a816f19c03 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 15 Jan 2026 12:11:06 +0100 Subject: [PATCH 5/5] feat: implement beta/stable release channels with update notifications Add dual-channel release strategy for stable and beta releases: Release Channels: - Stable channel: production-ready releases (stable, latest, v2.3.0) - Beta channel: early access features (beta, v2.3.0-beta.1) - Configurable via PICPEAK_CHANNEL environment variable Update Notifications: - Admin dashboard shows available updates for configured channel - Checks GitHub Releases API with 1-hour cache - Can be disabled with UPDATE_CHECK_ENABLED=false CI/CD Changes: - New release-please-beta.yml workflow for beta prereleases - Docker build workflow produces stable/beta tags based on branch - Beta versions use v2.3.0-beta.1 format New Files: - .github/workflows/release-please-beta.yml - release-please-config-beta.json - .release-please-manifest-beta.json - backend/src/services/updateCheckService.js - frontend/src/components/admin/UpdateNotification.tsx Modified Files: - docker-compose.production.yml (channel selection) - .env.example (PICPEAK_CHANNEL, UPDATE_CHECK_ENABLED) - backend/src/routes/adminSystem.js (/updates endpoint) - frontend components (VersionInfo, AdminDashboard) - i18n locales (en.json, de.json) - README.md and DEPLOYMENT_GUIDE.md (documentation) --- .env.example | 10 + .github/workflows/docker-build.yml | 42 +++- .github/workflows/release-please-beta.yml | 72 ++++++ .release-please-manifest-beta.json | 3 + DEPLOYMENT_GUIDE.md | 82 ++++++- README.md | 46 +++- backend/src/routes/adminSystem.js | 34 ++- backend/src/services/updateCheckService.js | 212 ++++++++++++++++++ docker-compose.production.yml | 7 +- .../components/admin/UpdateNotification.tsx | 103 +++++++++ frontend/src/components/admin/VersionInfo.tsx | 40 +++- frontend/src/i18n/locales/de.json | 14 ++ frontend/src/i18n/locales/en.json | 14 ++ frontend/src/pages/admin/AdminDashboard.tsx | 4 + release-please-config-beta.json | 30 +++ 15 files changed, 694 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/release-please-beta.yml create mode 100644 .release-please-manifest-beta.json create mode 100644 backend/src/services/updateCheckService.js create mode 100644 frontend/src/components/admin/UpdateNotification.tsx create mode 100644 release-please-config-beta.json diff --git a/.env.example b/.env.example index 3265047c..3585dbf6 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,16 @@ VITE_API_URL=/api # DB_PORT=5432 # REDIS_PORT=6379 +# Release Channel +# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0' +# 'stable' uses the :stable tag (same as :latest on main) +# 'beta' uses the :beta tag for pre-release versions +PICPEAK_CHANNEL=stable + +# Update Check Configuration +# Set to 'false' to disable update notifications in admin UI +UPDATE_CHECK_ENABLED=true + # Timezone TZ=UTC diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index faa24054..6395d081 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -8,10 +8,10 @@ name: Build and Push Docker Images on: push: - branches: [ main, develop ] - tags: [ 'v*.*.*' ] # Triggered by Release Please tags + branches: [ main, beta ] + tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta) pull_request: - branches: [ main ] + branches: [ main, beta ] release: types: [ published ] # Triggered when Release Please creates a release workflow_dispatch: @@ -42,6 +42,18 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Determine build context + id: context + run: | + # Determine if this is a beta or stable release + if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then + echo "channel=beta" >> $GITHUB_OUTPUT + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "channel=stable" >> $GITHUB_OUTPUT + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi + - name: Determine build platforms id: platforms run: | @@ -88,10 +100,12 @@ jobs: type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }} + type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }} type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }} + type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }} - name: Build and push Backend Docker image uses: docker/build-push-action@v5 @@ -139,6 +153,18 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Determine build context + id: context + run: | + # Determine if this is a beta or stable release + if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then + echo "channel=beta" >> $GITHUB_OUTPUT + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + echo "channel=stable" >> $GITHUB_OUTPUT + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi + - name: Determine build platforms id: platforms run: | @@ -185,10 +211,12 @@ jobs: type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} + type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }} + type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }} type=sha,format=short type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }} + type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }} - name: Build and push Frontend Docker image uses: docker/build-push-action@v5 diff --git a/.github/workflows/release-please-beta.yml b/.github/workflows/release-please-beta.yml new file mode 100644 index 00000000..5db96823 --- /dev/null +++ b/.github/workflows/release-please-beta.yml @@ -0,0 +1,72 @@ +name: Release Please (Beta) + +on: + push: + branches: [beta] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + steps: + - name: Run Release Please + uses: googleapis/release-please-action@v4 + id: release + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: release-please-config-beta.json + manifest-file: .release-please-manifest-beta.json + target-branch: beta + + - name: Output Release Info + if: ${{ steps.release.outputs.release_created }} + run: | + echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY + + # Sync version to package.json files after release + sync-versions: + needs: release-please + if: ${{ needs.release-please.outputs.release_created }} + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: beta + + - name: Update package.json versions + run: | + VERSION="${{ needs.release-please.outputs.version }}" + echo "Updating package.json files to version $VERSION" + + # Update backend package.json + cd backend + npm version $VERSION --no-git-tag-version --allow-same-version + cd .. + + # Update frontend package.json + cd frontend + npm version $VERSION --no-git-tag-version --allow-same-version + cd .. + + - name: Commit version updates + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add backend/package.json frontend/package.json + git diff --staged --quiet || git commit -m "chore: sync package.json versions to ${{ needs.release-please.outputs.version }}" + git push diff --git a/.release-please-manifest-beta.json b/.release-please-manifest-beta.json new file mode 100644 index 00000000..da921703 --- /dev/null +++ b/.release-please-manifest-beta.json @@ -0,0 +1,3 @@ +{ + ".": "2.3.0-beta.0" +} diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md index 3e0cbece..a68d0e1e 100644 --- a/DEPLOYMENT_GUIDE.md +++ b/DEPLOYMENT_GUIDE.md @@ -9,6 +9,7 @@ This guide covers multiple deployment options for PicPeak, from simple local set - [Configuration](#-configuration) - [Deployment](#-deployment) - [First Login](#-first-login) +- [Release Channels](#-release-channels) - [Reverse Proxy Setup](#-reverse-proxy-setup) - [External Media Library](#external-media-library) - [Maintenance](#-maintenance) @@ -52,7 +53,14 @@ docker compose -f docker-compose.production.yml up -d docker compose -f docker-compose.production.yml logs -f ``` -Available image tags: `latest` (stable), `main`, `develop`, `v1.0.0` (version tags) +**Available image tags:** +| Channel | Tags | Description | +|---------|------|-------------| +| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases | +| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features | +| Branch | `main`, `beta` | Latest from each branch | + +To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section) ### Option 3: Build from Source @@ -344,6 +352,61 @@ ADMIN_EMAIL=your-email@yourdomain.com **Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel. +## 🔄 Release Channels + +PicPeak offers two release channels for different needs: + +### Stable Channel (Recommended) +- Production-ready releases +- Thoroughly tested before release +- Docker tags: `stable`, `latest`, or specific version like `v2.3.0` + +### Beta Channel +- Early access to new features +- May contain bugs or incomplete functionality +- Docker tags: `beta` or specific version like `v2.3.0-beta.1` + +### Configuring Your Channel + +Set the `PICPEAK_CHANNEL` environment variable in your `.env` file: + +```bash +# For stable releases (default) +PICPEAK_CHANNEL=stable + +# For beta releases +PICPEAK_CHANNEL=beta + +# For a specific version +PICPEAK_CHANNEL=v2.3.0 +``` + +The `docker-compose.production.yml` uses this variable for both backend and frontend images: +```yaml +image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable} +``` + +### Switching Channels + +To switch between channels: + +```bash +# Edit your .env file +nano .env +# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa) + +# Pull the new images and restart +docker compose -f docker-compose.production.yml pull +docker compose -f docker-compose.production.yml up -d +``` + +### Update Notifications + +The admin dashboard automatically notifies you when updates are available for your channel. This feature: +- Checks GitHub releases hourly (cached to avoid rate limits) +- Shows updates relevant to your current channel (stable or beta) +- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env` + ## 🔒 Reverse Proxy Setup For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution. @@ -554,20 +617,27 @@ docker compose up -d docker compose ps ``` -#### Specific Version Updates +#### Specific Version or Channel Updates -To use a specific version of the images: +To use a specific version or switch channels, update your `.env` file: ```bash -# Edit docker-compose.production.yml to specify version tags -# Change: ghcr.io/the-luap/picpeak/backend:latest -# To: ghcr.io/the-luap/picpeak/backend:v1.0.0 +# Edit .env to change the channel or pin to a specific version +nano .env + +# Options for PICPEAK_CHANNEL: +# - stable (recommended, production-ready) +# - beta (early access to new features) +# - v2.3.0 (pin to specific stable version) +# - v2.3.0-beta.1 (pin to specific beta version) # Then pull and restart docker compose -f docker-compose.production.yml pull docker compose -f docker-compose.production.yml up -d ``` +The admin dashboard will notify you when updates are available for your configured channel. + ### Database Migrations Migrations run automatically on startup, but you can run them manually: diff --git a/README.md b/README.md index 30b07bbd..9047c055 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,51 @@ Note on Docker file permissions (PUID/PGID) - Example in `.env`: - `PUID=1000` - `PGID=1000` -- Without this, creating events, uploads, thumbnails, or logs can fail with “Permission denied”. +- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied". + +## 🔄 Release Channels + +PicPeak offers two release channels for different needs: + +### Stable Channel (Recommended) +- Production-ready releases +- Thoroughly tested before release +- Docker tags: `stable`, `latest`, or specific version like `v2.3.0` + +### Beta Channel +- Early access to new features +- May contain bugs or incomplete functionality +- Docker tags: `beta` or specific version like `v2.3.0-beta.1` + +### Switching Channels + +Set the `PICPEAK_CHANNEL` environment variable in your `.env` file: + +```bash +# For stable releases (default) +PICPEAK_CHANNEL=stable + +# For beta releases +PICPEAK_CHANNEL=beta + +# For a specific version +PICPEAK_CHANNEL=v2.3.0 +``` + +Then update your containers: + +```bash +docker-compose -f docker-compose.production.yml pull +docker-compose -f docker-compose.production.yml up -d +``` + +### Update Notifications + +The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set: + +```bash +UPDATE_CHECK_ENABLED=false +``` ## 📖 Documentation diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index 491bbb41..da93b30c 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -7,6 +7,7 @@ const path = require('path'); const os = require('os'); const { formatBoolean } = require('../utils/dbCompat'); const logger = require('../utils/logger'); +const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService'); const router = express.Router(); // Get system version @@ -22,12 +23,15 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req } catch (err) { console.error('Could not read package.json:', err); } - + + const channel = getCurrentChannel(backendVersion); + res.json({ backend: backendVersion, frontend: '1.0.0', // This will be set by frontend node: process.version, - environment: process.env.NODE_ENV || 'production' + environment: process.env.NODE_ENV || 'production', + channel: channel }); } catch (error) { console.error('Error fetching version:', error); @@ -35,6 +39,32 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req } }); +// Check for updates +router.get('/updates', adminAuth, requirePermission('settings.view'), async (req, res) => { + try { + // Check if update checking is enabled + const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false'; + + if (!updateCheckEnabled) { + return res.json({ + enabled: false, + message: 'Update checking is disabled' + }); + } + + const forceRefresh = req.query.refresh === 'true'; + const updateInfo = await checkForUpdates(forceRefresh); + + res.json({ + enabled: true, + ...updateInfo + }); + } catch (error) { + logger.error('Error checking for updates:', error); + res.status(500).json({ error: 'Failed to check for updates' }); + } +}); + // Get comprehensive system status router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => { try { diff --git a/backend/src/services/updateCheckService.js b/backend/src/services/updateCheckService.js new file mode 100644 index 00000000..549b29b6 --- /dev/null +++ b/backend/src/services/updateCheckService.js @@ -0,0 +1,212 @@ +const axios = require('axios'); +const fs = require('fs').promises; +const path = require('path'); +const logger = require('../utils/logger'); + +// Cache for version info (avoid hitting GitHub API too often) +let versionCache = null; +let lastCheck = 0; +const CACHE_TTL = 60 * 60 * 1000; // 1 hour cache + +/** + * Get current installed version from package.json + */ +async function getCurrentVersion() { + try { + const packagePath = path.join(__dirname, '../../package.json'); + const packageContent = await fs.readFile(packagePath, 'utf8'); + const packageJson = JSON.parse(packageContent); + return packageJson.version || '0.0.0'; + } catch (err) { + logger.error('Could not read package.json for version:', err); + return '0.0.0'; + } +} + +/** + * Determine current release channel from version or environment + */ +function getCurrentChannel(version) { + // Check environment variable first + const envChannel = process.env.PICPEAK_RELEASE_CHANNEL; + if (envChannel && ['stable', 'beta'].includes(envChannel)) { + return envChannel; + } + + // Infer from version string + if (version && version.includes('-beta')) { + return 'beta'; + } + return 'stable'; +} + +/** + * Parse version string into comparable parts + */ +function parseVersion(version) { + if (!version) return null; + + // Handle versions like "2.3.0" or "2.3.0-beta.1" + const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/); + if (!match) return null; + + return { + major: parseInt(match[1], 10), + minor: parseInt(match[2], 10), + patch: parseInt(match[3], 10), + beta: match[4] ? parseInt(match[4], 10) : null, + isBeta: !!match[4] + }; +} + +/** + * Compare two versions + * Returns: 1 if a > b, -1 if a < b, 0 if equal + */ +function compareVersions(a, b) { + const va = parseVersion(a); + const vb = parseVersion(b); + + if (!va || !vb) return 0; + + // Compare major.minor.patch + if (va.major !== vb.major) return va.major > vb.major ? 1 : -1; + if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1; + if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1; + + // Handle beta vs stable + if (va.isBeta && !vb.isBeta) return -1; // beta < stable + if (!va.isBeta && vb.isBeta) return 1; // stable > beta + + // Both are beta - compare beta numbers + if (va.isBeta && vb.isBeta) { + if (va.beta !== vb.beta) return va.beta > vb.beta ? 1 : -1; + } + + return 0; +} + +/** + * Fetch available versions from GitHub Releases + * Uses GitHub Releases API which is publicly accessible without authentication + */ +async function fetchAvailableVersions() { + try { + // Use GitHub Releases API (public, no auth required) + const response = await axios.get( + 'https://api.github.com/repos/the-luap/picpeak/releases', + { + headers: { + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'PicPeak-Update-Checker' + }, + timeout: 10000 + } + ); + + // Extract version tags from releases + const versions = { + stable: [], + beta: [] + }; + + for (const release of response.data) { + const tag = release.tag_name; + if (!tag) continue; + + // Remove 'v' prefix if present + const version = tag.startsWith('v') ? tag.substring(1) : tag; + + if (version.match(/^\d+\.\d+\.\d+$/)) { + // Stable version + versions.stable.push(version); + } else if (version.match(/^\d+\.\d+\.\d+-beta\.\d+$/)) { + // Beta version + versions.beta.push(version); + } + } + + // Sort versions descending (newest first) + versions.stable.sort((a, b) => compareVersions(b, a)); + versions.beta.sort((a, b) => compareVersions(b, a)); + + return versions; + } catch (error) { + logger.error('Failed to fetch available versions from GitHub:', error.message); + return null; + } +} + +/** + * Check for available updates + */ +async function checkForUpdates(forceRefresh = false) { + const now = Date.now(); + + // Use cache if available and not expired + if (!forceRefresh && versionCache && (now - lastCheck) < CACHE_TTL) { + return versionCache; + } + + const currentVersion = await getCurrentVersion(); + const currentChannel = getCurrentChannel(currentVersion); + const availableVersions = await fetchAvailableVersions(); + + if (!availableVersions) { + return { + current: currentVersion, + channel: currentChannel, + updateAvailable: false, + error: 'Unable to check for updates' + }; + } + + // Determine latest version for current channel + const latestStable = availableVersions.stable[0] || currentVersion; + const latestBeta = availableVersions.beta[0] || currentVersion; + const latestForChannel = currentChannel === 'beta' ? latestBeta : latestStable; + + const updateAvailable = compareVersions(latestForChannel, currentVersion) > 0; + + // Also check if there's a newer beta for stable users who want to preview + const newerBetaAvailable = currentChannel === 'stable' && + availableVersions.beta.length > 0 && + compareVersions(latestBeta, currentVersion) > 0; + + const result = { + current: currentVersion, + channel: currentChannel, + latest: { + stable: latestStable, + beta: latestBeta, + forChannel: latestForChannel + }, + updateAvailable, + newerBetaAvailable, + lastChecked: new Date().toISOString() + }; + + // Update cache + versionCache = result; + lastCheck = now; + + return result; +} + +/** + * Clear the version cache (useful for testing) + */ +function clearCache() { + versionCache = null; + lastCheck = 0; +} + +module.exports = { + checkForUpdates, + getCurrentVersion, + getCurrentChannel, + compareVersions, + parseVersion, + clearCache +}; diff --git a/docker-compose.production.yml b/docker-compose.production.yml index c0c869d4..fc53952d 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -38,7 +38,8 @@ services: backend: # Use pre-built image from GitHub Container Registry - image: ghcr.io/the-luap/picpeak/backend:latest + # PICPEAK_CHANNEL: 'stable' (default), 'beta', or specific version like 'v2.3.0' + image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable} container_name: picpeak-backend env_file: .env environment: @@ -46,6 +47,7 @@ services: - DB_HOST=${DB_HOST:-postgres} - REDIS_HOST=redis - PHOTOS_DIR=/app/storage/events + - PICPEAK_RELEASE_CHANNEL=${PICPEAK_CHANNEL:-stable} volumes: - ${APP_STORAGE}:/app/storage - ${LOGS}:/app/logs @@ -69,7 +71,8 @@ services: frontend: # Use pre-built image from GitHub Container Registry - image: ghcr.io/the-luap/picpeak/frontend:latest + # Uses same channel as backend for consistency + image: ghcr.io/the-luap/picpeak/frontend:${PICPEAK_CHANNEL:-stable} container_name: picpeak-frontend # Note: Pre-built frontend uses Nginx to proxy /api to backend:3001. # Prefer keeping API base as '/api' in builds to avoid CORS. diff --git a/frontend/src/components/admin/UpdateNotification.tsx b/frontend/src/components/admin/UpdateNotification.tsx new file mode 100644 index 00000000..4284eb02 --- /dev/null +++ b/frontend/src/components/admin/UpdateNotification.tsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { ArrowUpCircle, X, ExternalLink } from 'lucide-react'; +import { api } from '../../config/api'; + +interface UpdateInfo { + enabled: boolean; + current: string; + channel: 'stable' | 'beta'; + latest: { + stable: string; + beta: string; + forChannel: string; + }; + updateAvailable: boolean; + newerBetaAvailable?: boolean; + lastChecked: string; + error?: string; + message?: string; +} + +async function fetchUpdateInfo(): Promise { + const response = await api.get('/admin/system/updates'); + return response.data; +} + +interface UpdateNotificationProps { + onDismiss?: () => void; +} + +export const UpdateNotification: React.FC = ({ onDismiss }) => { + const { t } = useTranslation(); + const [dismissed, setDismissed] = useState(false); + + const { data: updateInfo } = useQuery({ + queryKey: ['update-check'], + queryFn: fetchUpdateInfo, + staleTime: 60 * 60 * 1000, // 1 hour + retry: false, + refetchOnWindowFocus: false + }); + + // Don't render if no update available, not enabled, or dismissed + if (!updateInfo?.enabled || !updateInfo?.updateAvailable || dismissed) { + return null; + } + + const handleDismiss = () => { + setDismissed(true); + onDismiss?.(); + }; + + const channelLabel = updateInfo.channel === 'beta' + ? t('admin.updates.channelBeta', 'Beta') + : t('admin.updates.channelStable', 'Stable'); + + return ( +
+
+
+ +
+

+ {t('admin.updates.available', 'Update Available')} +

+

+ {t('admin.updates.newVersion', 'Version {{version}} is available', { + version: updateInfo.latest.forChannel + })} + + ({t('admin.updates.currentVersion', 'Current: {{version}}', { + version: updateInfo.current + })}) + +

+

+ {t('admin.updates.channel', 'Channel: {{channel}}', { + channel: channelLabel + })} +

+ + {t('admin.updates.viewReleaseNotes', 'View Release Notes')} + + +
+
+ +
+
+ ); +}; diff --git a/frontend/src/components/admin/VersionInfo.tsx b/frontend/src/components/admin/VersionInfo.tsx index 18f8d1e1..7063722f 100644 --- a/frontend/src/components/admin/VersionInfo.tsx +++ b/frontend/src/components/admin/VersionInfo.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { Info } from 'lucide-react'; +import { Info, ArrowUpCircle } from 'lucide-react'; import { api } from '../../config/api'; import packageJson from '../../../package.json'; @@ -13,6 +13,15 @@ interface SystemVersion { frontend: string; node: string; environment: string; + channel?: 'stable' | 'beta'; +} + +interface UpdateInfo { + enabled: boolean; + updateAvailable: boolean; + latest?: { + forChannel: string; + }; } async function fetchSystemVersion(): Promise { @@ -20,6 +29,11 @@ async function fetchSystemVersion(): Promise { return response.data; } +async function fetchUpdateInfo(): Promise { + const response = await api.get('/admin/system/updates'); + return response.data; +} + export const VersionInfo: React.FC = () => { const { t } = useTranslation(); const { data: versionInfo } = useQuery({ @@ -28,11 +42,25 @@ export const VersionInfo: React.FC = () => { staleTime: 5 * 60 * 1000, // Cache for 5 minutes }); + const { data: updateInfo } = useQuery({ + queryKey: ['update-check'], + queryFn: fetchUpdateInfo, + staleTime: 60 * 60 * 1000, // 1 hour + retry: false + }); + + const channelBadge = versionInfo?.channel === 'beta' ? ( + + {t('admin.updates.beta', 'BETA')} + + ) : null; + return (
{t('admin.version')} + {channelBadge}
Frontend: v{FRONTEND_VERSION}
@@ -40,6 +68,16 @@ export const VersionInfo: React.FC = () => {
Backend: v{versionInfo.backend}
)}
+ {updateInfo?.enabled && updateInfo?.updateAvailable && ( +
+ + + {t('admin.updates.updateAvailableShort', 'v{{version}} available', { + version: updateInfo.latest?.forChannel + })} + +
+ )}
); }; \ No newline at end of file diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 5105eb20..5f24300f 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -1232,6 +1232,20 @@ "error": "Fehler", "checking": "Prüfe..." }, + "updates": { + "available": "Update verfügbar", + "newVersion": "Version {{version}} ist verfügbar", + "currentVersion": "Aktuell: {{version}}", + "channel": "Kanal: {{channel}}", + "channelStable": "Stabil", + "channelBeta": "Beta", + "beta": "BETA", + "viewReleaseNotes": "Versionshinweise anzeigen", + "updateAvailableShort": "v{{version}} verfügbar", + "checkForUpdates": "Nach Updates suchen", + "upToDate": "Alles aktuell", + "lastChecked": "Zuletzt geprüft: {{time}}" + }, "notifications": "Benachrichtigungen", "viewAllNotifications": "Alle Benachrichtigungen anzeigen", "noNotifications": "Keine neuen Benachrichtigungen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 8428feb9..3cfdc0ba 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1037,6 +1037,20 @@ "error": "Error", "checking": "Checking..." }, + "updates": { + "available": "Update Available", + "newVersion": "Version {{version}} is available", + "currentVersion": "Current: {{version}}", + "channel": "Channel: {{channel}}", + "channelStable": "Stable", + "channelBeta": "Beta", + "beta": "BETA", + "viewReleaseNotes": "View Release Notes", + "updateAvailableShort": "v{{version}} available", + "checkForUpdates": "Check for Updates", + "upToDate": "You're up to date", + "lastChecked": "Last checked: {{time}}" + }, "notifications": "Notifications", "viewAllNotifications": "View all notifications", "noNotifications": "No new notifications", diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index c73d3ba9..92d8a24e 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Card, Loading } from '../../components/common'; +import { UpdateNotification } from '../../components/admin/UpdateNotification'; import { useQuery } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { adminService } from '../../services/admin.service'; @@ -140,6 +141,9 @@ export const AdminDashboard: React.FC = () => { return (
+ {/* Update Notification */} + + {/* Page Header */}
diff --git a/release-please-config-beta.json b/release-please-config-beta.json new file mode 100644 index 00000000..8cff4c7f --- /dev/null +++ b/release-please-config-beta.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "include-component-in-tag": false, + "include-v-in-tag": true, + "prerelease": true, + "prerelease-type": "beta", + "changelog-sections": [ + { "type": "feat", "section": "Features", "hidden": false }, + { "type": "fix", "section": "Bug Fixes", "hidden": false }, + { "type": "perf", "section": "Performance Improvements", "hidden": false }, + { "type": "revert", "section": "Reverts", "hidden": false }, + { "type": "docs", "section": "Documentation", "hidden": false }, + { "type": "style", "section": "Styles", "hidden": true }, + { "type": "chore", "section": "Miscellaneous", "hidden": true }, + { "type": "refactor", "section": "Code Refactoring", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "CI/CD", "hidden": true } + ], + "packages": { + ".": { + "release-type": "simple", + "changelog-path": "CHANGELOG.md", + "extra-files": [] + } + } +}