From ad4dc6b984d0b7f99f49f851eb5fc4dbf94f0e0d Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Mon, 5 Oct 2026 17:07:48 -0400 Subject: [PATCH] Initial commit: RSS feed dashboard aggregating YouTube, Reddit and RT - PHP 8 + PDO SQLite cache with on-demand TTL refresh and dedup by link hash - laminas-feed + Guzzle for RSS/Atom parsing and HTTP - Bootstrap 5 UI with source and time-range filters, AJAX load-more - User cron-free deployment under Apache/php-fpm; .htaccess hardening --- .gitignore | 7 + .htaccess | 9 + api/items.php | 44 ++ assets/app.js | 124 ++++++ assets/style.css | 439 ++++++++++++++++++++ composer.json | 22 + composer.lock | 914 +++++++++++++++++++++++++++++++++++++++++ config/.htaccess | 1 + config/app.php | 38 ++ config/feeds.php | 64 +++ index.php | 35 ++ src/.htaccess | 1 + src/Database.php | 65 +++ src/FeedFetcher.php | 303 ++++++++++++++ src/FeedRepository.php | 207 ++++++++++ src/FeedService.php | 114 +++++ src/View.php | 103 +++++ src/bootstrap.php | 30 ++ templates/.htaccess | 1 + templates/cards.php | 49 +++ templates/layout.php | 162 ++++++++ 21 files changed, 2732 insertions(+) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 api/items.php create mode 100644 assets/app.js create mode 100644 assets/style.css create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/.htaccess create mode 100644 config/app.php create mode 100644 config/feeds.php create mode 100644 index.php create mode 100644 src/.htaccess create mode 100644 src/Database.php create mode 100644 src/FeedFetcher.php create mode 100644 src/FeedRepository.php create mode 100644 src/FeedService.php create mode 100644 src/View.php create mode 100644 src/bootstrap.php create mode 100644 templates/.htaccess create mode 100644 templates/cards.php create mode 100644 templates/layout.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5b0c3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/vendor/ +/data/ +/composer.phar +composer-setup.php +*.sqlite +*.sqlite-shm +*.sqlite-wal diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..a12272e --- /dev/null +++ b/.htaccess @@ -0,0 +1,9 @@ +DirectoryIndex index.php index.html + +# Prevent directory listing if index.php is unavailable. +Options -Indexes + +# Never expose Composer or project metadata over HTTP. + + Require all denied + diff --git a/api/items.php b/api/items.php new file mode 100644 index 0000000..a8f117d --- /dev/null +++ b/api/items.php @@ -0,0 +1,44 @@ + $app */ +$app = require dirname(__DIR__) . '/src/bootstrap.php'; + +$config = $app['config']; +$service = $app['service']; + +// Respect the TTL so long-lived pages stay fresh without hammering providers. +$refresh = $service->refreshStale(); + +$sources = $service->sources(); +$source = $_GET['source'] ?? null; +if (!is_string($source) || !in_array($source, $sources, true)) { + $source = null; +} + +$ranges = $config['ranges'] ?? []; +$range = $_GET['range'] ?? ''; +if (!is_string($range) || !array_key_exists($range, $ranges)) { + $range = ''; +} +$since = $range !== '' ? gmdate('c', time() - (int) $ranges[$range]) : null; + +$pageNumber = max(1, (int) ($_GET['page'] ?? 1)); +$page = $service->page($source, $pageNumber, (int) $config['page_size'], $since); + +$html = App\View::render('cards', ['items' => $page['items']]); + +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: no-store'); + +echo json_encode([ + 'html' => $html, + 'page' => $page['page'], + 'page_size' => $page['page_size'], + 'count' => count($page['items']), + 'total' => $page['total'], + 'has_more' => $page['has_more'], + 'range' => $range, + 'refresh' => $refresh, +], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..c8b8058 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,124 @@ +(function () { + 'use strict'; + + var feed = document.getElementById('feed'); + if (!feed) { + return; + } + + var loadMoreBtn = document.getElementById('load-more'); + var emptyState = document.getElementById('feed-empty'); + var spinner = loadMoreBtn ? loadMoreBtn.querySelector('.spinner-border') : null; + + var apiUrl = feed.dataset.api; + var source = feed.dataset.source || ''; + var range = feed.dataset.range || ''; + + var currentPage = parseInt(feed.dataset.page, 10) || 1; + var hasMore = feed.dataset.hasMore === '1'; + var loading = false; + + function timeAgo(iso) { + var ts = Date.parse(iso); + if (isNaN(ts)) { + return ''; + } + var diff = Math.floor((Date.now() - ts) / 1000); + if (diff < 60) { + return 'just now'; + } + var units = [ + [31536000, 'y'], [2592000, 'mo'], [604800, 'w'], + [86400, 'd'], [3600, 'h'], [60, 'm'] + ]; + for (var i = 0; i < units.length; i++) { + if (diff >= units[i][0]) { + return Math.floor(diff / units[i][0]) + units[i][1] + ' ago'; + } + } + return 'just now'; + } + + function refreshTimes() { + document.querySelectorAll('time[data-timestamp]').forEach(function (el) { + var text = timeAgo(el.dataset.timestamp); + if (text) { + el.textContent = text; + } + }); + } + + function setLoading(isLoading) { + if (!loadMoreBtn) { + return; + } + loadMoreBtn.disabled = isLoading; + if (spinner) { + spinner.classList.toggle('d-none', !isLoading); + } + } + + function updateButton() { + if (!loadMoreBtn) { + return; + } + loadMoreBtn.classList.toggle('d-none', !hasMore); + } + + function loadMore() { + if (loading || !hasMore) { + return; + } + loading = true; + setLoading(true); + + var params = new URLSearchParams({ page: String(currentPage + 1) }); + if (source) { + params.set('source', source); + } + if (range) { + params.set('range', range); + } + + fetch(apiUrl + '?' + params.toString(), { + headers: { 'Accept': 'application/json' } + }) + .then(function (response) { + if (!response.ok) { + throw new Error('HTTP ' + response.status); + } + return response.json(); + }) + .then(function (data) { + if (data.html) { + feed.insertAdjacentHTML('beforeend', data.html); + } + + currentPage = data.page; + hasMore = Boolean(data.has_more); + feed.dataset.page = String(currentPage); + feed.dataset.hasMore = hasMore ? '1' : '0'; + + if (emptyState) { + emptyState.classList.toggle('d-none', data.total > 0); + } + + refreshTimes(); + updateButton(); + }) + .catch(function (error) { + console.error('Failed to load more items:', error); + }) + .finally(function () { + loading = false; + setLoading(false); + }); + } + + if (loadMoreBtn) { + loadMoreBtn.addEventListener('click', loadMore); + } + + refreshTimes(); + window.setInterval(refreshTimes, 60000); +})(); diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..d924428 --- /dev/null +++ b/assets/style.css @@ -0,0 +1,439 @@ +:root { + --ink: #160b2e; + --ink-soft: #5b5178; + --bg: #f4f1ff; + --surface: #ffffff; + --line: #e6dffb; + + --accent: #6a3dff; + --youtube: #ff1f4b; + --reddit: #ff6a00; + --rt: #00c389; + --sun: #ffc24b; + + --display: "Space Grotesk", system-ui, sans-serif; + --body: "Plus Jakarta Sans", system-ui, -apple-system, sans-serif; + --mono: "JetBrains Mono", ui-monospace, monospace; +} + +/* ---------- Shell ---------- */ + +.app-body { + position: relative; + min-height: 100vh; + background-color: var(--bg); + color: var(--ink); + font-family: var(--body); + -webkit-font-smoothing: antialiased; +} + +.app-body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background: + radial-gradient(42rem 30rem at 6% -8%, rgba(106, 61, 255, 0.18), transparent 62%), + radial-gradient(36rem 26rem at 98% -4%, rgba(255, 31, 75, 0.14), transparent 60%), + radial-gradient(34rem 26rem at 52% 112%, rgba(0, 195, 137, 0.16), transparent 62%), + radial-gradient(30rem 22rem at 82% 78%, rgba(255, 106, 0, 0.12), transparent 60%); +} + +.site-header, +main { + position: relative; + z-index: 1; +} + +/* ---------- Header ---------- */ + +.site-header { + position: sticky; + top: 0; + z-index: 20; + background: rgba(255, 255, 255, 0.78); + border-bottom: 1px solid var(--line); + backdrop-filter: saturate(180%) blur(16px); + -webkit-backdrop-filter: saturate(180%) blur(16px); +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.65rem; + text-decoration: none; +} + +.brand-mark { + width: 26px; + height: 26px; + border-radius: 9px; + background: conic-gradient(from 220deg, var(--accent), var(--youtube), var(--reddit), var(--rt), var(--accent)); + box-shadow: 0 6px 16px rgba(106, 61, 255, 0.4); +} + +.brand-text { + font-family: var(--display); + font-weight: 700; + font-size: 1.12rem; + letter-spacing: -0.01em; + color: var(--ink); +} + +.brand-text em { + font-style: normal; + background: linear-gradient(90deg, var(--accent), var(--youtube) 55%, var(--reddit)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.header-meta { + gap: 0.55rem; + font-size: 0.76rem; + color: var(--ink-soft); +} + +.header-meta .sep { + opacity: 0.4; +} + +.pulse { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--rt); + animation: pulse 2.2s ease-out infinite; +} + +@keyframes pulse { + 0% { box-shadow: 0 0 0 0 rgba(0, 195, 137, 0.55); } + 70% { box-shadow: 0 0 0 9px rgba(0, 195, 137, 0); } + 100% { box-shadow: 0 0 0 0 rgba(0, 195, 137, 0); } +} + +.spectrum-rail { + height: 5px; + background: linear-gradient(90deg, + var(--accent), var(--youtube) 32%, var(--reddit) 60%, var(--sun) 78%, var(--rt)); +} + +/* ---------- Filter pills ---------- */ + +.filter-pills { + gap: 0.5rem; +} + +.filter-pill { + --pill: var(--accent); + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.42rem 0.95rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.7); + color: var(--ink); + font-weight: 600; + font-size: 0.86rem; + text-decoration: none; + white-space: nowrap; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease, color 0.15s ease; +} + +.filter-pill .pill-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--pill); + transition: background 0.15s ease; +} + +.filter-pill:hover { + transform: translateY(-1px); + box-shadow: 0 8px 18px -10px var(--pill); + color: var(--ink); +} + +.filter-pill.active { + background: var(--pill); + border-color: transparent; + color: #fff; + box-shadow: 0 10px 22px -10px var(--pill); +} + +.filter-pill.active .pill-dot { + background: rgba(255, 255, 255, 0.9); +} + +.refresh-chip { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.32rem 0.7rem; + border-radius: 999px; + background: rgba(106, 61, 255, 0.1); + color: var(--accent); + font-size: 0.72rem; + font-weight: 500; +} + +/* ---------- Time range ---------- */ + +.toolbar-label { + font-size: 0.66rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-soft); + padding-right: 0.15rem; +} + +.range-pills { + gap: 0.35rem; +} + +.range-pill { + display: inline-flex; + align-items: center; + padding: 0.32rem 0.75rem; + border-radius: 999px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.7); + color: var(--ink-soft); + font-size: 0.78rem; + font-weight: 600; + text-decoration: none; + white-space: nowrap; + transition: transform 0.15s ease, box-shadow 0.15s ease, background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.range-pill:hover { + color: var(--ink); + border-color: var(--accent); + transform: translateY(-1px); +} + +.range-pill.active { + background: var(--accent); + border-color: transparent; + color: #fff; + box-shadow: 0 10px 22px -10px var(--accent); +} + +/* ---------- Cards ---------- */ + +.feed-card { + --src: var(--accent); + position: relative; + border: 1px solid var(--line); + border-top: 4px solid var(--src); + border-radius: 18px; + background: var(--surface); + overflow: hidden; + box-shadow: 0 1px 2px rgba(22, 11, 46, 0.04), 0 14px 34px -26px rgba(22, 11, 46, 0.5); + transition: transform 0.18s ease, box-shadow 0.18s ease; +} + +.feed-card:hover { + transform: translateY(-4px); + box-shadow: 0 20px 44px -20px rgba(22, 11, 46, 0.4); + box-shadow: 0 20px 44px -20px color-mix(in srgb, var(--src) 55%, transparent); +} + +.feed-thumb-link { + position: relative; + display: block; + overflow: hidden; + background: #efeaff; +} + +.feed-thumb-link::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(to top, rgba(22, 11, 46, 0.5), transparent 52%); +} + +.feed-thumb { + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + display: block; + transition: transform 0.4s ease; +} + +.feed-card:hover .feed-thumb { + transform: scale(1.06); +} + +.thumb-tag, +.source-chip { + font-family: var(--mono); + font-size: 0.64rem; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: #fff; + background: var(--src); +} + +.thumb-tag { + position: absolute; + left: 0.7rem; + bottom: 0.7rem; + z-index: 1; + padding: 0.24rem 0.6rem; + border-radius: 999px; + box-shadow: 0 6px 16px -6px rgba(22, 11, 46, 0.6); +} + +.source-chip { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: 999px; +} + +.feed-card .card-body { + padding: 1rem 1.1rem 1.1rem; +} + +.feed-name { + font-size: 0.68rem; + letter-spacing: 0.02em; + color: var(--ink-soft); +} + +.feed-card .card-title { + font-family: var(--display); + font-size: 1.02rem; + font-weight: 600; + line-height: 1.32; +} + +.feed-card .card-title a { + color: var(--ink); + text-decoration: none; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + transition: color 0.15s ease; +} + +.feed-card .card-title a:hover { + color: var(--src); +} + +.feed-card .card-text { + font-size: 0.86rem; + line-height: 1.55; + color: var(--ink-soft); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.card-footer-meta { + border-top: 1px dashed var(--line); +} + +.author { + font-size: 0.78rem; + color: var(--ink-soft); +} + +.timestamp { + font-size: 0.72rem; + font-weight: 500; + color: var(--src); +} + +.mono { + font-family: var(--mono); +} + +/* ---------- Empty state ---------- */ + +.empty-state { + padding: 4.5rem 1rem; + color: var(--ink-soft); +} + +.empty-mark { + width: 58px; + height: 58px; + margin: 0 auto 1rem; + border-radius: 20px; + background: conic-gradient(from 220deg, var(--accent), var(--youtube), var(--reddit), var(--rt), var(--accent)); + filter: saturate(1.1); + animation: spin 12s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.empty-title { + font-family: var(--display); + font-size: 1.15rem; + color: var(--ink); +} + +/* ---------- Button ---------- */ + +.btn-energy { + border: 0; + border-radius: 999px; + padding: 0.7rem 1.7rem; + font-family: var(--display); + font-weight: 600; + color: #fff; + background: linear-gradient(100deg, var(--accent), var(--youtube) 48%, var(--reddit) 78%, var(--rt)); + background-size: 160% 100%; + box-shadow: 0 14px 30px -14px rgba(106, 61, 255, 0.8); + transition: transform 0.15s ease, background-position 0.4s ease, box-shadow 0.15s ease; +} + +.btn-energy:hover, +.btn-energy:focus { + color: #fff; + transform: translateY(-2px); + background-position: 100% 0; + box-shadow: 0 18px 36px -14px rgba(255, 31, 75, 0.7); +} + +.btn-energy:disabled { + opacity: 0.65; + transform: none; + color: #fff; +} + +.alert-soft { + border: 1px solid #ffe1a8; + border-radius: 14px; + background: #fff8ea; + color: #7a5a12; +} + +/* ---------- Focus & motion ---------- */ + +a:focus-visible, +button:focus-visible { + outline: 3px solid rgba(106, 61, 255, 0.55); + outline-offset: 2px; + border-radius: 8px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..1bdc009 --- /dev/null +++ b/composer.json @@ -0,0 +1,22 @@ +{ + "name": "harwinder/rssfeed", + "description": "Dashboard aggregating YouTube and Reddit RSS feeds with pagination.", + "type": "project", + "license": "MIT", + "require": { + "php": ">=8.1", + "ext-pdo": "*", + "ext-curl": "*", + "laminas/laminas-feed": "^2.23", + "guzzlehttp/guzzle": "^7.9" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "config": { + "sort-packages": true, + "optimize-autoloader": true + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..264903f --- /dev/null +++ b/composer.lock @@ -0,0 +1,914 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c77683974e2ef866a5984862d2060cf3", + "packages": [ + { + "name": "guzzlehttp/guzzle", + "version": "7.15.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:21:06+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:11:28+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "95e7828100de18b4e269fb1703be530082d5166d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:13:11+00:00" + }, + { + "name": "laminas/laminas-escaper", + "version": "2.18.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-escaper.git", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-escaper/zipball/06f211dfffff18d91844c1f55250d5d13c007e18", + "reference": "06f211dfffff18d91844c1f55250d5d13c007e18", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-mbstring": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "zendframework/zend-escaper": "*" + }, + "require-dev": { + "infection/infection": "^0.31.0", + "laminas/laminas-coding-standard": "~3.1.0", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Escaper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs", + "homepage": "https://laminas.dev", + "keywords": [ + "escaper", + "laminas" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-escaper/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-escaper/issues", + "rss": "https://github.com/laminas/laminas-escaper/releases.atom", + "source": "https://github.com/laminas/laminas-escaper" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-14T18:31:13+00:00" + }, + { + "name": "laminas/laminas-feed", + "version": "2.26.3", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-feed.git", + "reference": "86ce3b1ea7c5010413cb24c8d58530b084e8f901" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-feed/zipball/86ce3b1ea7c5010413cb24c8d58530b084e8f901", + "reference": "86ce3b1ea7c5010413cb24c8d58530b084e8f901", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-libxml": "*", + "laminas/laminas-escaper": "^2.9", + "laminas/laminas-stdlib": "^3.6", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "laminas/laminas-servicemanager": "<3.3", + "zendframework/zend-feed": "*" + }, + "require-dev": { + "laminas/laminas-cache": "^2.13.2 || ^3.12", + "laminas/laminas-cache-storage-adapter-memory": "^1.1.0 || ^2.3", + "laminas/laminas-coding-standard": "~2.5.0", + "laminas/laminas-db": "^2.18", + "laminas/laminas-http": "^2.19", + "laminas/laminas-servicemanager": "^3.22.1", + "laminas/laminas-validator": "^2.46", + "phpunit/phpunit": "^10.5.5", + "psalm/plugin-phpunit": "^0.19.0", + "psr/http-message": "^2.0", + "vimeo/psalm": "^5.18.0" + }, + "suggest": { + "laminas/laminas-cache": "Laminas\\Cache component, for optionally caching feeds between requests", + "laminas/laminas-db": "Laminas\\Db component, for use with PubSubHubbub", + "laminas/laminas-http": "Laminas\\Http for PubSubHubbub, and optionally for use with Laminas\\Feed\\Reader", + "laminas/laminas-servicemanager": "Laminas\\ServiceManager component, for easily extending ExtensionManager implementations", + "laminas/laminas-validator": "Laminas\\Validator component, for validating email addresses used in Atom feeds and entries when using the Writer subcomponent", + "psr/http-message": "PSR-7 ^1.0.1, if you wish to use Laminas\\Feed\\Reader\\Http\\Psr7ResponseDecorator" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Feed\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "provides functionality for creating and consuming RSS and Atom feeds", + "homepage": "https://laminas.dev", + "keywords": [ + "atom", + "feed", + "laminas", + "rss" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-feed/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-feed/issues", + "rss": "https://github.com/laminas/laminas-feed/releases.atom", + "source": "https://github.com/laminas/laminas-feed" + }, + "funding": [ + { + "url": "https://crowdfunding.linuxfoundation.org/initiatives/laminas-project", + "type": "custom" + } + ], + "time": "2026-10-03T12:45:16+00:00" + }, + { + "name": "laminas/laminas-stdlib", + "version": "3.21.0", + "source": { + "type": "git", + "url": "https://github.com/laminas/laminas-stdlib.git", + "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laminas/laminas-stdlib/zipball/b1c81514cfe158aadf724c42b34d3d0a8164c096", + "reference": "b1c81514cfe158aadf724c42b34d3d0a8164c096", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + }, + "conflict": { + "zendframework/zend-stdlib": "*" + }, + "require-dev": { + "laminas/laminas-coding-standard": "^3.1.0", + "phpbench/phpbench": "^1.4.1", + "phpunit/phpunit": "^11.5.42", + "psalm/plugin-phpunit": "^0.19.5", + "vimeo/psalm": "^6.13.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Laminas\\Stdlib\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "SPL extensions, array utilities, error handlers, and more", + "homepage": "https://laminas.dev", + "keywords": [ + "laminas", + "stdlib" + ], + "support": { + "chat": "https://laminas.dev/chat", + "docs": "https://docs.laminas.dev/laminas-stdlib/", + "forum": "https://discourse.laminas.dev", + "issues": "https://github.com/laminas/laminas-stdlib/issues", + "rss": "https://github.com/laminas/laminas-stdlib/releases.atom", + "source": "https://github.com/laminas/laminas-stdlib" + }, + "funding": [ + { + "url": "https://funding.communitybridge.org/projects/laminas-project", + "type": "community_bridge" + } + ], + "time": "2025-10-11T18:13:12+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + } + ], + "packages-dev": [], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.1", + "ext-pdo": "*", + "ext-curl": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/.htaccess b/config/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/config/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..e3247cd --- /dev/null +++ b/config/app.php @@ -0,0 +1,38 @@ + dirname(__DIR__) . '/data/feeds.sqlite', + + // Seconds before a feed is considered stale and re-fetched on demand. + 'cache_ttl' => 1800, + + // Shorter retry interval for feeds whose last fetch failed (e.g. rate limited). + 'error_retry_ttl' => 120, + + // Minimum milliseconds between requests to the same host. + 'host_interval_ms' => 1000, + + // Hosts with aggressive rate limits: only fetch this many of their feeds per refresh. + 'throttled_hosts' => ['www.reddit.com'], + 'throttled_host_max_per_refresh' => 1, + + // Number of items per page. + 'page_size' => 20, + + // Time-range filters (key => window length in seconds), shown newest-first. + 'ranges' => [ + '1h' => 3600, + '2h' => 7200, + '6h' => 21600, + '24h' => 86400, + '7d' => 604800, + ], + + // HTTP client settings. + 'request_timeout' => 15, + 'connect_timeout' => 10, + + // Identify the client to feed providers (important for Reddit). + 'user_agent' => 'RSSFeed/1.0 (+https://localhost; contact: admin@localhost)', +]; diff --git a/config/feeds.php b/config/feeds.php new file mode 100644 index 0000000..06c93bc --- /dev/null +++ b/config/feeds.php @@ -0,0 +1,64 @@ + 'youtube', + 'label' => 'Prof. Jiang - Fluxhistory', + 'url' => 'https://www.youtube.com/feeds/videos.xml?channel_id=UCtmugk6zDcFsiqLpSiE3Wmw', // @Prof.Jiang_Fluxhistory + ], + [ + 'source' => 'youtube', + 'label' => 'Fireship', + 'url' => 'https://www.youtube.com/feeds/videos.xml?channel_id=UCsBjURrPoezykLs9EqgamOA', // @Fireship + ], + [ + 'source' => 'youtube', + 'label' => 'Ravish Kumar', + 'url' => 'https://www.youtube.com/feeds/videos.xml?channel_id=UC0yXUUIaPVAqZLgRjvtMftw', // @ravishkumar.official + ], + [ + 'source' => 'youtube', + 'label' => 'David Bombal', + 'url' => 'https://www.youtube.com/feeds/videos.xml?channel_id=UCP7WmQ_U4GB3K51Od9QvM0w', // @davidbombal + ], + + // Reddit RSS feeds. + [ + 'source' => 'reddit', + 'label' => 'r/DataHoarder', + 'url' => 'https://www.reddit.com/r/DataHoarder/.rss', + ], + [ + 'source' => 'reddit', + 'label' => 'r/Piracy', + 'url' => 'https://www.reddit.com/r/Piracy/.rss', + ], + [ + 'source' => 'reddit', + 'label' => 'r/homelabindia', + 'url' => 'https://www.reddit.com/r/homelabindia/.rss', + ], + [ + 'source' => 'reddit', + 'label' => 'r/LocalLLM', + 'url' => 'https://www.reddit.com/r/LocalLLM/.rss', + ], + [ + 'source' => 'reddit', + 'label' => 'r/homelab', + 'url' => 'https://www.reddit.com/r/homelab/.rss', + ], + [ + 'source' => 'reddit', + 'label' => 'r/networking', + 'url' => 'https://www.reddit.com/r/networking/.rss', + ], + + // RT news (RSS with image enclosures). + [ + 'source' => 'rt', + 'label' => 'RT News', + 'url' => 'https://www.rt.com/rss/', + ], +]; diff --git a/index.php b/index.php new file mode 100644 index 0000000..1f79a91 --- /dev/null +++ b/index.php @@ -0,0 +1,35 @@ + $app */ +$app = require __DIR__ . '/src/bootstrap.php'; + +$config = $app['config']; +$service = $app['service']; + +$refresh = $service->refreshStale(); + +$sources = $service->sources(); +$activeSource = $_GET['source'] ?? null; +if (!is_string($activeSource) || !in_array($activeSource, $sources, true)) { + $activeSource = null; +} + +$ranges = $config['ranges'] ?? []; +$activeRange = $_GET['range'] ?? ''; +if (!is_string($activeRange) || !array_key_exists($activeRange, $ranges)) { + $activeRange = ''; +} +$since = $activeRange !== '' ? gmdate('c', time() - (int) $ranges[$activeRange]) : null; + +$page = $service->page($activeSource, 1, (int) $config['page_size'], $since); + +echo App\View::render('layout', [ + 'config' => $config, + 'sources' => $sources, + 'activeSource' => $activeSource, + 'activeRange' => $activeRange, + 'page' => $page, + 'refresh' => $refresh, +]); diff --git a/src/.htaccess b/src/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/src/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/src/Database.php b/src/Database.php new file mode 100644 index 0000000..3c13c44 --- /dev/null +++ b/src/Database.php @@ -0,0 +1,65 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + + $pdo->exec('PRAGMA journal_mode = WAL'); + $pdo->exec('PRAGMA busy_timeout = 5000'); + $pdo->exec('PRAGMA foreign_keys = ON'); + + self::migrate($pdo); + + return $pdo; + } + + private static function migrate(PDO $pdo): void + { + $pdo->exec(<<<'SQL' + CREATE TABLE IF NOT EXISTS items ( + id TEXT PRIMARY KEY, + source TEXT NOT NULL, + feed_label TEXT, + feed_url TEXT NOT NULL, + title TEXT NOT NULL, + link TEXT NOT NULL, + author TEXT, + published_at TEXT, + description TEXT, + thumbnail TEXT, + fetched_at TEXT NOT NULL + ) + SQL); + + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_items_published ON items (published_at DESC)'); + $pdo->exec('CREATE INDEX IF NOT EXISTS idx_items_source ON items (source, published_at DESC)'); + + $pdo->exec(<<<'SQL' + CREATE TABLE IF NOT EXISTS feeds ( + url TEXT PRIMARY KEY, + source TEXT NOT NULL, + label TEXT, + last_attempt_at TEXT, + last_success_at TEXT, + last_error TEXT + ) + SQL); + } +} diff --git a/src/FeedFetcher.php b/src/FeedFetcher.php new file mode 100644 index 0000000..2581603 --- /dev/null +++ b/src/FeedFetcher.php @@ -0,0 +1,303 @@ + */ + private array $lastRequestPerHost = []; + + private int $hostIntervalMs; + + public function __construct(private array $options = []) + { + $this->hostIntervalMs = (int) ($options['host_interval_ms'] ?? 1000); + + $this->http = new Client([ + 'timeout' => $options['request_timeout'] ?? 15, + 'connect_timeout' => $options['connect_timeout'] ?? 10, + 'http_errors' => false, + 'allow_redirects' => true, + 'headers' => [ + 'User-Agent' => $options['user_agent'] ?? 'RSSFeed/1.0', + 'Accept' => 'application/atom+xml,application/rss+xml,application/xml,text/xml;q=0.9,*/*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.9', + ], + ]); + } + + /** + * Fetch and normalize a single feed. + * + * @return array{title: ?string, items: array>} + */ + public function fetch(array $feed): array + { + $url = (string) $feed['url']; + $source = (string) $feed['source']; + $label = (string) ($feed['label'] ?? $url); + + $xml = $this->download($url); + $parsed = Reader::importString($xml); + $extras = self::extractExtras($xml); + + $now = gmdate('c'); + $items = []; + + foreach ($parsed as $entry) { + $link = self::safeUrl((string) $entry->getLink()); + if ($link === null) { + continue; + } + + $title = self::cleanText((string) $entry->getTitle()); + $description = (string) $entry->getDescription(); + if (trim($description) === '') { + $description = (string) $entry->getContent(); + } + + $thumbnail = $extras[$link]['thumbnail'] ?? null; + $mediaDescription = $extras[$link]['description'] ?? null; + if (trim($description) === '' && $mediaDescription !== null) { + $description = $mediaDescription; + } + + if ($thumbnail === null) { + $enclosure = $entry->getEnclosure(); + if ($enclosure !== null && self::isImageEnclosure($enclosure)) { + $thumbnail = (string) $enclosure->url; + } + } + + if ($source === 'youtube' && $thumbnail === null) { + $videoId = self::youtubeVideoId($link); + if ($videoId !== null) { + $thumbnail = 'https://i.ytimg.com/vi/' . $videoId . '/mqdefault.jpg'; + } + } + + $date = $entry->getDateCreated() ?: $entry->getDateModified(); + + $items[] = [ + 'id' => hash('sha256', $link), + 'source' => $source, + 'feed_label' => $label, + 'feed_url' => $url, + 'title' => $title !== '' ? $title : '(untitled)', + 'link' => $link, + 'author' => self::authorName($entry->getAuthor()), + 'published_at' => $date?->setTimezone(new \DateTimeZone('UTC'))->format('c'), + 'description' => self::cleanText($description, self::MAX_DESCRIPTION_LENGTH), + 'thumbnail' => self::safeUrl($thumbnail), + 'fetched_at' => $now, + ]; + } + + return ['title' => (string) $parsed->getTitle(), 'items' => $items]; + } + + private function download(string $url): string + { + $attempt = 0; + $lastStatus = 0; + + while ($attempt < self::MAX_ATTEMPTS) { + $attempt++; + $this->throttle($url); + $response = $this->http->get($url); + $lastStatus = $response->getStatusCode(); + + if ($lastStatus === 200) { + return (string) $response->getBody(); + } + + // Only retry transient server errors; client errors (incl. 429) are + // handled by the caller with a staggered cooldown. + if ($lastStatus < 500) { + break; + } + + if ($attempt < self::MAX_ATTEMPTS) { + $retryAfter = (int) $response->getHeaderLine('Retry-After'); + $delay = $retryAfter > 0 ? min($retryAfter, 3) : 1; + sleep($delay); + } + } + + throw new \RuntimeException(sprintf('HTTP %d while fetching %s', $lastStatus, $url)); + } + + /** + * Space out requests to the same host to avoid provider rate limiting. + */ + private function throttle(string $url): void + { + $host = (string) parse_url($url, PHP_URL_HOST); + if ($host === '' || $this->hostIntervalMs <= 0) { + return; + } + + $now = microtime(true); + if (isset($this->lastRequestPerHost[$host])) { + $elapsedMs = ($now - $this->lastRequestPerHost[$host]) * 1000; + $waitMs = $this->hostIntervalMs - $elapsedMs; + if ($waitMs > 0) { + usleep((int) ($waitMs * 1000)); + } + } + + $this->lastRequestPerHost[$host] = microtime(true); + } + + /** + * Extract media thumbnails / descriptions from the raw XML, which the + * laminas reader does not expose (YouTube media: RSS extension). + * + * @return array + */ + private static function extractExtras(string $xml): array + { + $dom = new DOMDocument(); + $previous = libxml_use_internal_errors(true); + $loaded = $dom->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + if (!$loaded) { + return []; + } + + $xpath = new DOMXPath($dom); + $nodes = $xpath->query('//*[local-name()="entry" or local-name()="item"]'); + if ($nodes === false) { + return []; + } + + $map = []; + foreach ($nodes as $node) { + $link = self::nodeLink($xpath, $node); + if ($link === '') { + continue; + } + + $thumbnail = null; + $thumbNode = $xpath->query('.//*[local-name()="thumbnail"]', $node)->item(0); + if ($thumbNode instanceof DOMElement) { + $thumbnail = $thumbNode->getAttribute('url'); + } + + $description = null; + $descNode = $xpath->query('.//*[local-name()="group"]/*[local-name()="description"]', $node)->item(0); + if ($descNode !== null) { + $description = $descNode->textContent; + } + + $map[$link] = [ + 'thumbnail' => self::safeUrl($thumbnail), + 'description' => $description, + ]; + } + + return $map; + } + + private static function nodeLink(DOMXPath $xpath, \DOMNode $node): string + { + $links = $xpath->query('./*[local-name()="link"]', $node); + if ($links === false) { + return ''; + } + + $fallback = ''; + foreach ($links as $link) { + if ($link instanceof DOMElement && $link->hasAttribute('href')) { + $href = trim($link->getAttribute('href')); + if ($link->getAttribute('rel') === 'alternate') { + return $href; + } + $fallback = $fallback ?: $href; + } else { + $fallback = $fallback ?: trim($link->textContent); + } + } + + return $fallback; + } + + private static function authorName(array|string|null $author): ?string + { + $name = is_array($author) ? ($author['name'] ?? null) : $author; + if (!is_string($name)) { + return null; + } + + $name = ltrim(self::cleanText($name), '/'); + + return $name === '' ? null : $name; + } + + private static function youtubeVideoId(string $link): ?string + { + if (preg_match('~[?&]v=([A-Za-z0-9_-]{6,})~', $link, $m)) { + return $m[1]; + } + + return null; + } + + private static function cleanText(string $text, int $limit = 0): string + { + $text = html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $text = preg_replace('/\s+/u', ' ', $text) ?? $text; + $text = trim($text); + + if ($limit > 0 && mb_strlen($text) > $limit) { + $text = mb_substr($text, 0, $limit) . '…'; + } + + return $text; + } + + private static function isImageEnclosure(object $enclosure): bool + { + $url = isset($enclosure->url) ? (string) $enclosure->url : ''; + $type = isset($enclosure->type) ? (string) $enclosure->type : ''; + + if ($url === '' || self::safeUrl($url) === null) { + return false; + } + + if (str_starts_with($type, 'image/')) { + return true; + } + + $path = strtolower((string) parse_url($url, PHP_URL_PATH)); + + return (bool) preg_match('/\.(jpe?g|png|gif|webp|avif)$/', $path); + } + + private static function safeUrl(?string $url): ?string + { + if ($url === null) { + return null; + } + + $url = trim($url); + $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME)); + + return in_array($scheme, ['http', 'https'], true) ? $url : null; + } +} diff --git a/src/FeedRepository.php b/src/FeedRepository.php new file mode 100644 index 0000000..7bf583c --- /dev/null +++ b/src/FeedRepository.php @@ -0,0 +1,207 @@ +> $feeds + */ + public function syncFeeds(array $feeds): void + { + $stmt = $this->pdo->prepare(<<<'SQL' + INSERT INTO feeds (url, source, label) + VALUES (:url, :source, :label) + ON CONFLICT(url) DO UPDATE SET source = excluded.source, label = excluded.label + SQL); + + foreach ($feeds as $feed) { + $stmt->execute([ + ':url' => (string) $feed['url'], + ':source' => (string) $feed['source'], + ':label' => (string) ($feed['label'] ?? $feed['url']), + ]); + } + } + + /** + * Return the subset of feeds whose last attempt is missing or older than TTL. + * Feeds that previously failed use a shorter retry interval. + * + * @param array> $feeds + * @return array> + */ + public function staleFeeds(array $feeds, int $ttlSeconds, int $errorTtlSeconds): array + { + $attempts = []; + foreach ($this->pdo->query('SELECT url, last_attempt_at, last_error FROM feeds') as $row) { + $attempts[(string) $row['url']] = $row; + } + + $now = time(); + $stale = []; + foreach ($feeds as $feed) { + $row = $attempts[(string) $feed['url']] ?? null; + $last = $row['last_attempt_at'] ?? null; + + if ($last === null) { + $stale[] = $feed; + continue; + } + + $age = $now - (int) strtotime((string) $last); + $isError = ($row['last_error'] ?? null) !== null; + $threshold = $ttlSeconds; + + if ($isError) { + // Stagger retries so rate-limited feeds do not all retry at once. + $threshold = $errorTtlSeconds + (crc32((string) $feed['url']) % max(1, $errorTtlSeconds)); + } + + if ($age >= $threshold) { + $stale[] = $feed; + } + } + + return $stale; + } + + /** + * Insert new items, ignoring ones already stored (deduplicated by link hash). + * + * @param array> $items + */ + public function upsertItems(array $items): int + { + if ($items === []) { + return 0; + } + + $stmt = $this->pdo->prepare(<<<'SQL' + INSERT OR IGNORE INTO items + (id, source, feed_label, feed_url, title, link, author, published_at, description, thumbnail, fetched_at) + VALUES + (:id, :source, :feed_label, :feed_url, :title, :link, :author, :published_at, :description, :thumbnail, :fetched_at) + SQL); + + $inserted = 0; + $this->pdo->beginTransaction(); + try { + foreach ($items as $item) { + $stmt->execute([ + ':id' => $item['id'], + ':source' => $item['source'], + ':feed_label' => $item['feed_label'], + ':feed_url' => $item['feed_url'], + ':title' => $item['title'], + ':link' => $item['link'], + ':author' => $item['author'], + ':published_at' => $item['published_at'], + ':description' => $item['description'], + ':thumbnail' => $item['thumbnail'], + ':fetched_at' => $item['fetched_at'], + ]); + $inserted += $stmt->rowCount(); + } + $this->pdo->commit(); + } catch (\Throwable $e) { + $this->pdo->rollBack(); + throw $e; + } + + return $inserted; + } + + public function recordAttempt(string $url, bool $success, ?string $error = null): void + { + $now = gmdate('c'); + + if ($success) { + $stmt = $this->pdo->prepare( + 'UPDATE feeds SET last_attempt_at = :now, last_success_at = :now2, last_error = NULL WHERE url = :url' + ); + $stmt->execute([':now' => $now, ':now2' => $now, ':url' => $url]); + + return; + } + + $stmt = $this->pdo->prepare( + 'UPDATE feeds SET last_attempt_at = :now, last_error = :error WHERE url = :url' + ); + $stmt->execute([':now' => $now, ':error' => $error, ':url' => $url]); + } + + /** + * Build the shared WHERE clause for item queries. + * + * @param array $params + */ + private function itemFilter(?string $source, ?string $since, array &$params): string + { + $where = []; + + if ($source !== null) { + $where[] = 'source = :source'; + $params[':source'] = $source; + } + + if ($since !== null) { + $where[] = 'COALESCE(published_at, fetched_at) >= :since'; + $params[':since'] = $since; + } + + return $where === [] ? '' : ' WHERE ' . implode(' AND ', $where); + } + + /** + * @return array> + */ + public function getItems(int $limit, int $offset, ?string $source = null, ?string $since = null): array + { + $limit = max(1, $limit); + $offset = max(0, $offset); + + $params = []; + $sql = 'SELECT * FROM items' . $this->itemFilter($source, $since, $params) + . ' ORDER BY COALESCE(published_at, fetched_at) DESC, id LIMIT :limit OFFSET :offset'; + + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value, PDO::PARAM_STR); + } + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->bindValue(':offset', $offset, PDO::PARAM_INT); + $stmt->execute(); + + return $stmt->fetchAll(); + } + + public function countItems(?string $source = null, ?string $since = null): int + { + $params = []; + $sql = 'SELECT COUNT(*) FROM items' . $this->itemFilter($source, $since, $params); + + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + + return (int) $stmt->fetchColumn(); + } + + /** + * @return array + */ + public function feedsStatus(): array + { + return $this->pdo->query('SELECT url, label, source, last_success_at, last_error FROM feeds ORDER BY label')->fetchAll(); + } +} diff --git a/src/FeedService.php b/src/FeedService.php new file mode 100644 index 0000000..1aa98d0 --- /dev/null +++ b/src/FeedService.php @@ -0,0 +1,114 @@ +> $feeds + */ + public function __construct( + private FeedRepository $repository, + private FeedFetcher $fetcher, + private array $feeds, + private int $ttlSeconds, + private int $errorRetryTtlSeconds = 300, + private array $throttledHosts = [], + private int $throttledHostMax = 1, + ) { + } + + /** + * Refresh every feed whose cached copy is stale. Failures are recorded and + * skipped so previously cached items remain available. + * + * @return array{attempted: int, succeeded: int, failed: int, new_items: int, errors: array} + */ + public function refreshStale(): array + { + $this->repository->syncFeeds($this->feeds); + + $report = [ + 'attempted' => 0, + 'succeeded' => 0, + 'failed' => 0, + 'new_items' => 0, + 'errors' => [], + ]; + + $hostCounts = []; + + foreach ($this->repository->staleFeeds($this->feeds, $this->ttlSeconds, $this->errorRetryTtlSeconds) as $feed) { + $url = (string) $feed['url']; + $host = (string) parse_url($url, PHP_URL_HOST); + + if (in_array($host, $this->throttledHosts, true)) { + $seen = $hostCounts[$host] ?? 0; + if ($seen >= $this->throttledHostMax) { + // Leave it stale so a later request picks it up. + continue; + } + $hostCounts[$host] = $seen + 1; + } + + $report['attempted']++; + + try { + $result = $this->fetcher->fetch($feed); + $report['new_items'] += $this->repository->upsertItems($result['items']); + $this->repository->recordAttempt($url, true); + $report['succeeded']++; + } catch (\Throwable $e) { + $this->repository->recordAttempt($url, false, $e->getMessage()); + $report['failed']++; + $report['errors'][] = ($feed['label'] ?? $url) . ': ' . $e->getMessage(); + } + } + + return $report; + } + + /** + * @return array{items: array>, page: int, page_size: int, total: int, has_more: bool} + */ + public function page(?string $source, int $page, int $pageSize, ?string $since = null): array + { + $page = max(1, $page); + $pageSize = max(1, $pageSize); + $offset = ($page - 1) * $pageSize; + + $items = $this->repository->getItems($pageSize, $offset, $source, $since); + $total = $this->repository->countItems($source, $since); + + return [ + 'items' => $items, + 'page' => $page, + 'page_size' => $pageSize, + 'total' => $total, + 'has_more' => ($offset + count($items)) < $total, + ]; + } + + /** + * @return array> + */ + public function feedsStatus(): array + { + return $this->repository->feedsStatus(); + } + + /** + * @return array + */ + public function sources(): array + { + $sources = []; + foreach ($this->feeds as $feed) { + $sources[(string) $feed['source']] = true; + } + + return array_keys($sources); + } +} diff --git a/src/View.php b/src/View.php new file mode 100644 index 0000000..fcdb625 --- /dev/null +++ b/src/View.php @@ -0,0 +1,103 @@ + 'YouTube', + 'reddit' => 'Reddit', + 'rt' => 'RT', + default => ucfirst($source), + }; + } + + public static function sourceBadge(string $source): string + { + return match ($source) { + 'youtube' => 'text-bg-danger', + 'reddit' => 'text-bg-warning', + 'rt' => 'text-bg-dark', + default => 'text-bg-secondary', + }; + } + + public static function sourceColor(string $source): string + { + return match ($source) { + 'youtube' => '#ff1f4b', + 'reddit' => '#ff6a00', + 'rt' => '#00c389', + default => '#6a3dff', + }; + } + + public static function rangeLabel(string $range): string + { + return match ($range) { + '1h' => '1 hour', + '2h' => '2 hours', + '6h' => '6 hours', + '24h' => '24 hours', + '7d' => '7 days', + default => 'All time', + }; + } + + public static function render(string $template, array $data = []): string + { + $path = dirname(__DIR__) . '/templates/' . $template . '.php'; + if (!is_file($path)) { + throw new \RuntimeException(sprintf('Template "%s" not found.', $template)); + } + + extract($data, EXTR_SKIP); + ob_start(); + include $path; + + return (string) ob_get_clean(); + } + + public static function timeAgo(?string $iso): string + { + if ($iso === null || $iso === '') { + return ''; + } + + $timestamp = strtotime($iso); + if ($timestamp === false) { + return ''; + } + + $diff = time() - $timestamp; + if ($diff < 60) { + return 'just now'; + } + + $units = [ + 31536000 => 'y', + 2592000 => 'mo', + 604800 => 'w', + 86400 => 'd', + 3600 => 'h', + 60 => 'm', + ]; + + foreach ($units as $seconds => $label) { + if ($diff >= $seconds) { + return floor($diff / $seconds) . $label . ' ago'; + } + } + + return 'just now'; + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php new file mode 100644 index 0000000..b851ada --- /dev/null +++ b/src/bootstrap.php @@ -0,0 +1,30 @@ + $config, + 'feeds' => $feeds, + 'pdo' => $pdo, + 'repository' => $repository, + 'fetcher' => $fetcher, + 'service' => $service, +]; diff --git a/templates/.htaccess b/templates/.htaccess new file mode 100644 index 0000000..b66e808 --- /dev/null +++ b/templates/.htaccess @@ -0,0 +1 @@ +Require all denied diff --git a/templates/cards.php b/templates/cards.php new file mode 100644 index 0000000..0108245 --- /dev/null +++ b/templates/cards.php @@ -0,0 +1,49 @@ +> $items */ + +foreach ($items as $item): + $source = (string) $item['source']; + $color = View::sourceColor($source); + $link = View::e((string) $item['link']); + $publishedAt = $item['published_at'] ?? null; + ?> +
+
+ + + + + + +
+
+ + + + +
+

