From d63e3c91a97d77b202e280ab0fa007dfbe1baa46 Mon Sep 17 00:00:00 2001 From: Thomas Vanbesien Date: Sat, 21 Mar 2026 22:36:11 +0100 Subject: Add editor with webcam/upload capture, overlay compositing, and gallery feed --- src/app/Controllers/EditorController.php | 157 ++++++++++++++++++++++++++++++ src/app/Controllers/GalleryController.php | 32 ++++++ src/app/Models/Post.php | 71 ++++++++++++++ src/app/Views/editor/index.php | 49 ++++++++++ src/app/Views/gallery/index.php | 40 ++++++++ 5 files changed, 349 insertions(+) create mode 100644 src/app/Controllers/EditorController.php create mode 100644 src/app/Controllers/GalleryController.php create mode 100644 src/app/Models/Post.php create mode 100644 src/app/Views/editor/index.php create mode 100644 src/app/Views/gallery/index.php (limited to 'src/app') 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 @@ +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 @@ +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'; + } +} diff --git a/src/app/Models/Post.php b/src/app/Models/Post.php new file mode 100644 index 0000000..66c8c18 --- /dev/null +++ b/src/app/Models/Post.php @@ -0,0 +1,71 @@ +pdo = Database::getInstance()->getPdo(); + } + + public function create(int $userId, string $imagePath): int + { + $stmt = $this->pdo->prepare( + 'INSERT INTO posts (user_id, image_path) VALUES (:user_id, :image_path)' + ); + $stmt->execute(['user_id' => $userId, 'image_path' => $imagePath]); + return (int) $this->pdo->lastInsertId(); + } + + public function findById(int $id): ?array + { + $stmt = $this->pdo->prepare('SELECT * FROM posts WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + return $row ?: null; + } + + public function findByUserId(int $userId): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM posts WHERE user_id = :user_id ORDER BY created_at DESC' + ); + $stmt->execute(['user_id' => $userId]); + return $stmt->fetchAll(); + } + + public function findAllPaginated(int $limit, int $offset): array + { + $stmt = $this->pdo->prepare( + 'SELECT posts.*, users.username FROM posts + JOIN users ON posts.user_id = users.id + ORDER BY posts.created_at DESC + LIMIT :limit OFFSET :offset' + ); + // PDO needs explicit int binding for LIMIT/OFFSET + $stmt->bindValue('limit', $limit, \PDO::PARAM_INT); + $stmt->bindValue('offset', $offset, \PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); + } + + public function countAll(): int + { + $stmt = $this->pdo->query('SELECT COUNT(*) FROM posts'); + return (int) $stmt->fetchColumn(); + } + + public function delete(int $id): void + { + $stmt = $this->pdo->prepare('DELETE FROM posts WHERE id = :id'); + $stmt->execute(['id' => $id]); + } +} diff --git a/src/app/Views/editor/index.php b/src/app/Views/editor/index.php new file mode 100644 index 0000000..624002a --- /dev/null +++ b/src/app/Views/editor/index.php @@ -0,0 +1,49 @@ + +
+

Create a post

+ + + +
+
+
+ + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ +

Overlays

+ +

No overlays available.

+ +
+ + overlay + +
+ + + +
+
+
diff --git a/src/app/Views/gallery/index.php b/src/app/Views/gallery/index.php new file mode 100644 index 0000000..62fd7f3 --- /dev/null +++ b/src/app/Views/gallery/index.php @@ -0,0 +1,40 @@ + + -- cgit v1.2.3