# RSS Feed Dashboard A small PHP dashboard that aggregates RSS/Atom feeds (YouTube channels, Reddit subreddits, RT news) into a single stream, newest first, with source and time-range filters and AJAX pagination. No framework, no build step. PHP + SQLite for caching, Bootstrap 5 for the UI. --- ## What it does - Pulls **YouTube** (Atom), **Reddit** (RSS 2.0) and **RT** (RSS) feeds. - Merges every item into one timeline ordered by `published_at` (fallback `fetched_at`), newest first. - Filters by **source** (`youtube` / `reddit` / `rt`) and **time range** (all time, 1h, 2h, 6h, 24h, 7d). - Paginates with a **"Load more stories"** button (AJAX, 20 per page). - Caches everything in **SQLite** so page loads never wait on the network; feeds are refreshed on demand when stale (default TTL 30 min). --- ## Tech stack | Layer | Choice | |------------|---------------------------------------------------------------| | Runtime | PHP 8.1+ (`ext-pdo`, `ext-curl`) | | Parsing | [`laminas/laminas-feed`](https://github.com/laminas/laminas-feed) | | HTTP | [`guzzlehttp/guzzle`](https://github.com/guzzle/guzzle) | | Storage | SQLite via PDO (WAL mode) | | Front end | Bootstrap 5 (CDN) + vanilla JS, custom `assets/style.css` | | Autoload | Composer PSR-4, `App\` → `src/` | --- ## Installation ### Requirements - PHP **8.1+** with `pdo_sqlite`, `curl`, `dom`, `simplexml`, `mbstring` - Composer (or the bundled `composer.phar`) - A web server with PHP (Apache/nginx + php-fpm) — or PHP's built-in server for a quick look - No database server: SQLite is embedded Verify PHP: ```bash php -v php -m | grep -E 'pdo_sqlite|curl|dom|simplexml|mbstring' ``` ### 1. Get the code ```bash git clone ssh://git@192.168.0.11:222/harry/RSS-Feed.git cd RSS-Feed ``` ### 2. Install dependencies `composer.phar` is committed with the repo, so no global Composer is required: ```bash php composer.phar install --no-dev --optimize-autoloader ``` This populates `vendor/` (both `vendor/` and the SQLite DB are gitignored, so they are created here). A system Composer works the same: `composer install --no-dev --optimize-autoloader`. If `composer.phar` is ever missing, fetch it once: ```bash php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" php composer-setup.php --install-dir=. --filename=composer.phar rm composer-setup.php ``` ### 3. Configure - **Feeds** — edit `config/feeds.php` (see *Configuration* for the format and the YouTube `@handle` → `channel_id` note). - **Runtime** — optionally tune `config/app.php` (page size, TTLs, ranges, timeouts, user agent). ### 4. Create the cache directory ```bash mkdir -p data ``` The PHP/web user must be able to write `data/feeds.sqlite` (SQLite also creates `-wal`/`-shm` files there). Make it writable by the web user: ```bash chmod 775 data # or, when the web user differs from the owner: chmod 1777 data ``` Cleaner still, let the web server's user own it: ```bash sudo chown -R www-data:www-data data # Debian/Ubuntu # sudo chown -R apache:apache data # RHEL/Fedora ``` ### 5. Point a web server at the project The **project root is the web root**. Apache example: ```apache ServerName localhost DocumentRoot "/path/to/RSS-Feed" Options Indexes FollowSymLinks AllowOverride All Require all granted ``` Ensure `index.php` is a directory index (`DirectoryIndex index.php`) and that `.htaccess` overrides are allowed (`AllowOverride All`) so the bundled protection files take effect. For nginx: set `root /path/to/RSS-Feed;`, route requests to `index.php` / `api/items.php`, and deny `src/`, `config/`, `vendor/`, `templates/`, `data/`. > The `.htaccess` files block HTTP access to `src/`, `config/`, `vendor/`, > `templates/`, `data/` and `composer.*`. They do **not** affect PHP's filesystem > reads, so the app keeps working normally. ### 6. SELinux (Fedora / RHEL only) ```bash # Let php-fpm write the SQLite cache chcon -R -t httpd_sys_rw_content_t data # Let php-fpm make outbound HTTP requests (Reddit/YouTube/RT) sudo setsebool -P httpd_can_network_connect on ``` Skipping the second command still renders cached data, but every fetch fails with `cURL error 7 ... Could not connect to server`. See *Deployment notes*. ### 7. First run Open the site (`http://localhost/`, or `http://localhost/RSS-Feed/` if it lives under a parent document root). The first request fetches all feeds and builds `data/feeds.sqlite` — this can take several seconds. Reddit may return `429` at first; its items appear on subsequent refreshes. ### Quick start without a web server ```bash php -S 127.0.0.1:8000 -t . # http://127.0.0.1:8000/ ``` The built-in server does not read `.htaccess`, so use it for local evaluation only. ### Optional: scheduled refresh (no root) If you can't change SELinux (or prefer not to fetch during web requests), run the refresher from cron as your own user — CLI processes are unconfined and have network access: ```bash cd /path/to/RSS-Feed php -r '$app = require "src/bootstrap.php"; $app["service"]->refreshStale();' ``` Add to `crontab -e` to refresh every 30 minutes: ``` */30 * * * * cd /path/to/RSS-Feed && php -r '$app = require "src/bootstrap.php"; $app["service"]->refreshStale();' >/dev/null 2>&1 ``` When the cron user differs from the web user, make sure both can write `data/` (see step 4). --- ## Architecture ``` Browser │ GET /RSSFeed/ ?source=&range= GET /RSSFeed/api/items.php ?source=&range=&page= ▼ ▼ index.php api/items.php └── require src/bootstrap.php ─────────────────────────┘ │ builds: PDO, FeedRepository, FeedFetcher, FeedService, config, feeds ▼ FeedService::refreshStale() ← runs on both entry points, respects TTL │ ├─ FeedRepository::syncFeeds() ensure every configured feed has a row ├─ FeedRepository::staleFeeds() feeds older than TTL (or error cooldown) └─ for each stale feed: FeedFetcher::fetch() Guzzle GET → laminas parse → normalize FeedRepository::upsertItems() INSERT OR IGNORE (dedup by sha256(link)) FeedRepository::recordAttempt() success/error + timestamps FeedService::page(source, page, pageSize, since) ← read path └─ FeedRepository::getItems()/countItems() SQL over the SQLite cache │ └─ View::render('layout' | 'cards') server-rendered HTML (api/items.php returns that HTML inside a JSON envelope) ``` **Request lifecycle (page load):** bootstrap → refresh stale feeds → validate `source`/`range` → query one page → render `templates/layout.php` which includes `templates/cards.php`. **Request lifecycle (load more):** `assets/app.js` calls `api/items.php` with the next `page` (plus current `source` and `range`), receives `{ html, page, total, has_more, ... }`, appends `html` to `#feed`, and hides the button when `has_more` is false. ### Caching & refresh semantics - `items` and `feeds` live in `data/feeds.sqlite` (WAL mode, outside the web root). - A feed is **stale** when `now - last_attempt_at >= cache_ttl`, or, if its last attempt errored, when `now - last_attempt_at >= error_retry_ttl + jitter` (jitter derived from `crc32(url)` so rate-limited feeds don't all retry at once). - Fetch failures are recorded (`last_error`) and **never delete cached items** — the dashboard keeps serving the last good data. - Reddit rate-limits hard. `throttled_hosts` caps how many feeds of a given host are fetched per refresh (`throttled_host_max_per_refresh`), and requests to the same host are spaced by `host_interval_ms`. Only 5xx responses retry in-request. --- ## Files ``` . ├── index.php Page entry point: refresh, filter, render page 1 ├── api/items.php JSON endpoint for "Load more" (returns rendered HTML) ├── config/ │ ├── app.php Runtime settings (see Configuration) │ └── feeds.php The feed list: [{source, label, url}, ...] ├── src/ │ ├── bootstrap.php Wires config + classes together, returns the container │ ├── Database.php PDO SQLite connection + schema migration │ ├── FeedFetcher.php HTTP + parse + normalize a single feed │ ├── FeedRepository.php All SQL: upsert/query/count, staleness, attempt state │ ├── FeedService.php Orchestration: refreshStale(), page(), sources() │ └── View.php Escaping + render() + labels/colors/time formatting ├── templates/ │ ├── layout.php Full page shell, filters, grid, empty state, Load more │ └── cards.php One feed card partial (used by page + API) ├── assets/ │ ├── app.js Load-more, relative timestamps, filter propagation │ └── style.css Design tokens + card/pill/header styling ├── data/ SQLite database (gitignored, created at runtime) └── vendor/ Composer deps (gitignored) ``` ### Class reference | Class / file | Responsibility | Key methods | |---|---|---| | `App\Database` | Creates the SQLite file/dir, applies schema, sets WAL + busy timeout | `connect()` | | `App\FeedFetcher` | Fetch one feed (with host throttle + 5xx retry), parse with laminas, extract media fields, normalize to a plain array | `fetch()` | | `App\FeedRepository` | Every SQL statement; returns arrays/DTO-less rows | `syncFeeds()`, `staleFeeds()`, `upsertItems()`, `recordAttempt()`, `getItems()`, `countItems()`, `feedsStatus()` | | `App\FeedService` | Business flow: refresh policy + paginated reads | `refreshStale()`, `page()`, `sources()`, `feedsStatus()` | | `App\View` | Output escaping, template rendering, source labels/colors, ranges, `timeAgo()` | `e()`, `render()`, `sourceLabel()`, `sourceColor()`, `rangeLabel()`, `timeAgo()` | | `src/bootstrap.php` | Builds and returns `['config','feeds','pdo','repository','fetcher','service']` | — | `FeedFetcher` notes: laminas does not expose the YouTube `media:` extension, so `extractExtras()` does a second DOM/XPath pass over the raw XML to pull `media:thumbnail` and `media:description`. Thumbnail resolution order is `media:thumbnail` → image `` → YouTube `i.ytimg.com` fallback. Descriptions are stripped to plain text and truncated (XSS-safe; the template escapes again on output). --- ## Data model `items` — one row per article/video/post: | column | notes | |---|---| | `id` | `sha256(link)`, primary key (dedup) | | `source` | `youtube` / `reddit` / `rt` | | `feed_label`, `feed_url` | which feed it came from | | `title`, `link`, `author` | | | `published_at` | ISO-8601 UTC (nullable) | | `description` | plain text, truncated | | `thumbnail` | image URL (nullable) | | `fetched_at` | ISO-8601 UTC | Indexes: `idx_items_published(published_at DESC)`, `idx_items_source(source, published_at DESC)`. `feeds` — one row per configured feed + health state: | column | notes | |---|---| | `url` | primary key | | `source`, `label` | from `config/feeds.php` | | `last_attempt_at`, `last_success_at` | ISO-8601 UTC | | `last_error` | last failure message, `NULL` on success | --- ## Configuration `config/app.php` returns an array: | key | default | meaning | |---|---|---| | `db_path` | `data/feeds.sqlite` | SQLite file location | | `cache_ttl` | `1800` | seconds before a healthy feed is re-fetched | | `error_retry_ttl` | `120` | base retry delay for failed feeds (plus jitter) | | `host_interval_ms` | `1000` | min gap between requests to the same host | | `throttled_hosts` | `['www.reddit.com']` | hosts fetched sparingly | | `throttled_host_max_per_refresh` | `1` | max feeds of a throttled host per refresh | | `page_size` | `20` | items per page | | `ranges` | `1h,2h,6h,24h,7d` | time-filter windows (key → seconds) | | `request_timeout` / `connect_timeout` | `15` / `10` | Guzzle timeouts | | `user_agent` | `RSSFeed/1.0 (...)` | sent to providers (matters for Reddit) | `config/feeds.php` returns a list of feeds: ```php [ 'source' => 'reddit', // groups the item + drives its color 'label' => 'r/homelab', // shown under the badge 'url' => 'https://www.reddit.com/r/homelab/.rss', ] ``` **YouTube note:** YouTube serves Atom only by `channel_id`, not `@handle`. Resolve a handle once (fetch `https://www.youtube.com/@handle` and read `` or the canonical channel link) and store `https://www.youtube.com/feeds/videos.xml?channel_id=...`. --- ## Routes & query parameters | Route | Params | Returns | |---|---|---| | `index.php` (`/RSSFeed/`) | `source` (optional), `range` (optional) | full HTML page, page 1 | | `api/items.php` | `page` (default 1), `source`, `range` | JSON `{ html, page, page_size, count, total, has_more, range, refresh }` | - `source` must match a `source` in `config/feeds.php`, else ignored. - `range` must be a key in `ranges`, else ignored. - Both filters are preserved across navigation and across "Load more". --- ## Adding feeds and sources **New feed (existing source):** add an entry to `config/feeds.php`. `syncFeeds()` registers it automatically on the next request; it will be fetched on the next refresh. **New source type** (e.g. `hackernews`): add feeds with `'source' => 'hackernews'`, then teach the UI its identity in `src/View.php`: ```php sourceLabel('hackernews') // tab / badge text sourceColor('hackernews') // hex used for the card spine, chip and active pill ``` The source tab and filter pill are generated automatically from `sources()`. --- ## Deployment notes (Apache + php-fpm on SELinux, e.g. Fedora) 1. **Document root = project root.** `index.php` is served for `/RSSFeed/`; the built-in files under `api/` and `assets/` are served directly. 2. **Protect internals.** `.htaccess` files deny HTTP access to `src/`, `config/`, `vendor/`, `templates/`, `data/`, and `composer.*`. These only affect HTTP; PHP still reads the files from disk. Directory listing is disabled. 3. **Let the web user write the cache.** `data/` must be writable by the php-fpm user (e.g. `apache`), and on SELinux it needs the `httpd_sys_rw_content_t` type: ```bash chmod 1777 data chcon -R -t httpd_sys_rw_content_t data ``` 4. **Allow outbound HTTP.** On SELinux, php-fpm runs in `httpd_t` and outbound connections are denied by default (symptom: `cURL error 7 ... Could not connect to server`). Enable it once: ```bash sudo setsebool -P httpd_can_network_connect on ``` Without this, the app still renders the cached data but cannot refresh. As an alternative (no root), run the fetcher from a user cron/CLI process instead — CLI processes run unconfined and do have network access. --- ## Troubleshooting | Symptom | Cause / fix | |---|---| | `cURL error 7 ... Could not connect` | SELinux: enable `httpd_can_network_connect` (see above). | | Reddit feeds empty / `HTTP 429` | Reddit's unauthenticated limit (~1 req/window). It self-heals via `error_retry_ttl` + jitter and the per-host cap; cached items still show. | | `403` on `/src/...`, `/config/...` | Expected — `.htaccess` denies those paths. | | `unable to open database file` | `data/` not writable by the web user, or wrong SELinux type on `data/`. | | Blank page / 500 | Check the PHP error log; run `php -l` on changed files. | | Stale data | Feeds refresh only when older than `cache_ttl`; delete `data/feeds.sqlite` to force a rebuild, or lower `cache_ttl`. | | New source has no color | Add a case to `View::sourceLabel()` / `View::sourceColor()`. | --- ## Conventions - Strict types (`declare(strict_types=1)`) in all PHP files; PSR-12 style. - All SQL is parameterized; all output is escaped via `App\View::e()`. - No comments in code unless they explain non-obvious behavior. - Tests: none yet — verify manually via the two entry points (page + API) and `php -l`.