Skip to content

Commit dfcdd61

Browse files
committed
feat: Implement caching for GitHub contribution stats with a 24-hour expiration
Signed-off-by: Michele Palazzi <[email protected]>
1 parent ae980ab commit dfcdd61

File tree

4 files changed

+227
-27
lines changed

4 files changed

+227
-27
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ yarn.lock
77
package-lock.json
88
.vercel
99

10+
# Cache directory
11+
cache/
12+
1013
# Local Configuration
1114
.DS_Store
1215

Dockerfile

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,30 +28,33 @@ RUN composer install --no-dev --optimize-autoloader --no-scripts
2828
# Configure Apache to serve from src/ directory and pass environment variables
2929
RUN a2enmod rewrite headers && \
3030
echo 'ServerTokens Prod\n\
31-
ServerSignature Off\n\
32-
PassEnv TOKEN\n\
33-
PassEnv WHITELIST\n\
34-
<VirtualHost *:80>\n\
31+
ServerSignature Off\n\
32+
PassEnv TOKEN\n\
33+
PassEnv WHITELIST\n\
34+
<VirtualHost *:80>\n\
3535
ServerAdmin webmaster@localhost\n\
3636
DocumentRoot /var/www/html/src\n\
3737
<Directory /var/www/html/src>\n\
38-
Options -Indexes\n\
39-
AllowOverride None\n\
40-
Require all granted\n\
41-
Header always set Access-Control-Allow-Origin "*"\n\
42-
Header always set Content-Type "image/svg+xml" "expr=%{REQUEST_URI} =~ m#\\.svg$#i"\n\
43-
Header always set Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:;" "expr=%{REQUEST_URI} =~ m#\\.svg$#i"\n\
44-
Header always set Referrer-Policy "no-referrer-when-downgrade"\n\
45-
Header always set X-Content-Type-Options "nosniff"\n\
38+
Options -Indexes\n\
39+
AllowOverride None\n\
40+
Require all granted\n\
41+
Header always set Access-Control-Allow-Origin "*"\n\
42+
Header always set Content-Type "image/svg+xml" "expr=%{REQUEST_URI} =~ m#\\.svg$#i"\n\
43+
Header always set Content-Security-Policy "default-src 'none'; style-src 'unsafe-inline'; img-src data:;" "expr=%{REQUEST_URI} =~ m#\\.svg$#i"\n\
44+
Header always set Referrer-Policy "no-referrer-when-downgrade"\n\
45+
Header always set X-Content-Type-Options "nosniff"\n\
4646
</Directory>\n\
4747
ErrorLog ${APACHE_LOG_DIR}/error.log\n\
4848
CustomLog ${APACHE_LOG_DIR}/access.log combined\n\
49-
</VirtualHost>' > /etc/apache2/sites-available/000-default.conf
49+
</VirtualHost>' > /etc/apache2/sites-available/000-default.conf
5050

51-
# Set secure permissions
51+
RUN mkdir -p /var/www/html/cache
52+
53+
# Set secure permissions (cache dir needs write access for www-data)
5254
RUN chown -R www-data:www-data /var/www/html && \
5355
find /var/www/html -type d -exec chmod 755 {} \; && \
54-
find /var/www/html -type f -exec chmod 644 {} \;
56+
find /var/www/html -type f -exec chmod 644 {} \; && \
57+
chmod 775 /var/www/html/cache
5558

5659
# Health check
5760
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \

