aboutsummaryrefslogtreecommitdiffstats

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

# 1. Clone the repository
git clone <repo-url> && cd camagru

# 2. Create your environment file
cp .env.example .env
# Edit .env with your own values (see "Environment variables" below)

# 3. (Optional) Install dev tools — only needed for linting, not for running the app
composer install

# 4. Build and run
docker-compose up --build

# 5. Open in browser
# App:   http://localhost:8080
# Email: http://localhost:8025  (MailPit — catches all outgoing emails)

Environment variables

Copy .env.example to .env and fill in the values. Since it contains secrets (database passwords, app secret), restrict read access to your user only:

cp .env.example .env
chmod 600 .env   # owner-only read/write — prevents other users on the host from reading secrets
Variable Purpose
MYSQL_ROOT_PASSWORD MariaDB root password
MYSQL_DATABASE Database name (default: camagru)
MYSQL_USER Database user for the app
MYSQL_PASSWORD Database user password
APP_URL Base URL of the app (default: http://localhost:8080) — used in email links
APP_SECRET Random string used for generating verification/reset tokens
MAIL_FROM Sender address for outgoing emails

Email in development

All outgoing emails (verification, password reset, comment notifications) are caught by MailPit — nothing is actually sent. Open http://localhost:8025 to view them.

Host setup (Manjaro)

phpactor (LSP) requires the iconv extension. Install and enable it:

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

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 included 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

Security concepts

CSRF (Cross-Site Request Forgery)

Imagine you're logged into Camagru. A malicious website could contain a hidden form that submits a request to camagru.local/delete-account — and your browser would automatically send your session cookie along with it, so the server thinks you made that request. This is a CSRF attack: the attacker forges a request that rides on your existing session.

How tokens prevent it: every form on Camagru includes a hidden field with a random token that was generated server-side and stored in the user's session. When the form is submitted, the server checks that the token in the request matches the one in the session. The attacker's malicious site can't know this token (it's unique per session and never exposed in a URL), so their forged request will be rejected.

In the code, App\Csrf handles this: - Csrf::generate() creates a random token (via random_bytes()) and stores it in $_SESSION - Csrf::field() outputs a hidden <input> to embed in forms - Csrf::validate() checks the submitted token against the session using hash_equals() — a constant-time comparison that prevents timing attacks (where an attacker measures response times to guess the token character by character)

Password hashing

Passwords are never stored in plain text. PHP's password_hash() uses bcrypt by default, which: 1. Generates a random salt (so two users with the same password get different hashes) 2. Runs the password through a deliberately slow hashing algorithm (to make brute-force attacks impractical) 3. Produces a string that embeds the algorithm, cost factor, salt, and hash — so password_verify() can check a password without needing to know the salt separately

Prepared statements

SQL injection happens when user input is concatenated directly into a query: "SELECT * FROM users WHERE name = '$name'" — if $name is ' OR 1=1 --, the attacker gets all users. Prepared statements separate the query structure from the data: the database compiles the query first, then plugs in the values, so user input can never be interpreted as SQL. PDO's prepare() + execute() handle this.

XSS (Cross-Site Scripting)

If user-generated content (usernames, comments) is rendered directly into HTML, an attacker could inject <script>alert('hacked')</script> and it would execute in other users' browsers. htmlspecialchars() escapes characters like <, >, &, " into their HTML entities (&lt;, &gt;, etc.) so the browser displays them as text instead of interpreting them as code. Every piece of user data rendered in a view must be escaped this way.

Sessions

HTTP is stateless — the server doesn't inherently know that two requests come from the same person. PHP sessions solve this: session_start() generates a unique session ID, stores it in a cookie on the client, and creates a server-side data store ($_SESSION) keyed by that ID. On every request, the browser sends the cookie, PHP looks up the matching session data, and the server knows who you are. This is how login state, CSRF tokens, and flash messages persist across page loads.

Session fixation

