From 41f07b6ad8529552abf9122409fe987309589af7 Mon Sep 17 00:00:00 2001 From: Harwinder Sekhon Date: Mon, 5 Oct 2026 18:58:38 -0400 Subject: [PATCH] Add manual subreddit/YouTube channel configuration (manage.php) 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. --- README.md | 85 ++++++++++++-- assets/manage.js | 11 ++ assets/style.css | 248 ++++++++++++++++++++++++++++++++++++++++ config/app.php | 5 + manage.php | 71 ++++++++++++ src/ChannelResolver.php | 139 ++++++++++++++++++++++ src/Database.php | 20 +++- src/FeedManager.php | 159 ++++++++++++++++++++++++++ src/FeedRepository.php | 91 +++++++++++++-- src/FeedService.php | 7 +- src/ManageAuth.php | 100 ++++++++++++++++ src/bootstrap.php | 4 + templates/layout.php | 13 ++- templates/manage.php | 198 ++++++++++++++++++++++++++++++++ 14 files changed, 1123 insertions(+), 28 deletions(-) create mode 100644 assets/manage.js create mode 100644 manage.php create mode 100644 src/ChannelResolver.php create mode 100644 src/FeedManager.php create mode 100644 src/ManageAuth.php create mode 100644 templates/manage.php diff --git a/README.md b/README.md index 17acc01..e163b27 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ No framework, no build step. PHP + SQLite for caching, Bootstrap 5 for the UI. `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). @@ -80,7 +83,9 @@ rm composer-setup.php ### 3. Configure - **Feeds** — edit `config/feeds.php` (see *Configuration* for the format and the - YouTube `@handle` → `channel_id` note). + 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). @@ -223,6 +228,8 @@ 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 **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). @@ -239,23 +246,29 @@ button when `has_more` is false. ``` . ├── 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 feed list: [{source, label, url}, ...] +│ └── 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, attempt state +│ ├── 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 -│ └── style.css Design tokens + card/pill/header styling +│ ├── 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) ``` @@ -266,10 +279,13 @@ button when `has_more` is false. |---|---|---| | `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\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','service']` | — | +| `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 @@ -298,14 +314,19 @@ escapes again on output). Indexes: `idx_items_published(published_at DESC)`, `idx_items_source(source, published_at DESC)`. -`feeds` — one row per configured feed + health state: +`feeds` — one row per feed (config or manually added) + selection/health state: | column | notes | |---|---| | `url` | primary key | -| `source`, `label` | from `config/feeds.php` | +| `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. --- @@ -325,6 +346,7 @@ Indexes: `idx_items_published(published_at DESC)`, | `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: @@ -343,12 +365,52 @@ Resolve a handle once (fetch `https://www.youtube.com/@handle` and read --- +## 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//.rss`, labeled `r/` | +| 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. @@ -358,9 +420,10 @@ Resolve a handle once (fetch `https://www.youtube.com/@handle` and read ## 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 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`: diff --git a/assets/manage.js b/assets/manage.js new file mode 100644 index 0000000..5c05870 --- /dev/null +++ b/assets/manage.js @@ -0,0 +1,11 @@ +(function () { + 'use strict'; + + document.querySelectorAll('form[data-confirm]').forEach(function (form) { + form.addEventListener('submit', function (event) { + if (!window.confirm(form.dataset.confirm)) { + event.preventDefault(); + } + }); + }); +})(); diff --git a/assets/style.css b/assets/style.css index d924428..7b41d17 100644 --- a/assets/style.css +++ b/assets/style.css @@ -419,6 +419,254 @@ main { color: #7a5a12; } +.alert-ok-soft { + border: 1px solid #b7ecd2; + border-radius: 14px; + background: #ecfbf3; + color: #14624a; +} + +/* ---------- Manage page ---------- */ + +.manage-link { + display: inline-flex; + align-items: center; + padding: 0.38rem 0.85rem; + 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: color 0.15s ease, border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease; +} + +.manage-link:hover { + color: var(--accent); + border-color: var(--accent); + transform: translateY(-1px); + box-shadow: 0 8px 18px -12px var(--accent); +} + +.manage-title { + font-size: 0.68rem; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--ink-soft); +} + +.manage-main { + max-width: 920px; +} + +.manage-panel { + background: var(--surface); + border: 1px solid var(--line); + border-radius: 18px; + padding: 1.25rem 1.4rem; + box-shadow: 0 1px 2px rgba(22, 11, 46, 0.04), 0 14px 34px -26px rgba(22, 11, 46, 0.5); +} + +.manage-notice { + max-width: 560px; + margin: 3rem auto; +} + +.manage-login { + max-width: 460px; + margin: 3rem auto; +} + +.manage-heading { + font-family: var(--display); + font-size: 1.25rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.manage-subheading { + font-family: var(--display); + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.85rem; +} + +.btn-compact { + padding: 0.5rem 1.35rem; + white-space: nowrap; +} + +.manage-group { + margin-bottom: 1.9rem; +} + +.manage-group-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-family: var(--display); + font-size: 1.02rem; + font-weight: 700; + margin-bottom: 0.7rem; +} + +.manage-group-title .pill-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--src); +} + +.manage-group-title .count { + font-size: 0.68rem; + font-weight: 600; + color: var(--ink-soft); + background: rgba(22, 11, 46, 0.06); + padding: 0.12rem 0.5rem; + border-radius: 999px; +} + +.feed-rows { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.feed-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.6rem 0.9rem; + background: var(--surface); + border: 1px solid var(--line); + border-left: 4px solid var(--src); + border-radius: 14px; + padding: 0.7rem 1rem; + transition: box-shadow 0.15s ease, opacity 0.15s ease; +} + +.feed-row:hover { + box-shadow: 0 12px 26px -18px rgba(22, 11, 46, 0.55); +} + +.feed-row.is-off { + opacity: 0.55; +} + +.feed-row-main { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem 0.6rem; + min-width: 0; + flex: 1 1 320px; +} + +.feed-row-label { + font-weight: 600; + font-size: 0.9rem; +} + +.origin-badge { + font-size: 0.6rem; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--ink-soft); + background: rgba(22, 11, 46, 0.06); + padding: 0.16rem 0.5rem; + border-radius: 999px; +} + +.feed-row-url { + font-size: 0.66rem; + color: var(--ink-soft); + text-decoration: none; + max-width: 36ch; + opacity: 0.85; +} + +.feed-row-url:hover { + color: var(--accent); + opacity: 1; +} + +.health { + display: inline-flex; + align-items: center; + gap: 0.32rem; + font-size: 0.64rem; + letter-spacing: 0.03em; + white-space: nowrap; +} + +.health-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.health-ok { + color: var(--rt); +} + +.health-err { + color: var(--youtube); +} + +.health-idle { + color: var(--ink-soft); + opacity: 0.75; +} + +.feed-row-actions { + display: flex; + align-items: center; + gap: 0.45rem; + flex-shrink: 0; +} + +.btn-mini { + border: 1px solid var(--line); + border-radius: 999px; + background: rgba(255, 255, 255, 0.75); + color: var(--ink-soft); + font-size: 0.72rem; + font-weight: 600; + padding: 0.3rem 0.85rem; + transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease, transform 0.15s ease; +} + +.btn-mini:hover, +.btn-mini:focus { + color: var(--ink); + border-color: var(--ink-soft); +} + +.btn-mini-on { + color: var(--accent); + border-color: rgba(106, 61, 255, 0.45); + background: rgba(106, 61, 255, 0.08); +} + +.btn-mini-on:hover, +.btn-mini-on:focus { + color: var(--accent); + border-color: var(--accent); + background: rgba(106, 61, 255, 0.14); +} + +.btn-mini-danger:hover, +.btn-mini-danger:focus { + color: var(--youtube); + border-color: rgba(255, 31, 75, 0.5); + background: rgba(255, 31, 75, 0.07); +} + /* ---------- Focus & motion ---------- */ a:focus-visible, diff --git a/config/app.php b/config/app.php index e3247cd..c04b201 100644 --- a/config/app.php +++ b/config/app.php @@ -35,4 +35,9 @@ return [ // Identify the client to feed providers (important for Reddit). 'user_agent' => 'RSSFeed/1.0 (+https://localhost; contact: admin@localhost)', + + // Password for manage.php (manual subreddit/channel configuration). + // Accepts a plain password or a password_hash() value; empty disables the + // manage page entirely. Change this before exposing the app publicly. + 'manage_password' => 'rssfeed-admin', ]; diff --git a/manage.php b/manage.php new file mode 100644 index 0000000..de6d8a4 --- /dev/null +++ b/manage.php @@ -0,0 +1,71 @@ + $app */ +$app = require __DIR__ . '/src/bootstrap.php'; + +$config = $app['config']; +$manager = $app['manager']; +$service = $app['service']; + +$auth = new App\ManageAuth($config); + +$redirect = static function (): never { + header('Location: manage.php', true, 303); + exit; +}; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $token = is_string($_POST['csrf'] ?? null) ? $_POST['csrf'] : ''; + $action = is_string($_POST['action'] ?? null) ? $_POST['action'] : ''; + $input = is_string($_POST['input'] ?? null) ? trim($_POST['input']) : ''; + $url = is_string($_POST['url'] ?? null) ? trim($_POST['url']) : ''; + $enabled = is_string($_POST['enabled'] ?? null) && $_POST['enabled'] === '1'; + + if (!$auth->enabled() || !$auth->checkCsrf($token)) { + $auth->flash('err', 'Session expired — please try again.'); + $redirect(); + } + + if ($action === 'logout') { + $auth->logout(); + $auth->flash('ok', 'Signed out.'); + $redirect(); + } + + if ($action === 'login') { + if (!$auth->login($input)) { + $auth->flash('err', 'Wrong password.'); + } + $redirect(); + } + + $result = $auth->authed() ? match ($action) { + 'add_reddit' => $manager->addReddit($input), + 'add_youtube' => $manager->addYouTube($input), + 'toggle' => $manager->setEnabled($url, $enabled), + 'remove' => $manager->remove($url), + default => null, + } : null; + + if ($result === null) { + $auth->flash('err', 'Please sign in first.'); + } else { + $auth->flash($result['ok'] ? 'ok' : 'err', $result['message']); + } + + $redirect(); +} + +echo App\View::render('manage', [ + 'config' => $config, + 'auth' => $auth, + 'feeds' => $auth->authed() ? $service->feedsStatus() : [], + 'flash' => $auth->takeFlash(), +]); diff --git a/src/ChannelResolver.php b/src/ChannelResolver.php new file mode 100644 index 0000000..760d93b --- /dev/null +++ b/src/ChannelResolver.php @@ -0,0 +1,139 @@ +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' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language' => 'en-US,en;q=0.9', + ], + ]); + } + + /** + * @return array{url: string, label: string} + */ + public function resolve(string $input): array + { + $input = trim($input); + + if ($input === '') { + throw new \RuntimeException('Enter a channel ID, @handle or channel URL.'); + } + + if (preg_match('~^(?:https?://)?(?:[a-z0-9-]+\.)*youtube\.com/~i', $input) && parse_url($input, PHP_URL_HOST) === false) { + $input = 'https://' . $input; + } + + $channelId = $this->channelIdFromInput($input); + + if ($channelId === null) { + $channelId = $this->resolveFromChannelPage($input); + } + + // The label starts as the handle/ID; FeedManager replaces it with the + // real channel title after the first fetch of the feed. + return [ + 'url' => 'https://www.youtube.com/feeds/videos.xml?channel_id=' . $channelId, + 'label' => $channelId, + ]; + } + + /** + * Extract a channel ID directly from raw input or a YouTube URL. + */ + private function channelIdFromInput(string $input): ?string + { + if (preg_match('~^(UC[A-Za-z0-9_-]{22})$~', $input, $m)) { + return $m[1]; + } + + $host = strtolower((string) parse_url($input, PHP_URL_HOST)); + if ($host === '' || (!str_ends_with($host, 'youtube.com'))) { + return null; + } + + if (preg_match('~[?&]channel_id=(UC[A-Za-z0-9_-]{22})~', $input, $m)) { + return $m[1]; + } + + if (preg_match('~/channel/(UC[A-Za-z0-9_-]{22})~', $input, $m)) { + return $m[1]; + } + + return null; + } + + /** + * Resolve @handle and /c/name style inputs by fetching the channel page + * (or the given channel URL) once and extracting its channel ID. + */ + private function resolveFromChannelPage(string $input): string + { + $pageUrl = $this->channelPageUrl($input); + + $response = $this->http->get($pageUrl); + $status = $response->getStatusCode(); + + if ($status !== 200) { + throw new \RuntimeException(sprintf('YouTube returned HTTP %d for %s', $status, $pageUrl)); + } + + $html = (string) $response->getBody(); + $channelId = null; + + if (preg_match('~itemprop="identifier"\s+content="(UC[A-Za-z0-9_-]{22})"~', $html, $m)) { + $channelId = $m[1]; + } elseif (preg_match('~"(?:externalChannelId|channelId)":"(UC[A-Za-z0-9_-]{22})"~', $html, $m)) { + $channelId = $m[1]; + } + + if ($channelId === null) { + throw new \RuntimeException(sprintf('Could not find a channel ID for %s', $pageUrl)); + } + + return $channelId; + } + + private function channelPageUrl(string $input): string + { + if (preg_match('~^@([A-Za-z0-9._-]{3,30})/?$~', $input, $m)) { + return 'https://www.youtube.com/@' . $m[1]; + } + + $host = strtolower((string) parse_url($input, PHP_URL_HOST)); + if ($host !== '' && str_ends_with($host, 'youtube.com')) { + $path = (string) parse_url($input, PHP_URL_PATH); + + if (preg_match('~^/@([A-Za-z0-9._%-]{3,30})/?$~', rawurldecode($path), $m)) { + return 'https://www.youtube.com/@' . $m[1]; + } + + if (preg_match('~^/(c|user)/([A-Za-z0-9._-]{3,30})/?$~', $path, $m)) { + return 'https://www.youtube.com/' . $m[1] . '/' . $m[2]; + } + } + + throw new \RuntimeException(sprintf('"%s" is not a channel ID, @handle or YouTube channel URL.', $input)); + } +} diff --git a/src/Database.php b/src/Database.php index 3c13c44..0c57e25 100644 --- a/src/Database.php +++ b/src/Database.php @@ -58,8 +58,26 @@ final class Database label TEXT, last_attempt_at TEXT, last_success_at TEXT, - last_error TEXT + last_error TEXT, + origin TEXT NOT NULL DEFAULT 'config', + enabled INTEGER NOT NULL DEFAULT 1 ) SQL); + + // SQLite has no "ADD COLUMN IF NOT EXISTS": migrate pre-existing tables. + self::addColumn($pdo, 'feeds', 'origin', "ALTER TABLE feeds ADD COLUMN origin TEXT NOT NULL DEFAULT 'config'"); + self::addColumn($pdo, 'feeds', 'enabled', 'ALTER TABLE feeds ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1'); + } + + private static function addColumn(PDO $pdo, string $table, string $column, string $sql): void + { + $existing = []; + foreach ($pdo->query(sprintf('PRAGMA table_info(%s)', $table)) as $row) { + $existing[strtolower((string) $row['name'])] = true; + } + + if (!isset($existing[strtolower($column)])) { + $pdo->exec($sql); + } } } diff --git a/src/FeedManager.php b/src/FeedManager.php new file mode 100644 index 0000000..ec672e4 --- /dev/null +++ b/src/FeedManager.php @@ -0,0 +1,159 @@ + false, 'message' => sprintf('"%s" is not a subreddit name or reddit.com URL.', trim($input))]; + } + + // Reddit feed titles are verbose ("Linux, GNU/Linux, free software…"); + // the clean r/name form matches how config/feeds.php labels them. + return $this->addFeed('reddit', 'r/' . $name, 'https://www.reddit.com/r/' . $name . '/.rss', trim($input), false); + } + + /** + * @return array{ok: bool, message: string} + */ + public function addYouTube(string $input): array + { + try { + $resolved = $this->resolver->resolve($input); + } catch (\Throwable $e) { + return ['ok' => false, 'message' => $e->getMessage()]; + } + + return $this->addFeed('youtube', $resolved['label'], $resolved['url'], trim($input), true); + } + + /** + * Remove a feed. Manual feeds are deleted; config feeds can only be + * disabled here because config/feeds.php re-registers them on every sync. + * + * @return array{ok: bool, message: string} + */ + public function remove(string $url): array + { + $feed = $this->repository->findFeed($url); + + if ($feed === null) { + return ['ok' => false, 'message' => 'That feed is not registered.']; + } + + $label = (string) ($feed['label'] ?? $url); + + if ((string) $feed['origin'] === 'config') { + $this->repository->setFeedEnabled($url, false); + + return ['ok' => true, 'message' => sprintf('Disabled "%s" (it is defined in config/feeds.php, so it is hidden rather than deleted).', $label)]; + } + + $this->repository->deleteFeed($url); + + return ['ok' => true, 'message' => sprintf('Removed "%s" (cached stories stay hidden until it is added again).', $label)]; + } + + /** + * @return array{ok: bool, message: string} + */ + public function setEnabled(string $url, bool $enabled): array + { + $feed = $this->repository->findFeed($url); + + if ($feed === null) { + return ['ok' => false, 'message' => 'That feed is not registered.']; + } + + $this->repository->setFeedEnabled($url, $enabled); + + return ['ok' => true, 'message' => sprintf('%s "%s".', $enabled ? 'Enabled' : 'Disabled', (string) ($feed['label'] ?? $url))]; + } + + /** + * Register the feed, then fetch it once directly (bypassing the refresh + * host cap, since the user asked for this feed explicitly): a 404 rejects + * the entry, anything else keeps it and lets the normal refresh retry. + * + * @return array{ok: bool, message: string} + */ + private function addFeed(string $source, string $label, string $url, string $displayInput, bool $labelFromTitle): array + { + $this->repository->addFeed($source, $label, $url); + + try { + $result = $this->fetcher->fetch(['source' => $source, 'label' => $label, 'url' => $url]); + + $title = trim((string) ($result['title'] ?? '')); + if ($labelFromTitle && $title !== '') { + $label = $title; + $this->repository->setFeedLabel($url, $title); + + // Cache the new items under the real name, not the input handle/ID. + foreach ($result['items'] as &$item) { + $item['feed_label'] = $title; + } + unset($item); + } + + $newItems = $this->repository->upsertItems($result['items']); + $this->repository->recordAttempt($url, true); + + return ['ok' => true, 'message' => sprintf('Added "%s" — %d stories cached.', $label, $newItems)]; + } catch (\Throwable $e) { + $error = $e->getMessage(); + $this->repository->recordAttempt($url, false, $error); + + if (str_contains($error, 'HTTP 404')) { + $this->repository->deleteFeed($url); + + return ['ok' => false, 'message' => sprintf('"%s" was not found (HTTP 404); nothing was added.', $displayInput)]; + } + + return ['ok' => true, 'message' => sprintf('Added "%s" — not fetched yet (%s); it will fill in on a later refresh.', $label, $error)]; + } + } + + /** + * Normalize "name", "r/name" or any reddit.com/r/name URL to the + * subreddit name, or null when the input cannot be a subreddit. + */ + private static function subredditName(string $input): ?string + { + $input = trim($input); + + if ($input === '') { + return null; + } + + if (preg_match('~^(?:https?://)?(?:[a-z0-9-]+\.)*reddit\.com/r/([A-Za-z0-9_]{2,21})(?:[/?#]|$)~i', $input, $m)) { + return strtolower($m[1]); + } + + if (preg_match('~^(?:r/)?([A-Za-z0-9_]{2,21})/?$~i', $input, $m)) { + return strtolower($m[1]); + } + + return null; + } +} diff --git a/src/FeedRepository.php b/src/FeedRepository.php index 7bf583c..f421138 100644 --- a/src/FeedRepository.php +++ b/src/FeedRepository.php @@ -13,16 +13,21 @@ final class FeedRepository } /** - * Keep the feeds table in sync with the configured feed list. + * Keep the feeds table in sync with the configured feed list. Manual feeds + * and the enabled flag of existing rows are never touched, so a disabled + * config feed stays disabled across syncs. * * @param array> $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 + INSERT INTO feeds (url, source, label, origin) + VALUES (:url, :source, :label, 'config') + ON CONFLICT(url) DO UPDATE SET + source = excluded.source, + label = excluded.label, + origin = 'config' SQL); foreach ($feeds as $feed) { @@ -34,6 +39,67 @@ final class FeedRepository } } + /** + * Feeds currently selected for the dashboard, in the normalized shape the + * refresher and sources() expect (insert order: config feeds, then manual). + * + * @return array + */ + public function enabledFeeds(): array + { + return $this->pdo->query( + 'SELECT url, COALESCE(label, url) AS label, source FROM feeds WHERE enabled = 1 ORDER BY rowid' + )->fetchAll(); + } + + /** + * Register a feed manually, or re-enable it when its URL already exists. + */ + public function addFeed(string $source, string $label, string $url, string $origin = 'manual'): void + { + $stmt = $this->pdo->prepare(<<<'SQL' + INSERT INTO feeds (url, source, label, origin, enabled) + VALUES (:url, :source, :label, :origin, 1) + ON CONFLICT(url) DO UPDATE SET + source = excluded.source, + label = excluded.label, + origin = excluded.origin, + enabled = 1 + SQL); + + $stmt->execute([':url' => $url, ':source' => $source, ':label' => $label, ':origin' => $origin]); + } + + public function setFeedEnabled(string $url, bool $enabled): void + { + $stmt = $this->pdo->prepare('UPDATE feeds SET enabled = :enabled WHERE url = :url'); + $stmt->execute([':enabled' => $enabled ? 1 : 0, ':url' => $url]); + } + + public function setFeedLabel(string $url, string $label): void + { + $stmt = $this->pdo->prepare('UPDATE feeds SET label = :label WHERE url = :url'); + $stmt->execute([':label' => $label, ':url' => $url]); + } + + /** + * @return array{url: string, source: string, label: string, origin: string, enabled: int}|null + */ + public function findFeed(string $url): ?array + { + $stmt = $this->pdo->prepare('SELECT url, source, label, origin, enabled FROM feeds WHERE url = :url'); + $stmt->execute([':url' => $url]); + $row = $stmt->fetch(); + + return $row === false ? null : $row; + } + + public function deleteFeed(string $url): void + { + $stmt = $this->pdo->prepare('DELETE FROM feeds WHERE url = :url'); + $stmt->execute([':url' => $url]); + } + /** * Return the subset of feeds whose last attempt is missing or older than TTL. * Feeds that previously failed use a shorter retry interval. @@ -142,13 +208,15 @@ final class FeedRepository } /** - * Build the shared WHERE clause for item queries. + * Build the shared WHERE clause for item queries. Items only ever surface + * from feeds that are currently enabled, so deselecting a feed hides its + * cached stories without deleting them. * * @param array $params */ private function itemFilter(?string $source, ?string $since, array &$params): string { - $where = []; + $where = ['feed_url IN (SELECT url FROM feeds WHERE enabled = 1)']; if ($source !== null) { $where[] = 'source = :source'; @@ -160,7 +228,7 @@ final class FeedRepository $params[':since'] = $since; } - return $where === [] ? '' : ' WHERE ' . implode(' AND ', $where); + return ' WHERE ' . implode(' AND ', $where); } /** @@ -198,10 +266,15 @@ final class FeedRepository } /** - * @return array + * Every registered feed (enabled and disabled) plus its health state, + * grouped by source order, for the manage page. + * + * @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(); + return $this->pdo->query( + 'SELECT url, label, source, origin, enabled, last_success_at, last_error FROM feeds ORDER BY source, rowid' + )->fetchAll(); } } diff --git a/src/FeedService.php b/src/FeedService.php index 1aa98d0..ccf1070 100644 --- a/src/FeedService.php +++ b/src/FeedService.php @@ -39,8 +39,9 @@ final class FeedService ]; $hostCounts = []; + $activeFeeds = $this->repository->enabledFeeds(); - foreach ($this->repository->staleFeeds($this->feeds, $this->ttlSeconds, $this->errorRetryTtlSeconds) as $feed) { + foreach ($this->repository->staleFeeds($activeFeeds, $this->ttlSeconds, $this->errorRetryTtlSeconds) as $feed) { $url = (string) $feed['url']; $host = (string) parse_url($url, PHP_URL_HOST); @@ -100,12 +101,14 @@ final class FeedService } /** + * Sources that currently have at least one enabled feed. + * * @return array */ public function sources(): array { $sources = []; - foreach ($this->feeds as $feed) { + foreach ($this->repository->enabledFeeds() as $feed) { $sources[(string) $feed['source']] = true; } diff --git a/src/ManageAuth.php b/src/ManageAuth.php new file mode 100644 index 0000000..f076d79 --- /dev/null +++ b/src/ManageAuth.php @@ -0,0 +1,100 @@ + true, + 'cookie_samesite' => 'Lax', + ]); + } + } + + public function enabled(): bool + { + return trim((string) ($this->config['manage_password'] ?? '')) !== ''; + } + + public function authed(): bool + { + return $this->enabled() && ($_SESSION['manage_authed'] ?? false) === true; + } + + public function login(string $password): bool + { + if (!$this->enabled() || !$this->matches($password)) { + return false; + } + + session_regenerate_id(true); + $_SESSION['manage_authed'] = true; + + return true; + } + + public function logout(): void + { + $_SESSION['manage_authed'] = false; + } + + public function csrfToken(): string + { + if (!is_string($_SESSION['manage_csrf'] ?? null)) { + $_SESSION['manage_csrf'] = bin2hex(random_bytes(16)); + } + + return $_SESSION['manage_csrf']; + } + + public function checkCsrf(string $token): bool + { + return $token !== '' && hash_equals($this->csrfToken(), $token); + } + + public function flash(string $type, string $text): void + { + $_SESSION['manage_flash'] = ['type' => $type, 'text' => $text]; + } + + /** + * @return array{type: string, text: string}|null + */ + public function takeFlash(): ?array + { + $flash = $_SESSION['manage_flash'] ?? null; + + if (is_array($flash) && isset($flash['type'], $flash['text'])) { + unset($_SESSION['manage_flash']); + + return ['type' => (string) $flash['type'], 'text' => (string) $flash['text']]; + } + + unset($_SESSION['manage_flash']); + + return null; + } + + /** + * Accepts either a password_hash() value or a plain password. + */ + private function matches(string $password): bool + { + $stored = trim((string) ($this->config['manage_password'] ?? '')); + + if (str_starts_with($stored, '$2') || str_starts_with($stored, '$argon2')) { + return password_verify($password, $stored); + } + + return hash_equals($stored, $password); + } +} diff --git a/src/bootstrap.php b/src/bootstrap.php index b851ada..c3668c5 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -10,6 +10,8 @@ $feeds = require dirname(__DIR__) . '/config/feeds.php'; $pdo = App\Database::connect($config['db_path']); $repository = new App\FeedRepository($pdo); $fetcher = new App\FeedFetcher($config); +$resolver = new App\ChannelResolver($config); +$manager = new App\FeedManager($repository, $fetcher, $resolver); $service = new App\FeedService( $repository, $fetcher, @@ -26,5 +28,7 @@ return [ 'pdo' => $pdo, 'repository' => $repository, 'fetcher' => $fetcher, + 'resolver' => $resolver, + 'manager' => $manager, 'service' => $service, ]; diff --git a/templates/layout.php b/templates/layout.php index 7f2b467..073a51c 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -59,11 +59,14 @@ $total = (int) $page['total']; RSS Feed Dashboard -
- - stories - / - YouTube · Reddit · RT +
+
+ + stories + / + YouTube · Reddit · RT +
+ Manage
diff --git a/templates/manage.php b/templates/manage.php new file mode 100644 index 0000000..dde00f6 --- /dev/null +++ b/templates/manage.php @@ -0,0 +1,198 @@ + $config */ +/** @var App\ManageAuth $auth */ +/** @var array> $feeds */ +/** @var array{type: string, text: string}|null $flash */ + +$csrf = View::e($auth->csrfToken()); + +$feedGroups = []; +foreach ($feeds as $feed) { + $feedGroups[(string) $feed['source']][] = $feed; +} + +$actionUrl = 'manage.php'; +?> + + + + + + Manage feeds · RSS Feed Dashboard + + + + + + + + + +
+ enabled()): ?> +
+

Management is disabled

+

+ Set manage_password in config/app.php + to enable this page. +

+
+ authed()): ?> + + + + + + + + + +
+
+
+

Add a subreddit

+
+ + + + +
+
+
+
+
+

Add a YouTube channel

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

No feeds registered yet — add one above, or edit config/feeds.php.

+
+ + + $groupFeeds): ?> +
+

+ + + +

+ +
+ + +
+
+ + + + + + failed + + + + + + + not fetched yet + +
+
+
+ + + + + +
+
+ + + + +
+
+
+ +
+
+ + +
+
+ + + +
+
+ +
+ + + + +