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..5fa5d189 --- /dev/null +++ b/src/cache.php @@ -0,0 +1,209 @@ + $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; +} diff --git a/src/index.php b/src/index.php index 610543ad..bbaaf473 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 = 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"])) { @@ -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()}"); diff --git a/tests/CacheTest.php b/tests/CacheTest.php new file mode 100644 index 00000000..030998b8 --- /dev/null +++ b/tests/CacheTest.php @@ -0,0 +1,165 @@ + "weekly"]); + $key2 = getCacheKey("testuser", ["mode" => "weekly"]); + $this->assertEquals($key1, $key2, "Same inputs should produce same cache key"); + } + + /** + * Test cache key generation produces different results for different inputs + */ + public function testCacheKeyDifferentInputs(): void + { + $key1 = getCacheKey("user1", []); + $key2 = getCacheKey("user2", []); + $this->assertNotEquals($key1, $key2, "Different users should produce different cache keys"); + + $key3 = getCacheKey("testuser", ["mode" => "weekly"]); + $key4 = getCacheKey("testuser", ["mode" => "daily"]); + $this->assertNotEquals($key3, $key4, "Different options should produce different cache keys"); + } + + /** + * Test that cache key prevents hash collisions + * e.g., user "ab" with options containing "cd" should not collide with user "abc" with options containing "d" + */ + public function testCacheKeyNoCollisions(): void + { + // This tests the fix for the hash collision vulnerability + $key1 = getCacheKey("ab", ["option" => "cd"]); + $key2 = getCacheKey("abc", ["option" => "d"]); + $this->assertNotEquals($key1, $key2, "Similar concatenated strings should not produce same hash"); + + $key3 = getCacheKey("ab", ["x" => "cd"]); + $key4 = getCacheKey("abcd", []); + $this->assertNotEquals($key3, $key4, "User + options should not collide with user alone"); + } + + /** + * Test cache key generation sorts options for consistency + */ + public function testCacheKeyOptionOrdering(): void + { + $key1 = getCacheKey("testuser", ["a" => "1", "b" => "2"]); + $key2 = getCacheKey("testuser", ["b" => "2", "a" => "1"]); + $this->assertEquals($key1, $key2, "Option order should not affect cache key"); + } + + /** + * Test cache key is filename-safe (SHA256 hex) + */ + public function testCacheKeyFormat(): void + { + $key = getCacheKey("testuser", ["mode" => "weekly"]); + $this->assertMatchesRegularExpression("/^[a-f0-9]{64}$/", $key, "Cache key should be 64-character hex string"); + } + + /** + * Test cache file path generation + */ + public function testGetCacheFilePath(): void + { + $key = "abc123"; + $path = getCacheFilePath($key); + $this->assertStringEndsWith("/cache/abc123.json", $path); + } + + /** + * Test setCachedStats and getCachedStats roundtrip + */ + public function testCacheRoundtrip(): void + { + $user = "roundtripuser"; + $options = ["mode" => "weekly", "starting_year" => 2020]; + $stats = [ + "totalContributions" => 100, + "currentStreak" => ["start" => "2024-01-01", "end" => "2024-01-10", "length" => 10], + "longestStreak" => ["start" => "2023-06-01", "end" => "2023-07-15", "length" => 45], + "firstContribution" => "2020-01-15", + ]; + + // Write to cache + $result = setCachedStats($user, $options, $stats); + $this->assertTrue($result, "setCachedStats should return true on success"); + + // Read back from cache + $cached = getCachedStats($user, $options); + $this->assertNotNull($cached, "getCachedStats should return cached data"); + $this->assertEquals($stats, $cached, "Cached data should match original"); + } + + /** + * Test getCachedStats returns null for non-existent cache + */ + public function testGetCachedStatsNotFound(): void + { + $result = getCachedStats("nonexistentuser12345", []); + $this->assertNull($result, "getCachedStats should return null for non-existent cache"); + } + + /** + * Test setCachedStats handles invalid data gracefully + */ + public function testSetCachedStatsWithEmptyStats(): void + { + $result = setCachedStats("emptyuser", [], []); + $this->assertTrue($result, "setCachedStats should handle empty stats array"); + + $cached = getCachedStats("emptyuser", []); + $this->assertEquals([], $cached, "Empty stats should be cached and retrieved"); + } + + /** + * Test clearUserCache clears cache for user with default options + */ + public function testClearUserCache(): void + { + $user = "clearableuser"; + $stats = ["totalContributions" => 50]; + + // Set cache + setCachedStats($user, [], $stats); + $this->assertNotNull(getCachedStats($user, [])); + + // Clear cache + $result = clearUserCache($user); + $this->assertTrue($result); + + // Verify cleared + $this->assertNull(getCachedStats($user, [])); + } + + /** + * Test clearUserCache returns true for non-existent user + */ + public function testClearUserCacheNonExistent(): void + { + $result = clearUserCache("definitelynotauser999"); + $this->assertTrue($result, "clearUserCache should return true for non-existent cache"); + } + + /** + * Test ensureCacheDir creates directory + */ + public function testEnsureCacheDir(): void + { + $result = ensureCacheDir(); + $this->assertTrue($result); + $this->assertTrue(is_dir(CACHE_DIR)); + } +}