403fd2c604
- Un-ignore composer.phar (keep vendor/ and SQLite gitignored) - Document install/bootstrap in README
428 lines
16 KiB
Markdown
428 lines
16 KiB
Markdown
# 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
|
|
<VirtualHost *:80>
|
|
ServerName localhost
|
|
DocumentRoot "/path/to/RSS-Feed"
|
|
|
|
<Directory "/path/to/RSS-Feed">
|
|
Options Indexes FollowSymLinks
|
|
AllowOverride All
|
|
Require all granted
|
|
</Directory>
|
|
</VirtualHost>
|
|
```
|
|
|
|
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 `<enclosure>` → 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
|
|
`<meta itemprop="identifier">` 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`.
|