Compare commits

...

6 Commits

Author SHA1 Message Date
Gitea Actions Bot 6b5c08e99b chore: bump version to 1.0.71 (backend + frontend)
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2025-07-20 19:07:39 +00:00
paul 11ecad136b Merge branch 'main' of https://gitea.nothaft.cloud/paul/picpeak
Mirror to GitHub / mirror (push) Successful in 41s
Test and Lint / backend-test (push) Successful in 1m21s
Test and Lint / frontend-test (push) Successful in 2m23s
Version and Release / version-bump (push) Successful in 52s
Version and Release / trigger-drone (push) Successful in 5s
continuous-integration/drone/push Build is failing
2025-07-20 21:01:27 +02:00
paul 3a4dccd9f0 fix: resolve production UI and API issues
- Fixed backend version endpoint by adding retry logic import
- Gallery login page improvements:
  * Increased title size from text-xl to text-2xl (responsive scaling)
  * Title now uses event's custom primary color (var(--color-primary))
  * Removed event category badge from login page
- Fixed Umami analytics configuration check:
  * Added proper enabled state tracking
  * Warning now only shows when Umami is explicitly not configured
  * Checks both admin settings and environment variables properly

These changes improve user experience and fix false warnings in production.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 21:00:43 +02:00
Gitea Actions Bot 0a5e55ca96 chore: bump backend version to 1.0.70
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-20 19:00:09 +00:00
paul a72741c0d9 Merge branch 'main' of https://gitea.nothaft.cloud/paul/picpeak
Version and Release / trigger-drone (push) Blocked by required conditions
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 2m48s
Version and Release / version-bump (push) Successful in 1m4s
2025-07-20 20:52:38 +02:00
paul e7ed7006fd fix: critical database connection pool exhaustion issues
- Disabled duplicate email service (emailService.js) that was creating redundant connections
- Increased connection pool size from 10 to 25 for production environment
- Extended session timeout cache from 5 to 30 minutes to reduce DB queries
- Added connection retry logic with exponential backoff for transient failures
- Fixed password validation to use retry wrapper and correct setting key
- Updated public settings and gallery middleware to handle connection failures gracefully