src/cache.php

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* Simple file-based cache for GitHub contribution stats
7+
*
8+
* Caches stats for 24 hours to avoid repeated API calls
9+
*/
10+
11+
// Default cache duration: 24 hours (in seconds)
12+
define('CACHE_DURATION', 24 * 60 * 60);
13+
define('CACHE_DIR', __DIR__ . '/../cache');
14+
15+
/**
16+
* Generate a cache key for a user's request
17+
*
18+
* @param string $user GitHub username
19+
* @param array $options Additional options that affect the stats (mode, exclude_days, starting_year)
20+
* @return string Cache key (filename-safe)
21+
*/
22+
function getCacheKey(string $user, array $options = []): string
23+
{
24+
// Normalize options
25+
ksort($options);
26+
$optionsString = json_encode($options);
27+
return md5($user . $optionsString);
28+
}
29+
30+
/**
31+
* Get the cache file path for a given key
32+
*
33+
* @param string $key Cache key
34+
* @return string Full path to cache file
35+
*/
36+
function getCacheFilePath(string $key): string
37+
{
38+
return CACHE_DIR . '/' . $key . '.json';
39+
}
40+
41+
/**
42+
* Ensure the cache directory exists
43+
*
44+
* @return bool True if directory exists or was created
45+
*/
46+
function ensureCacheDir(): bool
47+
{
48+
if (!is_dir(CACHE_DIR)) {
49+
return mkdir(CACHE_DIR, 0755, true);
50+
}
51+
return true;
52+
}
53+
54+
/**
55+
* Get cached stats if available and not expired
56+
*
57+
* @param string $user GitHub username
58+
* @param array $options Additional options
59+
* @param int $maxAge Maximum age in seconds (default: 24 hours)
60+
* @return array|null Cached stats array or null if not cached/expired
61+
*/
62+
function getCachedStats(string $user, array $options = [], int $maxAge = CACHE_DURATION): ?array
63+
{
64+
$key = getCacheKey($user, $options);
65+
$filePath = getCacheFilePath($key);
66+
67+
if (!file_exists($filePath)) {
68+
return null;
69+
}
70+
71+
$fileAge = time() - filemtime($filePath);
72+
if ($fileAge > $maxAge) {
73+
// Cache expired, delete the file
74+
@unlink($filePath);
75+
return null;
76+
}
77+
78+
$contents = @file_get_contents($filePath);
79+
if ($contents === false) {
80+
return null;
81+
}
82+
83+
$data = json_decode($contents, true);
84+
if (!is_array($data)) {
85+
return null;
86+
}
87+
88+
return $data;
89+
}
90+
91+
/**
92+
* Save stats to cache
93+
*
94+
* @param string $user GitHub username
95+
* @param array $options Additional options
96+
* @param array $stats Stats array to cache
97+
* @return bool True if successfully cached
98+
*/
99+
function setCachedStats(string $user, array $options, array $stats): bool
100+
{
101+
if (!ensureCacheDir()) {
102+
return false;
103+
}
104+
105+
$key = getCacheKey($user, $options);
106+
$filePath = getCacheFilePath($key);
107+
108+
$data = json_encode($stats, JSON_PRETTY_PRINT);
109+
if ($data === false) {
110+
return false;
111+
}
112+
113+
return file_put_contents($filePath, $data, LOCK_EX) !== false;
114+
}
115+
116+
/**
117+
* Clear all expired cache files
118+
*
119+
* @param int $maxAge Maximum age in seconds
120+
* @return int Number of files deleted
121+
*/
122+
function clearExpiredCache(int $maxAge = CACHE_DURATION): int
123+
{
124+
if (!is_dir(CACHE_DIR)) {
125+
return 0;
126+
}
127+
128+
$deleted = 0;
129+
$files = glob(CACHE_DIR . '/*.json');
130+
131+
if ($files === false) {
132+
return 0;
133+
}
134+
135+
foreach ($files as $file) {
136+
$fileAge = time() - filemtime($file);
137+
if ($fileAge > $maxAge) {
138+
if (@unlink($file)) {
139+
$deleted++;
140+
}
141+
}
142+
}
143+
144+
return $deleted;
145+
}
146+
147+
/**
148+
* Clear cache for a specific user
149+
*
150+
* @param string $user GitHub username
151+
* @return bool True if cache was cleared
152+
*/
153+
function clearUserCache(string $user): bool
154+
{
155+
if (!is_dir(CACHE_DIR)) {
156+
return true;
157+
}
158+
159+
// Since we use md5 hash, we need to check all files
160+
// For simplicity, just clear the cache with empty options
161+
$key = getCacheKey($user, []);
162+
$filePath = getCacheFilePath($key);
163+
164+
if (file_exists($filePath)) {
165+
return @unlink($filePath);
166+
}
167+
168+
return true;
169+
}

