Merge pull request #185 from the-luap/feat/new-features

feat: original filename in admin UI, update dialog, and security hardening
This commit is contained in:
Paul Nothaft
2026-02-17 15:37:56 +01:00
committed by GitHub
38 changed files with 2165 additions and 121 deletions
@@ -0,0 +1,161 @@
/**
* Migration 070: Add update notification settings and email template
* - Settings for email notifications when new versions are available
* - Email template for version update notifications
*/
exports.up = async function(knex) {
console.log('Running migration: 070_add_update_notification_settings');
// Add app_settings for update notifications
const settings = [
{
setting_key: 'update_email_notifications_enabled',
setting_value: JSON.stringify(false),
setting_type: 'notifications'
},
{
setting_key: 'update_email_recipients',
setting_value: JSON.stringify(''), // Comma-separated emails, or empty for all admin emails
setting_type: 'notifications'
},
{
setting_key: 'last_notified_version',
setting_value: JSON.stringify(''),
setting_type: 'notifications'
}
];
for (const setting of settings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
}
}
// Check if email template already exists
const existingTemplate = await knex('email_templates')
.where('template_key', 'version_update_available')
.first();
if (!existingTemplate) {
await knex('email_templates').insert({
template_key: 'version_update_available',
subject_en: 'PicPeak Update Available: Version {{new_version}}',
subject_de: 'PicPeak Update verfugbar: Version {{new_version}}',
body_html_en: `
<h2>A New Version of PicPeak is Available</h2>
<p>Great news! A new version of PicPeak is available for your installation.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Current Version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>New Version:</strong> {{new_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Channel:</strong> {{channel}}</p>
</div>
<h3>What's New?</h3>
<p>Check the release notes to see what's included in this update:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{release_notes_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">View Release Notes</a>
</div>
<h3>How to Update</h3>
<p>To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.</p>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Reminder:</strong> Always backup your database before updating to ensure you can recover if anything goes wrong.</p>
</div>
<p>Best regards,<br>
Your PicPeak Installation</p>`,
body_text_en: `A New Version of PicPeak is Available
Great news! A new version of PicPeak is available for your installation.
Current Version: {{current_version}}
New Version: {{new_version}}
Channel: {{channel}}
What's New?
Check the release notes to see what's included in this update:
{{release_notes_url}}
How to Update
To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.
REMINDER: Always backup your database before updating to ensure you can recover if anything goes wrong.
Best regards,
Your PicPeak Installation`,
body_html_de: `
<h2>Eine neue Version von PicPeak ist verfugbar</h2>
<p>Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Aktuelle Version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Neue Version:</strong> {{new_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
</div>
<h3>Was ist neu?</h3>
<p>Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{release_notes_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Versionshinweise anzeigen</a>
</div>
<h3>So aktualisieren Sie</h3>
<p>Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.</p>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Erinnerung:</strong> Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.</p>
</div>
<p>Mit freundlichen Grussen,<br>
Ihre PicPeak-Installation</p>`,
body_text_de: `Eine neue Version von PicPeak ist verfugbar
Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.
Aktuelle Version: {{current_version}}
Neue Version: {{new_version}}
Kanal: {{channel}}
Was ist neu?
Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:
{{release_notes_url}}
So aktualisieren Sie
Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.
ERINNERUNG: Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.
Mit freundlichen Grussen,
Ihre PicPeak-Installation`,
variables: JSON.stringify(['current_version', 'new_version', 'channel', 'release_notes_url'])
});
}
console.log('Migration 070_add_update_notification_settings completed');
};
exports.down = async function(knex) {
console.log('Rollback: 070_add_update_notification_settings');
// Remove settings
await knex('app_settings')
.whereIn('setting_key', [
'update_email_notifications_enabled',
'update_email_recipients',
'last_notified_version'
])
.del();
// Remove email template
await knex('email_templates')
.where('template_key', 'version_update_available')
.del();
};
@@ -0,0 +1,42 @@
/**
* Migration 071: Add captured_at column to photos table
* - Stores the original capture date from EXIF metadata
* - Enables sorting photos by capture date instead of upload date
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 071_add_captured_at');
// Add captured_at column to photos table
await addColumnIfNotExists(knex, 'photos', 'captured_at', (table) => {
table.datetime('captured_at').nullable();
});
// Add index for sorting performance
const indexExists = await knex.schema.hasIndex
? await knex.schema.hasIndex('photos', 'idx_photos_captured_at')
: false;
if (!indexExists) {
// Use raw query for index creation with IF NOT EXISTS
const client = knex.client.config.client;
if (client === 'pg') {
await knex.raw('CREATE INDEX IF NOT EXISTS idx_photos_captured_at ON photos(captured_at)');
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
// SQLite doesn't support IF NOT EXISTS for indexes, so we need to check first
const existingIndexes = await knex.raw("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_photos_captured_at'");
if (existingIndexes.length === 0) {
await knex.raw('CREATE INDEX idx_photos_captured_at ON photos(captured_at)');
}
}
}
console.log('Migration 071_add_captured_at completed');
};
exports.down = async function(knex) {
console.log('Rollback: 071_add_captured_at');
// Keep column for safe rollback (intentionally no-op)
};
+7
View File
@@ -20,6 +20,7 @@
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"exifr": "^7.1.3",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
@@ -5730,6 +5731,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/exifr": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/exifr/-/exifr-7.1.3.tgz",
"integrity": "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==",
"license": "MIT"
},
"node_modules/exit": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
+1
View File
@@ -25,6 +25,7 @@
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"exifr": "^7.1.3",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
+26 -14
View File
@@ -359,8 +359,8 @@ async function initializeRateLimiters() {
}
// Note: Rate limiters will be initialized after database connection
app.use(express.json({ limit: '10gb' }));
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
@@ -386,8 +386,23 @@ app.use('/api/admin', sessionTimeoutMiddleware);
// Middleware to set CORS headers for static files
const setCorsHeaders = (req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
res.header('Access-Control-Allow-Credentials', 'true');
const origin = req.headers.origin;
const staticAllowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3005',
process.env.ADMIN_URL || 'http://localhost:3005'
];
if (process.env.NODE_ENV === 'development') {
staticAllowedOrigins.push(
'http://localhost:5173',
'http://localhost:3002',
'http://localhost:3001',
'http://localhost:3000'
);
}
if (origin && staticAllowedOrigins.indexOf(origin) !== -1) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
}
res.header('Cross-Origin-Resource-Policy', 'cross-origin');
next();
};
@@ -451,19 +466,16 @@ app.get('/health', async (req, res) => {
try {
// Check database connectivity
await db.raw('SELECT 1');
res.json({
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString()
res.json({
status: 'ok',
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Health check failed:', error);
res.status(503).json({
status: 'error',
database: 'disconnected',
error: error.message,
timestamp: new Date().toISOString()
res.status(503).json({
status: 'error',
timestamp: new Date().toISOString()
});
}
});
+3 -2
View File
@@ -495,8 +495,9 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
const offset = (page - 1) * limit;
const search = req.query.search || '';
const status = req.query.status || 'all';
const sortBy = req.query.sortBy || 'created_at';
const sortOrder = req.query.sortOrder || 'desc';
const allowedSortBy = ['created_at', 'event_name', 'slug', 'updated_at', 'expires_at', 'capture_date'];
const sortBy = allowedSortBy.includes(req.query.sortBy) ? req.query.sortBy : 'created_at';
const sortOrder = ['asc', 'desc'].includes(req.query.sortOrder) ? req.query.sortOrder : 'desc';
// Build query
let query = db('events');
+16 -4
View File
@@ -5,7 +5,7 @@ const fs = require('fs').promises;
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
@@ -253,7 +253,16 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
const finalPath = path.join(finalDestPath, newFilename);
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath);
// Extract capture date from EXIF metadata
let capturedAt = null;
try {
capturedAt = await extractCaptureDate(tempPath);
} catch (exifError) {
// Non-fatal - just log and continue without capture date
console.log(`Could not extract EXIF date for ${file.originalname}`);
}
// Prepare photo data for batch insert
const photoData = {
event_id: parseInt(eventId),
@@ -263,7 +272,8 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
thumbnail_path: null, // Will generate after successful commit
type: photoType,
category_id: parsedCategoryId, // Save the selected category
size_bytes: tempStats.size // Use actual file size from stat
size_bytes: tempStats.size, // Use actual file size from stat
captured_at: capturedAt // EXIF capture date (if available)
};
batchPhotos.push(photoData);
@@ -717,7 +727,8 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const { eventId } = req.params;
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
const { category_id, type, search, sort = 'date' } = req.query;
const order = ['asc', 'desc'].includes(req.query.order) ? req.query.order : 'desc';
let query = db('photos')
.where({ 'photos.event_id': eventId })
@@ -781,6 +792,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
original_filename: photo.original_filename || null,
// Use the correct admin photos router base for serving images
url: `/admin/photos/${eventId}/photo/${photo.id}`,
// Always expose a thumbnail URL; backend will generate on demand if missing
+10
View File
@@ -122,6 +122,11 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
}
});
// Mask sensitive secrets before sending to client
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
}
res.json(settingsObject);
} catch (error) {
console.error('Settings fetch error:', error);
@@ -160,6 +165,11 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
}
});
// Mask sensitive secrets before sending to client
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
}
res.json(settingsObject);
} catch (error) {
console.error('Settings fetch error:', error);
+111
View File
@@ -8,6 +8,12 @@ const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
const {
checkAndNotifyUpdates,
sendUpdateNotificationNow,
getUpdateNotificationSettings
} = require('../services/updateNotificationService');
const router = express.Router();
// Get system version
@@ -65,6 +71,47 @@ router.get('/updates', adminAuth, requirePermission('settings.view'), async (req
}
});
// Get update instructions for current environment
router.get('/updates/instructions', 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 env = await detectEnvironment();
const updateInfo = await checkForUpdates();
if (!updateInfo.updateAvailable) {
return res.json({
updateAvailable: false,
currentVersion: updateInfo.current,
message: 'You are running the latest version'
});
}
const instructions = generateUpdateInstructions(env, updateInfo.latest.forChannel);
res.json({
updateAvailable: true,
currentVersion: updateInfo.current,
targetVersion: updateInfo.latest.forChannel,
channel: updateInfo.channel,
environment: env,
instructions,
releaseNotesUrl: `https://github.com/the-luap/picpeak/releases/tag/v${updateInfo.latest.forChannel}`
});
} catch (error) {
logger.error('Error generating update instructions:', error);
res.status(500).json({ error: 'Failed to generate update instructions' });
}
});
// Get comprehensive system status
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
@@ -262,4 +309,68 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re
}
});
// Get update notification settings
router.get('/updates/notifications', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const settings = await getUpdateNotificationSettings();
res.json(settings);
} catch (error) {
logger.error('Error fetching update notification settings:', error);
res.status(500).json({ error: 'Failed to fetch update notification settings' });
}
});
// Update notification settings
router.put('/updates/notifications', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { enabled, recipients } = req.body;
if (typeof enabled !== 'undefined') {
await db('app_settings')
.where('setting_key', 'update_email_notifications_enabled')
.update({
setting_value: JSON.stringify(enabled === true),
updated_at: db.fn.now()
});
}
if (typeof recipients !== 'undefined') {
await db('app_settings')
.where('setting_key', 'update_email_recipients')
.update({
setting_value: JSON.stringify(recipients || ''),
updated_at: db.fn.now()
});
}
const updatedSettings = await getUpdateNotificationSettings();
res.json({ success: true, settings: updatedSettings });
} catch (error) {
logger.error('Error updating notification settings:', error);
res.status(500).json({ error: 'Failed to update notification settings' });
}
});
// Manually trigger update notification email
router.post('/updates/notifications/send', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const result = await sendUpdateNotificationNow();
res.json(result);
} catch (error) {
logger.error('Error sending update notification:', error);
res.status(500).json({ error: 'Failed to send update notification' });
}
});
// Check and send update notifications (called on admin login or periodically)
router.post('/updates/notifications/check', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const result = await checkAndNotifyUpdates();
res.json(result);
} catch (error) {
logger.error('Error checking for update notifications:', error);
res.status(500).json({ error: 'Failed to check for update notifications' });
}
});
module.exports = router;
+20 -6
View File
@@ -187,8 +187,8 @@ router.get('/:slug/info', async (req, res) => {
// Get all photos
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
// Get filter parameters from query
const { filter, guest_id } = req.query;
// Get filter and sort parameters from query
const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query;
// Get watermark settings to generate cache-busting version for URLs
const watermarkSettings = await watermarkService.getWatermarkSettings();
@@ -196,11 +196,25 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '';
// First get all photos
let photos = await db('photos')
// Build the query with sorting
const sortOrder = order === 'asc' ? 'asc' : 'desc';
let photosQuery = db('photos')
.where('photos.event_id', req.event.id)
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
.select('photos.*');
// Apply sort option
if (sort === 'capture_date') {
// Sort by capture date, falling back to uploaded_at if capture date is null
photosQuery = photosQuery.orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder);
} else if (sort === 'filename') {
photosQuery = photosQuery.orderBy('photos.filename', sortOrder);
} else {
// Default: sort by upload date
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder);
}
// Execute the query
let photos = await photosQuery;
// Apply filtering if requested (supports global stats + per-guest interactions)
if (filter) {
+10 -10
View File
@@ -437,7 +437,7 @@ async function performLocalBackup(config, files) {
};
}
function buildRsyncCommand(config) {
function buildRsyncArgs(config) {
const storagePath = getStoragePath();
const host = config.backup_rsync_host;
const remotePath = config.backup_rsync_path;
@@ -446,20 +446,21 @@ function buildRsyncCommand(config) {
throw new Error('Rsync configuration incomplete');
}
const options = ['-avz', '--delete', '--stats'];
const args = ['-avz', '--delete', '--stats'];
if (config.backup_rsync_ssh_key) {
options.push(`-e "ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no"`);
args.push('-e', `ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no`);
}
const excludePatterns = config.backup_exclude_patterns || [];
excludePatterns.forEach(pattern => options.push(`--exclude="${pattern}"`));
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
const source = `${storagePath}/`;
const destination = config.backup_rsync_user
? `${config.backup_rsync_user}@${host}:${remotePath}`
: `${host}:${remotePath}`;
return `rsync ${options.join(' ')} "${source}" "${destination}"`;
args.push(source, destination);
return args;
}
function parseRsyncStats(output) {
@@ -479,9 +480,9 @@ function parseRsyncStats(output) {
}
async function performRsyncBackup(config, files) {
const command = buildRsyncCommand(config);
const execAsync = getExecAsync();
const { stdout } = await execAsync(command);
const { spawnAsync } = require('../utils/safeExec');
const rsyncArgs = buildRsyncArgs(config);
const { stdout } = await spawnAsync('rsync', rsyncArgs);
const stats = parseRsyncStats(stdout);
const backedUpFiles = files.map(file => file.relativePath);
@@ -503,8 +504,7 @@ async function performRsyncBackup(config, files) {
backedUpCount: typeof stats.filesTransferred === 'number' ? stats.filesTransferred : backedUpFiles.length,
backedUpSize: totalSize,
backedUpFiles,
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`,
rsyncCommand: command
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`
};
}
+15 -22
View File
@@ -1,9 +1,7 @@
const fs = require('fs').promises;
const path = require('path');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const crypto = require('crypto');
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream } = require('fs');
@@ -163,10 +161,10 @@ class DatabaseBackupService {
try {
// Use SQLite's backup API for consistency
await execAsync(`sqlite3 "${dbPath}" ".backup '${tempPath}'"`);
await spawnAsync('sqlite3', [dbPath, `.backup '${tempPath}'`]);
// Verify the backup
const verifyResult = await execAsync(`sqlite3 "${tempPath}" "PRAGMA integrity_check"`);
const verifyResult = await spawnAsync('sqlite3', [tempPath, 'PRAGMA integrity_check']);
if (!verifyResult.stdout.includes('ok')) {
throw new Error('Backup integrity check failed');
}
@@ -192,14 +190,6 @@ class DatabaseBackupService {
async createPostgreSQLBackup(outputPath, options = {}) {
const { host, port, user, password, database } = knexConfig.connection;
// Build connection string with proper escaping
const connectionParts = [
`host=${host}`,
`port=${port}`,
`dbname=${database}`,
`user=${user}`
];
// Set PGPASSWORD environment variable for security
const env = { ...process.env };
if (password) {
@@ -227,14 +217,17 @@ class DatabaseBackupService {
pgDumpOptions.push('--compress=6');
}
const command = `pg_dump "${connectionParts.join(' ')}" ${pgDumpOptions.join(' ')} > "${outputPath}"`;
const pgDumpArgs = [
...pgDumpOptions,
'-h', host,
'-p', String(port),
'-U', user,
'-d', database
];
try {
const { stderr } = await execAsync(command, {
env,
maxBuffer: 1024 * 1024 * 100 // 100MB buffer
});
const { stderr } = await spawnToFile('pg_dump', pgDumpArgs, outputPath, { env });
// pg_dump writes progress to stderr, not an error
if (stderr && !stderr.includes('dump complete')) {
logger.warn('pg_dump warnings:', stderr);
@@ -261,7 +254,7 @@ class DatabaseBackupService {
try {
if (this.dbType === 'sqlite') {
// For SQLite, we can directly check integrity
const result = await execAsync(`sqlite3 "${backupPath}" "PRAGMA integrity_check"`);
const result = await spawnAsync('sqlite3', [backupPath, 'PRAGMA integrity_check']);
if (!result.stdout.includes('ok')) {
throw new Error('Backup integrity check failed');
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Environment Detection Service
* Detects the deployment environment and generates update instructions accordingly.
*/
const fs = require('fs');
const path = require('path');
const logger = require('../utils/logger');
/**
* Detect the current deployment environment
* @returns {Object} Environment information
*/
async function detectEnvironment() {
// Check for Docker environment
const isDocker = fs.existsSync('/.dockerenv') ||
process.env.DOCKER_CONTAINER === 'true';
// Determine project root (services -> src -> backend)
const projectRoot = path.join(__dirname, '../../..');
// Check for git repository
const isGit = fs.existsSync(path.join(projectRoot, '.git'));
// Check for docker-compose files
const hasDockerCompose = fs.existsSync(path.join(projectRoot, 'docker-compose.yml')) ||
fs.existsSync(path.join(projectRoot, 'docker-compose.yaml'));
// Get app version
let appVersion = '0.0.0';
try {
const packagePath = path.join(__dirname, '../../package.json');
const packageContent = fs.readFileSync(packagePath, 'utf8');
const packageJson = JSON.parse(packageContent);
appVersion = packageJson.version || '0.0.0';
} catch (err) {
logger.warn('Could not read package.json for version:', err.message);
}
// Determine environment type
let type;
if (isDocker) {
type = 'docker';
} else if (isGit) {
type = 'git';
} else {
type = 'standalone';
}
return {
type,
isDocker,
isGit,
hasDockerCompose,
platform: process.platform,
nodeVersion: process.version,
appVersion
};
}
/**
* Generate environment-specific update instructions
* @param {Object} env - Environment info from detectEnvironment()
* @param {string} targetVersion - Target version to update to
* @returns {Object} Update instructions with pre-checks, steps, and post-checks
*/
function generateUpdateInstructions(env, targetVersion) {
const instructions = {
preChecks: [
{
id: 'backup',
text: 'I have backed up my database',
required: true
},
{
id: 'no-uploads',
text: 'No uploads are currently in progress',
required: true
},
{
id: 'downtime-aware',
text: 'I understand the application will restart during update',
required: false
}
],
steps: [],
postChecks: [
'Verify the application starts correctly',
'Check the version in Admin -> System',
'Review release notes for any breaking changes or required actions'
],
warnings: []
};
if (env.isDocker) {
instructions.environmentName = 'Docker';
instructions.steps = [
{
description: 'Pull latest images',
command: 'docker compose pull',
note: 'Downloads the new version images'
},
{
description: 'Recreate containers with new images',
command: 'docker compose up -d',
note: 'Restarts containers with new version'
},
{
description: 'Watch logs for startup (optional)',
command: 'docker compose logs -f backend',
note: 'Press Ctrl+C to exit logs',
optional: true
}
];
instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file');
} else if (env.isGit) {
instructions.environmentName = 'Git (Development)';
instructions.steps = [
{
description: 'Fetch latest changes',
command: 'git fetch origin',
note: 'Downloads references from remote'
},
{
description: 'Switch to new version tag',
command: `git checkout v${targetVersion}`,
note: 'Switches to the release version'
},
{
description: 'Install backend dependencies',
command: 'cd backend && npm install',
note: 'Updates npm packages'
},
{
description: 'Build frontend',
command: 'cd frontend && npm install && npm run build',
note: 'Compiles the frontend application'
},
{
description: 'Run database migrations',
command: 'cd backend && npm run migrate',
note: 'Updates database schema'
},
{
description: 'Restart application',
command: '# Restart your application (pm2, systemd, etc.)',
note: 'Method depends on your setup - e.g., pm2 restart picpeak'
}
];
instructions.warnings.push('Adjust the restart command based on your process manager (pm2, systemd, etc.)');
} else {
instructions.environmentName = 'Standalone';
instructions.steps = [
{
description: 'Download release archive',
command: `# Download v${targetVersion} from GitHub Releases`,
note: `https://github.com/the-luap/picpeak/releases/tag/v${targetVersion}`
},
{
description: 'Backup current installation',
command: '# Create backup of current files',
note: 'Keep a copy of your current installation'
},
{
description: 'Extract and replace application files',
command: '# Extract release archive to installation directory',
note: 'Preserve your .env file and storage directory'
},
{
description: 'Install dependencies',
command: 'cd backend && npm install --production',
note: 'Updates npm packages'
},
{
description: 'Run database migrations',
command: 'cd backend && npm run migrate',
note: 'Updates database schema'
},
{
description: 'Restart application',
command: '# Restart your application service',
note: 'Method depends on your setup'
}
];
instructions.warnings.push('Make sure to preserve your .env file and storage directory when updating');
instructions.warnings.push('Consider creating a full backup before updating');
}
return instructions;
}
module.exports = {
detectEnvironment,
generateUpdateInstructions
};
+58 -1
View File
@@ -1,4 +1,5 @@
const sharp = require('sharp');
const exifr = require('exifr');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
@@ -119,6 +120,9 @@ async function generateThumbnail(imagePath, options = {}) {
failOnError: false // Don't fail on minor issues
});
// Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.)
sharpInstance = sharpInstance.withMetadata(false);
// Apply resize with configured settings
// For square thumbnails with 'cover' fit, we crop to center
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
@@ -338,6 +342,9 @@ async function generateHeroImage(imagePath, options = {}) {
failOnError: false
});
// Strip EXIF/metadata from hero images (privacy: prevent GPS leak etc.)
sharpInstance = sharpInstance.withMetadata(false);
// Resize to fit hero dimensions while maintaining aspect ratio
// Use 'cover' to fill the hero area (crops if needed)
sharpInstance = sharpInstance.resize(heroWidth, heroHeight, {
@@ -442,6 +449,55 @@ async function ensureHeroImage(photo) {
return null;
}
/**
* Extract capture date from EXIF metadata
* @param {string} imagePath - Path to the image file
* @returns {Date|null} - The capture date or null if not available
*/
async function extractCaptureDate(imagePath) {
try {
// Parse EXIF data, looking for common date fields
const exif = await exifr.parse(imagePath, {
pick: ['DateTimeOriginal', 'CreateDate', 'DateTimeDigitized', 'ModifyDate']
});
if (!exif) {
return null;
}
// Priority order: DateTimeOriginal > CreateDate > DateTimeDigitized > ModifyDate
const captureDate = exif.DateTimeOriginal ||
exif.CreateDate ||
exif.DateTimeDigitized ||
exif.ModifyDate;
if (captureDate) {
// exifr returns Date objects directly when parsing dates
if (captureDate instanceof Date) {
// Validate the date is reasonable (not in the future, not before 1990)
const now = new Date();
const minDate = new Date('1990-01-01');
if (captureDate > minDate && captureDate <= now) {
return captureDate;
}
}
// Handle string dates if necessary
if (typeof captureDate === 'string') {
const parsed = new Date(captureDate);
if (!isNaN(parsed.getTime())) {
return parsed;
}
}
}
return null;
} catch (error) {
// Log only as debug - many images don't have EXIF data
logger.debug(`Could not extract EXIF date from ${path.basename(imagePath)}:`, error.message);
return null;
}
}
module.exports = {
generateThumbnail,
isThumbnailValid,
@@ -449,5 +505,6 @@ module.exports = {
generateVideoPlaceholder,
generateHeroImage,
isHeroValid,
ensureHeroImage
ensureHeroImage,
extractCaptureDate
};
+22 -37
View File
@@ -4,9 +4,7 @@ const crypto = require('crypto');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream } = require('fs');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const { spawnAsync, spawnToFile, spawnFromFile } = require('../utils/safeExec');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
@@ -418,12 +416,14 @@ class RestoreService {
let availableBytes = 0;
let diskCheckSucceeded = false;
try {
const { exec } = require('child_process');
const execAsync = promisify(exec);
// Use root path as fallback if storage path doesn't exist yet
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
const parsed = parseInt(stdout.trim());
const { stdout } = await spawnAsync('df', ['-k', checkPath]);
// Parse df output: last line, 4th column is available KB
const lines = stdout.trim().split('\n');
const lastLine = lines[lines.length - 1];
const columns = lastLine.trim().split(/\s+/);
const parsed = parseInt(columns[3]);
if (!isNaN(parsed) && parsed > 0) {
availableBytes = parsed * 1024; // Convert from KB to bytes
diskCheckSucceeded = true;
@@ -492,15 +492,12 @@ class RestoreService {
if (this.dbType === 'sqlite') {
const dbPath = knexConfig.connection.filename;
await execAsync(`sqlite3 "${dbPath}" ".backup '${dbBackupPath}'"`);
await spawnAsync('sqlite3', [dbPath, `.backup '${dbBackupPath}'`]);
} else {
// PostgreSQL backup
const { host, port, user, password, database } = knexConfig.connection;
const env = { ...process.env, PGPASSWORD: password };
await execAsync(
`pg_dump -h ${host} -p ${port} -U ${user} -d ${database} > "${dbBackupPath}"`,
{ env }
);
await spawnToFile('pg_dump', ['-h', host, '-p', String(port), '-U', user, '-d', database], dbBackupPath, { env });
}
// Compress database backup
@@ -513,8 +510,8 @@ class RestoreService {
this.log('info', 'Backing up current files...');
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const filesBackupPath = path.join(backupPath, 'files.tar.gz');
await execAsync(`tar -czf "${filesBackupPath}" -C "${path.dirname(storagePath)}" "${path.basename(storagePath)}"`);
await spawnAsync('tar', ['-czf', filesBackupPath, '-C', path.dirname(storagePath), path.basename(storagePath)]);
}
// Create backup manifest
@@ -696,10 +693,10 @@ class RestoreService {
try {
// Restore from backup
await execAsync(`sqlite3 "${dbPath}" ".restore '${restoreFile}'"`);
await spawnAsync('sqlite3', [dbPath, `.restore '${restoreFile}'`]);
// Verify integrity
const integrityCheck = await execAsync(`sqlite3 "${dbPath}" "PRAGMA integrity_check"`);
const integrityCheck = await spawnAsync('sqlite3', [dbPath, 'PRAGMA integrity_check']);
if (!integrityCheck.stdout.includes('ok')) {
throw new Error('Database integrity check failed after restore');
}
@@ -722,21 +719,12 @@ class RestoreService {
// Drop and recreate database (extremely dangerous!)
this.log('warn', 'Dropping and recreating PostgreSQL database...');
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -c "DROP DATABASE IF EXISTS ${database}"`,
{ env }
);
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -c "CREATE DATABASE ${database}"`,
{ env }
);
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `DROP DATABASE IF EXISTS ${database}`], { env });
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `CREATE DATABASE ${database}`], { env });
// Restore from backup
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${restoreFile}"`,
{ env, maxBuffer: 1024 * 1024 * 100 } // 100MB buffer
);
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], restoreFile, { env });
}
// Re-initialize database connection
@@ -987,14 +975,11 @@ class RestoreService {
if (this.dbType === 'sqlite') {
const dbPath = knexConfig.connection.filename;
await execAsync(`sqlite3 "${dbPath}" ".restore '${decompressedPath}'"`);
await spawnAsync('sqlite3', [dbPath, `.restore '${decompressedPath}'`]);
} else {
const { host, port, user, password, database } = knexConfig.connection;
const env = { ...process.env, PGPASSWORD: password };
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${decompressedPath}"`,
{ env }
);
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], decompressedPath, { env });
}
await fs.unlink(decompressedPath);
@@ -1004,7 +989,7 @@ class RestoreService {
const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz');
if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
await execAsync(`tar -xzf "${filesBackupPath}" -C "${path.dirname(storagePath)}"`);
await spawnAsync('tar', ['-xzf', filesBackupPath, '-C', path.dirname(storagePath)]);
}
this.log('info', 'Rollback completed successfully');
@@ -0,0 +1,246 @@
/**
* Update Notification Service
* Checks for updates and sends email notifications to administrators.
*/
const { db } = require('../database/db');
const { checkForUpdates } = require('./updateCheckService');
const { sendTemplateEmail, initializeTransporter } = require('./emailProcessor');
const logger = require('../utils/logger');
/**
* Get update notification settings from database
*/
async function getUpdateNotificationSettings() {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'update_email_notifications_enabled',
'update_email_recipients',
'last_notified_version'
])
.select('setting_key', 'setting_value');
const result = {};
for (const setting of settings) {
try {
result[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
result[setting.setting_key] = setting.setting_value;
}
}
return {
enabled: result.update_email_notifications_enabled === true,
recipients: result.update_email_recipients || '',
lastNotifiedVersion: result.last_notified_version || ''
};
} catch (error) {
logger.error('Error fetching update notification settings:', error);
return {
enabled: false,
recipients: '',
lastNotifiedVersion: ''
};
}
}
/**
* Update the last notified version in database
*/
async function updateLastNotifiedVersion(version) {
try {
await db('app_settings')
.where('setting_key', 'last_notified_version')
.update({
setting_value: JSON.stringify(version),
updated_at: db.fn.now()
});
} catch (error) {
logger.error('Error updating last notified version:', error);
}
}
/**
* Get admin email addresses to notify
* If recipients setting is empty, get all active admin emails
*/
async function getNotificationRecipients(recipientsSetting) {
try {
if (recipientsSetting && recipientsSetting.trim()) {
// Use configured recipients (comma-separated)
return recipientsSetting.split(',').map(email => email.trim()).filter(Boolean);
}
// Fallback: get all active admin user emails
const admins = await db('admin_users')
.where('is_active', true)
.whereNotNull('email')
.select('email');
return admins.map(admin => admin.email).filter(Boolean);
} catch (error) {
logger.error('Error fetching notification recipients:', error);
return [];
}
}
/**
* Check for updates and send notification emails if new version is available
*/
async function checkAndNotifyUpdates() {
logger.info('Update notification service: Checking for updates...');
try {
// Check if update notifications are enabled
const settings = await getUpdateNotificationSettings();
if (!settings.enabled) {
logger.info('Update email notifications are disabled');
return { notified: false, reason: 'notifications_disabled' };
}
// Check for available updates
const updateInfo = await checkForUpdates();
if (!updateInfo.updateAvailable) {
logger.info('No updates available');
return { notified: false, reason: 'no_updates' };
}
const newVersion = updateInfo.latest.forChannel;
// Check if we've already notified about this version
if (settings.lastNotifiedVersion === newVersion) {
logger.info(`Already notified about version ${newVersion}`);
return { notified: false, reason: 'already_notified' };
}
// Get recipients
const recipients = await getNotificationRecipients(settings.recipients);
if (recipients.length === 0) {
logger.warn('No recipients configured for update notifications');
return { notified: false, reason: 'no_recipients' };
}
// Ensure email transporter is initialized
await initializeTransporter();
// Send email to each recipient
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
let successCount = 0;
let errorCount = 0;
for (const email of recipients) {
try {
await sendTemplateEmail(email, 'version_update_available', {
current_version: updateInfo.current,
new_version: newVersion,
channel: channelLabel,
release_notes_url: releaseNotesUrl,
admin_url: `${frontendUrl}/admin`
});
successCount++;
logger.info(`Update notification sent to ${email}`);
} catch (error) {
errorCount++;
logger.error(`Failed to send update notification to ${email}:`, error);
}
}
// Update last notified version
if (successCount > 0) {
await updateLastNotifiedVersion(newVersion);
logger.info(`Update notifications sent: ${successCount} success, ${errorCount} failed`);
}
return {
notified: successCount > 0,
newVersion,
successCount,
errorCount,
totalRecipients: recipients.length
};
} catch (error) {
logger.error('Error in update notification service:', error);
return { notified: false, reason: 'error', error: error.message };
}
}
/**
* Force send update notification (for manual trigger from admin UI)
*/
async function sendUpdateNotificationNow() {
logger.info('Manually triggering update notification...');
try {
// Check for available updates
const updateInfo = await checkForUpdates(true); // Force refresh
if (!updateInfo.updateAvailable) {
return { success: false, message: 'No updates available' };
}
const newVersion = updateInfo.latest.forChannel;
const settings = await getUpdateNotificationSettings();
// Get recipients
const recipients = await getNotificationRecipients(settings.recipients);
if (recipients.length === 0) {
return { success: false, message: 'No recipients configured' };
}
// Ensure email transporter is initialized
await initializeTransporter();
// Send email to each recipient
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
let successCount = 0;
let errorCount = 0;
for (const email of recipients) {
try {
await sendTemplateEmail(email, 'version_update_available', {
current_version: updateInfo.current,
new_version: newVersion,
channel: channelLabel,
release_notes_url: releaseNotesUrl
});
successCount++;
} catch (error) {
errorCount++;
logger.error(`Failed to send update notification to ${email}:`, error);
}
}
// Update last notified version
if (successCount > 0) {
await updateLastNotifiedVersion(newVersion);
}
return {
success: successCount > 0,
newVersion,
successCount,
errorCount,
totalRecipients: recipients.length
};
} catch (error) {
logger.error('Error sending manual update notification:', error);
return { success: false, message: error.message };
}
}
module.exports = {
checkAndNotifyUpdates,
sendUpdateNotificationNow,
getUpdateNotificationSettings,
getNotificationRecipients
};
+113
View File
@@ -0,0 +1,113 @@
const { spawn } = require('child_process');
/**
* Safe command execution utilities using spawn (shell: false).
* These prevent command injection by never invoking a shell interpreter.
*/
/**
* Run a command with arguments, returning { stdout, stderr }.
* Equivalent to execAsync(cmd) but safe from injection.
*/
function spawnAsync(cmd, args = [], options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, {
shell: false,
...options,
stdio: ['ignore', 'pipe', 'pipe']
});
const stdoutChunks = [];
const stderrChunks = [];
child.stdout.on('data', chunk => stdoutChunks.push(chunk));
child.stderr.on('data', chunk => stderrChunks.push(chunk));
child.on('error', reject);
child.on('close', (code) => {
const stdout = Buffer.concat(stdoutChunks).toString();
const stderr = Buffer.concat(stderrChunks).toString();
if (code !== 0) {
const err = new Error(`${cmd} exited with code ${code}: ${stderr}`);
err.code = code;
err.stdout = stdout;
err.stderr = stderr;
return reject(err);
}
resolve({ stdout, stderr });
});
});
}
/**
* Run a command and redirect stdout to a file (replaces shell `> file`).
*/
function spawnToFile(cmd, args, outputPath, options = {}) {
const fs = require('fs');
return new Promise((resolve, reject) => {
const outStream = fs.createWriteStream(outputPath);
const child = spawn(cmd, args, {
shell: false,
...options,
stdio: ['ignore', outStream, 'pipe']
});
const stderrChunks = [];
child.stderr.on('data', chunk => stderrChunks.push(chunk));
child.on('error', (err) => {
outStream.destroy();
reject(err);
});
child.on('close', (code) => {
outStream.end();
const stderr = Buffer.concat(stderrChunks).toString();
if (code !== 0) {
const err = new Error(`${cmd} exited with code ${code}: ${stderr}`);
err.code = code;
err.stderr = stderr;
return reject(err);
}
resolve({ stderr });
});
});
}
/**
* Run a command and pipe a file into stdin (replaces shell `< file`).
*/
function spawnFromFile(cmd, args, inputPath, options = {}) {
const fs = require('fs');
return new Promise((resolve, reject) => {
const inStream = fs.createReadStream(inputPath);
const child = spawn(cmd, args, {
shell: false,
...options,
stdio: [inStream, 'pipe', 'pipe']
});
const stdoutChunks = [];
const stderrChunks = [];
child.stdout.on('data', chunk => stdoutChunks.push(chunk));
child.stderr.on('data', chunk => stderrChunks.push(chunk));
child.on('error', (err) => {
inStream.destroy();
reject(err);
});
child.on('close', (code) => {
const stdout = Buffer.concat(stdoutChunks).toString();
const stderr = Buffer.concat(stderrChunks).toString();
if (code !== 0) {
const err = new Error(`${cmd} exited with code ${code}: ${stderr}`);
err.code = code;
err.stdout = stdout;
err.stderr = stderr;
return reject(err);
}
resolve({ stdout, stderr });
});
});
}
module.exports = { spawnAsync, spawnToFile, spawnFromFile };