These changes address the "Connection terminated unexpectedly" errors in production by:
1. Reducing unnecessary database connections
2. Increasing available connection pool capacity
3. Implementing automatic retry for transient connection failures
4. Caching frequently accessed data for longer periods

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 20:51:44 +02:00
14 changed files with 83 additions and 50 deletions
+4 -4
View File
@@ -40,10 +40,10 @@ const config = {
keepAliveInitialDelayMillis: 0 keepAliveInitialDelayMillis: 0
}, },
pool: { pool: {
min: 2, min: 5,
max: 10, max: 25,
acquireTimeoutMillis: 30000, acquireTimeoutMillis: 60000,
createTimeoutMillis: 30000, createTimeoutMillis: 60000,
idleTimeoutMillis: 30000, idleTimeoutMillis: 30000,
reapIntervalMillis: 1000, reapIntervalMillis: 1000,
createRetryIntervalMillis: 200, createRetryIntervalMillis: 200,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.69", "version": "1.0.71",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.69", "version": "1.0.71",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^5.3.1", "archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "1.0.69", "version": "1.0.71",
"description": "Backend for PicPeak event photo sharing platform", "description": "Backend for PicPeak event photo sharing platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+28 -1
View File
@@ -4,6 +4,33 @@ const knexConfig = require('../../knexfile');
// Create database connection with built-in retry logic // Create database connection with built-in retry logic
const db = knex(knexConfig); const db = knex(knexConfig);
// Connection retry configuration
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
// Wrapper function to handle connection retries
async function withRetry(queryFn, retries = MAX_RETRIES) {
for (let i = 0; i < retries; i++) {
try {
return await queryFn();
} catch (error) {
const isConnectionError = error.message && (
error.message.includes('Connection terminated unexpectedly') ||
error.message.includes('Connection ended unexpectedly') ||
error.message.includes('ECONNREFUSED') ||
error.message.includes('ETIMEDOUT')
);
if (isConnectionError && i < retries - 1) {
console.log(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
continue;
}
throw error;
}
}
}
async function initializeDatabase() { async function initializeDatabase() {
// Events table // Events table
const hasEventsTable = await db.schema.hasTable('events'); const hasEventsTable = await db.schema.hasTable('events');
@@ -225,4 +252,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
} }
} }
module.exports = { db, initializeDatabase, logActivity }; module.exports = { db, initializeDatabase, logActivity, withRetry };
+10 -8
View File
@@ -1,5 +1,5 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { db } = require('../database/db'); const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat'); const { formatBoolean } = require('../utils/dbCompat');
// Middleware to verify gallery access // Middleware to verify gallery access
@@ -11,13 +11,15 @@ async function verifyGalleryAccess(req, res, next) {
} }
const decoded = jwt.verify(token, process.env.JWT_SECRET); const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events') const event = await withRetry(async () => {
.where({ return await db('events')
id: decoded.eventId, .where({
is_active: formatBoolean(true), id: decoded.eventId,
is_archived: formatBoolean(false) is_active: formatBoolean(true),
}) is_archived: formatBoolean(false)
.first(); })
.first();
});
if (!event) { if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' }); return res.status(404).json({ error: 'Gallery not found or expired' });
+1 -1
View File
@@ -10,7 +10,7 @@ const DEFAULT_SESSION_TIMEOUT = 60 * 60 * 1000;
// Cache for session timeout setting // Cache for session timeout setting
let cachedTimeout = null; let cachedTimeout = null;
let cacheExpiry = 0; let cacheExpiry = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
// Clean up expired sessions every 5 minutes // Clean up expired sessions every 5 minutes
setInterval(() => { setInterval(() => {
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { db } = require('../database/db'); const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth-enhanced-v2'); const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises; const fs = require('fs').promises;
const path = require('path'); const path = require('path');
+9 -7
View File
@@ -1,5 +1,5 @@
const express = require('express'); const express = require('express');
const { db } = require('../database/db'); const { db, withRetry } = require('../database/db');
const router = express.Router(); const router = express.Router();
// Get public settings (branding and theme) // Get public settings (branding and theme)
@@ -7,12 +7,14 @@ router.get('/', async (req, res) => {
try { try {
// Fetch branding, theme, general, and security settings // Fetch branding, theme, general, and security settings
// Note: We include analytics in the query but it might not exist yet // Note: We include analytics in the query but it might not exist yet
const settings = await db('app_settings') const settings = await withRetry(async () => {
.where(function() { return await db('app_settings')
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics']) .where(function() {
.orWhere('setting_key', 'like', 'analytics_%'); this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
}) .orWhere('setting_key', 'like', 'analytics_%');
.select('setting_key', 'setting_value'); })
.select('setting_key', 'setting_value');
});
// Convert to object format // Convert to object format
const settingsObject = {}; const settingsObject = {};
+2 -1
View File
@@ -60,6 +60,7 @@ async function processEmailQueue() {
} }
// Start email queue processor // Start email queue processor
setInterval(processEmailQueue, 60000); // Process every minute // DISABLED: Using emailProcessor.js instead to prevent duplicate connections
// setInterval(processEmailQueue, 60000); // Process every minute
module.exports = { sendEmail, processEmailQueue }; module.exports = { sendEmail, processEmailQueue };
+9 -6
View File
@@ -119,11 +119,14 @@ function validatePassword(password, options = {}) {
*/ */
async function getPasswordComplexitySettings() { async function getPasswordComplexitySettings() {
try { try {
const db = require('../db'); const { db, withRetry } = require('../database/db');
const settings = await db('app_settings')
.where('setting_key', 'password_complexity') // Use retry wrapper to handle connection failures
.where('setting_type', 'security') const settings = await withRetry(async () => {
.first(); return await db('app_settings')
.where('setting_key', 'security_password_complexity_level')
.first();
});
if (!settings || !settings.setting_value) { if (!settings || !settings.setting_value) {
return 'moderate'; // Default return 'moderate'; // Default
@@ -136,7 +139,7 @@ async function getPasswordComplexitySettings() {
return value; return value;
} catch (error) { } catch (error) {
logger.error('Failed to get password complexity settings:', error); logger.error('Failed to get password complexity settings:', error);
return 'moderate'; // Default on error return 'moderate'; // Default on error - ensures app continues working
} }
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.0.69", "version": "1.0.71",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"version": "1.0.69", "version": "1.0.71",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1", "@tiptap/extension-character-count": "^2.26.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"private": true, "private": true,
"version": "1.0.69", "version": "1.0.71",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -10
View File
@@ -289,18 +289,9 @@ export const GalleryPage: React.FC = () => {
alt={settingsData?.branding_company_name || 'PicPeak'} alt={settingsData?.branding_company_name || 'PicPeak'}
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4" className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
/> />
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}> <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-2 px-2" style={{ color: 'var(--color-primary, #5C8762)' }}>
{galleryInfo?.event_name} {galleryInfo?.event_name}
</h1> </h1>
{galleryInfo?.event_type && (
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
<span className="px-3 py-1 rounded-full" style={{ backgroundColor: 'var(--color-primary, #5C8762)', opacity: 0.1 }}>
<span style={{ color: 'var(--color-primary, #5C8762)' }}>
{t(`events.types.${galleryInfo.event_type}`)}
</span>
</span>
</div>
)}
</div> </div>
{/* Expiration Warning */} {/* Expiration Warning */}
+12 -5
View File
@@ -53,7 +53,7 @@ export const AnalyticsPage: React.FC = () => {
const [isEmbedMode, setIsEmbedMode] = useState(false); const [isEmbedMode, setIsEmbedMode] = useState(false);
// Check if Umami is configured from settings or environment // Check if Umami is configured from settings or environment
const [umamiConfig, setUmamiConfig] = useState<{ url?: string; shareUrl?: string }>({}); const [umamiConfig, setUmamiConfig] = useState<{ url?: string; shareUrl?: string; enabled?: boolean }>({});
// Fetch analytics data from backend // Fetch analytics data from backend
const { data: apiData, isLoading, refetch } = useQuery({ const { data: apiData, isLoading, refetch } = useQuery({
@@ -82,7 +82,8 @@ export const AnalyticsPage: React.FC = () => {
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) { if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
setUmamiConfig({ setUmamiConfig({
url: settings.umami_url, url: settings.umami_url,
shareUrl: settings.umami_share_url shareUrl: settings.umami_share_url,
enabled: true
}); });
} else { } else {
// Fall back to environment variables if they exist // Fall back to environment variables if they exist
@@ -92,8 +93,11 @@ export const AnalyticsPage: React.FC = () => {
if (envUrl && envWebsiteId) { if (envUrl && envWebsiteId) {
setUmamiConfig({ setUmamiConfig({
url: envUrl, url: envUrl,
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL,
enabled: true
}); });
} else {
setUmamiConfig({ enabled: false });
} }
} }
} catch (error) { } catch (error) {
@@ -105,8 +109,11 @@ export const AnalyticsPage: React.FC = () => {
if (envUrl && envWebsiteId) { if (envUrl && envWebsiteId) {
setUmamiConfig({ setUmamiConfig({
url: envUrl, url: envUrl,
shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL shareUrl: import.meta.env.VITE_UMAMI_SHARE_URL,
enabled: true
}); });
} else {
setUmamiConfig({ enabled: false });
} }
} }
}; };
@@ -450,7 +457,7 @@ export const AnalyticsPage: React.FC = () => {
</div> </div>
{/* Configuration Notice */} {/* Configuration Notice */}
{!umamiConfig.url && ( {umamiConfig.enabled === false && (
<Card padding="md" className="mt-6 bg-amber-50 border-amber-200"> <Card padding="md" className="mt-6 bg-amber-50 border-amber-200">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Activity className="w-5 h-5 text-amber-600 flex-shrink-0" /> <Activity className="w-5 h-5 text-amber-600 flex-shrink-0" />