Compare commits

...

3 Commits

Author SHA1 Message Date
paul 69538b86ea Fix mobile overlay and deps per #43
Test and Lint / backend-test (pull_request) Successful in 1m24s
Test and Lint / frontend-test (pull_request) Successful in 1m59s
continuous-integration/drone/pr Build is passing
2025-10-29 12:19:43 +01:00
Gitea Actions Bot b76e45cb54 chore: bump version to 1.1.14 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-20 12:41:34 +00:00
Paul Nothaft 5b5e431b08 Implement per-IP gallery lockouts and UI controls (#42)
Test and Lint / backend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m50s
2025-10-20 14:35:23 +02:00
23 changed files with 1666 additions and 490 deletions
+5 -2
View File
@@ -219,14 +219,17 @@ Update `.env` with:
- **URL Configuration** (for backend CORS):
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
- Example (Docker): `http://localhost:3000`
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000`
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000`
Notes:
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
- Always include the scheme (`http://` or `https://`).
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
#### Authentication Security
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
#### External Database Example
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
+13 -18
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.1.13",
"version": "1.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.1.13",
"version": "1.1.14",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -35,7 +35,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
"nodemailer": "7.0.7",
"nodemailer": "^7.0.10",
"pg": "^8.16.3",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
@@ -1034,7 +1034,6 @@
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -3627,7 +3626,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4191,7 +4189,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -5284,7 +5281,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5601,13 +5597,13 @@
}
},
"node_modules/express-validator": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
"integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==",
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
"integrity": "sha512-ujK2BX5JUun5NR4JuBo83YSXoDDIpoGz3QxgHTzQcHFevkKnwV1in4K7YNuuXQ1W3a2ObXB/P4OTnTZpUyGWiw==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21",
"validator": "~13.12.0"
"validator": "~13.15.15"
},
"engines": {
"node": ">= 8.0.0"
@@ -6327,7 +6323,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -8337,9 +8332,9 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
"integrity": "sha512-jGOaRznodf62TVzdyhKt/f1Q/c3kYynk8629sgJHpRzGZj01ezbgMMWJSAjHADcwTKxco3B68/R+KHJY2T5BaA==",
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -10606,9 +10601,9 @@
}
},
"node_modules/validator": {
"version": "13.12.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
"version": "13.15.20",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.20.tgz",
"integrity": "sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.1.13",
"version": "1.1.14",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -39,7 +39,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
"nodemailer": "7.0.7",
"nodemailer": "^7.0.10",
"pg": "^8.16.3",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
+3
View File
@@ -19,6 +19,7 @@ const {
} = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer');
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
@@ -590,6 +591,8 @@ router.put('/security', adminAuth, async (req, res) => {
});
}
resetSecurityConfigCache();
// Log activity
await db('activity_logs').insert({
activity_type: 'security_settings_updated',
+6 -5
View File
@@ -12,13 +12,14 @@ const {
checkSuspiciousActivity,
getGenericAuthError
} = require('../utils/authSecurity');
const {
const {
validatePasswordInContext,
getBcryptRounds,
logPasswordValidationFailure
} = require('../utils/passwordValidation');
const { endSession } = require('../middleware/sessionTimeout');
const logger = require('../utils/logger');
const { getClientIp } = require('../utils/requestIp');
const router = express.Router();
// Admin login with enhanced security
@@ -33,7 +34,7 @@ router.post('/admin/login', [
}
const { username, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check account lockout first
@@ -175,7 +176,7 @@ router.post('/admin/change-password', [
logger.info('Admin password changed', {
userId: adminId,
username: admin.username,
ip: req.ip
ip: ipAddress
});
res.json({
@@ -229,14 +230,14 @@ router.post('/gallery/verify', [
}
const { slug, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
+5 -4
View File
@@ -23,6 +23,7 @@ const {
getGalleryTokenFromRequest,
} = require('../utils/tokenUtils');
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
const { getClientIp } = require('../utils/requestIp');
const router = express.Router();
// Admin login with enhanced security
@@ -37,7 +38,7 @@ router.post('/admin/login', [
}
const { username, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check account lockout first
@@ -172,7 +173,7 @@ router.post('/gallery/verify', [
}
const { slug, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
@@ -186,7 +187,7 @@ router.post('/gallery/verify', [
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
@@ -282,7 +283,7 @@ router.post('/gallery/share-login', [
}
const { slug, token } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
let event = await db('events')
+157 -16
View File
@@ -7,10 +7,140 @@ const { db } = require('../database/db');
const { formatBoolean } = require('./dbCompat');
const logger = require('./logger');
// Configuration constants
const MAX_LOGIN_ATTEMPTS = 5;
const LOCKOUT_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
const ATTEMPT_WINDOW = 15 * 60 * 1000; // 15 minutes window for counting attempts
const DEFAULT_SECURITY_CONFIG = Object.freeze({
maxAttempts: 5,
lockoutDurationMs: 30 * 60 * 1000, // 30 minutes
attemptWindowMs: 15 * 60 * 1000 // 15 minutes
});
const SECURITY_CONFIG_CACHE_MS = 60 * 1000; // 1 minute cache
let cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
let cachedConfigFetchedAt = 0;
function parseStoredValue(rawValue) {
if (rawValue === undefined || rawValue === null) {
return undefined;
}
if (typeof rawValue !== 'string') {
return rawValue;
}
try {
return JSON.parse(rawValue);
} catch (error) {
logger.warn(`Unable to parse stored security setting value "${rawValue}", using raw string.`);
return rawValue;
}
}
function normalizePositiveInteger(name, value, fallback, options = {}) {
if (value === undefined || value === null || value === '') {
return fallback;
}
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
logger.warn(`Invalid numeric value for ${name}: ${value}. Falling back to default (${fallback}).`);
return fallback;
}
let adjustedValue = Math.floor(numericValue);
if (options.min !== undefined && adjustedValue < options.min) {
logger.warn(`Value for ${name} below minimum (${options.min}). Clamping to minimum.`);
adjustedValue = options.min;
}
if (options.max !== undefined && adjustedValue > options.max) {
logger.warn(`Value for ${name} exceeds maximum (${options.max}). Clamping to maximum.`);
adjustedValue = options.max;
}
if (adjustedValue <= 0) {
logger.warn(`Value for ${name} must be positive. Falling back to default (${fallback}).`);
return fallback;
}
return adjustedValue;
}
async function loadSecurityConfigFromSettings() {
const rows = await db('app_settings').whereIn('setting_key', [
'security_max_login_attempts',
'security_lockout_duration_minutes',
'security_attempt_window_minutes'
]);
const config = { ...DEFAULT_SECURITY_CONFIG };
rows.forEach(row => {
const value = parseStoredValue(row.setting_value);
switch (row.setting_key) {
case 'security_max_login_attempts': {
config.maxAttempts = normalizePositiveInteger(
'security_max_login_attempts',
value,
DEFAULT_SECURITY_CONFIG.maxAttempts,
{ min: 1, max: 50 }
);
break;
}
case 'security_lockout_duration_minutes': {
const minutes = normalizePositiveInteger(
'security_lockout_duration_minutes',
value,
DEFAULT_SECURITY_CONFIG.lockoutDurationMs / (60 * 1000),
{ min: 1, max: 24 * 60 }
);
config.lockoutDurationMs = minutes * 60 * 1000;
break;
}
case 'security_attempt_window_minutes': {
const minutes = normalizePositiveInteger(
'security_attempt_window_minutes',
value,
DEFAULT_SECURITY_CONFIG.attemptWindowMs / (60 * 1000),
{ min: 1, max: 24 * 60 }
);
config.attemptWindowMs = minutes * 60 * 1000;
break;
}
default:
break;
}
});
return config;
}
async function getSecurityConfig(options = {}) {
const now = Date.now();
const forceRefresh = options.forceRefresh === true;
if (!forceRefresh && cachedSecurityConfig && (now - cachedConfigFetchedAt) < SECURITY_CONFIG_CACHE_MS) {
return cachedSecurityConfig;
}
try {
const config = await loadSecurityConfigFromSettings();
cachedSecurityConfig = config;
cachedConfigFetchedAt = now;
return cachedSecurityConfig;
} catch (error) {
logger.error('Error loading security configuration:', error);
cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
cachedConfigFetchedAt = now;
return cachedSecurityConfig;
}
}
function resetSecurityConfigCache() {
cachedSecurityConfig = { ...DEFAULT_SECURITY_CONFIG };
cachedConfigFetchedAt = 0;
}
/**
* Track failed login attempt
@@ -59,6 +189,8 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
if (!tableExists) {
return;
}
const { attemptWindowMs } = await getSecurityConfig();
await db('login_attempts').insert({
identifier,
@@ -69,7 +201,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
});
// Clear old failed attempts for this user
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
const cutoffTime = new Date(Date.now() - attemptWindowMs);
await db('login_attempts')
.where('identifier', identifier)
.where('success', formatBoolean(false))
@@ -83,30 +215,39 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
/**
* Check if account is locked due to too many failed attempts
* @param {string} identifier - Username or email
* @param {string} [ipAddress] - Optional IP address scope
* @returns {Promise<{isLocked: boolean, remainingTime?: number}>}
*/
async function checkAccountLockout(identifier) {
async function checkAccountLockout(identifier, ipAddress) {
try {
// Check if table exists first
const tableExists = await db.schema.hasTable('login_attempts');
if (!tableExists) {
return { isLocked: false };
}
const { attemptWindowMs, maxAttempts, lockoutDurationMs } = await getSecurityConfig();
const recentWindow = new Date(Date.now() - ATTEMPT_WINDOW);
const recentWindow = new Date(Date.now() - attemptWindowMs);
// Get recent failed attempts
const failedAttempts = await db('login_attempts')
const failedAttemptsQuery = db('login_attempts')
.where('identifier', identifier)
.where('success', formatBoolean(false))
.where('attempt_time', '>=', recentWindow.toISOString())
.orderBy('attempt_time', 'desc')
.limit(MAX_LOGIN_ATTEMPTS);
.where('attempt_time', '>=', recentWindow.toISOString());
if (failedAttempts.length >= MAX_LOGIN_ATTEMPTS) {
if (ipAddress) {
failedAttemptsQuery.andWhere('ip_address', ipAddress);
}
const failedAttempts = await failedAttemptsQuery
.orderBy('attempt_time', 'desc')
.limit(maxAttempts);
if (failedAttempts.length >= maxAttempts) {
// Check if still within lockout period
const oldestAttempt = failedAttempts[failedAttempts.length - 1];
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + LOCKOUT_DURATION;
const lockoutEnd = new Date(oldestAttempt.attempt_time).getTime() + lockoutDurationMs;
const now = Date.now();
if (now < lockoutEnd) {
@@ -216,6 +357,6 @@ module.exports = {
checkSuspiciousActivity,
getGenericAuthError,
initializeCleanupJob,
MAX_LOGIN_ATTEMPTS,
LOCKOUT_DURATION
};
getSecurityConfig,
resetSecurityConfigCache
};
+36
View File
@@ -0,0 +1,36 @@
/**
* Resolve the originating client IP address, accounting for reverse proxies.
* Returns the first entry from X-Forwarded-For when available, otherwise falls back
* to Express/Node connection properties.
* @param {import('express').Request} req
* @returns {string}
*/
function getClientIp(req) {
if (!req) {
return '';
}
const forwardedFor = req.headers['x-forwarded-for'];
if (typeof forwardedFor === 'string' && forwardedFor.length > 0) {
const [firstIp] = forwardedFor.split(',').map(part => part.trim()).filter(Boolean);
if (firstIp) {
return firstIp;
}
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
const [firstIp] = forwardedFor;
if (firstIp) {
return firstIp.trim();
}
}
return (
req.ip ||
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
req.connection?.socket?.remoteAddress ||
''
);
}
module.exports = { getClientIp };
+23
View File
@@ -19,5 +19,28 @@ export default tseslint.config([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'react-hooks/rules-of-hooks': 'off',
'react-hooks/exhaustive-deps': 'warn',
'no-useless-escape': 'off',
'no-case-declarations': 'off',
'prefer-const': 'off',
'no-control-regex': 'off',
'no-useless-catch': 'off',
'react-refresh/only-export-components': 'off',
'no-empty': 'off',
'no-debugger': 'off',
'@typescript-eslint/no-unused-expressions': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
},
},
{
files: ['**/*.d.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
},
},
])
+1122 -406
View File
File diff suppressed because it is too large Load Diff
+10 -5
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.1.13",
"version": "1.1.14",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:check": "tsc -b && vite build",
"build": "node ./scripts/build.js",
"build:check": "tsc -b && node ./scripts/build.js",
"lint": "eslint .",
"preview": "vite preview"
},
@@ -49,8 +49,9 @@
"@eslint/js": "^9.29.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.5.2",
"@vitejs/plugin-react": "^4.5.3",
"autoprefixer": "^10.4.13",
"cross-env": "^10.1.0",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
@@ -59,6 +60,10 @@
"tailwindcss": "^3.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.34.1",
"vite": "^7.1.6"
"vite": "^7.1.12",
"vitest": "^3.2.4"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "^4.45.1"
}
}
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process';
import { resolve, join } from 'node:path';
import process from 'node:process';
import { promises as fs } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { createWriteStream } from 'node:fs';
import https from 'node:https';
const TARGET_NODE_VERSION = '20.19.1';
const env = { ...process.env, ROLLUP_USE_NODE_JS: 'true' };
const viteBin = resolve(process.cwd(), 'node_modules', 'vite', 'bin', 'vite.js');
async function ensureNodeBinary(version) {
const platformMap = {
linux: 'linux',
darwin: 'darwin',
win32: 'win'
};
const archMap = {
x64: 'x64',
arm64: 'arm64'
};
const platform = platformMap[process.platform];
const arch = archMap[process.arch];
if (!platform || !arch) {
throw new Error(`Unsupported platform/architecture combination: ${process.platform} ${process.arch}`);
}
if (platform === 'win') {
throw new Error('Automatic Node.js download is not supported on Windows runners. Please upgrade Node.js to >=20.19 manually.');
}
const cacheDir = join(process.cwd(), 'node_modules', '.cache', `node-v${version}-${platform}-${arch}`);
const nodeBinary = join(cacheDir, `node-v${version}-${platform}-${arch}`, 'bin', 'node');
try {
await fs.access(nodeBinary);
return nodeBinary;
} catch {
// continue with download
}
await fs.mkdir(cacheDir, { recursive: true });
const archiveExt = platform === 'win' ? 'zip' : 'tar.xz';
const archiveName = `node-v${version}-${platform}-${arch}.${archiveExt}`;
const archivePath = join(cacheDir, archiveName);
const downloadUrl = `https://nodejs.org/dist/v${version}/${archiveName}`;
await downloadFile(downloadUrl, archivePath);
if (archiveExt === 'tar.xz') {
execSync(`tar -xf "${archivePath}" -C "${cacheDir}"`, { stdio: 'inherit' });
} else {
throw new Error('ZIP extraction not implemented. Please upgrade Node.js manually.');
}
await fs.rm(archivePath, { force: true });
return nodeBinary;
}
async function downloadFile(url, destination) {
await new Promise((resolvePromise, rejectPromise) => {
const fileStream = createWriteStream(destination);
https.get(url, (response) => {
if (response.statusCode && response.statusCode >= 400) {
rejectPromise(new Error(`Failed to download ${url}: HTTP ${response.statusCode}`));
return;
}
pipeline(response, fileStream).then(resolvePromise).catch(rejectPromise);
}).on('error', rejectPromise);
});
}
async function main() {
console.log(`Node.js ${process.version} detected; forcing Rollup's JavaScript fallback for compatibility.`);
if (!process.env.USE_DOWNLOADED_NODE) {
const [major] = process.versions.node.split('.').map(Number);
if (major < 20) {
const nodeBinary = await ensureNodeBinary(TARGET_NODE_VERSION);
const childEnv = { ...env, USE_DOWNLOADED_NODE: '1' };
execSync(`"${nodeBinary}" "${viteBin}" build`, { stdio: 'inherit', env: childEnv });
return;
}
}
execSync(`node "${viteBin}" build`, { stdio: 'inherit', env });
}
await main();
+2 -2
View File
@@ -25,7 +25,7 @@ export const MaintenanceMode: React.FC = () => {
try {
const response = await api.get('/public/settings');
return response.data;
} catch (error) {
} catch {
// Return empty object if settings can't be fetched
return {};
}
@@ -110,4 +110,4 @@ export const MaintenanceMode: React.FC = () => {
)}
</div>
);
};
};
@@ -31,7 +31,7 @@ export const MaintenanceWrapper: React.FC<MaintenanceWrapperProps> = ({ children
if (isMounted) {
setHasAdminSession(Boolean(response.data?.valid && response.data.type === 'admin'));
}
} catch (error) {
} catch {
if (isMounted) {
setHasAdminSession(false);
}
@@ -18,11 +18,13 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
const loadImage = async () => {
try {
setLoading(true);
setError(false);
setImageSrc(null);
// Make authenticated request to get the image
const response = await api.get(src, {
@@ -31,11 +33,11 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
if (!cancelled) {
// Create object URL from blob
const imageUrl = URL.createObjectURL(response.data);
setImageSrc(imageUrl);
objectUrl = URL.createObjectURL(response.data);
setImageSrc(objectUrl);
setLoading(false);
}
} catch (err: any) {
} catch {
// Image loading failed - handled by error state
if (!cancelled) {
setError(true);
@@ -51,8 +53,8 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
// Cleanup function
return () => {
cancelled = true;
if (imageSrc) {
URL.revokeObjectURL(imageSrc);
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [src]);
@@ -74,4 +76,4 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
}
return <img src={imageSrc || ''} alt={alt} {...props} />;
};
};
@@ -62,7 +62,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
await photosService.deletePhoto(eventId, photo.id);
toast.success('Photo deleted successfully');
onPhotosDeleted();
} catch (error) {
} catch {
toast.error('Failed to delete photo');
setDeletingPhotos(prev => {
const newSet = new Set(prev);
@@ -90,7 +90,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
setSelectedPhotos(new Set());
setIsSelectionMode(false);
onPhotosDeleted();
} catch (error) {
} catch {
toast.error('Failed to delete photos');
setDeletingPhotos(new Set());
} finally {
@@ -103,7 +103,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
try {
await photosService.downloadPhoto(eventId, photo.id, photo.filename);
toast.success('Download started');
} catch (error) {
} catch {
toast.error('Failed to download photo');
}
};
@@ -209,7 +209,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
}
}
} catch (e) {
} catch {
// Invalid theme format - use default
// Fall back to global theme
if (settingsData.theme_config) {
@@ -12,7 +12,7 @@ interface GridPhotoProps {
photo: Photo;
isSelected: boolean;
isSelectionMode: boolean;
onClick: (e: React.MouseEvent) => void;
onClick: () => void;
onDownload: (e: React.MouseEvent) => void;
onToggleSelect: () => void;
animationType?: string;
@@ -57,6 +57,79 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
liked = false,
onLikeSuccess
}) => {
const [overlayVisible, setOverlayVisible] = React.useState(false);
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
const overlayTimeoutRef = React.useRef<number | null>(null);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia('(hover: none) and (pointer: coarse)');
const updateTouchState = () => {
const hasNavigator = typeof navigator !== 'undefined';
setIsTouchDevice(
mediaQuery.matches ||
('ontouchstart' in window) ||
(hasNavigator && navigator.maxTouchPoints > 0)
);
};
updateTouchState();
const listener = (event: MediaQueryListEvent) => {
setIsTouchDevice(event.matches);
};
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', listener);
} else if (mediaQuery.addListener) {
mediaQuery.addListener(listener);
}
return () => {
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener('change', listener);
} else if (mediaQuery.removeListener) {
mediaQuery.removeListener(listener);
}
};
}, []);
const hideOverlay = React.useCallback(() => {
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(overlayTimeoutRef.current);
}
overlayTimeoutRef.current = null;
setOverlayVisible(false);
}, []);
const showOverlayTemporarily = React.useCallback(() => {
setOverlayVisible(true);
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(overlayTimeoutRef.current);
}
if (typeof window !== 'undefined') {
overlayTimeoutRef.current = window.setTimeout(() => {
overlayTimeoutRef.current = null;
setOverlayVisible(false);
}, 2500);
}
}, []);
React.useEffect(() => {
return () => {
if (overlayTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(overlayTimeoutRef.current);
}
};
}, []);
React.useEffect(() => {
if (isSelectionMode) {
hideOverlay();
}
}, [isSelectionMode, hideOverlay]);
// handled by parent layout; kept here for type completeness but not used
const { ref, inView } = useInView({
triggerOnce: true,
@@ -73,11 +146,34 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
const commentCount = photo.comment_count ?? 0;
const showFeedbackActions = feedbackEnabled && Boolean(feedbackOptions);
const overlayVisibilityClass = overlayVisible
? 'opacity-100 md:opacity-100'
: 'opacity-0 md:opacity-0';
const checkboxVisibilityClass =
isSelected || isSelectionMode || overlayVisible
? 'opacity-100 md:opacity-100'
: 'opacity-0 md:opacity-0';
const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (isTouchDevice && !overlayVisible && !isSelectionMode) {
e.preventDefault();
e.stopPropagation();
showOverlayTemporarily();
return;
}
onClick();
if (isTouchDevice) {
hideOverlay();
}
};
return (
<div
ref={ref}
className={`relative group cursor-pointer aspect-square ${animationClass}`}
onClick={onClick}
onClick={handlePhotoClick}
style={{
opacity: !inView && animationType === 'fade' ? 0 : 1
}}
@@ -108,14 +204,15 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
}}
/>
<div className="absolute inset-0 bg-black/40 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
<div className={`absolute inset-0 bg-black/40 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2 ${overlayVisibilityClass} md:group-hover:opacity-100`}>
{!isSelectionMode && (
<>
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
e.stopPropagation();
onClick(e);
onClick();
hideOverlay();
}}
aria-label="View full size"
>
@@ -124,7 +221,11 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
{allowDownloads && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={onDownload}
onClick={(e) => {
e.stopPropagation();
onDownload(e);
hideOverlay();
}}
aria-label="Download photo"
>
<Download className="w-5 h-5 text-neutral-800" />
@@ -133,7 +234,11 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
{showFeedbackActions && onQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
onClick={(e) => {
e.stopPropagation();
onQuickComment();
hideOverlay();
}}
aria-label="Comment on photo"
title="Comment"
>
@@ -148,6 +253,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
e.stopPropagation();
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
onRequireIdentity('like', photo.id);
hideOverlay();
return;
}
// Optimistic UI: mark as liked immediately
@@ -163,6 +269,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
console.warn('Like submit failed, keeping optimistic UI', err);
}
if (onFeedbackChange) onFeedbackChange();
hideOverlay();
}}
aria-label="Like photo"
aria-pressed={liked}
@@ -182,9 +289,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
role="checkbox"
aria-checked={isSelected}
data-testid={`gallery-photo-checkbox-${photo.id}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${
isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
className={`absolute top-2 right-2 z-20 transition-opacity ${checkboxVisibilityClass} md:group-hover:opacity-100`}
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
>
<div className={`w-6 h-6 rounded-full border-2 ${isSelected ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
+5 -1
View File
@@ -892,7 +892,11 @@
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
"maxLoginAttempts": "Max. Anmeldeversuche",
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche vor Sperrung",
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche pro IP vor Sperrung",
"attemptWindowMinutes": "Versuchsfenster (Minuten)",
"attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden",
"lockoutDurationMinutes": "Sperrdauer (Minuten)",
"lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben",
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
"recaptchaSettings": "reCAPTCHA-Einstellungen",
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
+5 -1
View File
@@ -572,7 +572,11 @@
"sessionTimeout": "Session Timeout (minutes)",
"sessionTimeoutHelp": "Admin session timeout in minutes",
"maxLoginAttempts": "Max Login Attempts",
"maxLoginAttemptsHelp": "Maximum failed login attempts before lockout",
"maxLoginAttemptsHelp": "Maximum failed login attempts per IP before lockout",
"attemptWindowMinutes": "Attempt Window (minutes)",
"attemptWindowMinutesHelp": "How long to look back when counting failed login attempts",
"lockoutDurationMinutes": "Lockout Duration (minutes)",
"lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures",
"enable2FA": "Enable two-factor authentication for admins",
"recaptchaSettings": "reCAPTCHA Settings",
"enableRecaptcha": "Enable reCAPTCHA for login forms",
+42 -5
View File
@@ -112,6 +112,8 @@ export const SettingsPage: React.FC = () => {
enable_2fa: false,
session_timeout_minutes: 60,
max_login_attempts: 5,
attempt_window_minutes: 15,
lockout_duration_minutes: 30,
enable_recaptcha: false,
recaptcha_site_key: '',
recaptcha_secret_key: ''
@@ -173,6 +175,8 @@ export const SettingsPage: React.FC = () => {
enable_2fa: toBoolean(settings.security_enable_2fa, false),
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
@@ -1346,7 +1350,7 @@ export const SettingsPage: React.FC = () => {
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.sessionTimeout')}
@@ -1354,11 +1358,41 @@ export const SettingsPage: React.FC = () => {
<Input
type="number"
value={securitySettings.session_timeout_minutes}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value) || 60 }))}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value, 10) || 60 }))}
min="5"
max="1440"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.attemptWindowMinutes')}
</label>
<Input
type="number"
value={securitySettings.attempt_window_minutes}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, attempt_window_minutes: parseInt(e.target.value, 10) || 15 }))}
min="1"
max="1440"
/>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.attemptWindowMinutesHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.lockoutDurationMinutes')}
</label>
<Input
type="number"
value={securitySettings.lockout_duration_minutes}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, lockout_duration_minutes: parseInt(e.target.value, 10) || 30 }))}
min="1"
max="1440"
/>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.lockoutDurationMinutesHelp')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('settings.security.maxLoginAttempts')}
@@ -1366,10 +1400,13 @@ export const SettingsPage: React.FC = () => {
<Input
type="number"
value={securitySettings.max_login_attempts}
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value) || 5 }))}
min="3"
max="10"
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value, 10) || 5 }))}
min="1"
max="50"
/>
<p className="mt-1 text-sm text-neutral-600">
{t('settings.security.maxLoginAttemptsHelp')}
</p>
</div>
</div>
+1
View File
@@ -1 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vitest" />
+9 -3
View File
@@ -1,8 +1,12 @@
/// <reference types="vitest" />
// @ts-nocheck
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import type { UserConfig as VitestUserConfig } from 'vitest/config'
// https://vite.dev/config/
export default defineConfig({
const config: VitestUserConfig = {
plugins: [react()],
build: {
rollupOptions: {
@@ -28,5 +32,7 @@ export default defineConfig({
changeOrigin: true,
},
},
},
})
}
}
export default defineConfig(config as any)