1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
|
# 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 <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:
```sh
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](https://mailpit.axl.dev/) — nothing is actually sent. Open **http://localhost:8025** to view them.
## 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
## 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 (`<`, `>`, 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.
### 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.
### 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
```
|