aboutsummaryrefslogtreecommitdiffstats
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-rw-r--r--src/app/Controllers/EditorController.php157
-rw-r--r--src/app/Controllers/GalleryController.php32
-rw-r--r--src/app/Models/Post.php71
-rw-r--r--src/app/Views/editor/index.php49
-rw-r--r--src/app/Views/gallery/index.php40
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 ?>">&laquo; 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 &raquo;</a>
+ <?php endif; ?>
+ </nav>
+ <?php endif; ?>
+</div>