feat: beta/stable release channels with update notifications and bug fixes (#98)

feat: beta/stable release channels with update notifications and bug fixes
This commit is contained in:
Paul Nothaft
2026-01-15 06:17:41 -05:00
committed by GitHub
23 changed files with 970 additions and 227 deletions
@@ -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')
+6 -4
View File
@@ -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]');
}
+16 -6
View File
@@ -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()
});
+32 -2
View File
@@ -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 {
+18 -17
View File
@@ -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) {
+36 -24
View File
@@ -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
+64 -7
View File
@@ -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: {
+212
View File
@@ -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
};
+19 -8
View File
@@ -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, {