+ +

+ +

+ + +
+
+
+ diff --git a/templates/layout.php b/templates/layout.php new file mode 100644 index 0000000..7f2b467 --- /dev/null +++ b/templates/layout.php @@ -0,0 +1,162 @@ + $config */ +/** @var array $sources */ +/** @var string|null $activeSource */ +/** @var string|null $activeRange */ +/** @var array $page */ +/** @var array $refresh */ + +$tabs = ['' => 'All']; +foreach ($sources as $source) { + $tabs[$source] = View::sourceLabel($source); +} + +$activeRange = $activeRange ?? ''; +$ranges = $config['ranges'] ?? []; +$rangeOptions = ['' => 'All time']; +foreach (array_keys($ranges) as $rangeKey) { + $rangeOptions[$rangeKey] = View::rangeLabel($rangeKey); +} + +$linkFor = static function (string $source, string $range): string { + $params = []; + if ($source !== '') { + $params['source'] = $source; + } + if ($range !== '') { + $params['range'] = $range; + } + + return $params === [] ? '?' : '?' . http_build_query($params); +}; + +$errors = $refresh['errors'] ?? []; +$total = (int) $page['total']; +?> + + + + + + RSS Feed Dashboard + + + + + + + + + +
+
+
+ + + 0): ?> + + + / feeds + · new + + +
+ +
+ Time + +
+
+ + + + + +
+ $page['items']]) ?> +
+ +
+ + +

Nothing in the past

+

Try a wider time range to see more.

+ +

Nothing in the feed yet

+

Feeds may be rate limited right now — reload in a moment.

+ +
+ +
+ +
+
+ + + + +