diff options
| author | Thomas Vanbesien <tvanbesi@proton.me> | 2026-03-21 22:36:11 +0100 |
|---|---|---|
| committer | Thomas Vanbesien <tvanbesi@proton.me> | 2026-03-21 22:36:11 +0100 |
| commit | d63e3c91a97d77b202e280ab0fa007dfbe1baa46 (patch) | |
| tree | cd3533bfb947ea753d91f71a75406644a73d678d /src/app/Controllers | |
| parent | f60a390f5c51039fd1efc1df9a6a7f3864ce0062 (diff) | |
| download | camagru-d63e3c91a97d77b202e280ab0fa007dfbe1baa46.tar.gz camagru-d63e3c91a97d77b202e280ab0fa007dfbe1baa46.zip | |
Add editor with webcam/upload capture, overlay compositing, and gallery feed
Diffstat (limited to 'src/app/Controllers')
| -rw-r--r-- | src/app/Controllers/EditorController.php | 157 | ||||
| -rw-r--r-- | src/app/Controllers/GalleryController.php | 32 |
2 files changed, 189 insertions, 0 deletions
diff --git a/src/app/Controllers/EditorController.php b/src/app/Controllers/EditorController.php new file mode 100644 index 0000000..7cfb9b9 --- /dev/null +++ b/src/app/Controllers/EditorController.php @@ -0,0 +1,157 @@ +<?php + +declare(strict_types=1); +// Editor: webcam/upload image capture, overlay selection, and server-side +// GD compositing to create posts. + +namespace App\Controllers; + +use App\Csrf; +use App\Flash; +use App\Models\Post; + +class EditorController +{ + private Post $post; + + public function __construct() + { + $this->post = new Post(); + } + + public function show(): void + { + if (!isset($_SESSION['user_id'])) { + header('Location: /login'); + return; + } + + $overlaysDir = \dirname(__DIR__, 2) . '/public/assets/overlays'; + $overlayFiles = glob($overlaysDir . '/*.png'); + // Map filesystem paths to URL paths the browser can load + $overlays = array_map(static fn($path) => '/assets/overlays/' . basename($path), $overlayFiles); + + $content = __DIR__ . '/../Views/editor/index.php'; + include __DIR__ . '/../Views/layouts/main.php'; + } + + public function store(): void + { + header('Content-Type: application/json'); + + if (!isset($_SESSION['user_id'])) { + http_response_code(401); + echo json_encode(['error' => 'Not authenticated.']); + return; + } + + $input = json_decode(file_get_contents('php://input'), true); + if (!$input) { + http_response_code(400); + echo json_encode(['error' => 'Invalid request.']); + return; + } + + if (!Csrf::validate($input['csrf_token'] ?? '')) { + http_response_code(403); + echo json_encode(['error' => 'Invalid CSRF token.']); + return; + } + + $imageData = $input['image_data'] ?? ''; + $overlayName = $input['overlay'] ?? ''; + $overlayScale = (float) ($input['overlay_scale'] ?? 1.0); + + // Validate overlay exists — use basename() to prevent path traversal + $overlaysDir = \dirname(__DIR__, 2) . '/public/assets/overlays'; + $overlayPath = $overlaysDir . '/' . basename($overlayName); + if (!file_exists($overlayPath) || !str_ends_with($overlayPath, '.png')) { + http_response_code(400); + echo json_encode(['error' => 'Invalid overlay.']); + return; + } + + $overlayScale = max(0.1, min(2.0, $overlayScale)); + + // Strip the data URL prefix (e.g. "data:image/jpeg;base64,") to get raw base64 + $base64 = preg_replace('#^data:image/\w+;base64,#', '', $imageData); + $decoded = base64_decode($base64, true); + if ($decoded === false) { + http_response_code(400); + echo json_encode(['error' => 'Invalid image data.']); + return; + } + + $base = @imagecreatefromstring($decoded); + if ($base === false) { + http_response_code(400); + echo json_encode(['error' => 'Could not process image.']); + return; + } + + $outputPath = $this->composite($base, $overlayPath, $overlayScale); + + + if ($outputPath === null) { + http_response_code(500); + echo json_encode(['error' => 'Failed to save image.']); + return; + } + + // Store relative path from the web root so it works as a URL + $relativePath = 'uploads/posts/' . basename($outputPath); + $this->post->create($_SESSION['user_id'], $relativePath); + + echo json_encode(['success' => true, 'redirect' => '/gallery']); + } + + /** + * Composite the base image with an overlay using GD. + * Returns the saved file path, or null on failure. + */ + private function composite(\GdImage $base, string $overlayPath, float $scale): ?string + { + $canvasSize = 640; + + // Create a square canvas and fill with white + $canvas = imagecreatetruecolor($canvasSize, $canvasSize); + $white = imagecolorallocate($canvas, 255, 255, 255); + imagefill($canvas, 0, 0, $white); + + // Resize base image to cover the 640x640 canvas (center-crop) + $srcW = imagesx($base); + $srcH = imagesy($base); + // Pick the largest square that fits inside the source image + $cropSize = min($srcW, $srcH); + $srcX = (int) (($srcW - $cropSize) / 2); + $srcY = (int) (($srcH - $cropSize) / 2); + imagecopyresampled($canvas, $base, 0, 0, $srcX, $srcY, $canvasSize, $canvasSize, $cropSize, $cropSize); + + // Load the overlay PNG with alpha transparency + $overlay = imagecreatefrompng($overlayPath); + // Enable alpha blending so transparent overlay pixels don't overwrite the base + imagealphablending($canvas, true); + + $overlayW = imagesx($overlay); + $overlayH = imagesy($overlay); + $scaledW = (int) ($overlayW * $scale); + $scaledH = (int) ($overlayH * $scale); + // Center the overlay on the canvas + $destX = (int) (($canvasSize - $scaledW) / 2); + $destY = (int) (($canvasSize - $scaledH) / 2); + + imagecopyresampled($canvas, $overlay, $destX, $destY, 0, 0, $scaledW, $scaledH, $overlayW, $overlayH); + + + // Save as JPEG + $uploadsDir = \dirname(__DIR__, 2) . '/uploads/posts'; + + $filename = $_SESSION['user_id'] . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.jpg'; + $path = $uploadsDir . '/' . $filename; + + $ok = imagejpeg($canvas, $path, 85); + + + return $ok ? $path : null; + } +} diff --git a/src/app/Controllers/GalleryController.php b/src/app/Controllers/GalleryController.php new file mode 100644 index 0000000..2edcd17 --- /dev/null +++ b/src/app/Controllers/GalleryController.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); +// Gallery: public paginated feed of all posts, newest first. + +namespace App\Controllers; + +use App\Models\Post; + +class GalleryController +{ + private Post $post; + private const POSTS_PER_PAGE = 5; + + public function __construct() + { + $this->post = new Post(); + } + + public function index(): void + { + $page = max(1, (int) ($_GET['page'] ?? 1)); + $offset = ($page - 1) * self::POSTS_PER_PAGE; + + $posts = $this->post->findAllPaginated(self::POSTS_PER_PAGE, $offset); + $totalPosts = $this->post->countAll(); + $totalPages = max(1, (int) ceil($totalPosts / self::POSTS_PER_PAGE)); + + $content = __DIR__ . '/../Views/gallery/index.php'; + include __DIR__ . '/../Views/layouts/main.php'; + } +} |
