From dfcdd616fe52d0a7121cfe2f64a8a409529a99f2 Mon Sep 17 00:00:00 2001 From: Michele Palazzi Date: Mon, 15 Dec 2025 21:46:53 +0100 Subject: [PATCH 1/4] feat: Implement caching for GitHub contribution stats with a 24-hour expiration Signed-off-by: Michele Palazzi --- .gitignore | 3 + Dockerfile | 33 +++++----- src/cache.php | 169 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/index.php | 49 +++++++++++---- 4 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 src/cache.php diff --git a/.gitignore b/.gitignore index ea2ef0ba..bcccddfd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ yarn.lock package-lock.json .vercel +# Cache directory +cache/ + # Local Configuration .DS_Store diff --git a/Dockerfile b/Dockerfile index 911749da..0191e9f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,30 +28,33 @@ RUN composer install --no-dev --optimize-autoloader --no-scripts # Configure Apache to serve from src/ directory and pass environment variables RUN a2enmod rewrite headers && \ echo 'ServerTokens Prod\n\ -ServerSignature Off\n\ -PassEnv TOKEN\n\ -PassEnv WHITELIST\n\ -\n\ + ServerSignature Off\n\ + PassEnv TOKEN\n\ + PassEnv WHITELIST\n\ + \n\ ServerAdmin webmaster@localhost\n\ DocumentRoot /var/www/html/src\n\ \n\ - Options -Indexes\n\ - AllowOverride None\n\ - Require all granted\n\ - Header always set Access-Control-Allow-Origin "*"\n\ - Header always set Content-Type "image/svg+xml" "expr=%{REQUEST_URI} =~ m#\\.svg$#i"\n\ - Header always set Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:;" "expr=%{REQUEST_URI} =~ m#\\.svg$#i"\n\ - Header always set Referrer-Policy "no-referrer-when-downgrade"\n\ - Header always set X-Content-Type-Options "nosniff"\n\ + Options -Indexes\n\ + AllowOverride None\n\ + Require all granted\n\ + Header always set Access-Control-Allow-Origin "*"\n\ + Header always set Content-Type "image/svg+xml" "expr=%{REQUEST_URI} =~ m#\\.svg$#i"\n\ + Header always set Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:;" "expr=%{REQUEST_URI} =~ m#\\.svg$#i"\n\ + Header always set Referrer-Policy "no-referrer-when-downgrade"\n\ + Header always set X-Content-Type-Options "nosniff"\n\ \n\ ErrorLog ${APACHE_LOG_DIR}/error.log\n\ CustomLog ${APACHE_LOG_DIR}/access.log combined\n\ -' > /etc/apache2/sites-available/000-default.conf + ' > /etc/apache2/sites-available/000-default.conf -# Set secure permissions +RUN mkdir -p /var/www/html/cache + +# Set secure permissions (cache dir needs write access for www-data) RUN chown -R www-data:www-data /var/www/html && \ find /var/www/html -type d -exec chmod 755 {} \; && \ - find /var/www/html -type f -exec chmod 644 {} \; + find /var/www/html -type f -exec chmod 644 {} \; && \ + chmod 775 /var/www/html/cache # Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ diff --git a/src/cache.php b/src/cache.php new file mode 100644 index 00000000..c833d320 --- /dev/null +++ b/src/cache.php @@ -0,0 +1,169 @@ + $maxAge) { + // Cache expired, delete the file + @unlink($filePath); + return null; + } + + $contents = @file_get_contents($filePath); + if ($contents === false) { + return null; + } + + $data = json_decode($contents, true); + if (!is_array($data)) { + return null; + } + + return $data; +} + +/** + * Save stats to cache + * + * @param string $user GitHub username + * @param array $options Additional options + * @param array $stats Stats array to cache + * @return bool True if successfully cached + */ +function setCachedStats(string $user, array $options, array $stats): bool +{ + if (!ensureCacheDir()) { + return false; + } + + $key = getCacheKey($user, $options); + $filePath = getCacheFilePath($key); + + $data = json_encode($stats, JSON_PRETTY_PRINT); + if ($data === false) { + return false; + } + + return file_put_contents($filePath, $data, LOCK_EX) !== false; +} + +/** + * Clear all expired cache files + * + * @param int $maxAge Maximum age in seconds + * @return int Number of files deleted + */ +function clearExpiredCache(int $maxAge = CACHE_DURATION): int +{ + if (!is_dir(CACHE_DIR)) { + return 0; + } + + $deleted = 0; + $files = glob(CACHE_DIR . '/*.json'); + + if ($files === false) { + return 0; + } + + foreach ($files as $file) { + $fileAge = time() - filemtime($file); + if ($fileAge > $maxAge) { + if (@unlink($file)) { + $deleted++; + } + } + } + + return $deleted; +} + +/** + * Clear cache for a specific user + * + * @param string $user GitHub username + * @return bool True if cache was cleared + */ +function clearUserCache(string $user): bool +{ + if (!is_dir(CACHE_DIR)) { + return true; + } + + // Since we use md5 hash, we need to check all files + // For simplicity, just clear the cache with empty options + $key = getCacheKey($user, []); + $filePath = getCacheFilePath($key); + + if (file_exists($filePath)) { + return @unlink($filePath); + } + + return true; +} diff --git a/src/index.php b/src/index.php index 610543ad..59068521 100644 --- a/src/index.php +++ b/src/index.php @@ -6,6 +6,7 @@ require_once "../vendor/autoload.php"; require_once "stats.php"; require_once "card.php"; +require_once "cache.php"; // load .env $dotenv = \Dotenv\Dotenv::createImmutable(dirname(__DIR__, 1)); @@ -19,11 +20,11 @@ renderOutput($message, 500); } -// set cache to refresh once per three horus -$cacheMinutes = 3 * 60 * 60; -header("Expires: " . gmdate("D, d M Y H:i:s", time() + $cacheMinutes) . " GMT"); +// set cache to refresh once per day (24 hours) +$cacheSeconds = 24 * 60 * 60; +header("Expires: " . gmdate("D, d M Y H:i:s", time() + $cacheSeconds) . " GMT"); header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT"); -header("Cache-Control: public, max-age=$cacheMinutes"); +header("Cache-Control: public, max-age=$cacheSeconds"); // redirect to demo site if user is not given if (!isset($_REQUEST["user"])) { @@ -35,16 +36,40 @@ // get streak stats for user given in query string $user = preg_replace("/[^a-zA-Z0-9\-]/", "", $_REQUEST["user"]); $startingYear = isset($_REQUEST["starting_year"]) ? intval($_REQUEST["starting_year"]) : null; - $contributionGraphs = getContributionGraphs($user, $startingYear); - $contributions = getContributionDates($contributionGraphs); - if (isset($_GET["mode"]) && $_GET["mode"] === "weekly") { - $stats = getWeeklyContributionStats($contributions); + $mode = isset($_GET["mode"]) ? $_GET["mode"] : null; + $excludeDaysRaw = $_GET["exclude_days"] ?? ""; + + // Build cache options based on request parameters + $cacheOptions = [ + 'starting_year' => $startingYear, + 'mode' => $mode, + 'exclude_days' => $excludeDaysRaw, + ]; + + // Check for cached stats first (24 hour cache) + $cachedStats = getCachedStats($user, $cacheOptions); + + if ($cachedStats !== null) { + // Use cached stats - instant response! + renderOutput($cachedStats); } else { - // split and normalize excluded days - $excludeDays = normalizeDays(explode(",", $_GET["exclude_days"] ?? "")); - $stats = getContributionStats($contributions, $excludeDays); + // Fetch fresh data from GitHub API + $contributionGraphs = getContributionGraphs($user, $startingYear); + $contributions = getContributionDates($contributionGraphs); + + if ($mode === "weekly") { + $stats = getWeeklyContributionStats($contributions); + } else { + // split and normalize excluded days + $excludeDays = normalizeDays(explode(",", $excludeDaysRaw)); + $stats = getContributionStats($contributions, $excludeDays); + } + + // Cache the stats for 24 hours + setCachedStats($user, $cacheOptions, $stats); + + renderOutput($stats); } - renderOutput($stats); } catch (InvalidArgumentException | AssertionError $error) { error_log("Error {$error->getCode()}: {$error->getMessage()}"); if ($error->getCode() >= 500) { From 13e83a080f2ad6b293354786bf46ce1c7c73757d Mon Sep 17 00:00:00 2001 From: Michele Palazzi Date: Tue, 23 Dec 2025 17:28:55 +0100 Subject: [PATCH 2/4] refactor: Improve cache key generation and file handling in cache functions Signed-off-by: Michele Palazzi --- src/cache.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/cache.php b/src/cache.php index c833d320..b182a9da 100644 --- a/src/cache.php +++ b/src/cache.php @@ -24,7 +24,7 @@ function getCacheKey(string $user, array $options = []): string // Normalize options ksort($options); $optionsString = json_encode($options); - return md5($user . $optionsString); + return hash('sha256', $user . $optionsString); } /** @@ -71,11 +71,13 @@ function getCachedStats(string $user, array $options = [], int $maxAge = CACHE_D $fileAge = time() - filemtime($filePath); if ($fileAge > $maxAge) { // Cache expired, delete the file - @unlink($filePath); + if (file_exists($filePath)) { + unlink($filePath); + } return null; } - $contents = @file_get_contents($filePath); + $contents = file_get_contents($filePath); if ($contents === false) { return null; } @@ -135,7 +137,7 @@ function clearExpiredCache(int $maxAge = CACHE_DURATION): int foreach ($files as $file) { $fileAge = time() - filemtime($file); if ($fileAge > $maxAge) { - if (@unlink($file)) { + if (file_exists($file) && unlink($file)) { $deleted++; } } @@ -156,13 +158,13 @@ function clearUserCache(string $user): bool return true; } - // Since we use md5 hash, we need to check all files + // Since we use a hash, we need to check all files // For simplicity, just clear the cache with empty options $key = getCacheKey($user, []); $filePath = getCacheFilePath($key); if (file_exists($filePath)) { - return @unlink($filePath); + return unlink($filePath); } return true; From 197824b6e15130da9de54accacda47ce839f4867 Mon Sep 17 00:00:00 2001 From: Michele Palazzi Date: Tue, 23 Dec 2025 17:34:41 +0100 Subject: [PATCH 3/4] style: run prettier Signed-off-by: Michele Palazzi --- src/cache.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/cache.php b/src/cache.php index b182a9da..6f7b2b78 100644 --- a/src/cache.php +++ b/src/cache.php @@ -9,8 +9,8 @@ */ // Default cache duration: 24 hours (in seconds) -define('CACHE_DURATION', 24 * 60 * 60); -define('CACHE_DIR', __DIR__ . '/../cache'); +define("CACHE_DURATION", 24 * 60 * 60); +define("CACHE_DIR", __DIR__ . "/../cache"); /** * Generate a cache key for a user's request @@ -24,7 +24,7 @@ function getCacheKey(string $user, array $options = []): string // Normalize options ksort($options); $optionsString = json_encode($options); - return hash('sha256', $user . $optionsString); + return hash("sha256", $user . $optionsString); } /** @@ -35,7 +35,7 @@ function getCacheKey(string $user, array $options = []): string */ function getCacheFilePath(string $key): string { - return CACHE_DIR . '/' . $key . '.json'; + return CACHE_DIR . "/" . $key . ".json"; } /** @@ -128,7 +128,7 @@ function clearExpiredCache(int $maxAge = CACHE_DURATION): int } $deleted = 0; - $files = glob(CACHE_DIR . '/*.json'); + $files = glob(CACHE_DIR . "/*.json"); if ($files === false) { return 0; From 0f7a62fe7e7dc1f89dcf64f34c1febefd9d327b5 Mon Sep 17 00:00:00 2001 From: Michele Palazzi Date: Tue, 23 Dec 2025 17:39:13 +0100 Subject: [PATCH 4/4] style: format code with prettier --- src/index.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.php b/src/index.php index 59068521..be1d5101 100644 --- a/src/index.php +++ b/src/index.php @@ -41,9 +41,9 @@ // Build cache options based on request parameters $cacheOptions = [ - 'starting_year' => $startingYear, - 'mode' => $mode, - 'exclude_days' => $excludeDaysRaw, + "starting_year" => $startingYear, + "mode" => $mode, + "exclude_days" => $excludeDaysRaw, ]; // Check for cached stats first (24 hour cache)