Downloads Project

October 01, 2026 12:16am

Serving files safely: randomized storage, a closed folder, and a download handler that keeps the original filenames.

Overview

The Downloads module is where game mods, tools and other files I've made over the years can be shared: a filterable list at /downloads, a page per file with its description, version, size and a screenshot gallery, and a download button.

It's a small module, but it was the first one on the site to deal with uploaded files, not just database rows. That meant thinking about where files live, how they're served, and what happens when one is removed. It later became the template for the Photography module.

Technical Stack

Area Technologies
Backend PHP 8.3, OOP (DownloadRepo / DownloadRender)
Database MySQL 8.0 via PDO, two-connection model
File delivery A PHP download handler streaming from a folder that denies direct HTTP access
Frontend Server-rendered grid, shared lightbox for screenshots

Architecture & Design

  • new_horta_downloads: name, slug, short and long description, version, the original filename, the stored filename and the file size.
  • new_horta_downloads_screenshot: any number of screenshots per download, with a sort order and one marked as the cover.
  • new_horta_downloads_category: a bridge to the site-wide categories used by regular articles, instead of a module-specific lookup table. A mod can be filed under the same categories as the projects it belongs to.

The list query picks each download's cover with a correlated subquery ordered by is_cover DESC, sort_order, id. A download without an explicit cover simply shows its first screenshot.

Key Features

  • Filterable, paginated list (category and free-text search) with cover images and category names, and a Share button for the current filters.
  • Detail page with the description, version, original filename, a human-readable size and a screenshot gallery in a lightbox.
  • Admin CRUD: upload or replace the file, manage screenshots (add, remove, choose the cover) and tag categories.
  • SEO-friendly URLs (/downloads/{slug}) with slug collision handling.

Challenges & Solutions

Challenge Solution
Guessable file URLs Files are stored under a random name (random_bytes(16) in hex, plus the original extension) in a folder that refuses direct HTTP access.
Keeping the original filename Every download goes through a small handler that looks the file up by id and sends it with Content-Disposition: attachment and the original name. The user gets my-mod-v2.zip, not a random hex string.
Stale links to removed files The handler only serves enabled downloads. Disabling one in the admin immediately stops old direct links from working, no file deletion needed.
Replacing a file The update only touches the file columns when a new file was actually uploaded, so editing the text never loses the attached file.

Security & Best Practices

  1. No direct file access: the storage folder is closed to HTTP, and the handler is the only way in.
  2. Path safety: the served path is built from the stored name in the database, never from the request, and the outgoing filename goes through basename().
  3. Least privilege and prepared statements, as everywhere else on the site: public reads use the read-only user, and admin writes are parameterized.
  4. CSRF protection on every admin form and delete link.

Results & Learnings

Separating what the user sees (original filename, pretty URL) from what the server stores (random name, closed folder) turned out to be the key decision. It solved guessable URLs, filename collisions and stale links at once, and made the module a clean template for Photos, the next module to handle uploads.

The download handler is like a librarian's desk in front of a closed archive: you ask for a title, the librarian checks it's still on loan, fetches it from the shelf with the odd catalogue number, and hands it over with its proper cover.

Code Snippets

1. The download handler

$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
$repo = new DownloadRepo();
$download = $id ? $repo->getDownload($id) : null;

if (!$download) {
    http_response_code(404);
    include __DIR__ . '/404.php';
    exit;
}

$path = __DIR__ . '/files/downloads/' . $download['stored_filename'];
if (!is_file($path)) {
    http_response_code(404);
    include __DIR__ . '/404.php';
    exit;
}

header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($download['original_filename']) . '"');
header('Content-Length: ' . filesize($path));
readfile($path);

2. Picking the cover screenshot

SELECT d.*,
       GROUP_CONCAT(DISTINCT c.name ORDER BY c.name SEPARATOR ', ') AS categories,
       (SELECT s.image FROM new_horta_downloads_screenshot s
        WHERE s.download_id = d.id
        ORDER BY s.is_cover DESC, s.sort_order ASC, s.id ASC
        LIMIT 1) AS cover_image
FROM new_horta_downloads d
LEFT JOIN new_horta_downloads_category dc ON dc.download_id = d.id
LEFT JOIN new_horta_categories c ON c.id = dc.category_id
WHERE d.enabled = 1
GROUP BY d.id
ORDER BY d.created_at DESC

go to downloads