From d1ef15fa39935bfa0420c5ac2b8c269e294c9a6d Mon Sep 17 00:00:00 2001 From: Thomas Vanbesien Date: Sat, 21 Mar 2026 20:50:43 +0100 Subject: Initial project scaffold Set up MVC architecture with front controller, router, autoloader, database singleton, and Docker Compose stack (Nginx + PHP-FPM + MariaDB). Includes DB schema, responsive layout, dev tooling (php-cs-fixer, parallel-lint), and documentation. --- .env.example | 9 +++ .gitignore | 5 ++ .php-cs-fixer.dist.php | 30 +++++++++ README.md | 116 +++++++++++++++++++++++++++++++++ composer.json | 8 +++ composer.lock | 82 +++++++++++++++++++++++ docker-compose.yml | 31 +++++++++ docker/mariadb/init.sql | 39 +++++++++++ docker/nginx/Dockerfile | 2 + docker/nginx/default.conf | 21 ++++++ docker/php/Dockerfile | 15 +++++ docs/camagru.subject.pdf | Bin 0 -> 2314748 bytes src/app/Controllers/HomeController.php | 21 ++++++ src/app/Database.php | 44 +++++++++++++ src/app/Router.php | 56 ++++++++++++++++ src/app/Views/home/index.php | 3 + src/app/Views/layouts/main.php | 35 ++++++++++ src/app/bootstrap.php | 42 ++++++++++++ src/config/routes.php | 7 ++ src/public/assets/overlays/.gitkeep | 0 src/public/css/style.css | 64 ++++++++++++++++++ src/public/index.php | 11 ++++ src/public/js/app.js | 3 + src/uploads/.gitkeep | 0 24 files changed, 644 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .php-cs-fixer.dist.php create mode 100644 README.md create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 docker-compose.yml create mode 100644 docker/mariadb/init.sql create mode 100644 docker/nginx/Dockerfile create mode 100644 docker/nginx/default.conf create mode 100644 docker/php/Dockerfile create mode 100644 docs/camagru.subject.pdf create mode 100644 src/app/Controllers/HomeController.php create mode 100644 src/app/Database.php create mode 100644 src/app/Router.php create mode 100644 src/app/Views/home/index.php create mode 100644 src/app/Views/layouts/main.php create mode 100644 src/app/bootstrap.php create mode 100644 src/config/routes.php create mode 100644 src/public/assets/overlays/.gitkeep create mode 100644 src/public/css/style.css create mode 100644 src/public/index.php create mode 100644 src/public/js/app.js create mode 100644 src/uploads/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff57479 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +MYSQL_ROOT_PASSWORD=changeme +MYSQL_DATABASE=camagru +MYSQL_USER=camagru +MYSQL_PASSWORD=changeme + +APP_URL=http://localhost:8080 +APP_SECRET=changeme_random_string + +MAIL_FROM=noreply@camagru.local diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..12cc3bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +src/uploads/* +!src/uploads/.gitkeep +vendor/ +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..0723cc9 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,30 @@ +setRiskyAllowed(true) + ->setRules([ + '@auto' => true, + '@auto:risky' => true, + '@PhpCsFixer:risky' => true + ]) + // 💡 by default, Fixer looks for `*.php` files excluding `./vendor/` - here, you can groom this config + ->setFinder( + (new Finder()) + // 💡 root folder to check + ->in(__DIR__) + // 💡 additional files, eg bin entry file + // ->append([__DIR__.'/bin-entry-file']) + // 💡 folders to exclude, if any + // ->exclude([/* ... */]) + // 💡 path patterns to exclude, if any + // ->notPath([/* ... */]) + // 💡 extra configs + // ->ignoreDotFiles(false) // true by default in v3, false in v4 or future mode + // ->ignoreVCS(true) // true by default + ) +; diff --git a/README.md b/README.md new file mode 100644 index 0000000..4bea6c5 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# Camagru + +A web application for creating and sharing photos with superposable overlays. Users can capture images via webcam (or upload), apply overlay effects, and share them in a public gallery with likes and comments. + +## Features + +- **User accounts** — Register, email verification, login, password reset, profile editing +- **Photo editor** — Webcam capture or image upload with selectable overlay images (PNG with alpha), server-side compositing +- **Gallery** — Public paginated feed of all creations, likes and comments for logged-in users +- **Notifications** — Email notification when someone comments on your image (configurable) +- **Responsive** — Mobile-friendly layout + +## Stack + +| Component | Technology | +|-----------|------------| +| Backend | PHP 8.5 (standard library only) | +| Frontend | HTML / CSS / JavaScript | +| Database | MariaDB 10.11 | +| Web server | Nginx 1.27 | +| Containers | Docker Compose | + +## Getting started + +```sh +# 1. Clone the repository +git clone && cd camagru + +# 2. Create your environment file +cp .env.example .env +# Edit .env with your own values + +# 3. Install dev tools +composer install + +# 4. Build and run +docker-compose up --build + +# 5. Open in browser +# http://localhost:8080 +``` + +## Host setup (Manjaro) + +`phpactor` (LSP) requires the `iconv` extension. Install and enable it: + +```sh +pamac install php85-iconv +sudo sed -i 's/;extension=iconv/extension=iconv/' /etc/php/php.ini +``` + +## Note on composer.json + +This project does not use Composer for runtime dependencies — only the PHP standard library is used. The `composer.json` exists to satisfy dev tooling: + +- `php-cs-fixer` — code style formatting (requires `composer.json` to determine target PHP version) +- `php-parallel-lint` — syntax checking (dev dependency) + +These are development tools only and are not part of the application code. + +## Linting + +```sh +vendor/bin/parallel-lint src/ +``` + +## PHP architecture + +This project follows standard PHP patterns commonly used in framework-less applications: + +### Front controller + +All HTTP requests are routed by Nginx to a single file: `src/public/index.php`. This file bootstraps the app and delegates to the router. No other PHP file is directly accessible from the web. + +### Namespaces and autoloading + +PHP namespaces organize classes into a hierarchy (like directories). `namespace App\Controllers;` means the class lives under `App\Controllers`. The `use` keyword imports a class so you can refer to it by its short name instead of the full path (e.g. `Database` instead of `\App\Database`). + +The autoloader in `bootstrap.php` uses `spl_autoload_register` to automatically `require` class files when they're first used. It maps the `App\` namespace prefix to the `src/app/` directory, so `App\Controllers\HomeController` loads from `src/app/Controllers/HomeController.php`. + +### MVC pattern + +- **Models** (`src/app/Models/`) — Data access layer, each model wraps a database table and uses prepared statements via PDO +- **Views** (`src/app/Views/`) — PHP templates that output HTML. A layout file (`layouts/main.php`) wraps page-specific content via an `include`. View files have variables that appear undefined — this is because they are `include`d by controllers and inherit all variables from the calling scope (e.g. `$dbStatus` defined in `HomeController::index()` is available in the view it includes) +- **Controllers** (`src/app/Controllers/`) — Handle requests, call models, and render views + +### Request lifecycle + +1. Nginx receives request, forwards to `public/index.php` +2. `bootstrap.php` starts the session, loads `.env`, registers the autoloader +3. Routes are defined in `config/routes.php` (mapping URL patterns to controller methods) +4. `Router::dispatch()` matches the URL against registered patterns and calls the matching controller method +5. The controller fetches data via models and renders a view inside the layout + +## Image format choices + +- **Overlays** are PNG (alpha channel required for transparency) +- **User input** (webcam captures, uploads) may be JPEG or PNG — GD is compiled with JPEG support to handle both +- **Final composited images** are saved as PNG to preserve overlay transparency + +## Project structure + +``` +camagru/ +├── docker-compose.yml +├── .env.example +├── docker/ +│ ├── nginx/ # Nginx Dockerfile and config +│ ├── php/ # PHP-FPM Dockerfile (GD + PDO) +│ └── mariadb/ # Database init script +└── src/ + ├── public/ # Document root (entry point, CSS, JS, assets) + ├── app/ # Application code (Controllers, Models, Views) + ├── config/ # Route definitions + └── uploads/ # User-generated images +``` diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..31eae67 --- /dev/null +++ b/composer.json @@ -0,0 +1,8 @@ +{ + "require": { + "php": ">=8.5" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..dc72dac --- /dev/null +++ b/composer.lock @@ -0,0 +1,82 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "046262f846349d25321d6192e76120f8", + "packages": [], + "packages-dev": [ + { + "name": "php-parallel-lint/php-parallel-lint", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/php-parallel-lint/PHP-Parallel-Lint.git", + "reference": "6db563514f27e19595a19f45a4bf757b6401194e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-parallel-lint/PHP-Parallel-Lint/zipball/6db563514f27e19595a19f45a4bf757b6401194e", + "reference": "6db563514f27e19595a19f45a4bf757b6401194e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=5.3.0" + }, + "replace": { + "grogy/php-parallel-lint": "*", + "jakub-onderka/php-parallel-lint": "*" + }, + "require-dev": { + "nette/tester": "^1.3 || ^2.0", + "php-parallel-lint/php-console-highlighter": "0.* || ^1.0", + "squizlabs/php_codesniffer": "^3.6" + }, + "suggest": { + "php-parallel-lint/php-console-highlighter": "Highlight syntax in code snippet" + }, + "bin": [ + "parallel-lint" + ], + "type": "library", + "autoload": { + "classmap": [ + "./src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Jakub Onderka", + "email": "ahoj@jakubonderka.cz" + } + ], + "description": "This tool checks the syntax of PHP files about 20x faster than serial check.", + "homepage": "https://github.com/php-parallel-lint/PHP-Parallel-Lint", + "keywords": [ + "lint", + "static analysis" + ], + "support": { + "issues": "https://github.com/php-parallel-lint/PHP-Parallel-Lint/issues", + "source": "https://github.com/php-parallel-lint/PHP-Parallel-Lint/tree/v1.4.0" + }, + "time": "2024-03-27T12:14:49+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.5" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a173959 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + nginx: + build: ./docker/nginx + ports: + - "8080:80" + volumes: + - ./src:/var/www/html + depends_on: + - php + + php: + build: ./docker/php + volumes: + - ./src:/var/www/html + env_file: + - .env + depends_on: + - mariadb + + mariadb: + image: mariadb:10.11.11 + env_file: + - .env + volumes: + - db_data:/var/lib/mysql + - ./docker/mariadb/init.sql:/docker-entrypoint-initdb.d/init.sql + ports: + - "3306:3306" + +volumes: + db_data: diff --git a/docker/mariadb/init.sql b/docker/mariadb/init.sql new file mode 100644 index 0000000..2cc0d6f --- /dev/null +++ b/docker/mariadb/init.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_verified BOOLEAN DEFAULT FALSE, + verification_token VARCHAR(64), + reset_token VARCHAR(64), + reset_token_expires DATETIME, + notify_comments BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS posts ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + image_path VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS likes ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + post_id INT NOT NULL, + UNIQUE KEY unique_like (user_id, post_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS comments ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + post_id INT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE +); diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile new file mode 100644 index 0000000..7ab8af1 --- /dev/null +++ b/docker/nginx/Dockerfile @@ -0,0 +1,2 @@ +FROM nginx:1.27-alpine +COPY default.conf /etc/nginx/conf.d/default.conf diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 0000000..8a1bcc6 --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,21 @@ +server { + listen 80; + server_name localhost; + root /var/www/html/public; + index index.php; + + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\. { + deny all; + } +} diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile new file mode 100644 index 0000000..8626073 --- /dev/null +++ b/docker/php/Dockerfile @@ -0,0 +1,15 @@ +FROM php:8.5.4-fpm + +# Install system libs for GD (PNG + JPEG) and a mail agent, then compile PHP extensions +RUN apt-get update && apt-get install -y \ + libpng-dev \ + libjpeg62-turbo-dev \ + msmtp \ + && docker-php-ext-configure gd --with-jpeg \ + && docker-php-ext-install -j$(nproc) gd pdo_mysql \ + && rm -rf /var/lib/apt/lists/* + +RUN echo "upload_max_filesize = 10M" > /usr/local/etc/php/conf.d/uploads.ini \ + && echo "post_max_size = 10M" >> /usr/local/etc/php/conf.d/uploads.ini + +WORKDIR /var/www/html diff --git a/docs/camagru.subject.pdf b/docs/camagru.subject.pdf new file mode 100644 index 0000000..e31b5ea Binary files /dev/null and b/docs/camagru.subject.pdf differ diff --git a/src/app/Controllers/HomeController.php b/src/app/Controllers/HomeController.php new file mode 100644 index 0000000..8c462a0 --- /dev/null +++ b/src/app/Controllers/HomeController.php @@ -0,0 +1,21 @@ +getPdo(); + $stmt = $db->query('SELECT 1'); + $dbStatus = $stmt ? 'Connected' : 'Failed'; + + $content = __DIR__ . '/../Views/home/index.php'; + include __DIR__ . '/../Views/layouts/main.php'; + } +} diff --git a/src/app/Database.php b/src/app/Database.php new file mode 100644 index 0000000..f4dff79 --- /dev/null +++ b/src/app/Database.php @@ -0,0 +1,44 @@ +pdo = new \PDO( + "mysql:host=$host;dbname=$db;charset=utf8mb4", + $user, + $pass, + [ + \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, + \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC, + \PDO::ATTR_EMULATE_PREPARES => false, + ] + ); + } + + public static function getInstance(): self + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + public function getPdo(): \PDO + { + return $this->pdo; + } +} diff --git a/src/app/Router.php b/src/app/Router.php new file mode 100644 index 0000000..9bfe09d --- /dev/null +++ b/src/app/Router.php @@ -0,0 +1,56 @@ +routes[] = ['GET', $path, $controller, $method]; + } + + public function post(string $path, string $controller, string $method): void + { + $this->routes[] = ['POST', $path, $controller, $method]; + } + + public function dispatch(): void + { + $requestMethod = $_SERVER['REQUEST_METHOD']; + $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); + $uri = rtrim($uri, '/') ?: '/'; + + foreach ($this->routes as [$method, $path, $controller, $action]) { + if ($method !== $requestMethod) { + continue; + } + + // Convert route placeholders to named regex capture groups. + // Example with route '/post/{id}' and request '/post/42': + // 1. preg_replace turns '{id}' into '(?P[^/]+)' + // result: '#^/post/(?P[^/]+)$#' + // 2. preg_match against '/post/42' produces: + // [0 => '/post/42', 'id' => '42', 1 => '42'] + // 3. array_filter keeps only string keys: ['id' => '42'] + // 4. PostController->show('42') is called + $pattern = preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $path); + $pattern = '#^' . $pattern . '$#'; + + if (preg_match($pattern, $uri, $matches)) { + $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); + $controllerClass = "App\\Controllers\\$controller"; + $instance = new $controllerClass(); + $instance->$action(...array_values($params)); + return; + } + } + + http_response_code(404); + echo '404 Not Found'; + } +} diff --git a/src/app/Views/home/index.php b/src/app/Views/home/index.php new file mode 100644 index 0000000..f5cde93 --- /dev/null +++ b/src/app/Views/home/index.php @@ -0,0 +1,3 @@ + +

