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..6f7b2b78 --- /dev/null +++ b/src/cache.php @@ -0,0 +1,171 @@ + $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; +} diff --git a/src/index.php b/src/index.php index 610543ad..be1d5101 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) {