Files
picpeak/backend/src/services/secureImageService.js
T
paul 1b4b497fdf
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
chore: clean up codebase for production readiness
- Remove all console.log/debug statements from production code
- Add NODE_ENV checks for development-only logging
- Remove test scripts (test-feedback, test-image-security, test-backup-*, test-restore)
- Remove one-time fix scripts (fix-temp-photos, fix-migration-state, mark-migration-applied)
- Remove sensitive files (.env.backup, ADMIN_CREDENTIALS.txt)
- Update package.json to remove references to deleted scripts
- Replace console statements with logger utility in backend
- Secure error boundaries to not expose stack traces in production

This makes the codebase production-ready with no debug output or test scripts.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-24 23:19:30 +02:00

431 lines
12 KiB
JavaScript

const crypto = require('crypto');
const sharp = require('sharp');
const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const path = require('path');
const fs = require('fs').promises;
class SecureImageService {
constructor() {
this.tokenCache = new Map();
this.sessionTokens = new Map();
this.rateLimitCache = new Map();
}
/**
* Generate a secure, time-limited, single-use token for image access
*/
generateSecureToken(photoId, sessionId, options = {}) {
const {
expiresIn = 300, // 5 minutes default
maxUses = 1,
clientFingerprint = '',
protectionLevel = 'standard'
} = options;
const tokenData = {
photoId: parseInt(photoId),
sessionId,
clientFingerprint,
expiresAt: Date.now() + (expiresIn * 1000),
maxUses,
usedCount: 0,
protectionLevel,
createdAt: Date.now()
};
// Create tamper-proof token
const tokenPayload = Buffer.from(JSON.stringify(tokenData)).toString('base64');
const imageSecret = process.env.IMAGE_SECRET || process.env.JWT_SECRET + '_IMAGE_PROTECTION';
const signature = crypto
.createHmac('sha256', process.env.JWT_SECRET + imageSecret)
.update(tokenPayload)
.digest('hex');
const token = `${tokenPayload}.${signature}`;
// Cache token with metadata
this.tokenCache.set(token, tokenData);
// Set cleanup timer
setTimeout(() => {
this.tokenCache.delete(token);
}, expiresIn * 1000 + 60000); // Add 1 minute buffer
return token;
}
/**
* Verify and consume secure token
*/
verifySecureToken(token, clientFingerprint = '') {
try {
const cached = this.tokenCache.get(token);
if (!cached) {
return { valid: false, reason: 'Token not found or expired' };
}
// Verify token integrity
const [payload, signature] = token.split('.');
const imageSecret = process.env.IMAGE_SECRET || process.env.JWT_SECRET + '_IMAGE_PROTECTION';
const expectedSignature = crypto
.createHmac('sha256', process.env.JWT_SECRET + imageSecret)
.update(payload)
.digest('hex');
if (signature !== expectedSignature) {
return { valid: false, reason: 'Token tampered' };
}
// Check expiration
if (Date.now() > cached.expiresAt) {
this.tokenCache.delete(token);
return { valid: false, reason: 'Token expired' };
}
// Check usage count
if (cached.usedCount >= cached.maxUses) {
return { valid: false, reason: 'Token max uses exceeded' };
}
// Verify client fingerprint for enhanced security
if (cached.protectionLevel === 'enhanced' && cached.clientFingerprint !== clientFingerprint) {
return { valid: false, reason: 'Client fingerprint mismatch' };
}
// Consume usage
cached.usedCount++;
// Remove token if max uses reached
if (cached.usedCount >= cached.maxUses) {
this.tokenCache.delete(token);
}
return {
valid: true,
data: cached,
remaining: cached.maxUses - cached.usedCount
};
} catch (error) {
return { valid: false, reason: 'Token verification failed' };
}
}
/**
* Create client fingerprint from request
*/
createClientFingerprint(req) {
const components = [
req.ip,
req.get('User-Agent') || '',
req.get('Accept-Language') || '',
req.get('Accept-Encoding') || ''
];
return crypto
.createHash('sha256')
.update(components.join('|'))
.digest('hex')
.substring(0, 16);
}
/**
* Rate limiting for image requests
*/
checkRateLimit(clientId, limit = 50, windowMs = 60000) {
const now = Date.now();
const windowStart = now - windowMs;
if (!this.rateLimitCache.has(clientId)) {
this.rateLimitCache.set(clientId, []);
}
const requests = this.rateLimitCache.get(clientId);
// Remove old requests outside the window
const recentRequests = requests.filter(timestamp => timestamp > windowStart);
this.rateLimitCache.set(clientId, recentRequests);
if (recentRequests.length >= limit) {
return false;
}
// Add current request
recentRequests.push(now);
return true;
}
/**
* Process image with protection measures
*/
async processProtectedImage(imagePath, options = {}) {
const {
protectionLevel = 'standard',
quality = 85,
maxWidth = 1920,
maxHeight = 1080,
addFingerprint = true,
fragmentImage = false
} = options;
try {
let image = sharp(imagePath);
const metadata = await image.metadata();
// Resize if too large
if (metadata.width > maxWidth || metadata.height > maxHeight) {
image = image.resize(maxWidth, maxHeight, {
fit: 'inside',
withoutEnlargement: true
});
}
// Apply quality reduction for protection
if (protectionLevel === 'enhanced') {
quality = Math.min(quality, 70);
} else if (protectionLevel === 'maximum') {
quality = Math.min(quality, 60);
}
// Convert to appropriate format
image = image.jpeg({ quality, progressive: true });
// Add invisible watermark/fingerprint
if (addFingerprint) {
const fingerprint = crypto.randomBytes(16).toString('hex');
// Embed fingerprint in metadata
image = image.withMetadata({
exif: {
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
}
});
}
const buffer = await image.toBuffer();
// Fragment image if requested (for canvas reconstruction)
if (fragmentImage && protectionLevel === 'maximum') {
return await this.fragmentImageBuffer(buffer, metadata);
}
return buffer;
} catch (error) {
console.error('Error processing protected image:', error);
// Return original on error
return await fs.readFile(imagePath);
}
}
/**
* Fragment image into multiple pieces for canvas reconstruction
*/
async fragmentImageBuffer(buffer, metadata) {
const { width, height } = metadata;
const fragments = [];
// Create 3x3 grid of fragments
const cols = 3;
const rows = 3;
const fragmentWidth = Math.floor(width / cols);
const fragmentHeight = Math.floor(height / rows);
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const left = col * fragmentWidth;
const top = row * fragmentHeight;
const fragment = await sharp(buffer)
.extract({
left,
top,
width: fragmentWidth,
height: fragmentHeight
})
.toBuffer();
fragments.push({
index: row * cols + col,
row,
col,
buffer: fragment,
position: { left, top, width: fragmentWidth, height: fragmentHeight }
});
}
}
return {
type: 'fragmented',
fragments,
originalDimensions: { width, height },
fragmentDimensions: { width: fragmentWidth, height: fragmentHeight, cols, rows }
};
}
/**
* Log image access for security monitoring
*/
async logImageAccess(photoId, eventId, clientInfo, accessType = 'view', metadata = {}) {
try {
const logEntry = {
photo_id: photoId,
event_id: eventId,
client_ip: clientInfo.ip,
user_agent: clientInfo.userAgent?.substring(0, 500), // Limit length
access_type: accessType,
client_fingerprint: clientInfo.fingerprint?.substring(0, 32) || 'unknown',
accessed_at: new Date().toISOString(),
metadata: JSON.stringify({
timestamp: clientInfo.timestamp || Date.now(),
...metadata
})
};
await db('image_access_logs').insert(logEntry);
// Check for rapid successive access (potential scraping)
if (accessType === 'view' || accessType === 'download') {
await this.checkForRapidAccess(clientInfo.fingerprint, photoId, eventId);
}
} catch (error) {
console.error('Error logging image access:', error);
}
}
/**
* Enhanced suspicious activity detection
*/
async checkForRapidAccess(clientFingerprint, photoId, eventId = null) {
try {
const fiveMinutesAgo = new Date(Date.now() - 300000).toISOString();
// Check accesses to same photo
const samePhotoAccess = await db('image_access_logs')
.where('client_fingerprint', clientFingerprint)
.where('photo_id', photoId)
.where('accessed_at', '>', fiveMinutesAgo)
.count('* as count')
.first();
// Check total accesses across all photos
const totalAccess = await db('image_access_logs')
.where('client_fingerprint', clientFingerprint)
.where('accessed_at', '>', fiveMinutesAgo)
.count('* as count')
.first();
const samePhotoCount = parseInt(samePhotoAccess.count);
const totalCount = parseInt(totalAccess.count);
// Flag if suspicious patterns detected
if (samePhotoCount > 5 || totalCount > 30) {
await this.flagSuspiciousActivity(
clientFingerprint,
photoId,
'rapid_access',
{ samePhotoCount, totalCount, eventId }
);
}
} catch (error) {
console.error('Error checking for rapid access:', error);
}
}
/**
* Flag suspicious activity and take action
*/
async flagSuspiciousActivity(clientFingerprint, photoId, reason, details = {}) {
try {
// Try to get event_id from photo
let eventId = details.eventId;
if (!eventId && photoId) {
const photo = await db('photos').where({ id: photoId }).first();
eventId = photo?.event_id;
}
// Log the suspicious activity
await db('image_access_logs').insert({
photo_id: photoId,
event_id: eventId || 0, // Use 0 as a fallback for suspicious activity without event context
client_ip: details.clientIp || 'unknown',
client_fingerprint: clientFingerprint,
access_type: 'suspicious',
accessed_at: new Date().toISOString(),
metadata: JSON.stringify({
reason,
...details,
flaggedAt: Date.now()
})
});
console.warn(`Suspicious activity flagged: ${reason}`, {
clientFingerprint,
photoId,
details
});
// If multiple suspicious activities, consider blocking
const recentSuspicious = await db('image_access_logs')
.where('client_fingerprint', clientFingerprint)
.where('access_type', 'suspicious')
.where('accessed_at', '>', new Date(Date.now() - 3600000).toISOString()) // Last hour
.count('* as count')
.first();
if (parseInt(recentSuspicious.count) >= 3) {
console.warn(`Client fingerprint flagged for blocking: ${clientFingerprint}`);
// This would be handled by the middleware's blocking system
}
} catch (error) {
console.error('Error flagging suspicious activity:', error);
}
}
/**
* Detect suspicious access patterns
*/
async detectSuspiciousActivity(clientFingerprint, photoId) {
try {
const recentAccess = await db('image_access_logs')
.where('client_fingerprint', clientFingerprint)
.where('photo_id', photoId)
.where('accessed_at', '>', new Date(Date.now() - 300000).toISOString()) // Last 5 minutes
.count('* as count')
.first();
const accessCount = parseInt(recentAccess.count);
// Flag if more than 10 accesses to same photo in 5 minutes
if (accessCount > 10) {
console.warn(`Suspicious activity detected: ${accessCount} accesses to photo ${photoId} from ${clientFingerprint}`);
return true;
}
return false;
} catch (error) {
console.error('Error detecting suspicious activity:', error);
return false;
}
}
/**
* Clean up expired tokens and logs
*/
cleanup() {
// Clear expired rate limit entries
const now = Date.now();
for (const [clientId, requests] of this.rateLimitCache.entries()) {
const recent = requests.filter(timestamp => timestamp > now - 60000);
if (recent.length === 0) {
this.rateLimitCache.delete(clientId);
} else {
this.rateLimitCache.set(clientId, recent);
}
}
}
}
module.exports = new SecureImageService();