diff options
Diffstat (limited to 'src/app')
| -rw-r--r-- | src/app/Controllers/EditorController.php | 157 | ||||
| -rw-r--r-- | src/app/Controllers/GalleryController.php | 32 | ||||
| -rw-r--r-- | src/app/Models/Post.php | 71 | ||||
| -rw-r--r-- | src/app/Views/editor/index.php | 49 | ||||
| -rw-r--r-- | src/app/Views/gallery/index.php | 40 |
5 files changed, 349 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'; + } +} 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 @@ +<?php + +declare(strict_types=1); +// Post model: database operations for the posts table. + +namespace App\Models; + +use App\Database; + +class Post +{ + private \PDO $pdo; + + public function __construct() + { + $this->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 @@ +<?php // Editor page: capture or upload an image, pick an overlay, preview, and save as a post.?> +<div class="editor-page"> + <h1>Create a post</h1> + <?php include __DIR__ . '/../partials/flash.php'; ?> + <input type="hidden" id="csrf-token" value="<?= htmlspecialchars(\App\Csrf::generate()) ?>"> + + <div class="editor-layout"> + <div class="editor-source"> + <div class="source-tabs"> + <button id="tab-webcam" class="tab active">Webcam</button> + <button id="tab-upload" class="tab">Upload</button> + </div> + + <div id="webcam-panel"> + <video id="webcam-video" autoplay playsinline></video> + <button id="btn-capture" class="btn">Take photo</button> + </div> + + <div id="upload-panel" hidden> + <input type="file" id="file-input" accept="image/jpeg,image/png"> + </div> + </div> + + <div class="editor-preview"> + <canvas id="preview-canvas" width="640" height="640"></canvas> + + <div class="overlay-controls"> + <label for="overlay-scale">Overlay size</label> + <input type="range" id="overlay-scale" min="10" max="200" value="100"> + </div> + + <h2>Overlays</h2> + <?php if (empty($overlays)): ?> + <p class="hint">No overlays available.</p> + <?php else: ?> + <div class="overlay-grid"> + <?php foreach ($overlays as $overlay): ?> + <img src="<?= htmlspecialchars($overlay) ?>" + class="overlay-thumb" + alt="overlay" + data-filename="<?= htmlspecialchars(basename($overlay)) ?>"> + <?php endforeach; ?> + </div> + <?php endif; ?> + + <button id="btn-save" class="btn" disabled>Save post</button> + </div> + </div> +</div> 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 @@ +<?php // Gallery: public feed of all posts with pagination.?> +<div class="gallery-page"> + <h1>Gallery</h1> + + <?php if (empty($posts)): ?> + <p class="hint">No posts yet. Be the first!</p> + <?php endif; ?> + + <div class="gallery-feed"> + <?php foreach ($posts as $post): ?> + <article class="gallery-post"> + <div class="post-header"> + <strong><?= htmlspecialchars($post['username']) ?></strong> + <time><?= date('M j, Y', strtotime($post['created_at'])) ?></time> + </div> + <img src="/<?= htmlspecialchars($post['image_path']) ?>" alt="Post by <?= htmlspecialchars($post['username']) ?>"> + </article> + <?php endforeach; ?> + </div> + + <?php if ($totalPages > 1): ?> + <nav class="pagination"> + <?php if ($page > 1): ?> + <a href="/gallery?page=<?= $page - 1 ?>">« Previous</a> + <?php endif; ?> + + <?php for ($i = 1; $i <= $totalPages; $i++): ?> + <?php if ($i === $page): ?> + <span class="current-page"><?= $i ?></span> + <?php else: ?> + <a href="/gallery?page=<?= $i ?>"><?= $i ?></a> + <?php endif; ?> + <?php endfor; ?> + + <?php if ($page < $totalPages): ?> + <a href="/gallery?page=<?= $page + 1 ?>">Next »</a> + <?php endif; ?> + </nav> + <?php endif; ?> +</div> |
