397ee1a413
Reddit allows roughly one request per minute per IP on unauthenticated .rss, but the app fired six separate subreddit requests, retried 429s every couple of minutes, and only paced hosts within a single process. Concurrent page/API/cron requests stampeded providers. - Combine all due subreddits into one /r/a+b+c/.rss request and attribute items back to each subreddit by link; fall back to individual fetches when a banned/private subreddit poisons the combined feed. - Single-flight refresh via data/refresh.lock so concurrent callers serve the cache instead of refetching. - Enforce throttled-host spacing across processes (feeds.hostLastAttempt) in addition to the per-refresh cap; add www.youtube.com to throttled hosts. - Honor 429 Retry-After / x-ratelimit-reset and back off exponentially (fail_count doubling, capped at error_retry_cap, plus jitter), reset on success. New feeds columns fail_count and next_retry_at. - Jitter the healthy TTL so feeds don't all expire at once. - Detect add-feed 404 by status instead of matching the error string. - Update README (semantics, data model, config) and default user agent.
517 lines
23 KiB
Markdown
517 lines
23 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).
|
|
- 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`](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), or skip this entirely and manage the
|
|
selection from the browser on `manage.php` (see *Managing feeds*; set its
|
|
password in `config/app.php`).
|
|
- **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
|
|
├─ flock data/refresh.lock single-flight: concurrent callers skip
|
|
├─ FeedRepository::staleFeeds() due feeds, minus retry backoff, jittered TTL
|
|
├─ host pacing per-refresh cap + cross-process host interval
|
|
├─ all stale Reddit feeds → ONE combined request (/r/a+b+c/.rss),
|
|
│ items attributed back to each subreddit by link
|
|
└─ for each other stale feed:
|
|
FeedFetcher::fetch() Guzzle GET → laminas parse → normalize
|
|
FeedRepository::upsertItems() INSERT OR IGNORE (dedup by sha256(link))
|
|
FeedRepository::recordAttempt() success → clears error/fail_count;
|
|
failure → fail_count++, next_retry_at
|
|
|
|
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).
|
|
- Only **enabled** feeds are refreshed and served — disabling one from the
|
|
manage page stops both without deleting its cached items.
|
|
- A feed is **due** when its retry backoff has elapsed (`next_retry_at`) and its
|
|
jittered TTL has passed: `now - last_attempt_at >= cache_ttl + crc32(url) % ttl_jitter`
|
|
(jitter spreads expiry so feeds don't all refresh at once).
|
|
- **One refresh at a time.** `refreshStale()` takes a non-blocking lock on
|
|
`data/refresh.lock`; concurrent page/API/cron requests serve the cache and
|
|
return immediately instead of stampeding providers.
|
|
- **Rate limits are respected.** Throttled hosts (`throttled_hosts`) are limited
|
|
to `throttled_host_max_per_refresh` fetches per refresh *and* to a per-host
|
|
minimum interval enforced across processes. The gap between requests to the
|
|
same host is also paced in-request (`host_interval_ms`).
|
|
- **Reddit is fetched as one request.** All due subreddits are combined into a
|
|
single `reddit.com/r/a+b+c/.rss` call and the items are attributed back to each
|
|
subreddit by their links. If that fails with 404/403 (a private or banned
|
|
subreddit poisons the combined feed), the app falls back to individual fetches
|
|
so the culprit is isolated.
|
|
- **429 handling.** A `429` carries the server's own window (`Retry-After`, or
|
|
Reddit's `x-ratelimit-reset`); the feed is parked until then. Other failures
|
|
back off exponentially (`error_retry_ttl` doubling per consecutive failure, up
|
|
to `error_retry_cap`, plus jitter). Success resets the counters.
|
|
- Fetch failures are recorded (`last_error`, `fail_count`, `next_retry_at`) and
|
|
**never delete cached items** — the dashboard keeps serving the last good data.
|
|
|
|
---
|
|
|
|
## 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
|
|
│ ├── FeedFetchException.php Fetch failure carrying its HTTP status
|
|
│ ├── RateLimitException.php HTTP 429, carries the server's retry window
|
|
│ ├── Backoff.php Retry policy: server hint, else capped exponential + jitter
|
|
│ ├── 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\Backoff` | Turns a failure (fail count + optional server hint) into the next retry time | `nextRetryAt()` |
|
|
| `App\FeedRepository` | Every SQL statement; returns arrays/DTO-less rows | `syncFeeds()`, `enabledFeeds()`, `staleFeeds()`, `hostLastAttempt()`, `upsertItems()`, `recordAttempt()`, `getItems()`, `countItems()`, `addFeed()`, `setFeedEnabled()`, `deleteFeed()`, `feedsStatus()` |
|
|
| `App\FeedService` | Business flow: single-flight refresh (throttled hosts, combined Reddit fetch) + 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 |
|
|
| `fail_count` | consecutive failures; resets on success (drives exponential backoff) |
|
|
| `next_retry_at` | ISO-8601 UTC; the feed is skipped until this time (rate-limit / backoff) |
|
|
|
|
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` | `300` | base backoff for failed feeds (doubles per consecutive failure, plus jitter) |
|
|
| `error_retry_cap` | `7200` | maximum retry backoff for a failing feed |
|
|
| `ttl_jitter` | `300` | random seconds added to `cache_ttl` so feeds don't all expire at once |
|
|
| `host_interval_ms` | `1000` | min gap between requests to the same host within one refresh |
|
|
| `throttled_hosts` | `['www.reddit.com' => 60, 'www.youtube.com' => 15]` | host => min seconds between requests (enforced across processes) |
|
|
| `throttled_host_max_per_refresh` | `1` | max feeds of a throttled host fetched per refresh (combined Reddit = 1) |
|
|
| `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:
|
|
|
|
```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=...`.
|
|
|
|
---
|
|
|
|
## 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_password` in `config/app.php`. It accepts a plain password (fine
|
|
for a LAN/dev box) or a `password_hash()` value for better hygiene:
|
|
|
|
```bash
|
|
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 404` rejects 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.com` pages — 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.php` remains the default set and keeps working as before; the
|
|
manage page layers manual feeds and on/off state on top of it (persisted in
|
|
the `feeds` table, 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) |
|
|
|
|
- `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):** 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`:
|
|
|
|
```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 is tight (~1 request/min/IP). The app now combines all subreddits into one request, honors `x-ratelimit-reset`, backs off, and serves cached items meanwhile. If it persists, set a real `user_agent` contact and/or lower the Reddit refresh rate (`throttled_hosts`). |
|
|
| `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`.
|