Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
209 changes: 209 additions & 0 deletions src/cache.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<?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
*
* Uses structured JSON format to prevent hash collisions between different
* user/options combinations that could produce the same concatenated string.
*
* @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
{
ksort($options);
try {
$keyData = json_encode(["user" => $user, "options" => $options], JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
// Fallback to simple concatenation if JSON encoding fails
error_log("Cache key JSON encoding failed: " . $e->getMessage());
$keyData = $user . serialize($options);
}
return hash("sha256", $keyData);
}

/**
* 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;
}

/**
* 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;
}

$mtime = filemtime($filePath);
if ($mtime === false) {
return null;
}

$fileAge = time() - $mtime;
if ($fileAge > $maxAge) {
unlink($filePath);
return null;
}

$handle = fopen($filePath, "r");
if ($handle === false) {
return null;
}

if (!flock($handle, LOCK_SH)) {
fclose($handle);
return null;
}

$contents = stream_get_contents($handle);
flock($handle, LOCK_UN);
fclose($handle);

if ($contents === false || $contents === "") {
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()) {
error_log("Failed to create cache directory: " . CACHE_DIR);
return false;
}

$key = getCacheKey($user, $options);
$filePath = getCacheFilePath($key);

$data = json_encode($stats);
if ($data === false) {
error_log("Failed to encode stats to JSON for user: " . $user);
return false;
}

$result = file_put_contents($filePath, $data, LOCK_EX);
if ($result === false) {
error_log("Failed to write cache file: " . $filePath);
return false;
}

return true;
}

/**
* 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) {
$mtime = filemtime($file);
if ($mtime === false) {
continue;
}
$fileAge = time() - $mtime;
if ($fileAge > $maxAge) {
if (unlink($file)) {
$deleted++;
}
}
}

return $deleted;
}

/**
* Clear cache for a specific user
*
* Note: This function only clears the cache for the user with empty/default options.
* Cache entries with non-empty options (starting_year, mode, exclude_days) will NOT
* be cleared. This is a limitation of the hash-based cache key system - we cannot
* enumerate all possible option combinations without storing additional metadata.
*
* @param string $user GitHub username
* @return bool True if cache was cleared (or didn't exist)
*/
function clearUserCache(string $user): bool
{
if (!is_dir(CACHE_DIR)) {
return true;
}

$key = getCacheKey($user, []);
$filePath = getCacheFilePath($key);

if (file_exists($filePath)) {
return unlink($filePath);
}

return true;
}
47 changes: 36 additions & 11 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 = CACHE_DURATION;
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,15 +36,39 @@
// 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!
$stats = $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);
} catch (InvalidArgumentException | AssertionError $error) {
error_log("Error {$error->getCode()}: {$error->getMessage()}");
Expand Down
Loading