src/index.php

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require_once "../vendor/autoload.php";
77
require_once "stats.php";
88
require_once "card.php";
9+
require_once "cache.php";
910

1011
// load .env
1112
$dotenv = \Dotenv\Dotenv::createImmutable(dirname(__DIR__, 1));
@@ -19,11 +20,11 @@
1920
renderOutput($message, 500);
2021
}
2122

22-
// set cache to refresh once per three horus
23-
$cacheMinutes = 3 * 60 * 60;
24-
header("Expires: " . gmdate("D, d M Y H:i:s", time() + $cacheMinutes) . " GMT");
23+
// set cache to refresh once per day (24 hours)
24+
$cacheSeconds = 24 * 60 * 60;
25+
header("Expires: " . gmdate("D, d M Y H:i:s", time() + $cacheSeconds) . " GMT");
2526
header("Last-Modified: " . gmdate("D, d M Y H:i:s") . " GMT");
26-
header("Cache-Control: public, max-age=$cacheMinutes");
27+
header("Cache-Control: public, max-age=$cacheSeconds");
2728

2829
// redirect to demo site if user is not given
2930
if (!isset($_REQUEST["user"])) {
@@ -35,16 +36,40 @@
3536
// get streak stats for user given in query string
3637
$user = preg_replace("/[^a-zA-Z0-9\-]/", "", $_REQUEST["user"]);
3738
$startingYear = isset($_REQUEST["starting_year"]) ? intval($_REQUEST["starting_year"]) : null;
38-
$contributionGraphs = getContributionGraphs($user, $startingYear);
39-
$contributions = getContributionDates($contributionGraphs);
40-
if (isset($_GET["mode"]) && $_GET["mode"] === "weekly") {
41-
$stats = getWeeklyContributionStats($contributions);
39+
$mode = isset($_GET["mode"]) ? $_GET["mode"] : null;
40+
$excludeDaysRaw = $_GET["exclude_days"] ?? "";
41+
42+
// Build cache options based on request parameters
43+
$cacheOptions = [
44+
'starting_year' => $startingYear,
45+
'mode' => $mode,
46+
'exclude_days' => $excludeDaysRaw,
47+
];
48+
49+
// Check for cached stats first (24 hour cache)
50+
$cachedStats = getCachedStats($user, $cacheOptions);
51+
52+
if ($cachedStats !== null) {
53+
// Use cached stats - instant response!
54+
renderOutput($cachedStats);
4255
} else {
43-
// split and normalize excluded days
44-
$excludeDays = normalizeDays(explode(",", $_GET["exclude_days"] ?? ""));
45-
$stats = getContributionStats($contributions, $excludeDays);
56+
// Fetch fresh data from GitHub API
57+
$contributionGraphs = getContributionGraphs($user, $startingYear);
58+
$contributions = getContributionDates($contributionGraphs);
59+
60+
if ($mode === "weekly") {
61+
$stats = getWeeklyContributionStats($contributions);
62+
} else {
63+
// split and normalize excluded days
64+
$excludeDays = normalizeDays(explode(",", $excludeDaysRaw));
65+
$stats = getContributionStats($contributions, $excludeDays);
66+
}
67+
68+
// Cache the stats for 24 hours
69+
setCachedStats($user, $cacheOptions, $stats);
70+
71+
renderOutput($stats);
4672
}
47-
renderOutput($stats);
4873
} catch (InvalidArgumentException | AssertionError $error) {
4974
error_log("Error {$error->getCode()}: {$error->getMessage()}");
5075
if ($error->getCode() >= 500) {

0 commit comments

Comments
 (0)