Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ yarn.lock
package-lock.json
.vercel

# Cache directory
cache/

# Local Configuration
.DS_Store

Expand Down
33 changes: 18 additions & 15 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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\
<VirtualHost *:80>\n\
ServerSignature Off\n\
PassEnv TOKEN\n\
PassEnv WHITELIST\n\
<VirtualHost *:80>\n\
ServerAdmin webmaster@localhost\n\
DocumentRoot /var/www/html/src\n\
<Directory /var/www/html/src>\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\
</Directory>\n\
ErrorLog ${APACHE_LOG_DIR}/error.log\n\
CustomLog ${APACHE_LOG_DIR}/access.log combined\n\
</VirtualHost>' > /etc/apache2/sites-available/000-default.conf
</VirtualHost>' > /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 \
Expand Down
171 changes: 171 additions & 0 deletions src/cache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php

declare(strict_types=1);

/**
* Simple file-based cache for GitHub contribution stats
*
* Caches stats for 24 hours to avoid repeated API calls
*/

// Default cache duration: 24 hours (in seconds)
define("CACHE_DURATION", 24 * 60 * 60);
define("CACHE_DIR", __DIR__ . "/../cache");

/**
* Generate a cache key for a user's request
*
* @param string $user GitHub username
* @param array $options Additional options that affect the stats (mode, exclude_days, starting_year)
* @return string Cache key (filename-safe)
*/
function getCacheKey(string $user, array $options = []): string
{
// Normalize options
ksort($options);
$optionsString = json_encode($options);
Comment thread
ironashram marked this conversation as resolved.
Outdated
return hash("sha256", $user . $optionsString);
Comment thread
ironashram marked this conversation as resolved.
Outdated
}

/**
* Get the cache file path for a given key
*
* @param string $key Cache key
* @return string Full path to cache file
*/
function getCacheFilePath(string $key): string
{
return CACHE_DIR . "/" . $key . ".json";
}

/**
* Ensure the cache directory exists
*
* @return bool True if directory exists or was created
*/
function ensureCacheDir(): bool
{
if (!is_dir(CACHE_DIR)) {
return mkdir(CACHE_DIR, 0755, true);
}
return true;
}
Comment thread
DenverCoder1 marked this conversation as resolved.

/**
* Get cached stats if available and not expired
*
* @param string $user GitHub username
* @param array $options Additional options
* @param int $maxAge Maximum age in seconds (default: 24 hours)
* @return array|null Cached stats array or null if not cached/expired
*/
function getCachedStats(string $user, array $options = [], int $maxAge = CACHE_DURATION): ?array
{
$key = getCacheKey($user, $options);
$filePath = getCacheFilePath($key);

if (!file_exists($filePath)) {
return null;
}

$fileAge = time() - filemtime($filePath);
if ($fileAge > $maxAge) {
// Cache expired, delete the file
if (file_exists($filePath)) {
unlink($filePath);
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Race condition: There's a TOCTOU (Time-Of-Check-Time-Of-Use) issue here. Between checking if the file exists at line 74 and unlinking it at line 75, another process could have already deleted the file, causing a warning. Consider wrapping the unlink in a try-catch or using the error suppression operator (@Unlink) to handle this gracefully, or simply remove the redundant file_exists check since unlink already returns false if the file doesn't exist.

Suggested change
// Cache expired, delete the file
if (file_exists($filePath)) {
unlink($filePath);
}
// Cache expired, delete the file (ignore errors if it was already removed)
@unlink($filePath);

Copilot uses AI. Check for mistakes.
return null;
}
Comment thread
ironashram marked this conversation as resolved.
Outdated

$contents = file_get_contents($filePath);
Comment thread
ironashram marked this conversation as resolved.
Outdated
if ($contents === false) {
return null;
}

$data = json_decode($contents, true);
if (!is_array($data)) {
return null;
}

return $data;
Comment thread
ironashram marked this conversation as resolved.
Outdated
}

/**
* 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);
Comment thread
ironashram marked this conversation as resolved.
Outdated
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 (file_exists($file) && unlink($file)) {
$deleted++;
}
Comment thread
ironashram marked this conversation as resolved.
Outdated
}
}

return $deleted;
}
Comment thread
DenverCoder1 marked this conversation as resolved.

/**
* 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 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 true;
}
Comment thread
ironashram marked this conversation as resolved.
Comment thread
ironashram marked this conversation as resolved.
49 changes: 37 additions & 12 deletions src/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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;
Comment thread
ironashram marked this conversation as resolved.
Outdated
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"])) {
Expand All @@ -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);
Comment thread
ironashram marked this conversation as resolved.

renderOutput($stats);
Comment thread
ironashram marked this conversation as resolved.
Outdated
}
Comment thread
DenverCoder1 marked this conversation as resolved.
renderOutput($stats);
} catch (InvalidArgumentException | AssertionError $error) {
error_log("Error {$error->getCode()}: {$error->getMessage()}");
if ($error->getCode() >= 500) {
Expand Down