Welcome to Camagru

+

Database status:

diff --git a/src/app/Views/layouts/main.php b/src/app/Views/layouts/main.php new file mode 100644 index 0000000..b4c7dad --- /dev/null +++ b/src/app/Views/layouts/main.php @@ -0,0 +1,35 @@ + + + + + + + Camagru + + + +
+ +
+
+ +
+
+

Camagru ©

+
+ + + diff --git a/src/app/bootstrap.php b/src/app/bootstrap.php new file mode 100644 index 0000000..835615b --- /dev/null +++ b/src/app/bootstrap.php @@ -0,0 +1,42 @@ +get('/', 'HomeController', 'index'); diff --git a/src/public/assets/overlays/.gitkeep b/src/public/assets/overlays/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/public/css/style.css b/src/public/css/style.css new file mode 100644 index 0000000..5677e91 --- /dev/null +++ b/src/public/css/style.css @@ -0,0 +1,64 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + min-height: 100vh; + display: flex; + flex-direction: column; + color: #333; + background: #fafafa; +} + +header { + background: #fff; + border-bottom: 1px solid #dbdbdb; + padding: 0.75rem 1rem; +} + +header nav { + max-width: 960px; + margin: 0 auto; + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + font-size: 1.5rem; + font-weight: bold; + text-decoration: none; + color: #333; +} + +.nav-links a { + margin-left: 1rem; + text-decoration: none; + color: #0095f6; +} + +main { + flex: 1; + max-width: 960px; + margin: 2rem auto; + padding: 0 1rem; + width: 100%; +} + +footer { + text-align: center; + padding: 1rem; + color: #999; + font-size: 0.85rem; + border-top: 1px solid #dbdbdb; +} + +@media (max-width: 600px) { + header nav { + flex-direction: column; + gap: 0.5rem; + } +} diff --git a/src/public/index.php b/src/public/index.php new file mode 100644 index 0000000..2709bc0 --- /dev/null +++ b/src/public/index.php @@ -0,0 +1,11 @@ +dispatch(); diff --git a/src/public/js/app.js b/src/public/js/app.js new file mode 100644 index 0000000..86b3c33 --- /dev/null +++ b/src/public/js/app.js @@ -0,0 +1,3 @@ +document.addEventListener('DOMContentLoaded', function () { + // App initialization +}); diff --git a/src/uploads/.gitkeep b/src/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 -- cgit v1.2.3