00 / Architecture
How this site is built
I work in a SOC, so this site doubles as a statement of intent: if I could not secure my own website, I would have little to offer securing anyone else's. What follows are the decisions behind it and the reason for each one — not a list of technologies.
Stack at a glance
- Frontend
- Astro 7 (SSG) · Tailwind CSS · TypeScript
- Backend
- PHP 8 (PDO) · MySQL / MariaDB · sin framework
- Infrastructure
- CDMON · Apache · Cloudflare · TLS
- Languages
- Español · English · Català
01 / Frontend attack surface
The site compiles to plain HTML, so most classic server-side risk disappears. What remains is closed off at the header level.
-
CSP with hashes recalculated on every build
script-src carries the SHA-256 of every script on the site and does not include ‘unsafe-inline’. An injected <script> will not run: it cannot carry a hash the policy does not already know. The hashes are generated at build time, not maintained by hand.
astro.config.mjs -
One-year HSTS, deliberately without preload
includeSubDomains is on. It is deliberately kept out of the preload list: getting in is easy and getting out takes months, and for a portfolio that trade-off is not worth it.
server/.htaccess -
Document isolation
X-Frame-Options DENY against clickjacking, Cross-Origin-Opener-Policy and Cross-Origin-Resource-Policy set to same-origin, and a Permissions-Policy that switches off geolocation, microphone and camera even though nothing requests them.
server/.htaccess -
No third parties
No CDN, no Google Fonts, no external analytics tags. Typefaces are self-hosted with a Latin subset. Every domain removed is one less third party that can go down, swap the file it serves, or log the visitor IP.
02 / Admin panel
The part with access to the database and to uploads. This is where the details actually matter.
-
Real prepared statements, not emulated
PDO with ATTR_EMULATE_PREPARES set to false. With emulation on, the driver interpolates values client-side before sending them, and that interpolation is exactly where quote-escaping breakouts have historically lived in certain character sets.
server/db.php -
Login that does not reveal whether a user exists
For a non-existent user, a dummy hash is verified anyway. Without that step the response comes back sooner when the user does not exist, and the timing lets you enumerate valid accounts. Passwords use bcrypt and are transparently rehashed if the cost goes up.
server/admin/auth.php -
Constant-time CSRF, with the empty case covered
The token is compared with hash_equals. It also requires the session token to exist and be non-empty: without that condition, a request with no cookie created a fresh session with an empty token, and comparing two empty strings returned true, letting the request through.
server/admin/auth.php -
Hardened sessions with a double expiry
Session ID regenerated after login (session fixation), HttpOnly, Secure and SameSite=Lax cookies, use_strict_mode so nobody can impose their own ID, and expiry both on idle time and on absolute maximum duration.
server/admin/auth.php -
Per-IP brute-force lockout
Failed attempts are counted within a time window and lock the IP once the threshold is passed. The IP comes from REMOTE_ADDR unless a trusted proxy is explicitly declared: trusting X-Forwarded-For with no proxy in front lets anyone spoof their IP and skip the limit entirely.
server/lib/http.php
03 / Content I do not control
Images uploaded through the panel and messages written by strangers: anything arriving from outside is treated as hostile until proven otherwise.
-
Images are validated by content, not by extension
First the real MIME type via finfo, then getimagesize(), which only recognises genuine images. A polyglot with a forged GIF header passes the first check but not the second. There is also a dimension cap so nobody can take the server down with a 30000 px image.
server/lib/upload.php -
The uploads folder does not execute code
Defence in depth: even if validation failed and a .php slipped through, that folder’s .htaccess strips the handlers and switches the engine off, so it would be served as plain text. Files are stored under a random name, never the one the client sent.
server/uploads/.htaccess -
The contact form cannot become a spam relay
Line breaks and control characters are stripped before any value touches an email header. Without that, a name containing CRLF lets you inject headers (a Bcc:, for instance) and use the form to send mail to third parties.
server/api/contact.php -
CSV exports without formula injection
Excel and LibreOffice execute any cell starting with =, +, - or @ as a formula. Since the inbox exports text written by strangers, those cells are prefixed so they are treated as text rather than code when the file is opened.
server/admin/partials/layout.php
04 / First-party analytics and privacy
Knowing how many people visit does not require profiling anyone. Analytics are first-party and store the bare minimum.
-
The IP address is never stored
A SHA-256 of the IP with a server-side salt is stored instead. It is enough to count approximate unique visitors and to throttle floods, but it cannot be reversed to the address or cross-referenced against another database.
server/api/visit.php -
No tracking cookies, and Do Not Track honoured
There is no consent banner because there is nothing to consent to. If the browser sends the Do Not Track signal, the visit is not recorded at all.
-
Query strings are dropped from the referrer
Only scheme, host and path are kept. Query parameters from someone else’s URL often carry session or campaign identifiers, and there is no reason for those to end up in my database.
server/api/visit.php -
Bounded retention with automatic purging
Visits are deleted after 400 days, login attempts after 90, and the audit log after a year. Purging is built into the request flow itself, with no cron job that nobody remembers to check.
server/api/visit.php
05 / Technical SEO
A portfolio nobody finds is useless. Ranking here is a question of structure, not of repeating keywords.
-
A single structured-data graph
Person, WebSite, ProfilePage, BreadcrumbList and FAQPage travel in one JSON-LD block linked by @id, rather than as separate blocks that can contradict each other. Each blog article additionally emits its own BlogPosting.
src/components/Seo.astro -
410 Gone instead of 404 for withdrawn pages
Two projects were withdrawn with their URLs already indexed. A 404 tells the crawler the page might come back, so it keeps retrying for weeks; a 410 says the removal is deliberate and permanent, and gets it out of the index far sooner.
server/.htaccess -
Reciprocal hreflang, and only where a translation exists
Every translated page declares all its siblings plus an x-default. Pages that exist in one language only declare none: announcing an alternate that does not exist breaks the whole group, and Google then discards all of it.
src/pages/sitemap.xml.ts -
Local SEO with consistent signals
Badalona and Barcelona appear in the title, the description, the geo tags and inside the Person block via address, homeLocation and areaServed. The signal counts when it is consistent everywhere, not when it is repeated in one place.
src/config.ts
06 / Readable by language models
People increasingly ask an assistant instead of a search engine. If an AI is going to answer questions about me, I would rather it read structured facts than improvise.
-
llms.txt generated on every build
A plain-text summary following the llmstxt.org convention, with no HTML or noise. It is generated from the same sources that feed the site, so it cannot drift out of sync with what is on screen.
src/pages/llms.txt.ts -
An explicit policy for AI crawlers
GPTBot, ClaudeBot, PerplexityBot, Google-Extended and Applebot are explicitly allowed. Commercial SEO crawlers, which only burn shared-hosting bandwidth, are blocked. It is a decision taken, not the result of never touching the file.
public/robots.txt -
Vulnerability disclosure via RFC 9116
A /.well-known/security.txt with contact, languages and an expiry date. It is the first thing someone looks for when they find a flaw and want to report it properly; not having one is the usual reason a report ends up in a generic form or nowhere at all.
public/.well-known/security.txt -
Static analysis on every push
CodeQL covers the TypeScript and JavaScript frontend. Since CodeQL has no PHP analyser, the backend is reviewed by Semgrep with injection, XSS and secret-detection rules, and both sets of findings land in the same tab as SARIF. Dependabot watches dependencies weekly.
.github/workflows/codeql.yml
Every point on this page maps to code you can open in the repository. If something does not add up, the channel to tell me is in /.well-known/security.txt
Performance metrics (PageSpeed Insights)
* Measured against the CDMON production server with HTTPS and TLS enabled. It is a snapshot of one moment, not a standing guarantee: check it yourself if it matters to you.