Skip to content
Open
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
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);
return hash("sha256", $user . $optionsString);
}

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

$fileAge = time() - filemtime($filePath);
if ($fileAge > $maxAge) {
// Cache expired, delete the file
if (file_exists($filePath)) {
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 (file_exists($file) && 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 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;
}
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;
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);

renderOutput($stats);
}
renderOutput($stats);
} catch (InvalidArgumentException | AssertionError $error) {
error_log("Error {$error->getCode()}: {$error->getMessage()}");
if ($error->getCode() >= 500) {
Expand Down