Browser-based feed selection layered on top of config/feeds.php: - manage.php (password + CSRF gated, session based): add subreddits (name / r/name / URL) and YouTube channels (@handle / UC id / URL, handle resolved to channel_id once, youtube.com only); enable/disable toggles; remove manual feeds; config feeds are hidden, not deleted. - New feeds are validated with one immediate fetch (bypassing the per-refresh host cap): HTTP 404 rejects the entry, 429 keeps it for a later refresh. YouTube labels adopt the real channel title. - feeds table gains origin (config|manual) and enabled columns, with a guarded ALTER migration for existing databases. - Only enabled feeds are refreshed and served: getItems/countItems filter by feed_url in enabled feeds, so deselected feeds' cached stories are hidden, not deleted; re-adding a URL re-enables it. - Dashboard header gets a Manage link; manage page styled with the existing design tokens; README documents the feature.
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(fallbackfetched_at), newest first. - Filters by source (
youtube/reddit/rt) and time range (all time, 1h, 2h, 6h, 24h, 7d). - Lets you manually configure the subreddit and YouTube channel
selection from the browser (
manage.php, password protected): add channels by@handle, disable or remove feeds — no config editing needed. - 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 |
| HTTP | guzzlehttp/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:
php -v
php -m | grep -E 'pdo_sqlite|curl|dom|simplexml|mbstring'
1. Get the code
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:
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:
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_idnote), or skip this entirely and manage the selection from the browser onmanage.php(see Managing feeds; set its password inconfig/app.php). - Runtime — optionally tune
config/app.php(page size, TTLs, ranges, timeouts, user agent).
4. Create the cache directory
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:
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:
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:
<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
.htaccessfiles block HTTP access tosrc/,config/,vendor/,templates/,data/andcomposer.*. They do not affect PHP's filesystem reads, so the app keeps working normally.
6. SELinux (Fedora / RHEL only)
# 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
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:
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
itemsandfeedslive indata/feeds.sqlite(WAL mode, outside the web root).- Only enabled feeds are refreshed and served — disabling one from the manage page stops both without deleting its cached items.
- A feed is stale when
now - last_attempt_at >= cache_ttl, or, if its last attempt errored, whennow - last_attempt_at >= error_retry_ttl + jitter(jitter derived fromcrc32(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_hostscaps how many feeds of a given host are fetched per refresh (throttled_host_max_per_refresh), and requests to the same host are spaced byhost_interval_ms. Only 5xx responses retry in-request.
Files
.
├── index.php Page entry point: refresh, filter, render page 1
├── manage.php Manual feed configuration (password protected)
├── api/items.php JSON endpoint for "Load more" (returns rendered HTML)
├── config/
│ ├── app.php Runtime settings (see Configuration)
│ └── feeds.php The default 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, feed management
│ ├── FeedService.php Orchestration: refreshStale(), page(), sources()
│ ├── FeedManager.php Manual selection: addReddit()/addYouTube()/remove()/setEnabled()
│ ├── ChannelResolver.php YouTube @handle / channel URL → channel_id → Atom feed URL
│ ├── ManageAuth.php manage.php gate: password, CSRF, flash messages
│ └── View.php Escaping + render() + labels/colors/time formatting
├── templates/
│ ├── layout.php Full page shell, filters, grid, empty state, Load more
│ ├── manage.php Feed management page (login, add forms, feed list)
│ └── cards.php One feed card partial (used by page + API)
├── assets/
│ ├── app.js Load-more, relative timestamps, filter propagation
│ ├── manage.js Confirm dialogs on the manage page
│ └── style.css Design tokens + card/pill/header/manage 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(), enabledFeeds(), staleFeeds(), upsertItems(), recordAttempt(), getItems(), countItems(), addFeed(), setFeedEnabled(), deleteFeed(), feedsStatus() |
App\FeedService |
Business flow: refresh policy + paginated reads | refreshStale(), page(), sources(), feedsStatus() |
App\FeedManager |
Manual selection changes; validates new feeds with one immediate fetch (404 rejects the entry) | addReddit(), addYouTube(), remove(), setEnabled() |
App\ChannelResolver |
YouTube input (@handle, UC… ID or channel URL) → Atom feed URL; resolves handles once via the channel page (youtube.com only) |
resolve() |
App\ManageAuth |
Session gate for manage.php: password login, CSRF tokens, flash messages |
login(), authed(), csrfToken(), checkCsrf() |
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','resolver','manager','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 feed (config or manually added) + selection/health state:
| column | notes |
|---|---|
url |
primary key |
source, label |
from config/feeds.php or the manage page |
last_attempt_at, last_success_at |
ISO-8601 UTC |
last_error |
last failure message, NULL on success |
origin |
config (from config/feeds.php) or manual (added via manage.php) |
enabled |
1 = selected; disabled feeds are neither refreshed nor shown |
Only items whose feed_url belongs to an enabled feed are served — disabling or
removing a feed hides its cached stories without deleting them.
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) |
manage_password |
rssfeed-admin |
password for manage.php; plain or password_hash() value, empty = management disabled |
config/feeds.php returns a list of feeds:
[
'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=....
Managing feeds (manual configuration)
manage.php (linked as Manage in the dashboard header) configures the
subreddit/YouTube selection from the browser. It is password protected:
-
Set
manage_passwordinconfig/app.php. It accepts a plain password (fine for a LAN/dev box) or apassword_hash()value for better hygiene:php -r 'echo password_hash("your secret", PASSWORD_DEFAULT), PHP_EOL;' -
An empty value disables the page (it shows a setup notice instead).
What you can do there:
| Action | Input | Behavior |
|---|---|---|
| Add subreddit | homelab, r/homelab, or any reddit.com/r/… URL |
Normalized to https://www.reddit.com/r/<name>/.rss, labeled r/<name> |
| Add YouTube channel | @handle, UC… channel ID, or any channel URL |
Handle resolved to channel_id once, stored as the Atom feed URL; the label becomes the real channel title |
| Disable / enable | toggle per feed | Disabled feeds are neither refreshed nor shown (their cached stories are kept and reappear when re-enabled) |
| Remove | manual feeds | Deleted from the selection; re-add anytime |
| Remove | config feeds | Hidden (enabled = 0) — config/feeds.php re-registers them on every sync, so they cannot be deleted from the UI |
Semantics worth knowing:
- Adding a feed fetches it immediately (bypassing the per-refresh Reddit
host cap): an
HTTP 404rejects the entry ("not found, nothing was added"), while a rate-limited (429) add is kept and fills in on a later refresh. - Re-adding an existing feed URL re-enables it.
- Handle resolution only ever requests
youtube.compages — user input can never point the resolver at arbitrary URLs. - All POST actions are CSRF-protected (session token) and use POST-then-redirect.
config/feeds.phpremains the default set and keeps working as before; the manage page layers manual feeds and on/off state on top of it (persisted in thefeedstable, so they survive restarts).
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 } |
manage.php |
— (POST: login, logout, add_reddit, add_youtube, toggle, remove) |
management page (password gated) |
sourcemust match asourceinconfig/feeds.php, else ignored.rangemust be a key inranges, else ignored.- Both filters are preserved across navigation and across "Load more".
Adding feeds and sources
New feed (existing source): either add it on the manage page (no code
editing, see Managing feeds) or add an entry to config/feeds.php.
syncFeeds() registers config entries automatically on the next request; they
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:
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)
-
Document root = project root.
index.phpis served for/RSSFeed/; the built-in files underapi/andassets/are served directly. -
Protect internals.
.htaccessfiles deny HTTP access tosrc/,config/,vendor/,templates/,data/, andcomposer.*. These only affect HTTP; PHP still reads the files from disk. Directory listing is disabled. -
Let the web user write the cache.
data/must be writable by the php-fpm user (e.g.apache), and on SELinux it needs thehttpd_sys_rw_content_ttype:chmod 1777 data chcon -R -t httpd_sys_rw_content_t data -
Allow outbound HTTP. On SELinux, php-fpm runs in
httpd_tand outbound connections are denied by default (symptom:cURL error 7 ... Could not connect to server). Enable it once:sudo setsebool -P httpd_can_network_connect onWithout 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.