Projects

HackerChat

A browser chat with end-to-end encryption, in which the server that relays messages sees only ciphertext.

I built this version in February 2023 with Next.js 13, React, TypeScript and MongoDB, as a rewrite of a 2022 version. In March 2026 I moved it to Node.js 24 and fixed the cookie flags, a wrong JWT payload and several React hooks bugs.

Passwords are hashed with scrypt, using a random 8-byte salt and a 64-byte output. Signing up or in returns a JWT that expires after seven days, set in an HttpOnly, SameSite=Strict cookie that is marked Secure in production. On load the page calls /api/auth/currentuser, which verifies the token. MongoDB holds usernames and password hashes and nothing else.

When the page loads, the browser generates an ECDH key pair on the P-384 curve with the Web Crypto API. Two users who open a chat swap public keys through the relay, and each derives the same 256-bit AES-GCM key. Every message gets a fresh random 12-byte IV and is encrypted before it leaves the browser. The relay is a small Node.js ws server from the older matej-basic/hackerchat repository. It keeps connected users and open chats in memory and forwards ciphertext; no message is written to a database.

Limits

The key pair lasts until the tab is reloaded or closed, and every chat in that time reuses it. Public keys are not authenticated and there is no fingerprint to compare, so a malicious relay could swap them and read the traffic. The relay also accepts whatever username a client announces, because the WebSocket connection carries no token, and it prints each relayed ciphertext to its log.

Infoeduka down detector

A status page for student.algebra.hr, the Infoeduka student portal at Algebra, with 30 days of availability history.

I built this in May 2026 with Next.js 15 and deployed it on Vercel. A check is a GET request to https://student.algebra.hr/ with a 10-second timeout. Any 2xx answer counts as up; any other status, or no answer, counts as down. The response time is recorded either way.

Checks come from two places. A Vercel cron job calls /api/cron once a day at 06:00 UTC, the most often the Hobby plan allows, and the endpoint rejects calls that lack the cron secret. The first version ran every five minutes. Anyone on the page can also press “Check now”, which runs the same check at once and stores it like a scheduled one.

Results go to Upstash Redis as one record per UTC day: the number of checks, the number that succeeded and the total response time. Each day’s record expires after 35 days. The page reads the last 30 of them and shows the latest result, 30-day uptime, average response time, the number of days with at least one failed check, and a coloured bar for each day.

An outage that starts and ends between two checks leaves no trace.

Root Cause

A capture-the-flag game built around six real CVEs from 2021 and 2022, with GitHub sign-in, flag submission and a points leaderboard.

Two colleagues and I built the challenges between November 2022 and January 2023 for a university penetration-testing course. Each one is a vulnerable app around a single published CVE, packaged as a Docker image with a Kubernetes manifest so students can attack it in a sandboxed lab. Six are finished:

  • CVE-2021-44966, an SQL injection that bypasses the login of PHPGURUKUL Employee Record Management System
  • CVE-2022-0847, Dirty Pipe, a Linux kernel privilege escalation reached over SSH
  • CVE-2022-1271, arbitrary file write through zgrep
  • CVE-2022-22965, Spring4Shell, in a Spring MVC app on Tomcat
  • CVE-2022-37454, a buffer overflow in SHA-3 (Keccak)
  • CVE-2022-42889, Text4Shell in Apache Commons Text

In October 2026 I put a hosted platform in front of them. The catalog shows each challenge’s CVE, category, difficulty and points, from 100 for the ERMS login bypass to 500 for Dirty Pipe, with a short brief taken from the challenge’s write-up. There is a page to submit a flag, a leaderboard, and a profile that lists your solves and when you made each one. The front end is Next.js with Tailwind on Vercel. The API is Fastify with Prisma and Postgres on Railway.

Sign-in is GitHub OAuth, and no passwords are stored. The API runs the whole OAuth exchange: when GitHub sends the user back, it creates or updates the user, signs a JWT that expires after about seven days and redirects to the web app with the token in the URL fragment, which the browser never sends to a server. From then on the web app sends the token as a bearer header. Nothing depends on cookies, so the web app and the API would work the same on unrelated domains.

Every challenge has one flag, and the database keeps only its SHA-256 hash. The API hashes the trimmed submission and compares the two; a submission is recorded as right or wrong, without its text. The leaderboard ranks players by points, and on a tie the one who reached that score first comes higher.

WoT shooting stats

A replay parser and web app that tracks how often each player's shots hit and penetrate, per battle and across battles.

I built this in January 2026 for World of Tanks replays. In September 2026 I closed the API down: renaming and deleting a battle need an ADMIN_TOKEN, and those two routes answer 404 where no token is configured, /docs is off unless ENABLE_DOCS=1, CORS allows the front end and localhost rather than any origin, and an upload has to end in .wotreplay and stay under 20 MB. A .wotreplay file starts with a 4-byte magic number and a little-endian block count, followed by blocks that each open with a 4-byte length. The first block is JSON metadata: map, server, client version and the recording player. The second is a JSON array whose first element lists each player’s name, team and clan and each vehicle’s shots, hits, penetrations and damage. After the blocks comes the binary stream that replays the battle, which none of the code decodes.

Two command line tools sit at the repository root and the backend imports neither. event_parser.py walks the blocks by their length prefix, prints the layout and pulls the recording player’s shot statistics out of the JSON; shots.py prints those statistics from the text of the first line. The backend reads the file as UTF-8 with errors ignored, keeps the first line, drops every character outside codes 34 to 127 and decodes the JSON objects that remain. Spaces fall outside that range, so a battle on El Halluf is listed as ElHalluf.

For each player it computes accuracy (hits per shot), penetration rate (penetrations per hit) and penetrations per shot. Vehicle names come from Wargaming’s encyclopedia API. The backend is FastAPI over MySQL; besides clans, users, vehicles and battles, the schema has one row per player per battle with the four counts and three percentages. It returns team averages for a battle, and a player’s totals per vehicle and per battle, optionally between two dates.

The Next.js front end has three tabs: an upload form for several replays, a sortable battle table with a team filter, and a player list filtered by clan and date. Both halves have a Dockerfile, and a docker-compose.yml at the root brings up MySQL, the backend and the front end for local work. The front end runs on Vercel, the backend on Railway.

Limits

The database does not store the battle’s arenaUniqueID, so a battle uploaded by two of its players counts twice in every total.