A session fixation attack works like this: the attacker visits the site and gets a session ID (say abc123). They then trick the victim into using that same session ID — for example by sending a link like http://camagru.local/?PHPSESSID=abc123. If the victim clicks the link, logs in, and the server keeps using abc123, the attacker now has a valid logged-in session because they already know the ID.

session_regenerate_id(true) prevents this by generating a brand-new session ID the moment a user logs in. The old ID (abc123) is destroyed and becomes useless. Even if the attacker planted it, they can't use it after the victim authenticates — the real session now lives under a new, unknown ID.

The session ID is stored in a cookie, and by default PHP sends that cookie with no special flags — meaning JavaScript can read it, it's sent over plain HTTP, and it's attached to cross-origin requests. Each of these opens an attack surface. Camagru configures three flags via session_set_cookie_params() in bootstrap.php:

  • httponly — Tells the browser that this cookie is off-limits to JavaScript. Without this flag, a successful XSS attack (e.g. injected <script> in a comment) could run document.cookie to steal the session ID and send it to the attacker's server. With httponly, the cookie still gets sent with HTTP requests normally, but document.cookie simply won't include it — so even if XSS executes, it can't exfiltrate the session.

  • secure — Tells the browser to only send this cookie over HTTPS connections. Without this flag, if a user visits the site over plain HTTP (or an attacker downgrades the connection), the session cookie is transmitted in cleartext and can be intercepted by anyone on the same network (coffee shop Wi-Fi, for example). Since Camagru runs on HTTP in local development but might use HTTPS in production, this flag is set dynamically: the bootstrap checks $_SERVER['HTTPS'] and the X-Forwarded-Proto header (set by reverse proxies like load balancers that terminate TLS) to detect whether the current request arrived over HTTPS.

  • samesite=Lax — Controls when the browser attaches the cookie to cross-origin requests. With Strict, the cookie is never sent on cross-origin requests — but this breaks legitimate flows like clicking a link to Camagru from an email (you'd arrive logged out). Lax is the practical middle ground: the cookie is sent for top-level navigations (clicking links) but not for cross-origin form submissions or AJAX requests. This means an attacker's site can't trigger a POST to Camagru that rides on your session — the browser simply won't attach the cookie. This complements CSRF tokens: SameSite stops the browser from sending the cookie in the first place, while CSRF tokens catch anything that gets through.

Security headers

Nginx adds HTTP response headers that instruct the browser to enforce additional security policies. These are defense-in-depth — they don't fix server-side vulnerabilities, but they limit what an attacker can do if they find one:

  • X-Content-Type-Options: nosniff — Browsers sometimes try to be "helpful" by guessing the content type of a response (MIME sniffing). For example, if an attacker uploads a file that looks like HTML, the browser might render it as a web page even if the server says it's an image. nosniff tells the browser to trust the Content-Type header and never guess — a response served as image/jpeg will always be treated as an image.

  • X-Frame-Options: DENY — Prevents any website from embedding Camagru inside an <iframe>. Without this, an attacker could overlay invisible Camagru pages on top of a decoy site and trick users into clicking buttons they can't see (clickjacking). For example, the "delete post" button could be positioned exactly where the attacker's "Click to win a prize" button appears. DENY means no site (not even Camagru itself) can frame the page.

  • Content-Security-Policy — The most powerful header. It whitelists exactly which sources the browser is allowed to load resources from. Camagru's policy is:

  • default-src 'self' — Only load resources (scripts, fonts, etc.) from Camagru's own origin by default
  • style-src 'self' 'unsafe-inline' — Allow stylesheets from the same origin plus inline style attributes (needed for dynamic UI)
  • img-src 'self' blob: data: — Allow images from the same origin, blob: URLs (webcam snapshots), and data: URLs (base64 previews)
  • media-src 'self' blob: — Allow media (webcam video stream) from the same origin and blob: URLs

If an attacker manages to inject HTML into the page, CSP prevents them from loading external scripts (<script src="https://evil.com/steal.js">) because evil.com is not in the whitelist. The browser simply refuses to fetch it.

User enumeration

When a login fails, our error message says "Invalid username or password" rather than "Username not found" or "Wrong password". Similarly, the forgot-password form always says "If that email is registered, a reset link has been sent." This is deliberate: if the server told you which part was wrong, an attacker could probe the system to build a list of valid usernames or emails, then target those accounts specifically with brute-force or phishing attacks.

Rate limiting (brute-force protection)

Without rate limiting, an attacker can try thousands of passwords per second against a login form (a brute-force attack), or flood the password reset endpoint to spam a victim's inbox. Even strong passwords can be cracked given unlimited attempts — an 8-character password has a finite number of combinations, and automated tools can cycle through common passwords and variations very quickly.

Camagru tracks attempts per IP address using a rate_limits database table. Each row records which IP performed which action and when. Before processing a login or password reset request, the RateLimiter class counts how many attempts that IP has made within a sliding time window. If the count exceeds the threshold, the request is rejected immediately — the password isn't even checked.

Limits enforced: - Login: 5 failed attempts per IP per 15 minutes. Only failed attempts are recorded — a successful login doesn't count against you. This means a legitimate user who knows their password is never blocked, but an attacker guessing passwords hits the wall after 5 tries. - Password reset: 3 requests per IP per 15 minutes. Unlike login, every attempt is recorded (not just failures), because the reset endpoint doesn't reveal whether the email exists. If only "successful" resets counted, an attacker could probe for valid emails by watching which requests get rate-limited and which don't.

Why per-IP and not per-username? If rate limiting were per-username, an attacker could lock out any user by deliberately failing logins against their account (a denial-of-service). Per-IP limiting means only the attacker's own IP gets blocked — the real user can still log in from their own network. The trade-off is that a distributed attack from many IPs can bypass this, but that level of attack is beyond the scope of a school project and would require infrastructure-level solutions (WAF, fail2ban, etc.).

Table cleanup: to prevent the rate_limits table from growing indefinitely, old entries are purged on every check. Each call to isLimited() first deletes rows older than the time window, so only recent attempts are kept. This is a simple approach that works well for low-traffic applications — high-traffic production systems would typically use an in-memory store like Redis instead of a database table.

Upload security (preventing unwanted content)

A classic web vulnerability is allowing users to upload arbitrary files — an attacker might upload a .php script disguised as an image and then request it directly to execute server-side code. Camagru prevents this with multiple layers:

  1. No direct file uploads. Users don't upload files via a traditional <input type="file"> form submission. Instead, images (from webcam or file picker) are read client-side into a base64 data URL and sent as JSON. There's no $_FILES handling or move_uploaded_file() — so the usual file upload attack vector doesn't apply.

  2. GD re-encoding. The base64 data is decoded and passed to imagecreatefromstring(), which parses it as an actual image. If the data isn't a valid image (JPEG, PNG, GIF, etc.), GD rejects it and returns false. Even if a valid image contains embedded malicious data (e.g. PHP code hidden in EXIF metadata or a polyglot file), the image is then re-encoded through imagejpeg() — this produces a clean JPEG from pixel data only, stripping any non-image content.

  3. Size limit. The server rejects base64 payloads larger than 10 MB before any processing, preventing memory exhaustion from oversized uploads.

  4. Server-controlled filenames. Output filenames are generated server-side ({user_id}_{timestamp}_{random}.jpg) — the user has no control over the filename or extension, so they can't trick the server into creating a .php file.

  5. Front controller architecture. Nginx routes all requests to index.php. Even if an attacker somehow placed a malicious file in the uploads directory, Nginx wouldn't execute it as PHP — only the front controller entry point is configured for PHP processing.

  6. Storage capacity limits. Without limits, a malicious user could fill up the server's disk — either from one account or by creating many accounts. Two caps are enforced before any image processing begins: - Per-user: 50 posts maximum (~3.2 MB per user at ~65 KB/image) - Site-wide: 10,000 posts total (~650 MB on disk)

The per-user limit prevents a single account from monopolizing storage. The site-wide limit is the real safety net — it caps total disk usage regardless of how many accounts exist. Both are checked before image processing starts, so hitting the limit doesn't waste server CPU.

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