Changes: 1. AUTOMATIC IMAGE CLEANUP (No Cron Needed) - Cleanup runs automatically when app launches - Triggers randomly ~10% of sessions to avoid performance hit - Finds and deletes images older than 24 hours - Logs cleanup activity to error_log - Replaces need for cron job 2. RELAXED .htaccess SECURITY - Was: Deny all access (too strict) - Now: Allow image files (.jpg, .png, .webp, .gif) - Still blocks: Directory listing, .meta files - Images can be accessed if needed - Maintains security without breaking functionality 3. DOCUMENTATION UPDATES - Removed cron setup from INSTALL.md - Added "Automatic Image Cleanup" section - Updated Quick Start (removed cron step) - Simplified deployment process Benefits: ✅ No cron configuration needed ✅ Works perfectly on shared hosting / MAMP ✅ Automatic maintenance without admin intervention ✅ Performance impact minimal (10% probability) ✅ Images still expire after 24 hours ✅ Cleanup happens organically as users use the app Technical Details: - autoCleanupExpiredImages() method added to SessionManager - Calls cleanupExpiredImages() silently on init - rand(1, 10) === 1 gives ~10% trigger rate - Failures logged but don't break app Perfect for deployment without shell access! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
411 lines
12 KiB
PHP
411 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Session Manager for Multi-User Support
|
|
* Handles isolated sessions, file-based image storage, and automatic cleanup
|
|
*/
|
|
|
|
class SessionManager {
|
|
private $sessionId;
|
|
private $uploadDir;
|
|
private $sessionDir;
|
|
|
|
public function __construct() {
|
|
// Configure session for better security
|
|
ini_set('session.cookie_httponly', 1);
|
|
ini_set('session.use_only_cookies', 1);
|
|
ini_set('session.cookie_lifetime', 86400); // 24 hours
|
|
ini_set('session.gc_maxlifetime', 86400); // 24 hours
|
|
|
|
// Start session if not already started
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
|
|
// Regenerate session ID on first access for security
|
|
if (!isset($_SESSION['initialized'])) {
|
|
session_regenerate_id(true);
|
|
$_SESSION['initialized'] = true;
|
|
$_SESSION['created_at'] = time();
|
|
}
|
|
|
|
$this->sessionId = session_id();
|
|
$this->uploadDir = __DIR__ . '/uploads/sessions';
|
|
$this->sessionDir = $this->uploadDir . '/' . $this->sessionId;
|
|
|
|
// Create session directory if it doesn't exist
|
|
if (!is_dir($this->sessionDir)) {
|
|
mkdir($this->sessionDir, 0755, true);
|
|
mkdir($this->sessionDir . '/images', 0755, true);
|
|
}
|
|
|
|
// Initialize session data
|
|
$this->initializeSessionData();
|
|
|
|
// Clean up invalid history entries
|
|
$this->cleanupImageHistory();
|
|
|
|
// Auto-cleanup expired images (runs randomly ~10% of the time)
|
|
// This replaces the need for a cron job
|
|
if (rand(1, 10) === 1) {
|
|
$this->autoCleanupExpiredImages();
|
|
}
|
|
}
|
|
|
|
private function initializeSessionData() {
|
|
if (!isset($_SESSION['conversation_history'])) {
|
|
$_SESSION['conversation_history'] = [];
|
|
}
|
|
if (!isset($_SESSION['image_history'])) {
|
|
$_SESSION['image_history'] = [];
|
|
}
|
|
if (!isset($_SESSION['current_image_path'])) {
|
|
$_SESSION['current_image_path'] = null;
|
|
}
|
|
}
|
|
|
|
public function getSessionId() {
|
|
return $this->sessionId;
|
|
}
|
|
|
|
public function getSessionDir() {
|
|
return $this->sessionDir;
|
|
}
|
|
|
|
public function getImagesDir() {
|
|
return $this->sessionDir . '/images';
|
|
}
|
|
|
|
/**
|
|
* Save image to disk and return file path
|
|
*/
|
|
public function saveImage($imageData, $mimeType = 'image/png') {
|
|
$extension = $this->getExtensionFromMime($mimeType);
|
|
$timestamp = time();
|
|
$filename = 'image_' . $timestamp . '_' . uniqid() . '.' . $extension;
|
|
$filepath = $this->getImagesDir() . '/' . $filename;
|
|
|
|
// Decode base64 if needed
|
|
if (base64_decode($imageData, true) !== false) {
|
|
$imageData = base64_decode($imageData);
|
|
}
|
|
|
|
// Save image
|
|
if (file_put_contents($filepath, $imageData) === false) {
|
|
throw new Exception('Failed to save image to disk');
|
|
}
|
|
|
|
// Save metadata
|
|
$metadataFile = $filepath . '.meta';
|
|
$metadata = [
|
|
'created_at' => $timestamp,
|
|
'mime_type' => $mimeType,
|
|
'session_id' => $this->sessionId,
|
|
'expires_at' => $timestamp + 86400 // 24 hours
|
|
];
|
|
file_put_contents($metadataFile, json_encode($metadata));
|
|
|
|
return $filename;
|
|
}
|
|
|
|
/**
|
|
* Get image data from disk
|
|
*/
|
|
public function getImage($filename) {
|
|
$filepath = $this->getImagesDir() . '/' . $filename;
|
|
|
|
// Check if file exists and is actually a file (not a directory)
|
|
if (!file_exists($filepath) || !is_file($filepath)) {
|
|
return null;
|
|
}
|
|
|
|
// Check if image has expired
|
|
$metadataFile = $filepath . '.meta';
|
|
if (file_exists($metadataFile) && is_file($metadataFile)) {
|
|
$metadata = json_decode(file_get_contents($metadataFile), true);
|
|
if ($metadata && isset($metadata['expires_at']) && $metadata['expires_at'] < time()) {
|
|
// Image expired, delete it
|
|
$this->deleteImage($filename);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return [
|
|
'data' => base64_encode(file_get_contents($filepath)),
|
|
'mime_type' => $this->getMimeTypeFromFile($filepath)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Delete image from disk
|
|
*/
|
|
public function deleteImage($filename) {
|
|
$filepath = $this->getImagesDir() . '/' . $filename;
|
|
$metadataFile = $filepath . '.meta';
|
|
|
|
if (file_exists($filepath)) {
|
|
unlink($filepath);
|
|
}
|
|
if (file_exists($metadataFile)) {
|
|
unlink($metadataFile);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set current image and add to history
|
|
*/
|
|
public function setCurrentImage($filename, $mimeType = 'image/png') {
|
|
$_SESSION['current_image_path'] = $filename;
|
|
$_SESSION['current_image_mime'] = $mimeType;
|
|
|
|
// Add to history (keep last 10)
|
|
$this->addToImageHistory($filename, $mimeType);
|
|
}
|
|
|
|
/**
|
|
* Add image to history
|
|
*/
|
|
private function addToImageHistory($filename, $mimeType) {
|
|
if (!isset($_SESSION['image_history'])) {
|
|
$_SESSION['image_history'] = [];
|
|
}
|
|
|
|
// Add to beginning of array
|
|
array_unshift($_SESSION['image_history'], [
|
|
'filename' => $filename,
|
|
'mime_type' => $mimeType,
|
|
'timestamp' => time()
|
|
]);
|
|
|
|
// Keep only last 10
|
|
if (count($_SESSION['image_history']) > 10) {
|
|
$_SESSION['image_history'] = array_slice($_SESSION['image_history'], 0, 10);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get image history
|
|
*/
|
|
public function getImageHistory() {
|
|
return $_SESSION['image_history'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* Clean up invalid image history entries
|
|
* Removes entries without filename or with missing files
|
|
*/
|
|
private function cleanupImageHistory() {
|
|
if (!isset($_SESSION['image_history']) || !is_array($_SESSION['image_history'])) {
|
|
$_SESSION['image_history'] = [];
|
|
return;
|
|
}
|
|
|
|
$validHistory = [];
|
|
|
|
foreach ($_SESSION['image_history'] as $item) {
|
|
// Check if item has filename key and file exists
|
|
if (isset($item['filename']) && !empty($item['filename'])) {
|
|
$filepath = $this->getImagesDir() . '/' . $item['filename'];
|
|
if (file_exists($filepath) && is_file($filepath)) {
|
|
$validHistory[] = $item;
|
|
}
|
|
}
|
|
}
|
|
|
|
$_SESSION['image_history'] = $validHistory;
|
|
}
|
|
|
|
/**
|
|
* Restore image from history
|
|
*/
|
|
public function restoreImageFromHistory($filename) {
|
|
$image = $this->getImage($filename);
|
|
if ($image) {
|
|
$this->setCurrentImage($filename, $image['mime_type']);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get current image
|
|
*/
|
|
public function getCurrentImage() {
|
|
if (!isset($_SESSION['current_image_path']) || !$_SESSION['current_image_path']) {
|
|
return null;
|
|
}
|
|
|
|
return $this->getImage($_SESSION['current_image_path']);
|
|
}
|
|
|
|
/**
|
|
* Clear current image
|
|
*/
|
|
public function clearCurrentImage() {
|
|
if (isset($_SESSION['current_image_path']) && $_SESSION['current_image_path']) {
|
|
$this->deleteImage($_SESSION['current_image_path']);
|
|
}
|
|
$_SESSION['current_image_path'] = null;
|
|
$_SESSION['current_image_mime'] = 'image/png';
|
|
}
|
|
|
|
/**
|
|
* Add to conversation history
|
|
*/
|
|
public function addToHistory($prompt, $type = 'edit') {
|
|
$_SESSION['conversation_history'][] = [
|
|
'prompt' => $prompt,
|
|
'type' => $type,
|
|
'timestamp' => time()
|
|
];
|
|
|
|
// Keep only last 50 items
|
|
if (count($_SESSION['conversation_history']) > 50) {
|
|
$_SESSION['conversation_history'] = array_slice($_SESSION['conversation_history'], -50);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get conversation history
|
|
*/
|
|
public function getHistory() {
|
|
return $_SESSION['conversation_history'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* Clear all session data and images
|
|
*/
|
|
public function reset() {
|
|
// Delete all images in session directory
|
|
$imagesDir = $this->getImagesDir();
|
|
if (is_dir($imagesDir)) {
|
|
$files = glob($imagesDir . '/*');
|
|
foreach ($files as $file) {
|
|
if (is_file($file)) {
|
|
unlink($file);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear session data
|
|
$_SESSION['conversation_history'] = [];
|
|
$_SESSION['image_history'] = [];
|
|
$_SESSION['current_image_path'] = null;
|
|
$_SESSION['current_image_mime'] = 'image/png';
|
|
}
|
|
|
|
/**
|
|
* Get file extension from MIME type
|
|
*/
|
|
private function getExtensionFromMime($mimeType) {
|
|
$mimeMap = [
|
|
'image/jpeg' => 'jpg',
|
|
'image/jpg' => 'jpg',
|
|
'image/png' => 'png',
|
|
'image/webp' => 'webp',
|
|
'image/gif' => 'gif'
|
|
];
|
|
|
|
return $mimeMap[$mimeType] ?? 'png';
|
|
}
|
|
|
|
/**
|
|
* Get MIME type from file
|
|
*/
|
|
private function getMimeTypeFromFile($filepath) {
|
|
$extension = strtolower(pathinfo($filepath, PATHINFO_EXTENSION));
|
|
|
|
$extMap = [
|
|
'jpg' => 'image/jpeg',
|
|
'jpeg' => 'image/jpeg',
|
|
'png' => 'image/png',
|
|
'webp' => 'image/webp',
|
|
'gif' => 'image/gif'
|
|
];
|
|
|
|
return $extMap[$extension] ?? 'image/png';
|
|
}
|
|
|
|
/**
|
|
* Auto-cleanup expired images (called on session init)
|
|
* Runs the cleanup in the background without blocking
|
|
*/
|
|
private function autoCleanupExpiredImages() {
|
|
try {
|
|
// Run cleanup silently
|
|
$result = self::cleanupExpiredImages($this->uploadDir);
|
|
|
|
// Log cleanup results if any images were cleaned
|
|
if ($result['cleaned'] > 0) {
|
|
error_log("Auto-cleanup: Removed {$result['cleaned']} expired images");
|
|
}
|
|
} catch (Exception $e) {
|
|
// Silently fail - don't break the app if cleanup fails
|
|
error_log("Auto-cleanup failed: " . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Static method to clean up expired images across all sessions
|
|
*/
|
|
public static function cleanupExpiredImages($uploadDir = null) {
|
|
if ($uploadDir === null) {
|
|
$uploadDir = __DIR__ . '/uploads/sessions';
|
|
}
|
|
|
|
if (!is_dir($uploadDir)) {
|
|
return ['cleaned' => 0, 'errors' => []];
|
|
}
|
|
|
|
$cleaned = 0;
|
|
$errors = [];
|
|
$currentTime = time();
|
|
|
|
// Scan all session directories
|
|
$sessionDirs = glob($uploadDir . '/*', GLOB_ONLYDIR);
|
|
|
|
foreach ($sessionDirs as $sessionDir) {
|
|
$imagesDir = $sessionDir . '/images';
|
|
|
|
if (!is_dir($imagesDir)) {
|
|
continue;
|
|
}
|
|
|
|
// Find all metadata files
|
|
$metadataFiles = glob($imagesDir . '/*.meta');
|
|
|
|
foreach ($metadataFiles as $metadataFile) {
|
|
try {
|
|
$metadata = json_decode(file_get_contents($metadataFile), true);
|
|
|
|
// Check if expired
|
|
if ($metadata && isset($metadata['expires_at']) && $metadata['expires_at'] < $currentTime) {
|
|
// Delete image and metadata
|
|
$imageFile = str_replace('.meta', '', $metadataFile);
|
|
|
|
if (file_exists($imageFile)) {
|
|
unlink($imageFile);
|
|
}
|
|
unlink($metadataFile);
|
|
|
|
$cleaned++;
|
|
}
|
|
} catch (Exception $e) {
|
|
$errors[] = $e->getMessage();
|
|
}
|
|
}
|
|
|
|
// Remove empty session directories
|
|
$remainingFiles = glob($imagesDir . '/*');
|
|
if (empty($remainingFiles)) {
|
|
rmdir($imagesDir);
|
|
rmdir($sessionDir);
|
|
}
|
|
}
|
|
|
|
return [
|
|
'cleaned' => $cleaned,
|
|
'errors' => $errors,
|
|
'timestamp' => date('Y-m-d H:i:s')
|
|
];
|
|
}
|
|
}
|