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:
@@ -102,8 +102,10 @@ backup/
|
|||||||
|
|
||||||
# Local SQLite files in backend
|
# Local SQLite files in backend
|
||||||
backend/*.sqlite*
|
backend/*.sqlite*
|
||||||
|
backend/*.db
|
||||||
|
|
||||||
# Test files and artifacts
|
# Test files and artifacts
|
||||||
|
test-images/
|
||||||
test-logo*.jpg
|
test-logo*.jpg
|
||||||
test-logo*.png
|
test-logo*.png
|
||||||
test-results/
|
test-results/
|
||||||
|
|||||||
@@ -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)
|
||||||
|
};
|
||||||
Generated
+7
@@ -20,6 +20,7 @@
|
|||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^6.7.0",
|
"express-rate-limit": "^6.7.0",
|
||||||
"express-validator": "^7.0.1",
|
"express-validator": "^7.0.1",
|
||||||
@@ -5730,6 +5731,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/exit": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.0.3",
|
"dotenv": "^16.0.3",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^6.7.0",
|
"express-rate-limit": "^6.7.0",
|
||||||
"express-validator": "^7.0.1",
|
"express-validator": "^7.0.1",
|
||||||
|
|||||||
+26
-14
@@ -359,8 +359,8 @@ async function initializeRateLimiters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Note: Rate limiters will be initialized after database connection
|
// Note: Rate limiters will be initialized after database connection
|
||||||
app.use(express.json({ limit: '10gb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
|
||||||
// Request logging for API routes (with timestamps)
|
// Request logging for API routes (with timestamps)
|
||||||
const apiRequestLogger = (req, res, next) => {
|
const apiRequestLogger = (req, res, next) => {
|
||||||
@@ -386,8 +386,23 @@ app.use('/api/admin', sessionTimeoutMiddleware);
|
|||||||
|
|
||||||
// Middleware to set CORS headers for static files
|
// Middleware to set CORS headers for static files
|
||||||
const setCorsHeaders = (req, res, next) => {
|
const setCorsHeaders = (req, res, next) => {
|
||||||
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
|
const origin = req.headers.origin;
|
||||||
res.header('Access-Control-Allow-Credentials', 'true');
|
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');
|
res.header('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
@@ -451,19 +466,16 @@ app.get('/health', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
// Check database connectivity
|
// Check database connectivity
|
||||||
await db.raw('SELECT 1');
|
await db.raw('SELECT 1');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
database: 'connected',
|
timestamp: new Date().toISOString()
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Health check failed:', error);
|
logger.error('Health check failed:', error);
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
database: 'disconnected',
|
timestamp: new Date().toISOString()
|
||||||
error: error.message,
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -495,8 +495,9 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
|
|||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const search = req.query.search || '';
|
const search = req.query.search || '';
|
||||||
const status = req.query.status || 'all';
|
const status = req.query.status || 'all';
|
||||||
const sortBy = req.query.sortBy || 'created_at';
|
const allowedSortBy = ['created_at', 'event_name', 'slug', 'updated_at', 'expires_at', 'capture_date'];
|
||||||
const sortOrder = req.query.sortOrder || 'desc';
|
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
|
// Build query
|
||||||
let query = db('events');
|
let query = db('events');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ const fs = require('fs').promises;
|
|||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
|
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
|
||||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
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 finalPath = path.join(finalDestPath, newFilename);
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath);
|
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
|
// Prepare photo data for batch insert
|
||||||
const photoData = {
|
const photoData = {
|
||||||
event_id: parseInt(eventId),
|
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
|
thumbnail_path: null, // Will generate after successful commit
|
||||||
type: photoType,
|
type: photoType,
|
||||||
category_id: parsedCategoryId, // Save the selected category
|
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);
|
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) => {
|
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
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')
|
let query = db('photos')
|
||||||
.where({ 'photos.event_id': eventId })
|
.where({ 'photos.event_id': eventId })
|
||||||
@@ -781,6 +792,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
|||||||
photos: photos.map(photo => ({
|
photos: photos.map(photo => ({
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
filename: photo.filename,
|
filename: photo.filename,
|
||||||
|
original_filename: photo.original_filename || null,
|
||||||
// Use the correct admin photos router base for serving images
|
// Use the correct admin photos router base for serving images
|
||||||
url: `/admin/photos/${eventId}/photo/${photo.id}`,
|
url: `/admin/photos/${eventId}/photo/${photo.id}`,
|
||||||
// Always expose a thumbnail URL; backend will generate on demand if missing
|
// Always expose a thumbnail URL; backend will generate on demand if missing
|
||||||
|
|||||||
@@ -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);
|
res.json(settingsObject);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Settings fetch error:', 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);
|
res.json(settingsObject);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Settings fetch error:', error);
|
console.error('Settings fetch error:', error);
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ const os = require('os');
|
|||||||
const { formatBoolean } = require('../utils/dbCompat');
|
const { formatBoolean } = require('../utils/dbCompat');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
|
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
|
||||||
|
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
|
||||||
|
const {
|
||||||
|
checkAndNotifyUpdates,
|
||||||
|
sendUpdateNotificationNow,
|
||||||
|
getUpdateNotificationSettings
|
||||||
|
} = require('../services/updateNotificationService');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get system version
|
// 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
|
// Get comprehensive system status
|
||||||
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||||
try {
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -187,8 +187,8 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
// Get all photos
|
// Get all photos
|
||||||
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Get filter parameters from query
|
// Get filter and sort parameters from query
|
||||||
const { filter, guest_id } = req.query;
|
const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query;
|
||||||
|
|
||||||
// Get watermark settings to generate cache-busting version for URLs
|
// Get watermark settings to generate cache-busting version for URLs
|
||||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
@@ -196,11 +196,25 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// First get all photos
|
// Build the query with sorting
|
||||||
let photos = await db('photos')
|
const sortOrder = order === 'asc' ? 'asc' : 'desc';
|
||||||
|
let photosQuery = db('photos')
|
||||||
.where('photos.event_id', req.event.id)
|
.where('photos.event_id', req.event.id)
|
||||||
.select('photos.*')
|
.select('photos.*');
|
||||||
.orderBy('photos.uploaded_at', 'desc');
|
|
||||||
|
// 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)
|
// Apply filtering if requested (supports global stats + per-guest interactions)
|
||||||
if (filter) {
|
if (filter) {
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ async function performLocalBackup(config, files) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRsyncCommand(config) {
|
function buildRsyncArgs(config) {
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
const host = config.backup_rsync_host;
|
const host = config.backup_rsync_host;
|
||||||
const remotePath = config.backup_rsync_path;
|
const remotePath = config.backup_rsync_path;
|
||||||
@@ -446,20 +446,21 @@ function buildRsyncCommand(config) {
|
|||||||
throw new Error('Rsync configuration incomplete');
|
throw new Error('Rsync configuration incomplete');
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = ['-avz', '--delete', '--stats'];
|
const args = ['-avz', '--delete', '--stats'];
|
||||||
if (config.backup_rsync_ssh_key) {
|
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 || [];
|
const excludePatterns = config.backup_exclude_patterns || [];
|
||||||
excludePatterns.forEach(pattern => options.push(`--exclude="${pattern}"`));
|
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
|
||||||
|
|
||||||
const source = `${storagePath}/`;
|
const source = `${storagePath}/`;
|
||||||
const destination = config.backup_rsync_user
|
const destination = config.backup_rsync_user
|
||||||
? `${config.backup_rsync_user}@${host}:${remotePath}`
|
? `${config.backup_rsync_user}@${host}:${remotePath}`
|
||||||
: `${host}:${remotePath}`;
|
: `${host}:${remotePath}`;
|
||||||
|
|
||||||
return `rsync ${options.join(' ')} "${source}" "${destination}"`;
|
args.push(source, destination);
|
||||||
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRsyncStats(output) {
|
function parseRsyncStats(output) {
|
||||||
@@ -479,9 +480,9 @@ function parseRsyncStats(output) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function performRsyncBackup(config, files) {
|
async function performRsyncBackup(config, files) {
|
||||||
const command = buildRsyncCommand(config);
|
const { spawnAsync } = require('../utils/safeExec');
|
||||||
const execAsync = getExecAsync();
|
const rsyncArgs = buildRsyncArgs(config);
|
||||||
const { stdout } = await execAsync(command);
|
const { stdout } = await spawnAsync('rsync', rsyncArgs);
|
||||||
const stats = parseRsyncStats(stdout);
|
const stats = parseRsyncStats(stdout);
|
||||||
|
|
||||||
const backedUpFiles = files.map(file => file.relativePath);
|
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,
|
backedUpCount: typeof stats.filesTransferred === 'number' ? stats.filesTransferred : backedUpFiles.length,
|
||||||
backedUpSize: totalSize,
|
backedUpSize: totalSize,
|
||||||
backedUpFiles,
|
backedUpFiles,
|
||||||
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`,
|
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`
|
||||||
rsyncCommand: command
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { exec } = require('child_process');
|
|
||||||
const { promisify } = require('util');
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
|
||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
const { pipeline } = require('stream/promises');
|
const { pipeline } = require('stream/promises');
|
||||||
const { createReadStream, createWriteStream } = require('fs');
|
const { createReadStream, createWriteStream } = require('fs');
|
||||||
@@ -163,10 +161,10 @@ class DatabaseBackupService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Use SQLite's backup API for consistency
|
// Use SQLite's backup API for consistency
|
||||||
await execAsync(`sqlite3 "${dbPath}" ".backup '${tempPath}'"`);
|
await spawnAsync('sqlite3', [dbPath, `.backup '${tempPath}'`]);
|
||||||
|
|
||||||
// Verify the backup
|
// 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')) {
|
if (!verifyResult.stdout.includes('ok')) {
|
||||||
throw new Error('Backup integrity check failed');
|
throw new Error('Backup integrity check failed');
|
||||||
}
|
}
|
||||||
@@ -192,14 +190,6 @@ class DatabaseBackupService {
|
|||||||
async createPostgreSQLBackup(outputPath, options = {}) {
|
async createPostgreSQLBackup(outputPath, options = {}) {
|
||||||
const { host, port, user, password, database } = knexConfig.connection;
|
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
|
// Set PGPASSWORD environment variable for security
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
if (password) {
|
if (password) {
|
||||||
@@ -227,14 +217,17 @@ class DatabaseBackupService {
|
|||||||
pgDumpOptions.push('--compress=6');
|
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 {
|
try {
|
||||||
const { stderr } = await execAsync(command, {
|
const { stderr } = await spawnToFile('pg_dump', pgDumpArgs, outputPath, { env });
|
||||||
env,
|
|
||||||
maxBuffer: 1024 * 1024 * 100 // 100MB buffer
|
|
||||||
});
|
|
||||||
|
|
||||||
// pg_dump writes progress to stderr, not an error
|
// pg_dump writes progress to stderr, not an error
|
||||||
if (stderr && !stderr.includes('dump complete')) {
|
if (stderr && !stderr.includes('dump complete')) {
|
||||||
logger.warn('pg_dump warnings:', stderr);
|
logger.warn('pg_dump warnings:', stderr);
|
||||||
@@ -261,7 +254,7 @@ class DatabaseBackupService {
|
|||||||
try {
|
try {
|
||||||
if (this.dbType === 'sqlite') {
|
if (this.dbType === 'sqlite') {
|
||||||
// For SQLite, we can directly check integrity
|
// 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')) {
|
if (!result.stdout.includes('ok')) {
|
||||||
throw new Error('Backup integrity check failed');
|
throw new Error('Backup integrity check failed');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
|
const exifr = require('exifr');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -119,6 +120,9 @@ async function generateThumbnail(imagePath, options = {}) {
|
|||||||
failOnError: false // Don't fail on minor issues
|
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
|
// Apply resize with configured settings
|
||||||
// For square thumbnails with 'cover' fit, we crop to center
|
// For square thumbnails with 'cover' fit, we crop to center
|
||||||
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
|
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
|
||||||
@@ -338,6 +342,9 @@ async function generateHeroImage(imagePath, options = {}) {
|
|||||||
failOnError: false
|
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
|
// Resize to fit hero dimensions while maintaining aspect ratio
|
||||||
// Use 'cover' to fill the hero area (crops if needed)
|
// Use 'cover' to fill the hero area (crops if needed)
|
||||||
sharpInstance = sharpInstance.resize(heroWidth, heroHeight, {
|
sharpInstance = sharpInstance.resize(heroWidth, heroHeight, {
|
||||||
@@ -442,6 +449,55 @@ async function ensureHeroImage(photo) {
|
|||||||
return null;
|
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 = {
|
module.exports = {
|
||||||
generateThumbnail,
|
generateThumbnail,
|
||||||
isThumbnailValid,
|
isThumbnailValid,
|
||||||
@@ -449,5 +505,6 @@ module.exports = {
|
|||||||
generateVideoPlaceholder,
|
generateVideoPlaceholder,
|
||||||
generateHeroImage,
|
generateHeroImage,
|
||||||
isHeroValid,
|
isHeroValid,
|
||||||
ensureHeroImage
|
ensureHeroImage,
|
||||||
|
extractCaptureDate
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ const crypto = require('crypto');
|
|||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
const { pipeline } = require('stream/promises');
|
const { pipeline } = require('stream/promises');
|
||||||
const { createReadStream, createWriteStream } = require('fs');
|
const { createReadStream, createWriteStream } = require('fs');
|
||||||
const { exec } = require('child_process');
|
const { spawnAsync, spawnToFile, spawnFromFile } = require('../utils/safeExec');
|
||||||
const { promisify } = require('util');
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const knexConfig = require('../../knexfile');
|
const knexConfig = require('../../knexfile');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -418,12 +416,14 @@ class RestoreService {
|
|||||||
let availableBytes = 0;
|
let availableBytes = 0;
|
||||||
let diskCheckSucceeded = false;
|
let diskCheckSucceeded = false;
|
||||||
try {
|
try {
|
||||||
const { exec } = require('child_process');
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
// Use root path as fallback if storage path doesn't exist yet
|
// Use root path as fallback if storage path doesn't exist yet
|
||||||
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
|
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
|
||||||
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
|
const { stdout } = await spawnAsync('df', ['-k', checkPath]);
|
||||||
const parsed = parseInt(stdout.trim());
|
// 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) {
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
availableBytes = parsed * 1024; // Convert from KB to bytes
|
availableBytes = parsed * 1024; // Convert from KB to bytes
|
||||||
diskCheckSucceeded = true;
|
diskCheckSucceeded = true;
|
||||||
@@ -492,15 +492,12 @@ class RestoreService {
|
|||||||
|
|
||||||
if (this.dbType === 'sqlite') {
|
if (this.dbType === 'sqlite') {
|
||||||
const dbPath = knexConfig.connection.filename;
|
const dbPath = knexConfig.connection.filename;
|
||||||
await execAsync(`sqlite3 "${dbPath}" ".backup '${dbBackupPath}'"`);
|
await spawnAsync('sqlite3', [dbPath, `.backup '${dbBackupPath}'`]);
|
||||||
} else {
|
} else {
|
||||||
// PostgreSQL backup
|
// PostgreSQL backup
|
||||||
const { host, port, user, password, database } = knexConfig.connection;
|
const { host, port, user, password, database } = knexConfig.connection;
|
||||||
const env = { ...process.env, PGPASSWORD: password };
|
const env = { ...process.env, PGPASSWORD: password };
|
||||||
await execAsync(
|
await spawnToFile('pg_dump', ['-h', host, '-p', String(port), '-U', user, '-d', database], dbBackupPath, { env });
|
||||||
`pg_dump -h ${host} -p ${port} -U ${user} -d ${database} > "${dbBackupPath}"`,
|
|
||||||
{ env }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compress database backup
|
// Compress database backup
|
||||||
@@ -513,8 +510,8 @@ class RestoreService {
|
|||||||
this.log('info', 'Backing up current files...');
|
this.log('info', 'Backing up current files...');
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
const filesBackupPath = path.join(backupPath, 'files.tar.gz');
|
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
|
// Create backup manifest
|
||||||
@@ -696,10 +693,10 @@ class RestoreService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Restore from backup
|
// Restore from backup
|
||||||
await execAsync(`sqlite3 "${dbPath}" ".restore '${restoreFile}'"`);
|
await spawnAsync('sqlite3', [dbPath, `.restore '${restoreFile}'`]);
|
||||||
|
|
||||||
// Verify integrity
|
// 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')) {
|
if (!integrityCheck.stdout.includes('ok')) {
|
||||||
throw new Error('Database integrity check failed after restore');
|
throw new Error('Database integrity check failed after restore');
|
||||||
}
|
}
|
||||||
@@ -722,21 +719,12 @@ class RestoreService {
|
|||||||
// Drop and recreate database (extremely dangerous!)
|
// Drop and recreate database (extremely dangerous!)
|
||||||
this.log('warn', 'Dropping and recreating PostgreSQL database...');
|
this.log('warn', 'Dropping and recreating PostgreSQL database...');
|
||||||
|
|
||||||
await execAsync(
|
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `DROP DATABASE IF EXISTS ${database}`], { env });
|
||||||
`psql -h ${host} -p ${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 });
|
||||||
);
|
|
||||||
|
|
||||||
await execAsync(
|
|
||||||
`psql -h ${host} -p ${port} -U ${user} -c "CREATE DATABASE ${database}"`,
|
|
||||||
{ env }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Restore from backup
|
// Restore from backup
|
||||||
await execAsync(
|
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], restoreFile, { env });
|
||||||
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${restoreFile}"`,
|
|
||||||
{ env, maxBuffer: 1024 * 1024 * 100 } // 100MB buffer
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-initialize database connection
|
// Re-initialize database connection
|
||||||
@@ -987,14 +975,11 @@ class RestoreService {
|
|||||||
|
|
||||||
if (this.dbType === 'sqlite') {
|
if (this.dbType === 'sqlite') {
|
||||||
const dbPath = knexConfig.connection.filename;
|
const dbPath = knexConfig.connection.filename;
|
||||||
await execAsync(`sqlite3 "${dbPath}" ".restore '${decompressedPath}'"`);
|
await spawnAsync('sqlite3', [dbPath, `.restore '${decompressedPath}'`]);
|
||||||
} else {
|
} else {
|
||||||
const { host, port, user, password, database } = knexConfig.connection;
|
const { host, port, user, password, database } = knexConfig.connection;
|
||||||
const env = { ...process.env, PGPASSWORD: password };
|
const env = { ...process.env, PGPASSWORD: password };
|
||||||
await execAsync(
|
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], decompressedPath, { env });
|
||||||
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${decompressedPath}"`,
|
|
||||||
{ env }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.unlink(decompressedPath);
|
await fs.unlink(decompressedPath);
|
||||||
@@ -1004,7 +989,7 @@ class RestoreService {
|
|||||||
const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz');
|
const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz');
|
||||||
if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) {
|
if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) {
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
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');
|
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
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
+2
-2
@@ -70,7 +70,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "127.0.0.1:${DB_PORT:-5432}:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -89,7 +89,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
ports:
|
ports:
|
||||||
- "${REDIS_PORT:-6379}:6379"
|
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,7 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
server_tokens off;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
@@ -21,9 +22,9 @@ server {
|
|||||||
# Security headers
|
# Security headers
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
|
||||||
|
|
||||||
# Health check endpoint
|
# Health check endpoint
|
||||||
location /health {
|
location /health {
|
||||||
|
|||||||
@@ -287,6 +287,11 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
|||||||
<p className="text-white text-xs font-medium truncate mb-1">
|
<p className="text-white text-xs font-medium truncate mb-1">
|
||||||
{photo.filename}
|
{photo.filename}
|
||||||
</p>
|
</p>
|
||||||
|
{photo.original_filename && photo.original_filename !== photo.filename && (
|
||||||
|
<p className="text-white/60 text-[10px] truncate mb-1">
|
||||||
|
Original: {photo.original_filename}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-white/80 text-xs mb-2">
|
<p className="text-white/80 text-xs mb-2">
|
||||||
{photosService.formatBytes(photo.size)}
|
{photosService.formatBytes(photo.size)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -230,7 +230,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
|||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="lg:w-80 bg-neutral-900 rounded-lg p-6 overflow-y-auto">
|
<div className="lg:w-80 bg-neutral-900 rounded-lg p-6 overflow-y-auto">
|
||||||
<h3 className="text-white font-medium text-lg mb-4">{currentPhoto.filename}</h3>
|
<h3 className="text-white font-medium text-lg">{currentPhoto.filename}</h3>
|
||||||
|
{currentPhoto.original_filename && currentPhoto.original_filename !== currentPhoto.filename && (
|
||||||
|
<p className="text-neutral-400 text-sm">Original: {currentPhoto.original_filename}</p>
|
||||||
|
)}
|
||||||
|
<div className="mb-4" />
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-2 mb-6">
|
<div className="flex gap-2 mb-6">
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
X,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
AlertTriangle,
|
||||||
|
Server,
|
||||||
|
Terminal,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
|
interface UpdateStep {
|
||||||
|
description: string;
|
||||||
|
command: string;
|
||||||
|
note?: string;
|
||||||
|
optional?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PreCheck {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateInstructions {
|
||||||
|
environmentName: string;
|
||||||
|
preChecks: PreCheck[];
|
||||||
|
steps: UpdateStep[];
|
||||||
|
postChecks: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Environment {
|
||||||
|
type: 'docker' | 'git' | 'standalone';
|
||||||
|
isDocker: boolean;
|
||||||
|
isGit: boolean;
|
||||||
|
hasDockerCompose: boolean;
|
||||||
|
platform: string;
|
||||||
|
nodeVersion: string;
|
||||||
|
appVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateInstructionsResponse {
|
||||||
|
enabled?: boolean;
|
||||||
|
updateAvailable: boolean;
|
||||||
|
currentVersion: string;
|
||||||
|
targetVersion?: string;
|
||||||
|
channel?: string;
|
||||||
|
environment?: Environment;
|
||||||
|
instructions?: UpdateInstructions;
|
||||||
|
releaseNotesUrl?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUpdateInstructions(): Promise<UpdateInstructionsResponse> {
|
||||||
|
const response = await api.get<UpdateInstructionsResponse>('/admin/system/updates/instructions');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateInstructionsDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
targetVersion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateInstructionsDialog: React.FC<UpdateInstructionsDialogProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
targetVersion
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [checkedItems, setCheckedItems] = useState<Set<string>>(new Set());
|
||||||
|
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['update-instructions'],
|
||||||
|
queryFn: fetchUpdateInstructions,
|
||||||
|
enabled: isOpen,
|
||||||
|
staleTime: 5 * 60 * 1000 // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleCheckItem = (id: string) => {
|
||||||
|
const newChecked = new Set(checkedItems);
|
||||||
|
if (newChecked.has(id)) {
|
||||||
|
newChecked.delete(id);
|
||||||
|
} else {
|
||||||
|
newChecked.add(id);
|
||||||
|
}
|
||||||
|
setCheckedItems(newChecked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async (command: string, id: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(command);
|
||||||
|
setCopiedCommand(id);
|
||||||
|
setTimeout(() => setCopiedCommand(null), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyAllCommands = async () => {
|
||||||
|
if (!data?.instructions?.steps) return;
|
||||||
|
const allCommands = data.instructions.steps
|
||||||
|
.filter(step => !step.command.startsWith('#'))
|
||||||
|
.map(step => step.command)
|
||||||
|
.join('\n');
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(allCommands);
|
||||||
|
setCopiedCommand('all');
|
||||||
|
setTimeout(() => setCopiedCommand(null), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const requiredChecks = data?.instructions?.preChecks.filter(c => c.required) || [];
|
||||||
|
const allRequiredChecked = requiredChecks.every(check => checkedItems.has(check.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<div className="inline-block w-full max-w-2xl my-8 overflow-hidden text-left align-middle transition-all transform bg-white dark:bg-gray-800 rounded-lg shadow-xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{t('admin.updates.updateDialog.title', 'Update PicPeak')}
|
||||||
|
{data?.targetVersion && (
|
||||||
|
<span className="ml-2 text-blue-600 dark:text-blue-400">
|
||||||
|
v{data.targetVersion}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="px-6 py-4 max-h-[70vh] overflow-y-auto">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center p-4 bg-red-50 dark:bg-red-900/30 rounded-lg">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-500 mr-3" />
|
||||||
|
<p className="text-red-700 dark:text-red-300">
|
||||||
|
{t('admin.updates.updateDialog.error', 'Failed to load update instructions')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && !data.updateAvailable && (
|
||||||
|
<div className="flex items-center p-4 bg-green-50 dark:bg-green-900/30 rounded-lg">
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-green-500 mr-3" />
|
||||||
|
<p className="text-green-700 dark:text-green-300">
|
||||||
|
{t('admin.updates.upToDate', "You're up to date")} (v{data.currentVersion})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.instructions && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Environment Info */}
|
||||||
|
<div className="flex items-center p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<Server className="w-5 h-5 text-gray-500 dark:text-gray-400 mr-3" />
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{t('admin.updates.updateDialog.detectedEnv', 'Detected Environment')}:{' '}
|
||||||
|
<strong>{data.instructions.environmentName}</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{data.instructions.warnings.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.instructions.warnings.map((warning, idx) => (
|
||||||
|
<div key={idx} className="flex items-start p-3 bg-amber-50 dark:bg-amber-900/30 rounded-lg">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-500 mr-3 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300">{warning}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pre-flight Checklist */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-500 mr-2" />
|
||||||
|
{t('admin.updates.updateDialog.beforeUpdating', 'Before updating:')}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.instructions.preChecks.map((check) => (
|
||||||
|
<label
|
||||||
|
key={check.id}
|
||||||
|
className="flex items-center p-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checkedItems.has(check.id)}
|
||||||
|
onChange={() => handleCheckItem(check.id)}
|
||||||
|
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-3 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{check.text}
|
||||||
|
{check.required && (
|
||||||
|
<span className="text-red-500 ml-1">*</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="border-gray-200 dark:border-gray-700" />
|
||||||
|
|
||||||
|
{/* Update Commands */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
|
||||||
|
<Terminal className="w-4 h-4 text-blue-500 mr-2" />
|
||||||
|
{t('admin.updates.updateDialog.updateCommands', 'Update Commands:')}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{data.instructions.steps.map((step, idx) => (
|
||||||
|
<div key={idx} className={`${step.optional ? 'opacity-75' : ''}`}>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{idx + 1}. {step.description}
|
||||||
|
{step.optional && (
|
||||||
|
<span className="ml-2 text-xs text-gray-400">
|
||||||
|
({t('common.optional', 'optional')})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center bg-gray-900 dark:bg-gray-950 rounded-lg overflow-hidden">
|
||||||
|
<code className="flex-1 px-4 py-3 text-sm text-green-400 font-mono overflow-x-auto">
|
||||||
|
{step.command}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(step.command, `step-${idx}`)}
|
||||||
|
className="px-3 py-3 text-gray-400 hover:text-white border-l border-gray-700"
|
||||||
|
title={t('common.copy', 'Copy')}
|
||||||
|
>
|
||||||
|
{copiedCommand === `step-${idx}` ? (
|
||||||
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{step.note && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{step.note}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<hr className="border-gray-200 dark:border-gray-700" />
|
||||||
|
|
||||||
|
{/* Post-update Checks */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-gray-900 dark:text-white mb-3 flex items-center">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500 mr-2" />
|
||||||
|
{t('admin.updates.updateDialog.afterUpdating', 'After updating:')}
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{data.instructions.postChecks.map((check, idx) => (
|
||||||
|
<li key={idx} className="flex items-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<Circle className="w-2 h-2 mr-3 flex-shrink-0" />
|
||||||
|
{check}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Release Notes Link */}
|
||||||
|
{data.releaseNotesUrl && (
|
||||||
|
<a
|
||||||
|
href={data.releaseNotesUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4 mr-2" />
|
||||||
|
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{!allRequiredChecked && data?.instructions && (
|
||||||
|
<span className="text-amber-600 dark:text-amber-400">
|
||||||
|
{t('admin.updates.updateDialog.completeChecklist', 'Complete the checklist before updating')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{data?.instructions && (
|
||||||
|
<button
|
||||||
|
onClick={copyAllCommands}
|
||||||
|
className="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
{copiedCommand === 'all' ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-2 text-green-500" />
|
||||||
|
{t('common.copied', 'Copied!')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
{t('admin.updates.updateDialog.copyAllCommands', 'Copy All Commands')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
{t('common.close', 'Close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ArrowUpCircle, X, ExternalLink } from 'lucide-react';
|
import { ArrowUpCircle, X, ExternalLink, Wrench } from 'lucide-react';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
|
import { UpdateInstructionsDialog } from './UpdateInstructionsDialog';
|
||||||
|
|
||||||
interface UpdateInfo {
|
interface UpdateInfo {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -32,6 +33,7 @@ interface UpdateNotificationProps {
|
|||||||
export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismiss }) => {
|
export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismiss }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [showInstructions, setShowInstructions] = useState(false);
|
||||||
|
|
||||||
const { data: updateInfo } = useQuery({
|
const { data: updateInfo } = useQuery({
|
||||||
queryKey: ['update-check'],
|
queryKey: ['update-check'],
|
||||||
@@ -79,15 +81,24 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
|||||||
channel: channelLabel
|
channel: channelLabel
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<div className="flex items-center gap-3 mt-2">
|
||||||
href="https://github.com/the-luap/picpeak/releases"
|
<button
|
||||||
target="_blank"
|
onClick={() => setShowInstructions(true)}
|
||||||
rel="noopener noreferrer"
|
className="inline-flex items-center text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 px-3 py-1.5 rounded-md transition-colors"
|
||||||
className="inline-flex items-center text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mt-2"
|
>
|
||||||
>
|
<Wrench className="w-3 h-3 mr-1.5" />
|
||||||
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
|
{t('admin.updates.updateNow', 'Update Now')}
|
||||||
<ExternalLink className="w-3 h-3 ml-1" />
|
</button>
|
||||||
</a>
|
<a
|
||||||
|
href="https://github.com/the-luap/picpeak/releases"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||||
|
>
|
||||||
|
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
|
||||||
|
<ExternalLink className="w-3 h-3 ml-1" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -98,6 +109,13 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
|||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Update Instructions Dialog */}
|
||||||
|
<UpdateInstructionsDialog
|
||||||
|
isOpen={showInstructions}
|
||||||
|
onClose={() => setShowInstructions(false)}
|
||||||
|
targetVersion={updateInfo?.latest?.forChannel}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload } from 'lucide-react';
|
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload, Camera } from 'lucide-react';
|
||||||
import { Button } from '../common';
|
import { Button } from '../common';
|
||||||
import { PhotoCategory } from '../../types';
|
import { PhotoCategory } from '../../types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -13,8 +13,8 @@ interface GallerySidebarProps {
|
|||||||
onCategoryChange: (categoryId: number | string | null) => void;
|
onCategoryChange: (categoryId: number | string | null) => void;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
onSearchChange: (term: string) => void;
|
onSearchChange: (term: string) => void;
|
||||||
sortBy: 'date' | 'name' | 'size' | 'rating';
|
sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date';
|
||||||
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
|
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void;
|
||||||
isSelectionMode: boolean;
|
isSelectionMode: boolean;
|
||||||
onToggleSelectionMode: () => void;
|
onToggleSelectionMode: () => void;
|
||||||
selectedCount: number;
|
selectedCount: number;
|
||||||
@@ -101,6 +101,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ value: 'date', label: t('gallery.sortByDate'), icon: Calendar },
|
{ value: 'date', label: t('gallery.sortByDate'), icon: Calendar },
|
||||||
|
{ value: 'capture_date', label: t('gallery.sortByCaptureDate', 'Capture Date'), icon: Camera },
|
||||||
{ value: 'name', label: t('gallery.sortByName'), icon: Type },
|
{ value: 'name', label: t('gallery.sortByName'), icon: Type },
|
||||||
{ value: 'size', label: t('gallery.sortBySize'), icon: HardDrive },
|
{ value: 'size', label: t('gallery.sortBySize'), icon: HardDrive },
|
||||||
{ value: 'rating', label: t('gallery.sortByRating', 'Rating'), icon: Star }
|
{ value: 'rating', label: t('gallery.sortByRating', 'Rating'), icon: Star }
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
const { setTheme, theme } = useTheme();
|
const { setTheme, theme } = useTheme();
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
|
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
||||||
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
const [brandingSettings, setBrandingSettings] = useState<any>(null);
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
@@ -397,6 +397,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
// If ratings are equal, sort by comment count
|
// If ratings are equal, sort by comment count
|
||||||
return (b.comment_count || 0) - (a.comment_count || 0);
|
return (b.comment_count || 0) - (a.comment_count || 0);
|
||||||
|
case 'capture_date':
|
||||||
|
// Sort by capture date (from EXIF), fall back to upload date
|
||||||
|
const captureDateA = a.captured_at || a.uploaded_at;
|
||||||
|
const captureDateB = b.captured_at || b.uploaded_at;
|
||||||
|
return new Date(captureDateB).getTime() - new Date(captureDateA).getTime();
|
||||||
case 'date':
|
case 'date':
|
||||||
default:
|
default:
|
||||||
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Bell, Save, Mail, Send, RefreshCw } from 'lucide-react';
|
||||||
|
import { Card, Button, Input } from '../../../components/common';
|
||||||
|
import { api } from '../../../config/api';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
interface UpdateNotificationSettingsData {
|
||||||
|
enabled: boolean;
|
||||||
|
recipients: string;
|
||||||
|
lastNotifiedVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchNotificationSettings(): Promise<UpdateNotificationSettingsData> {
|
||||||
|
const response = await api.get<UpdateNotificationSettingsData>('/admin/system/updates/notifications');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateNotificationSettings(data: Partial<UpdateNotificationSettingsData>): Promise<UpdateNotificationSettingsData> {
|
||||||
|
const response = await api.put<{ success: boolean; settings: UpdateNotificationSettingsData }>(
|
||||||
|
'/admin/system/updates/notifications',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestNotification(): Promise<{ success: boolean; message?: string; successCount?: number }> {
|
||||||
|
const response = await api.post('/admin/system/updates/notifications/send');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkForNotifications(): Promise<{ notified: boolean; reason?: string }> {
|
||||||
|
const response = await api.post('/admin/system/updates/notifications/check');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateNotificationSettings: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['update-notification-settings'],
|
||||||
|
queryFn: fetchNotificationSettings
|
||||||
|
});
|
||||||
|
|
||||||
|
const [localEnabled, setLocalEnabled] = React.useState<boolean>(false);
|
||||||
|
const [localRecipients, setLocalRecipients] = React.useState<string>('');
|
||||||
|
const [isDirty, setIsDirty] = React.useState(false);
|
||||||
|
|
||||||
|
// Sync local state when data is loaded
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (settings && !isDirty) {
|
||||||
|
setLocalEnabled(settings.enabled);
|
||||||
|
setLocalRecipients(settings.recipients || '');
|
||||||
|
}
|
||||||
|
}, [settings, isDirty]);
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: updateNotificationSettings,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(['update-notification-settings'], data);
|
||||||
|
setIsDirty(false);
|
||||||
|
toast.success(t('settings.updateNotifications.saved', 'Settings saved'));
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('settings.updateNotifications.saveError', 'Failed to save settings'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMutation = useMutation({
|
||||||
|
mutationFn: sendTestNotification,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.success) {
|
||||||
|
toast.success(
|
||||||
|
t('settings.updateNotifications.emailSent', 'Notification email sent to {{count}} recipients', {
|
||||||
|
count: data.successCount || 0
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(data.message || t('settings.updateNotifications.emailFailed', 'Failed to send notification'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('settings.updateNotifications.emailFailed', 'Failed to send notification'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkMutation = useMutation({
|
||||||
|
mutationFn: checkForNotifications,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.notified) {
|
||||||
|
toast.success(t('settings.updateNotifications.checkSuccess', 'Notification sent for new version'));
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
t('settings.updateNotifications.checkNoAction', 'No notification needed: {{reason}}', {
|
||||||
|
reason: data.reason || 'unknown'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('settings.updateNotifications.checkError', 'Failed to check for updates'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
updateMutation.mutate({
|
||||||
|
enabled: localEnabled,
|
||||||
|
recipients: localRecipients
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEnabled = (value: boolean) => {
|
||||||
|
setLocalEnabled(value);
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecipientsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setLocalRecipients(e.target.value);
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card padding="md">
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
<div className="h-6 bg-neutral-200 dark:bg-neutral-700 rounded w-1/3"></div>
|
||||||
|
<div className="h-10 bg-neutral-200 dark:bg-neutral-700 rounded"></div>
|
||||||
|
<div className="h-10 bg-neutral-200 dark:bg-neutral-700 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
{t('settings.updateNotifications.title', 'Update Notifications')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||||
|
{t('settings.updateNotifications.description', 'Receive email notifications when new versions of PicPeak are available.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Enable/Disable Toggle */}
|
||||||
|
<label className="flex items-center gap-3 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={localEnabled}
|
||||||
|
onChange={(e) => handleToggleEnabled(e.target.checked)}
|
||||||
|
className="w-4 h-4 text-primary-600 bg-neutral-100 border-neutral-300 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||||
|
{t('settings.updateNotifications.enableEmails', 'Enable email notifications')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
{t('settings.updateNotifications.enableEmailsDesc', 'Send email to admins when a new version is available')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Recipients */}
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={localRecipients}
|
||||||
|
onChange={handleRecipientsChange}
|
||||||
|
label={t('settings.updateNotifications.recipients', 'Email Recipients')}
|
||||||
|
placeholder={t('settings.updateNotifications.recipientsPlaceholder', '[email protected], [email protected]')}
|
||||||
|
helperText={t('settings.updateNotifications.recipientsHelper', 'Comma-separated email addresses. Leave empty to send to all admin users.')}
|
||||||
|
leftIcon={<Mail className="w-4 h-4 text-neutral-400" />}
|
||||||
|
disabled={!localEnabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last notified version */}
|
||||||
|
{settings?.lastNotifiedVersion && (
|
||||||
|
<div className="p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
{t('settings.updateNotifications.lastNotified', 'Last notification sent for version: {{version}}', {
|
||||||
|
version: settings.lastNotifiedVersion
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => checkMutation.mutate()}
|
||||||
|
isLoading={checkMutation.isPending}
|
||||||
|
leftIcon={<RefreshCw className="w-4 h-4" />}
|
||||||
|
disabled={!localEnabled}
|
||||||
|
>
|
||||||
|
{t('settings.updateNotifications.checkNow', 'Check & Notify')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => sendMutation.mutate()}
|
||||||
|
isLoading={sendMutation.isPending}
|
||||||
|
leftIcon={<Send className="w-4 h-4" />}
|
||||||
|
disabled={!localEnabled}
|
||||||
|
>
|
||||||
|
{t('settings.updateNotifications.sendTest', 'Send Test Email')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
isLoading={updateMutation.isPending}
|
||||||
|
leftIcon={<Save className="w-4 h-4" />}
|
||||||
|
disabled={!isDirty}
|
||||||
|
>
|
||||||
|
{t('common.save', 'Save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import { Button, Card, Input } from '../../../components/common';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { settingsService } from '../../../services/settings.service';
|
import { settingsService } from '../../../services/settings.service';
|
||||||
import { useStatusTab } from '../hooks/useStatusTab';
|
import { useStatusTab } from '../hooks/useStatusTab';
|
||||||
|
import { UpdateNotificationSettings } from '../components/UpdateNotificationSettings';
|
||||||
|
|
||||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -527,6 +528,9 @@ export const StatusTab: React.FC<StatusTabProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Update Notification Settings */}
|
||||||
|
<UpdateNotificationSettings />
|
||||||
|
|
||||||
{/* Last update time */}
|
{/* Last update time */}
|
||||||
{systemStatus && (
|
{systemStatus && (
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400 text-right flex items-center justify-end gap-1">
|
<div className="text-xs text-neutral-500 dark:text-neutral-400 text-right flex items-center justify-end gap-1">
|
||||||
|
|||||||
@@ -674,6 +674,7 @@
|
|||||||
"searchPlaceholder": "Fotos suchen...",
|
"searchPlaceholder": "Fotos suchen...",
|
||||||
"sortBy": "Sortieren nach",
|
"sortBy": "Sortieren nach",
|
||||||
"sortByDate": "Nach Datum sortieren",
|
"sortByDate": "Nach Datum sortieren",
|
||||||
|
"sortByCaptureDate": "Nach Aufnahmedatum sortieren",
|
||||||
"sortByName": "Nach Name sortieren",
|
"sortByName": "Nach Name sortieren",
|
||||||
"sortBySize": "Nach Größe sortieren",
|
"sortBySize": "Nach Größe sortieren",
|
||||||
"sortByRating": "Nach Bewertung sortieren",
|
"sortByRating": "Nach Bewertung sortieren",
|
||||||
@@ -1248,6 +1249,25 @@
|
|||||||
"sent": "Gesendet",
|
"sent": "Gesendet",
|
||||||
"failed": "Fehlgeschlagen",
|
"failed": "Fehlgeschlagen",
|
||||||
"lastUpdate": "Letzte Aktualisierung"
|
"lastUpdate": "Letzte Aktualisierung"
|
||||||
|
},
|
||||||
|
"updateNotifications": {
|
||||||
|
"title": "Update-Benachrichtigungen",
|
||||||
|
"description": "Erhalten Sie E-Mail-Benachrichtigungen, wenn neue Versionen von PicPeak verfügbar sind.",
|
||||||
|
"enableEmails": "E-Mail-Benachrichtigungen aktivieren",
|
||||||
|
"enableEmailsDesc": "E-Mail an Admins senden, wenn eine neue Version verfügbar ist",
|
||||||
|
"recipients": "E-Mail-Empfänger",
|
||||||
|
"recipientsPlaceholder": "[email protected], [email protected]",
|
||||||
|
"recipientsHelper": "Kommagetrennte E-Mail-Adressen. Leer lassen, um an alle Admin-Benutzer zu senden.",
|
||||||
|
"lastNotified": "Letzte Benachrichtigung gesendet für Version: {{version}}",
|
||||||
|
"checkNow": "Prüfen & Benachrichtigen",
|
||||||
|
"sendTest": "Test-E-Mail senden",
|
||||||
|
"saved": "Einstellungen gespeichert",
|
||||||
|
"saveError": "Fehler beim Speichern der Einstellungen",
|
||||||
|
"emailSent": "Benachrichtigungs-E-Mail an {{count}} Empfänger gesendet",
|
||||||
|
"emailFailed": "Fehler beim Senden der Benachrichtigung",
|
||||||
|
"checkSuccess": "Benachrichtigung für neue Version gesendet",
|
||||||
|
"checkNoAction": "Keine Benachrichtigung erforderlich: {{reason}}",
|
||||||
|
"checkError": "Fehler beim Prüfen auf Updates"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"branding": {
|
"branding": {
|
||||||
@@ -1528,7 +1548,18 @@
|
|||||||
"updateAvailableShort": "v{{version}} verfügbar",
|
"updateAvailableShort": "v{{version}} verfügbar",
|
||||||
"checkForUpdates": "Nach Updates suchen",
|
"checkForUpdates": "Nach Updates suchen",
|
||||||
"upToDate": "Alles aktuell",
|
"upToDate": "Alles aktuell",
|
||||||
"lastChecked": "Zuletzt geprüft: {{time}}"
|
"lastChecked": "Zuletzt geprüft: {{time}}",
|
||||||
|
"updateNow": "Jetzt aktualisieren",
|
||||||
|
"updateDialog": {
|
||||||
|
"title": "PicPeak aktualisieren",
|
||||||
|
"detectedEnv": "Erkannte Umgebung",
|
||||||
|
"beforeUpdating": "Vor dem Update:",
|
||||||
|
"updateCommands": "Update-Befehle:",
|
||||||
|
"afterUpdating": "Nach dem Update:",
|
||||||
|
"copyAllCommands": "Alle Befehle kopieren",
|
||||||
|
"completeChecklist": "Checkliste vor dem Update ausfüllen",
|
||||||
|
"error": "Fehler beim Laden der Update-Anweisungen"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||||
|
|||||||
@@ -287,6 +287,7 @@
|
|||||||
"searchPlaceholder": "Search photos...",
|
"searchPlaceholder": "Search photos...",
|
||||||
"sortBy": "Sort By",
|
"sortBy": "Sort By",
|
||||||
"sortByDate": "Sort by Date",
|
"sortByDate": "Sort by Date",
|
||||||
|
"sortByCaptureDate": "Sort by Capture Date",
|
||||||
"sortByName": "Sort by Name",
|
"sortByName": "Sort by Name",
|
||||||
"sortBySize": "Sort by Size",
|
"sortBySize": "Sort by Size",
|
||||||
"sortByRating": "Sort by Rating",
|
"sortByRating": "Sort by Rating",
|
||||||
@@ -798,6 +799,25 @@
|
|||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"lastUpdate": "Last update"
|
"lastUpdate": "Last update"
|
||||||
},
|
},
|
||||||
|
"updateNotifications": {
|
||||||
|
"title": "Update Notifications",
|
||||||
|
"description": "Receive email notifications when new versions of PicPeak are available.",
|
||||||
|
"enableEmails": "Enable email notifications",
|
||||||
|
"enableEmailsDesc": "Send email to admins when a new version is available",
|
||||||
|
"recipients": "Email Recipients",
|
||||||
|
"recipientsPlaceholder": "[email protected], [email protected]",
|
||||||
|
"recipientsHelper": "Comma-separated email addresses. Leave empty to send to all admin users.",
|
||||||
|
"lastNotified": "Last notification sent for version: {{version}}",
|
||||||
|
"checkNow": "Check & Notify",
|
||||||
|
"sendTest": "Send Test Email",
|
||||||
|
"saved": "Settings saved",
|
||||||
|
"saveError": "Failed to save settings",
|
||||||
|
"emailSent": "Notification email sent to {{count}} recipients",
|
||||||
|
"emailFailed": "Failed to send notification",
|
||||||
|
"checkSuccess": "Notification sent for new version",
|
||||||
|
"checkNoAction": "No notification needed: {{reason}}",
|
||||||
|
"checkError": "Failed to check for updates"
|
||||||
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Event Creation",
|
"title": "Event Creation",
|
||||||
"requiredFields": "Required Fields",
|
"requiredFields": "Required Fields",
|
||||||
@@ -1241,7 +1261,18 @@
|
|||||||
"updateAvailableShort": "v{{version}} available",
|
"updateAvailableShort": "v{{version}} available",
|
||||||
"checkForUpdates": "Check for Updates",
|
"checkForUpdates": "Check for Updates",
|
||||||
"upToDate": "You're up to date",
|
"upToDate": "You're up to date",
|
||||||
"lastChecked": "Last checked: {{time}}"
|
"lastChecked": "Last checked: {{time}}",
|
||||||
|
"updateNow": "Update Now",
|
||||||
|
"updateDialog": {
|
||||||
|
"title": "Update PicPeak",
|
||||||
|
"detectedEnv": "Detected Environment",
|
||||||
|
"beforeUpdating": "Before updating:",
|
||||||
|
"updateCommands": "Update Commands:",
|
||||||
|
"afterUpdating": "After updating:",
|
||||||
|
"copyAllCommands": "Copy All Commands",
|
||||||
|
"completeChecklist": "Complete the checklist before updating",
|
||||||
|
"error": "Failed to load update instructions"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"viewAllNotifications": "View all notifications",
|
"viewAllNotifications": "View all notifications",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { api } from '../config/api';
|
|||||||
export interface AdminPhoto {
|
export interface AdminPhoto {
|
||||||
id: number;
|
id: number;
|
||||||
filename: string;
|
filename: string;
|
||||||
|
original_filename?: string;
|
||||||
path: string;
|
path: string;
|
||||||
url: string;
|
url: string;
|
||||||
thumbnail_url: string | null;
|
thumbnail_url: string | null;
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export interface Photo {
|
|||||||
category_slug?: string;
|
category_slug?: string;
|
||||||
size: number;
|
size: number;
|
||||||
uploaded_at: string;
|
uploaded_at: string;
|
||||||
|
captured_at?: string; // EXIF capture date (if available)
|
||||||
// Media type fields
|
// Media type fields
|
||||||
media_type?: 'photo' | 'video' | 'image';
|
media_type?: 'photo' | 'video' | 'image';
|
||||||
mime_type?: string;
|
mime_type?: string;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const config: VitestUserConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sourcemap: true,
|
sourcemap: false,
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|||||||
Generated
+14
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.48.2",
|
"@playwright/test": "^1.48.2",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"puppeteer": "^24.17.0"
|
"puppeteer": "^24.17.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -561,6 +562,19 @@
|
|||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
|
"node_modules/dotenv": {
|
||||||
|
"version": "17.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
|
||||||
|
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://dotenvx.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
|||||||
+3
-2
@@ -8,8 +8,9 @@
|
|||||||
"node-fetch": "^2.7.0"
|
"node-fetch": "^2.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"puppeteer": "^24.17.0",
|
"@playwright/test": "^1.48.2",
|
||||||
"@playwright/test": "^1.48.2"
|
"dotenv": "^17.3.1",
|
||||||
|
"puppeteer": "^24.17.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"prebuild-install": {
|
"prebuild-install": {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Load environment variables from .env file
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: 'tests/e2e',
|
testDir: 'tests/e2e',
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||||
|
|
||||||
|
// Helper to login to admin
|
||||||
|
async function loginToAdmin(page) {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
|
||||||
|
await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Admin Dark Mode Toggle', () => {
|
||||||
|
test('dark mode toggle button exists in admin header', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('Dark mode toggle validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginToAdmin(page);
|
||||||
|
|
||||||
|
// Look for the dark mode toggle button
|
||||||
|
const toggleButton = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
|
||||||
|
await expect(toggleButton).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking toggle switches to dark mode and adds dark class', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('Dark mode toggle validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginToAdmin(page);
|
||||||
|
|
||||||
|
// Check initial state - should be light
|
||||||
|
const html = page.locator('html');
|
||||||
|
const initialHasDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||||
|
|
||||||
|
// Click the toggle
|
||||||
|
const toggleButton = page.getByRole('button', { name: /dark mode|light mode|Dunkelmodus|Hellmodus/i });
|
||||||
|
await toggleButton.click();
|
||||||
|
|
||||||
|
// Wait a moment for the class to toggle
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify dark class toggled
|
||||||
|
const afterHasDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||||
|
expect(afterHasDark).toBe(!initialHasDark);
|
||||||
|
|
||||||
|
// Click again to revert
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const finalHasDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||||
|
expect(finalHasDark).toBe(initialHasDark);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dark mode preference persists across page reloads', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('Dark mode toggle validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginToAdmin(page);
|
||||||
|
|
||||||
|
// Set to dark mode
|
||||||
|
const html = page.locator('html');
|
||||||
|
const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||||
|
|
||||||
|
if (!isAlreadyDark) {
|
||||||
|
const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify dark mode is active
|
||||||
|
await expect(html).toHaveAttribute('class', /dark/);
|
||||||
|
|
||||||
|
// Reload the page
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
// Verify dark mode persists
|
||||||
|
await expect(html).toHaveAttribute('class', /dark/);
|
||||||
|
|
||||||
|
// Clean up - switch back to light mode
|
||||||
|
const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dark mode applies correct dark background to admin layout', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('Dark mode toggle validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginToAdmin(page);
|
||||||
|
|
||||||
|
// Enable dark mode
|
||||||
|
const html = page.locator('html');
|
||||||
|
const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||||
|
|
||||||
|
if (!isAlreadyDark) {
|
||||||
|
const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the main layout container has dark background
|
||||||
|
const mainContainer = page.locator('.h-screen.bg-neutral-50, .h-screen.dark\\:bg-neutral-950').first();
|
||||||
|
const bgColor = await mainContainer.evaluate(el => getComputedStyle(el).backgroundColor);
|
||||||
|
|
||||||
|
// In dark mode, background should be very dark (close to black)
|
||||||
|
// neutral-950 is approximately rgb(10, 10, 10)
|
||||||
|
expect(bgColor).not.toBe('rgb(255, 255, 255)'); // Not white
|
||||||
|
expect(bgColor).not.toBe('rgba(0, 0, 0, 0)'); // Not transparent
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dark mode preference is stored in localStorage', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('Dark mode toggle validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginToAdmin(page);
|
||||||
|
|
||||||
|
// Enable dark mode
|
||||||
|
const html = page.locator('html');
|
||||||
|
const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||||
|
|
||||||
|
if (!isAlreadyDark) {
|
||||||
|
const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify dark mode preference is stored in localStorage
|
||||||
|
const storedPreference = await page.evaluate(() => localStorage.getItem('admin-dark-mode'));
|
||||||
|
expect(storedPreference).toBe('dark');
|
||||||
|
|
||||||
|
// Clean up - toggle back to light
|
||||||
|
const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// Verify light mode preference is stored
|
||||||
|
const lightPreference = await page.evaluate(() => localStorage.getItem('admin-dark-mode'));
|
||||||
|
expect(lightPreference).toBe('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Settings Page Dark Mode', () => {
|
||||||
|
test('settings page tabs render correctly in dark mode', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('Settings dark mode validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginToAdmin(page);
|
||||||
|
|
||||||
|
// Enable dark mode
|
||||||
|
const html = page.locator('html');
|
||||||
|
const isAlreadyDark = await html.evaluate(el => el.classList.contains('dark'));
|
||||||
|
|
||||||
|
if (!isAlreadyDark) {
|
||||||
|
const toggleButton = page.getByRole('button', { name: /dark mode|Dunkelmodus/i });
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to settings
|
||||||
|
await page.goto('/admin/settings');
|
||||||
|
await expect(page.getByRole('heading', { name: /Settings|Einstellungen/i })).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify settings heading has dark text style
|
||||||
|
const heading = page.getByRole('heading', { name: /Settings|Einstellungen/i }).first();
|
||||||
|
const headingColor = await heading.evaluate(el => getComputedStyle(el).color);
|
||||||
|
|
||||||
|
// In dark mode, text should be light (not dark)
|
||||||
|
// neutral-100 is approximately rgb(245, 245, 245)
|
||||||
|
const [r, g, b] = headingColor.match(/\d+/g).map(Number);
|
||||||
|
expect(r + g + b).toBeGreaterThan(500); // Light colored text
|
||||||
|
|
||||||
|
// Verify tab buttons are visible (use exact: true to avoid matching save buttons)
|
||||||
|
await expect(page.getByRole('button', { name: 'General', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^SEO & Robots$|^SEO$/ })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Security', exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
const toggleButton = page.getByRole('button', { name: /light mode|Hellmodus/i });
|
||||||
|
await toggleButton.click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Gallery Theme Color Mode', () => {
|
||||||
|
test('branding page has color mode selector', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('Theme customizer validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginToAdmin(page);
|
||||||
|
|
||||||
|
await page.goto('/admin/branding');
|
||||||
|
await expect(page.getByText(/Theme|Themen/i).first()).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Look for the color mode selector
|
||||||
|
await expect(page.getByText(/Color Mode|Farbmodus/i)).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the mode buttons exist
|
||||||
|
await expect(page.getByRole('button', { name: /^Light$|^Hell$/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^Dark$|^Dunkel$/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /^Auto$/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || '[email protected]';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'Admin!234';
|
||||||
|
|
||||||
|
// Helper to login and navigate to settings
|
||||||
|
async function loginAndGoToSeoSettings(page) {
|
||||||
|
await page.goto('/admin/login');
|
||||||
|
await page.getByLabel(/Email|E-Mail/i).fill(ADMIN_EMAIL);
|
||||||
|
await page.getByLabel(/Password|Passwort/i).fill(ADMIN_PASSWORD);
|
||||||
|
await page.getByRole('button', { name: /Sign In|Log in|Anmelden/i }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: /Dashboard|Übersicht/i })).toBeVisible({ timeout: 20000 });
|
||||||
|
|
||||||
|
await page.goto('/admin/settings');
|
||||||
|
// Click on SEO tab
|
||||||
|
const seoTab = page.getByRole('button', { name: /SEO|Robots/i });
|
||||||
|
await seoTab.click();
|
||||||
|
await expect(page.getByRole('heading', { name: /Search Engine Indexing|Suchmaschinen-Indexierung/i })).toBeVisible({ timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('SEO Settings Tab', () => {
|
||||||
|
test('SEO tab renders all sections correctly', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('SEO settings UI validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginAndGoToSeoSettings(page);
|
||||||
|
|
||||||
|
// Verify all three cards are visible
|
||||||
|
await expect(page.getByRole('heading', { name: /Search Engine Indexing|Suchmaschinen-Indexierung/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: /AI & Bot Blocking|KI- & Bot-Blockierung/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: /Meta Tags|Meta-Tags/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Verify key form elements
|
||||||
|
await expect(page.getByLabel(/Allow search engine indexing|Suchmaschinen-Indexierung erlauben/i)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/Block AI\/LLM crawlers|KI-\/LLM-Crawler blockieren/i)).toBeVisible();
|
||||||
|
await expect(page.getByLabel(/Add noindex meta tag|noindex-Meta-Tag hinzufügen/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can toggle indexing and save settings', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('SEO settings UI validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginAndGoToSeoSettings(page);
|
||||||
|
|
||||||
|
const indexingToggle = page.getByLabel(/Allow search engine indexing|Suchmaschinen-Indexierung erlauben/i);
|
||||||
|
const initialState = await indexingToggle.isChecked();
|
||||||
|
|
||||||
|
// Toggle the setting
|
||||||
|
await indexingToggle.click();
|
||||||
|
|
||||||
|
// Save
|
||||||
|
const saveButton = page.getByRole('button', { name: /Save SEO Settings|SEO-Einstellungen speichern/i });
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Wait for success toast
|
||||||
|
await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Verify toggle state changed
|
||||||
|
const newState = await indexingToggle.isChecked();
|
||||||
|
expect(newState).toBe(!initialState);
|
||||||
|
|
||||||
|
// Revert to original state
|
||||||
|
await indexingToggle.click();
|
||||||
|
await saveButton.click();
|
||||||
|
await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('robots.txt preview updates based on settings', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('SEO settings UI validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginAndGoToSeoSettings(page);
|
||||||
|
|
||||||
|
// Click show preview button
|
||||||
|
const previewButton = page.getByRole('button', { name: /Show robots\.txt preview|robots\.txt-Vorschau anzeigen/i });
|
||||||
|
await previewButton.click();
|
||||||
|
|
||||||
|
// Verify preview content is visible
|
||||||
|
const previewContent = page.locator('pre');
|
||||||
|
await expect(previewContent).toBeVisible();
|
||||||
|
|
||||||
|
// Verify it contains expected content
|
||||||
|
const previewText = await previewContent.textContent();
|
||||||
|
expect(previewText).toContain('User-agent');
|
||||||
|
expect(previewText).toContain('Disallow');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('can add blocked AI agents', async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.project.name === 'mobile-chrome') {
|
||||||
|
test.skip('SEO settings UI validated on desktop viewport');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loginAndGoToSeoSettings(page);
|
||||||
|
|
||||||
|
// Find the blocked agents section
|
||||||
|
const agentInput = page.getByPlaceholder(/Enter agent name|Agentenname eingeben/i);
|
||||||
|
await expect(agentInput).toBeVisible();
|
||||||
|
|
||||||
|
// Add a new agent
|
||||||
|
const testAgent = 'TestBot-' + Date.now();
|
||||||
|
await agentInput.fill(testAgent);
|
||||||
|
await agentInput.press('Enter');
|
||||||
|
|
||||||
|
// Verify the agent tag appears in the list
|
||||||
|
await expect(page.getByText(testAgent)).toBeVisible();
|
||||||
|
|
||||||
|
// Save and verify it persists
|
||||||
|
const saveButton = page.getByRole('button', { name: /Save SEO Settings|SEO-Einstellungen speichern/i });
|
||||||
|
await saveButton.click();
|
||||||
|
|
||||||
|
// Wait for success toast
|
||||||
|
await expect(page.locator('.Toastify__toast--success')).toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
// Refresh and verify it's still there
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole('button', { name: /SEO|Robots/i }).click();
|
||||||
|
await expect(page.getByText(testAgent)).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('robots.txt Endpoint', () => {
|
||||||
|
test('returns valid robots.txt from backend', async ({ request }) => {
|
||||||
|
const response = await request.get('/robots.txt');
|
||||||
|
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
expect(response.headers()['content-type']).toContain('text/plain');
|
||||||
|
|
||||||
|
const body = await response.text();
|
||||||
|
expect(body).toContain('User-agent');
|
||||||
|
expect(body).toContain('Disallow: /admin');
|
||||||
|
expect(body).toContain('Disallow: /api');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('robots.txt blocks AI crawlers by default', async ({ request }) => {
|
||||||
|
const response = await request.get('/robots.txt');
|
||||||
|
const body = await response.text();
|
||||||
|
|
||||||
|
// Check for some of the default blocked AI agents
|
||||||
|
expect(body).toContain('GPTBot');
|
||||||
|
expect(body).toContain('Claude-Web');
|
||||||
|
expect(body).toContain('Google-Extended');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('SEO Meta Tags', () => {
|
||||||
|
test('public settings include SEO meta flags', async ({ request }) => {
|
||||||
|
const response = await request.get('/api/public/settings');
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
const settings = await response.json();
|
||||||
|
expect(settings).toHaveProperty('seo_meta_noindex');
|
||||||
|
expect(settings).toHaveProperty('seo_meta_nofollow');
|
||||||
|
expect(settings).toHaveProperty('seo_meta_noai');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user