Author SHA1 Message Date
mathiasandClaude Sonnet 5 169c294e62 Mark article read on display in article view, including on boot
markCurrentArticleRead() already marked the displayed article read
immediately at each deliberate navigation point (entering article view,
paging next/prev), but reloading the page while viewMode was already
'article' (persisted in localStorage) booted straight into article view
without marking anything read — the article on screen stayed unread
until the user paged forward/back at least once.

Close that gap with a single explicit call in RssFeeds.vue's onMounted:
once the initial fetch resolves, if we're already in article view, mark
the now-displayed article read the same way toggleViewMode()/
nextArticle()/prevArticle() do.

(A first attempt replaced the explicit call sites with a generic watch
on the displayed article's id, but that also fired on unrelated changes
to it — e.g. switching the feed filter while in article view resets
currentIndex and reprojects feeds, so the watch would silently mark an
article the user never viewed as read. Reverted to the explicit,
call-site-scoped approach and added only the one boot-time call needed.)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K2sYVA7rh7N5RYmcCd2KDV
2026-09-12 15:29:32 +02:00
mathias e2df60d112 Merge pull request 'Fix unread counter stuck after reading all articles' (#2) from hotfix/unread-counter-stuck into master
Reviewed-on: #2
2026-09-11 17:22:28 +02:00
mathiasandClaude Sonnet 5 5f816bb0f5 Fix unread counter stuck after reading all articles
markRead() fired its PUT fire-and-forget with no retry and silently
swallowed failures. If one PUT among a burst (paging through article
view fires one per page turn) failed or lost a race with the app's own
background sync()->fetchData() refetch, that article's read-mark was
permanently lost: the header's unreadCount (derived live from
allItems) would show it again mid-session, and a reload reproduced it
identically since the DB genuinely still had it unread.

- markRead() now retries transient failures (no response / 5xx / 429)
  with backoff and a request timeout, skips retrying non-retryable
  4xx, and surfaces a message on final failure instead of only
  console.log.
- A pendingReadCounts refcount (keyed by article id) tracks in-flight/
  retrying marks; fetchData() excludes those ids - snapshotting before
  its GET is dispatched, not just once the response lands - so a
  concurrent refetch can't resurrect an article whose read-confirmation
  is still in flight or unsettled at request time.
- markAllRead() now reports a partial-failure summary instead of
  unconditionally claiming full success when some marks fail.

Extends vue/src/composables/__tests__/useFeeds.spec.js with coverage
for retry/backoff, non-retryable errors, the dispatch-before-response
race, overlapping concurrent marks for the same id, and markAllRead's
partial-failure message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVw1XNQjodp4B7qiZqjtDE
2026-09-11 17:21:16 +02:00
mathiasandClaude Sonnet 5 67b970df9b Merge branch 'fix/concurrent-sync-duplicate-items'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CHcxDSPbHhe7sLJQBRVC9
2026-09-10 16:11:48 +02:00
mathiasandClaude Sonnet 5 6c8b4e9e32 Fix duplicate feed items from concurrent syncs
A sync button press racing a page-reload sync could run two sync
requests concurrently. create_feed_item used a check-then-insert
pattern (query for an existing item, then insert if none found), so
both requests could pass the check before either inserted, creating
duplicate items.

- Add a UNIQUE (feed_id, url) constraint on feed_item, keyed on the
  article's link rather than its title since that's RSS's stable
  article identity (a feed editing a headline after publishing, same
  link, would otherwise dupe under title-based matching).
- The migration first collapses any duplicates already created by the
  race, propagating read=true onto the surviving row when any
  duplicate in its group was already read, so the cleanup can't make
  an already-read article look unread.
- create_feed_item now does a single atomic
  INSERT ... ON CONFLICT (feed_id, url) DO NOTHING instead of
  select-then-insert, closing the race entirely.
- Add a test that races two threads on separate connections inserting
  the same item and asserts only one row survives.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CHcxDSPbHhe7sLJQBRVC9
2026-09-10 16:04:34 +02:00
mathias e2fb32f898 fix cargo audit reports and upgraded cargo dependencies 2026-08-29 14:28:59 +02:00
mathias a2c947f870 Change top bar to only appear whene scrolling up 2026-08-28 07:42:59 +02:00
mathias abb6002078 remove counter of selected feed 2026-08-28 07:37:42 +02:00
mathias f447a9a163 remove slide effect 2026-08-27 21:33:58 +02:00
mathias a26f31a150 bugfix: extend swipe listener to swipe on empty space 2026-08-27 14:25:38 +02:00
mathias c911258578 Revert "Merge branch 'swipe-drag-follow'"
This reverts commit d52fa90b6b, reversing
changes made to 77438d0532.
2026-08-26 22:07:58 +02:00
mathiasandClaude Sonnet 5 d52fa90b6b Merge branch 'swipe-drag-follow'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:04:01 +02:00
mathiasandClaude Sonnet 5 bfb926cec6 Replace threshold-based swipe with live drag-follow in article view
The article now visually tracks the finger in real time while swiping
(via a rAF-throttled touchmove handler), instead of only reacting
after the fact on touchend. On release it completes the page-turn if
dragged past ~35% of the article's width, or on a fast-enough flick
even short of that distance; otherwise it springs back to center.
Dragging past the first/last article is damped rather than free, for
tactile feedback that there's nothing further that way. A vertical
drag is detected within the first ~10px of movement and left alone so
it can't fight normal page scrolling.

Also fixes a real bug found while testing the flick path: Date.now()
has ~1ms resolution, so a genuinely fast flick often measures as
elapsed === 0. The velocity calculation was treating that as "zero
velocity" (dividing 0 elapsed into "no flick") instead of "as fast as
it gets" — now a zero-elapsed gesture with real movement counts as a
flick outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:03:41 +02:00
mathiasandClaude Sonnet 5 77438d0532 Merge branch 'fix-android-swipe-gesture'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:28:18 +02:00
mathiasandClaude Sonnet 5 49ceec5125 Fix article-view swipe being swallowed by Chrome-on-Android back gesture
On real Android touchscreens, a horizontal drag on content with nothing
left to scroll horizontally gets claimed by Chrome's native
swipe-to-go-back/forward gesture before our own touchend listener
(article-view swipe navigation) ever sees it — silently, with no
touchend firing at all. Synthetic touch testing (mouse-drag emulation
in devtools) doesn't go through that gesture recognizer, so it worked
there while doing nothing on an actual phone.

Fix: overscroll-behavior-x: contain on body stops Chrome from claiming
the gesture for its own navigation, letting our touchend fire normally.

Also drops the overflow-x: hidden added to .article-single for the
slide transition — body already has it (for the same full-bleed-image
reason), so it was redundant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:27:23 +02:00
mathiasandClaude Sonnet 5 15ac8c287b Merge branch 'article-view-swipe-nav'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:16:26 +02:00
mathiasandClaude Sonnet 5 13d9c2c908 Add swipe navigation and slide transition to article view
Swiping left/right in the single-article view now pages to the
next/previous article, same as the up/down nav buttons. Touch handlers
live on .article-single: a swipe needs >=50px horizontal travel and
<=75px vertical travel to count (so scrolling/diagonal drags don't
trigger it), and multi-touch or swipes starting inside a horizontally
scrollable <pre> block are ignored so code blocks keep their own
scroll behavior.

Also adds a direction-aware slide/fade transition (via Vue's
<Transition>, keyed on currentIndex) when paging between articles,
triggered by both the swipe gesture and the nav buttons.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:14:52 +02:00
mathias 01f8a30505 autorestrat images 2026-08-03 14:58:45 +02:00
mathias a898e7ac33 counter on feed select 2026-07-13 17:46:49 +02:00
mathias 671ed86b48 added test 2026-07-13 09:47:10 +02:00
mathias af5e2b8137 upgrade packages 2026-07-12 17:27:16 +02:00
mathias 2b1d1c57ff keep state of aritcle/list view 2026-07-12 17:22:30 +02:00
mathias 31991ea1f8 Feed filter implementation 2026-07-12 12:28:32 +02:00
mathias 0e3142bac9 remove deprecations, remove header when scrolling 2026-07-04 17:06:45 +02:00
mathias a73a1b57de padding for article view 2026-07-03 19:38:53 +02:00
mathias 3642635b20 remove variable header size 2026-07-03 11:10:14 +02:00
mathias b3cf5e4787 variable header size 2026-07-02 22:24:01 +02:00
mathias fe0adcf68e added shadow to header 2026-07-02 21:51:58 +02:00
mathias 3f9de099aa new textadjustment options 2026-06-23 18:03:11 +02:00
mathias b9c0951f2b Filter comercials in readable 2026-06-22 20:30:00 +02:00
mathias 7a24980101 Fix article get read when navigation to admin area. 2026-06-19 16:41:16 +02:00
mathias dfc2e29e36 Change version number 2026-06-19 13:44:05 +02:00
mathias a90d10368e New feauter, change font family and font size 2026-06-19 13:42:57 +02:00
mathias 5417176dd4 add some filters for unwanted content 2026-06-16 19:08:31 +02:00
mathias 4d3f5d3285 fix autoscroll 2026-06-16 16:26:33 +02:00
mathias 967803c326 bugfixes 2026-06-16 12:34:08 +02:00
mathias e9c865a254 Improved sticky header 2026-06-16 12:04:42 +02:00
mathias 570db2d948 keep original article link in readable 2026-06-15 19:58:13 +02:00
mathias a37d845875 Fix stuttering list view 2026-06-15 19:53:18 +02:00
mathias 8e57e2f02a Added sync on reload 2026-06-14 17:13:41 +02:00
mathias 3671b90b81 Fix fontend tests, move next button in list view 2026-06-14 17:04:37 +02:00
mathias a399ede401 Fonts, Docker fixes 2026-06-14 09:03:06 +02:00
mathias 82ec6ea902 fix article read after switching to article view 2026-06-13 11:59:33 +02:00
mathias fbf3597984 improve desktop readable for article view 2026-06-13 11:48:45 +02:00
mathias e9580037ef increase token lifetime to one month 2026-06-12 19:34:08 +02:00
mathias b457b8abaa Improve security 2026-06-12 19:22:07 +02:00
mathias 0820ce6ef7 fix some frontend issues 2026-06-12 10:57:48 +02:00
mathias ed1241490d fix scroll to top in article view 2026-06-12 09:19:15 +02:00
mathias 177d975b4d frontend improvement 2026-06-10 19:22:24 +02:00
mathias 52ea84747a added anyhow, improve hamburger menu, improve dw articles 2026-06-10 18:51:55 +02:00
mathias 0420cf0dd5 fix sync missing articles 2026-06-10 06:08:17 +02:00
mathias 972e967432 fix sync missing articles 2026-06-10 05:35:46 +02:00
mathias 400648c3d1 added admin area to delete feeds 2026-06-09 21:55:07 +02:00
mathias 6ae6490dec Merge pull request 'added admin area to delete feeds' (#1) from feature/admin-area into master
Reviewed-on: #1
2026-06-09 21:48:28 +02:00
57 changed files with 4118 additions and 515 deletions
+4
View File
@@ -1,3 +1,7 @@
/target /target
.env .env
.claude .claude
CLAUDE.md
LEARNINGS.md
PLAN.md
/memory
Generated
+221 -31
View File
@@ -34,6 +34,18 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "actix-governor"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a7ffa43d3e1e92518355ffbc82c146b5f0fe24fba87f19f405270da7a7b3c1e"
dependencies = [
"actix-http",
"actix-web",
"futures",
"governor",
]
[[package]] [[package]]
name = "actix-http" name = "actix-http"
version = "3.12.1" version = "3.12.1"
@@ -52,7 +64,7 @@ dependencies = [
"derive_more", "derive_more",
"encoding_rs", "encoding_rs",
"flate2", "flate2",
"foldhash", "foldhash 0.1.5",
"futures-core", "futures-core",
"h2 0.3.27", "h2 0.3.27",
"http 0.2.12", "http 0.2.12",
@@ -167,7 +179,7 @@ dependencies = [
"cookie", "cookie",
"derive_more", "derive_more",
"encoding_rs", "encoding_rs",
"foldhash", "foldhash 0.1.5",
"futures-core", "futures-core",
"futures-util", "futures-util",
"impl-more", "impl-more",
@@ -231,6 +243,24 @@ dependencies = [
"alloc-no-stdlib", "alloc-no-stdlib",
] ]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser",
"html5ever",
"maplit",
"url",
]
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -292,15 +322,15 @@ dependencies = [
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]] [[package]]
name = "atom_syndication" name = "atom_syndication"
version = "0.12.8" version = "0.12.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb" checksum = "d48d93a31c932d58b9fd68664a904ae5b74bea37f06c5f68d5bec92f0cdd106d"
dependencies = [ dependencies = [
"chrono", "chrono",
"derive_builder", "derive_builder",
@@ -355,12 +385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]] [[package]]
name = "bcrypt" name = "base64"
version = "0.19.1" version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ae5479c93d3720e4c1dbd6b945b97457c50cb672781104768190371df1a905" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "bcrypt"
version = "0.19.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.23.1",
"blowfish", "blowfish",
"getrandom 0.4.2", "getrandom 0.4.2",
"subtle", "subtle",
@@ -475,9 +511,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.10.0" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures 0.3.0", "cpufeatures 0.3.0",
@@ -612,6 +648,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -724,6 +766,20 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "dashmap"
version = "6.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
dependencies = [
"cfg-if",
"crossbeam-utils",
"hashbrown 0.14.5",
"lock_api",
"once_cell",
"parking_lot_core",
]
[[package]] [[package]]
name = "dateparser" name = "dateparser"
version = "0.3.1" version = "0.3.1"
@@ -889,10 +945,10 @@ dependencies = [
] ]
[[package]] [[package]]
name = "dotenv" name = "dotenvy"
version = "0.15.0" version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]] [[package]]
name = "downcast-rs" name = "downcast-rs"
@@ -1029,6 +1085,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -1115,6 +1177,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]] [[package]]
name = "futures-util" name = "futures-util"
version = "0.3.32" version = "0.3.32"
@@ -1185,11 +1253,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core 0.10.1", "rand_core 0.10.1",
"wasip2", "wasip2",
"wasip3", "wasip3",
"wasm-bindgen",
]
[[package]]
name = "governor"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
dependencies = [
"cfg-if",
"dashmap",
"futures-sink",
"futures-timer",
"futures-util",
"getrandom 0.3.4",
"hashbrown 0.16.1",
"nonzero_ext",
"parking_lot",
"portable-atomic",
"quanta",
"rand 0.9.4",
"smallvec",
"spinning_top",
"web-time",
] ]
[[package]] [[package]]
@@ -1213,9 +1306,9 @@ dependencies = [
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.14" version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@@ -1230,13 +1323,30 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [ dependencies = [
"foldhash", "foldhash 0.1.5",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
] ]
[[package]] [[package]]
@@ -1345,7 +1455,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2 0.4.14", "h2 0.4.19",
"http 1.4.1", "http 1.4.1",
"http-body", "http-body",
"httparse", "httparse",
@@ -1752,6 +1862,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.39.0" version = "0.39.0"
@@ -1824,6 +1940,12 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
@@ -2027,10 +2149,25 @@ dependencies = [
] ]
[[package]] [[package]]
name = "quick-xml" name = "quanta"
version = "0.39.4" version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
dependencies = [
"crossbeam-utils",
"libc",
"once_cell",
"raw-cpuid",
"wasi",
"web-sys",
"winapi",
]
[[package]]
name = "quick-xml"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
dependencies = [ dependencies = [
"encoding_rs", "encoding_rs",
"memchr", "memchr",
@@ -2058,15 +2195,16 @@ dependencies = [
[[package]] [[package]]
name = "quinn-proto" name = "quinn-proto"
version = "0.11.14" version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"bytes", "bytes",
"getrandom 0.3.4", "getrandom 0.4.2",
"lru-slab", "lru-slab",
"rand 0.9.4", "rand 0.10.1",
"rand_pcg",
"ring", "ring",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
@@ -2159,6 +2297,24 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -2215,7 +2371,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2 0.4.14", "h2 0.4.19",
"http 1.4.1", "http 1.4.1",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -2261,9 +2417,9 @@ dependencies = [
[[package]] [[package]]
name = "rss" name = "rss"
version = "2.0.13" version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5" checksum = "dc13570823abc675c60d7837f1fe7daaf18dd181a3cacb3c6ba7a062eef581c0"
dependencies = [ dependencies = [
"atom_syndication", "atom_syndication",
"derive_builder", "derive_builder",
@@ -2272,18 +2428,21 @@ dependencies = [
[[package]] [[package]]
name = "rss-reader" name = "rss-reader"
version = "0.1.0" version = "0.9.1"
dependencies = [ dependencies = [
"actix-cors", "actix-cors",
"actix-governor",
"actix-rt", "actix-rt",
"actix-service", "actix-service",
"actix-web", "actix-web",
"ammonia",
"anyhow",
"bcrypt", "bcrypt",
"chrono", "chrono",
"dateparser", "dateparser",
"diesel", "diesel",
"diesel_migrations", "diesel_migrations",
"dotenv", "dotenvy",
"env_logger", "env_logger",
"futures", "futures",
"hmac", "hmac",
@@ -2660,6 +2819,15 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "spinning_top"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"
@@ -3268,6 +3436,22 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -3277,6 +3461,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
+5 -2
View File
@@ -1,11 +1,12 @@
[package] [package]
name = "rss-reader" name = "rss-reader"
version = "0.1.0" version = "0.9.1"
edition = "2024" edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
anyhow = "1"
reqwest = { version = "0.13", features = ["json", "blocking"] } reqwest = { version = "0.13", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.13" } rss = { version = "2.0.13" }
@@ -17,7 +18,7 @@ serde_derive = "1.0.228"
actix-service = "2.0.3" actix-service = "2.0.3"
diesel = { version = "2.3", features = ["postgres", "chrono"] } diesel = { version = "2.3", features = ["postgres", "chrono"] }
diesel_migrations = "2.3" diesel_migrations = "2.3"
dotenv = "0.15.0" dotenvy = "0.15"
bcrypt = "0.19" bcrypt = "0.19"
uuid = {version = "1.23", features=["serde", "v4"]} uuid = {version = "1.23", features=["serde", "v4"]}
jwt = "0.16.0" jwt = "0.16.0"
@@ -29,6 +30,8 @@ scraper = "0.27"
actix-cors = "0.7" actix-cors = "0.7"
chrono = { version = "0.4.45", features = ["serde"] } chrono = { version = "0.4.45", features = ["serde"] }
dateparser = "0.3" dateparser = "0.3"
ammonia = "4.1.2"
actix-governor = "0.10.0"
[dependencies.serde_json] [dependencies.serde_json]
version = "1.0.150" version = "1.0.150"
+4 -2
View File
@@ -7,7 +7,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
COPY . . COPY . .
RUN cargo build --release RUN cargo build --release && \
cp target/release/rss-reader /usr/local/bin/rss-reader && \
rm -rf target
# --- runtime --- # --- runtime ---
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -16,7 +18,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 ca-certificates \ libpq5 ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/rss-reader /usr/local/bin/rss-reader COPY --from=builder /usr/local/bin/rss-reader /usr/local/bin/rss-reader
EXPOSE 8001 EXPOSE 8001
CMD ["rss-reader"] CMD ["rss-reader"]
+33 -1
View File
@@ -82,10 +82,12 @@ Create a user, then log in through the UI at `http://localhost:5173`:
```sh ```sh
curl -X POST -H "Content-Type: application/json" \ curl -X POST -H "Content-Type: application/json" \
-d '{"name": "mace", "email": "you@example.com", "password": "secret"}' \ -d '{"name": "mace", "email": "you@example.com", "password": "secret1"}' \
http://localhost:8001/api/v1/user/create http://localhost:8001/api/v1/user/create
``` ```
Passwords must be at least 6 characters.
### Useful commands during development ### Useful commands during development
```sh ```sh
@@ -110,6 +112,30 @@ docker exec -it rss-postgres psql -d rss -U admin
--- ---
## Security notes
- **Sessions**: login returns a JWT (`token` header) valid for 24 hours. Logging out
(`POST /api/v1/auth/logout`, requires the current token) bumps the user's
`token_version`, which immediately invalidates *all* outstanding tokens for that
account — there's no per-session revocation, so logging out on one device logs out
every device.
- **Login rate limiting**: `POST /api/v1/auth/login` is limited to a burst of 5 requests
per IP, replenishing one every 2 seconds (`actix-governor`).
- **Outbound fetches**: feed syncs and the article-reader endpoint only fetch
`http`/`https` URLs that resolve to public IP addresses (no loopback/private/
link-local, e.g. `127.0.0.1` or the `169.254.169.254` cloud metadata address).
Redirects are followed (up to 5 hops), but each redirect target is checked against the
same rules before being fetched, so a redirect can't be used to reach an internal
address.
- **Stored feed content**: `<img>` tags from synced feed content are sanitized
(`ammonia`) down to `src`/`alt`/`title` before being stored, since they're later
rendered with `v-html` in the frontend.
- **If `JWT_SECRET` or `POSTGRES_PASSWORD` are ever leaked** (e.g. committed to git),
rotate them in `.env` and restart the backend — rotating `JWT_SECRET` invalidates every
outstanding token as a side effect.
---
## Production setup (Docker) ## Production setup (Docker)
The whole stack — Postgres, backend, and frontend — runs via Docker Compose. The frontend is built as a static Vue bundle and served by nginx, which also reverse-proxies `/api/` to the backend container. The whole stack — Postgres, backend, and frontend — runs via Docker Compose. The frontend is built as a static Vue bundle and served by nginx, which also reverse-proxies `/api/` to the backend container.
@@ -143,8 +169,13 @@ docker compose logs -f backend # follow backend logs
docker compose down # stop everything (keeps the postgres_data volume) docker compose down # stop everything (keeps the postgres_data volume)
docker compose down -v # stop and wipe all data — careful! docker compose down -v # stop and wipe all data — careful!
docker compose up --build -d # rebuild after pulling code changes docker compose up --build -d # rebuild after pulling code changes
docker builder prune -af && docker image prune -af # reclaim disk used by old build layers/images
``` ```
> Each `docker compose up --build` leaves the previous build's cache layers and images
> behind, which adds up quickly given how much disk `cargo build` needs. Run the prune
> command above after each rebuild (or on a cron job) to reclaim that space.
### Optional: hardened deployment — isolated user + rootless Docker ### Optional: hardened deployment — isolated user + rootless Docker
Anyone who can run `docker` commands effectively has root on the host (container volume mounts can reach the whole filesystem) — being in the `docker` group is root-equivalent. For a production server, it's worth confining this stack to a dedicated, unprivileged system user running its own **rootless Docker** daemon, instead of using a system-wide install or adding the user to the `docker` group. Anyone who can run `docker` commands effectively has root on the host (container volume mounts can reach the whole filesystem) — being in the `docker` group is root-equivalent. For a production server, it's worth confining this stack to a dedicated, unprivileged system user running its own **rootless Docker** daemon, instead of using a system-wide install or adding the user to the `docker` group.
@@ -266,6 +297,7 @@ Fill in `.env` with strong, unique secrets — `openssl rand -hex 32` is a conve
```sh ```sh
docker compose up --build -d docker compose up --build -d
docker builder prune -af && docker image prune -af # reclaim disk used by old build layers/images
``` ```
**6. Firewall** (run as your normal sudo-capable user — not `rss-svc`): **6. Firewall** (run as your normal sudo-capable user — not `rss-svc`):
+3
View File
@@ -2,6 +2,7 @@ services:
postgres: postgres:
container_name: "rss-postgres" container_name: "rss-postgres"
image: "postgres:18" image: "postgres:18"
restart: unless-stopped
ports: ports:
- "5432:5432" - "5432:5432"
environment: environment:
@@ -16,6 +17,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped
depends_on: depends_on:
- postgres - postgres
environment: environment:
@@ -31,6 +33,7 @@ services:
build: build:
context: ./vue context: ./vue
dockerfile: Dockerfile dockerfile: Dockerfile
restart: unless-stopped
depends_on: depends_on:
- backend - backend
ports: ports:
@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
ALTER TABLE users
DROP COLUMN token_version;
@@ -0,0 +1,3 @@
-- Your SQL goes here
ALTER TABLE users
ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
-- This file should undo anything in `up.sql`
ALTER TABLE feed_item
DROP CONSTRAINT feed_item_feed_id_url_unique;
@@ -0,0 +1,36 @@
-- Your SQL goes here
-- Concurrent syncs for the same feed could previously race past the
-- application-level "does this article already exist" check and both
-- insert, producing duplicate feed items. Before enforcing uniqueness,
-- collapse any duplicates that already exist, keyed on the article's link
-- (rather than its title) since that's the stable identity RSS items are
-- built around — some feeds edit a headline after publishing while keeping
-- the same link, which title-based matching would treat as a new article.
-- If any duplicate in a group was already read, propagate that onto the
-- surviving (lowest-id) row first, so this one-time cleanup can't make an
-- already-read article look unread again.
UPDATE feed_item AS keep
SET read = true
FROM feed_item AS dupe
WHERE keep.feed_id = dupe.feed_id
AND keep.url = dupe.url
AND keep.id <> dupe.id
AND keep.id = (
SELECT MIN(candidate.id)
FROM feed_item AS candidate
WHERE candidate.feed_id = dupe.feed_id
AND candidate.url = dupe.url
)
AND dupe.read = true;
-- Keep the oldest (lowest id) row of each duplicate set.
DELETE FROM feed_item a
USING feed_item b
WHERE a.feed_id = b.feed_id
AND a.url = b.url
AND a.id > b.id;
ALTER TABLE feed_item
ADD CONSTRAINT feed_item_feed_id_url_unique UNIQUE (feed_id, url);
+23
View File
@@ -0,0 +1,23 @@
use std::future::{ready, Ready};
use actix_web::dev::Payload;
use actix_web::{error::ErrorUnauthorized, Error, FromRequest, HttpMessage, HttpRequest};
/// The user id of the caller, as established by the auth middleware after
/// verifying the `user-token` header. Extracting this (instead of trusting a
/// client-supplied `user_id` in the path/body) is the source of truth for
/// "who is making this request".
#[derive(Clone, Copy)]
pub struct AuthUser(pub i32);
impl FromRequest for AuthUser {
type Error = Error;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
match req.extensions().get::<AuthUser>() {
Some(auth_user) => ready(Ok(*auth_user)),
None => ready(Err(ErrorUnauthorized("missing authenticated user"))),
}
}
}
+63 -14
View File
@@ -2,17 +2,32 @@ extern crate hmac;
extern crate jwt; extern crate jwt;
extern crate sha2; extern crate sha2;
use std::collections::BTreeMap;
use std::env; use std::env;
use actix_web::HttpRequest; use actix_web::HttpRequest;
use dotenv::dotenv; use chrono::{Duration, Utc};
use dotenvy::dotenv;
use hmac::{Hmac, Mac}; use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey}; use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use serde::{Deserialize, Serialize};
use sha2::Sha256; use sha2::Sha256;
/// How long a freshly issued token remains valid for.
const TOKEN_LIFETIME_HOURS: i64 = 730;
pub struct JwtToken { pub struct JwtToken {
pub user_id: i32, pub user_id: i32,
pub token_version: i32,
}
#[derive(Serialize, Deserialize)]
struct Claims {
user_id: i32,
/// Must match `users.token_version` for the token to be accepted; bumping
/// the column (e.g. on logout) revokes every token issued before that point.
tv: i32,
/// Unix timestamp after which the token is rejected, independent of signature validity.
exp: i64,
} }
type HmacSha256 = Hmac<Sha256>; type HmacSha256 = Hmac<Sha256>;
@@ -20,29 +35,39 @@ type HmacSha256 = Hmac<Sha256>;
fn signing_key() -> HmacSha256 { fn signing_key() -> HmacSha256 {
dotenv().ok(); dotenv().ok();
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
HmacSha256::new_from_slice(secret.as_bytes()).unwrap() // HMAC-SHA256 accepts a key of any length, so this cannot fail.
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts a key of any length")
} }
impl JwtToken { impl JwtToken {
pub fn encode(user_id: i32) -> String { pub fn encode(user_id: i32, token_version: i32) -> String {
let key: HmacSha256 = signing_key(); let key: HmacSha256 = signing_key();
let mut claims = BTreeMap::new(); let claims = Claims {
claims.insert("user_id", user_id); user_id,
claims.sign_with_key(&key).unwrap() tv: token_version,
exp: (Utc::now() + Duration::hours(TOKEN_LIFETIME_HOURS)).timestamp(),
};
// Signing claims with a valid HMAC key cannot fail.
claims
.sign_with_key(&key)
.expect("signing claims with a valid HMAC key cannot fail")
} }
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> { pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
let key: HmacSha256 = signing_key(); let key: HmacSha256 = signing_key();
let token_str: &str = encoded_token.as_str(); let token_str: &str = encoded_token.as_str();
let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> = let token: Result<Token<Header, Claims, jwt::Verified>, jwt::Error> =
VerifyWithKey::verify_with_key(token_str, &key); VerifyWithKey::verify_with_key(token_str, &key);
match token { match token {
Ok(token) => { Ok(token) => {
let _header = token.header();
let claims = token.claims(); let claims = token.claims();
if claims.exp < Utc::now().timestamp() {
return Err("token has expired");
}
Ok(JwtToken { Ok(JwtToken {
user_id: claims["user_id"], user_id: claims.user_id,
token_version: claims.tv,
}) })
} }
Err(_err) => Err("could not decode token"), Err(_err) => Err("could not decode token"),
@@ -52,7 +77,10 @@ impl JwtToken {
#[allow(dead_code)] #[allow(dead_code)]
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> { pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
match request.headers().get("user-token") { match request.headers().get("user-token") {
Some(token) => JwtToken::decode(String::from(token.to_str().unwrap())), Some(token) => match token.to_str() {
Ok(token_str) => JwtToken::decode(String::from(token_str)),
Err(_) => Err("token header is not valid text"),
},
None => Err("There is no token"), None => Err("There is no token"),
} }
} }
@@ -61,14 +89,19 @@ impl JwtToken {
#[cfg(test)] #[cfg(test)]
mod jwt_test { mod jwt_test {
use actix_web::{http::header, test}; use actix_web::{http::header, test};
use chrono::{Duration, Utc};
use hmac::Hmac;
use jwt::SignWithKey;
use sha2::Sha256;
use super::JwtToken; use super::{Claims, JwtToken};
#[test] #[test]
async fn encode_decode() { async fn encode_decode() {
let encoded_token: String = JwtToken::encode(32); let encoded_token: String = JwtToken::encode(32, 0);
let decoded_token: JwtToken = JwtToken::decode(encoded_token).unwrap(); let decoded_token: JwtToken = JwtToken::decode(encoded_token).unwrap();
assert_eq!(32, decoded_token.user_id); assert_eq!(32, decoded_token.user_id);
assert_eq!(0, decoded_token.token_version);
} }
#[test] #[test]
@@ -81,9 +114,25 @@ mod jwt_test {
} }
} }
#[test]
async fn decode_expired_token() {
let key: Hmac<Sha256> = super::signing_key();
let claims = Claims {
user_id: 32,
tv: 0,
exp: (Utc::now() - Duration::hours(1)).timestamp(),
};
let expired_token: String = claims.sign_with_key(&key).unwrap();
match JwtToken::decode(expired_token) {
Err(message) => assert_eq!(message, "token has expired"),
_ => panic!("Expired token should not be accepted."),
}
}
#[actix_web::test] #[actix_web::test]
async fn decode_from_request_with_correct_token() { async fn decode_from_request_with_correct_token() {
let encoded_token: String = JwtToken::encode(32); let encoded_token: String = JwtToken::encode(32, 0);
let request = test::TestRequest::default() let request = test::TestRequest::default()
.insert_header(header::ContentType::json()) .insert_header(header::ContentType::json())
.insert_header(("user-token", encoded_token)) .insert_header(("user-token", encoded_token))
+14 -8
View File
@@ -1,12 +1,13 @@
use actix_web::dev::ServiceRequest; use actix_web::dev::ServiceRequest;
pub mod extractor;
pub mod jwt; pub mod jwt;
pub mod processes; pub mod processes;
use crate::auth::processes::check_password; use crate::auth::processes::check_token;
use crate::auth::processes::extract_header_token; use crate::auth::processes::extract_header_token;
pub fn process_token(request: &ServiceRequest) -> Result<i32, &'static str> { pub fn process_token(request: &ServiceRequest) -> Result<i32, &'static str> {
match extract_header_token(request) { match extract_header_token(request) {
Ok(token) => check_password(token), Ok(token) => check_token(token),
Err(message) => Err(message), Err(message) => Err(message),
} }
} }
@@ -16,19 +17,24 @@ mod mod_test {
use actix_web::test::TestRequest; use actix_web::test::TestRequest;
use super::{jwt::JwtToken, process_token}; use super::process_token;
use crate::auth::jwt::JwtToken;
use crate::database::establish_connection;
use crate::test_helpers::{delete_user, insert_user};
#[test] #[test]
fn process_token_test() { fn process_token_test() {
let token = JwtToken::encode(32); let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let token = JwtToken::encode(user.id, user.token_version);
let request = TestRequest::delete() let request = TestRequest::delete()
.insert_header(("user-token", token)) .insert_header(("user-token", token))
.to_srv_request(); .to_srv_request();
match process_token(&request) { assert_eq!(Ok(user.id), process_token(&request));
Ok(user_id) => assert_eq!(32, user_id),
Err(_) => panic!("process token failed"), delete_user(&mut connection, user.id);
}
} }
#[actix_web::test] #[actix_web::test]
+54 -23
View File
@@ -1,22 +1,33 @@
use super::jwt; use super::jwt;
use crate::database::establish_connection;
use crate::models::user::rss_user::User;
use crate::schema::users;
use actix_web::dev::ServiceRequest; use actix_web::dev::ServiceRequest;
use diesel::prelude::*;
pub fn check_password(password: String) -> Result<i32, &'static str> { /// Decodes the token and confirms it hasn't been revoked, i.e. its `token_version`
match jwt::JwtToken::decode(password) { /// still matches the one stored on the user (bumped on logout / password change).
Ok(token) => Ok(token.user_id), pub fn check_token(token: String) -> Result<i32, &'static str> {
Err(message) => Err(message), let decoded = jwt::JwtToken::decode(token)?;
let mut connection = establish_connection();
let user: User = users::table
.find(decoded.user_id)
.first(&mut connection)
.map_err(|_| "could not decode token")?;
if user.token_version != decoded.token_version {
return Err("token has been revoked");
} }
Ok(decoded.user_id)
} }
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> { pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
log::info!("Request: {:?}", request);
match request.headers().get("user-token") { match request.headers().get("user-token") {
Some(token) => match token.to_str() { Some(token) => match token.to_str() {
Ok(processed_password) => { Ok(processed_token) => Ok(String::from(processed_token)),
log::info!("Token provided: {}", processed_password); Err(_) => Err("there was an error processing token"),
Ok(String::from(processed_password))
}
Err(_processed_password) => Err("there was an error processing token"),
}, },
None => Err("there is no token"), None => Err("there is no token"),
} }
@@ -25,31 +36,51 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static
#[cfg(test)] #[cfg(test)]
mod processes_test { mod processes_test {
use actix_web::test::TestRequest; use actix_web::test::TestRequest;
use diesel::prelude::*;
use crate::auth::jwt::JwtToken; use crate::auth::jwt::JwtToken;
use crate::database::establish_connection;
use crate::test_helpers::{delete_user, insert_user};
use super::check_password; use super::check_token;
#[test] #[test]
fn check_correct_password() { fn check_correct_token() {
let password_string: String = JwtToken::encode(32); let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let result = check_password(password_string); let token: String = JwtToken::encode(user.id, user.token_version);
match result { let result = check_token(token);
Ok(user_id) => assert_eq!(32, user_id),
_ => panic!("Check correct password failed."), assert_eq!(Ok(user.id), result);
}
delete_user(&mut connection, user.id);
} }
#[test] #[test]
fn incorrect_check_password() { fn revoked_token_is_rejected() {
let password: String = String::from("test"); let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
match check_password(password) { // Token signed with the user's current version, then the version is bumped
Err(message) => assert_eq!("could not decode token", message), // (as logout would do), which must invalidate the previously issued token.
_ => panic!("check password should not be able to be decoded"), let token: String = JwtToken::encode(user.id, user.token_version);
diesel::update(crate::schema::users::table.find(user.id))
.set(crate::schema::users::token_version.eq(user.token_version + 1))
.execute(&mut connection)
.unwrap();
assert_eq!(Err("token has been revoked"), check_token(token));
delete_user(&mut connection, user.id);
} }
#[test]
fn incorrect_check_token() {
let token: String = String::from("test");
assert_eq!(Err("could not decode token"), check_token(token));
} }
#[test] #[test]
+1 -1
View File
@@ -1,7 +1,7 @@
use diesel::pg::PgConnection; use diesel::pg::PgConnection;
use diesel::prelude::*; use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv; use dotenvy::dotenv;
use std::env; use std::env;
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
+34
View File
@@ -0,0 +1,34 @@
use actix_web::{HttpResponse, ResponseError};
use std::fmt;
/// Wraps any error so it can be returned with `?` from request handlers.
/// Always surfaces as a 500 to the client; the real error is logged.
pub struct AppError(anyhow::Error);
impl fmt::Debug for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
log::error!("Unhandled error: {:?}", self.0);
HttpResponse::InternalServerError().finish()
}
}
impl<E> From<E> for AppError
where
E: Into<anyhow::Error>,
{
fn from(err: E) -> Self {
AppError(err.into())
}
}
+7 -2
View File
@@ -13,7 +13,12 @@ impl Responder for Articles {
type Body = String; type Body = String;
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
let body = serde_json::to_string(&self).unwrap(); match serde_json::to_string(&self) {
HttpResponse::with_body(StatusCode::OK, body) Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
Err(err) => {
log::error!("Failed to serialize response: {}", err);
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
}
}
} }
} }
+29
View File
@@ -0,0 +1,29 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, Responder};
use serde::Serialize;
#[derive(Serialize)]
pub struct FeedInfo {
pub id: i32,
pub title: String,
pub url: String,
}
#[derive(Serialize)]
pub struct FeedInfoList {
pub feeds: Vec<FeedInfo>,
}
impl Responder for FeedInfoList {
type Body = String;
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
match serde_json::to_string(&self) {
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
Err(err) => {
log::error!("Failed to serialize response: {}", err);
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
}
}
}
}
+7 -2
View File
@@ -11,7 +11,12 @@ impl Responder for Readable {
type Body = String; type Body = String;
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
let body = serde_json::to_string(&self).unwrap(); match serde_json::to_string(&self) {
HttpResponse::with_body(StatusCode::OK, body) Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
Err(err) => {
log::error!("Failed to serialize response: {}", err);
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
}
}
} }
} }
+20 -16
View File
@@ -1,14 +1,14 @@
extern crate diesel; extern crate diesel;
extern crate dotenv;
use actix_cors::Cors; use actix_cors::Cors;
use actix_service::Service; use actix_service::Service;
use actix_web::{App, HttpResponse, HttpServer}; use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
use dotenv::dotenv; use dotenvy::dotenv;
use futures::future::{ok, Either}; use futures::future::{ok, Either};
use std::env; use std::env;
mod auth; mod auth;
mod database; mod database;
mod error;
mod json_serialization; mod json_serialization;
mod models; mod models;
mod reader; mod reader;
@@ -36,28 +36,32 @@ async fn main() -> std::io::Result<()> {
App::new() App::new()
.wrap_fn(|req, srv| { .wrap_fn(|req, srv| {
let mut passed: bool;
let request_url: String = String::from(req.uri().path()); let request_url: String = String::from(req.uri().path());
log::info!("Request Url: {}", request_url); log::info!("Request Url: {}", request_url);
if req.path().contains("/article/") {
// Only these endpoints are reachable without a valid token. Everything
// else (in particular all `/article/*` endpoints) requires one.
let is_public = matches!(
request_url.as_str(),
"/api/v1/auth/login" | "/api/v1/user/create"
);
let passed = if is_public {
true
} else {
match auth::process_token(&req) { match auth::process_token(&req) {
Ok(user_id) => { Ok(user_id) => {
log::info!("Authenticated user {} for {}", user_id, request_url); log::info!("Authenticated user {} for {}", user_id, request_url);
passed = true; req.extensions_mut().insert(auth::extractor::AuthUser(user_id));
true
} }
Err(_message) => passed = false, Err(message) => {
log::warn!("Rejected request to {}: {}", request_url, message);
false
} }
} else {
log::warn!("No auth check done.");
passed = true;
} }
};
if req.path().contains("user/create") {
passed = true;
}
log::info!("passed: {:?}", passed);
let end_result = match passed { let end_result = match passed {
true => Either::Left(srv.call(req)), true => Either::Left(srv.call(req)),
+18 -4
View File
@@ -6,6 +6,19 @@ use uuid::Uuid;
use crate::schema::users; use crate::schema::users;
pub const MIN_PASSWORD_LENGTH: usize = 6;
/// Rejects empty/trivial passwords. Kept as a standalone function so callers
/// (e.g. the user-creation handler) can return a `400` for this case
/// specifically, rather than the `500` that `NewUser::new`'s `anyhow::Result`
/// would otherwise map to.
pub fn validate_password(password: &str) -> Result<(), &'static str> {
if password.trim().len() < MIN_PASSWORD_LENGTH {
return Err("password must be at least 6 characters long");
}
Ok(())
}
#[derive(Insertable, Clone)] #[derive(Insertable, Clone)]
#[diesel(table_name=users)] #[diesel(table_name=users)]
pub struct NewUser { pub struct NewUser {
@@ -16,14 +29,15 @@ pub struct NewUser {
} }
impl NewUser { impl NewUser {
pub fn new(username: String, email: String, password: String) -> NewUser { pub fn new(username: String, email: String, password: String) -> anyhow::Result<NewUser> {
let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap(); validate_password(&password).map_err(anyhow::Error::msg)?;
let hashed_password: String = hash(password.as_str(), DEFAULT_COST)?;
let uuid = Uuid::new_v4(); let uuid = Uuid::new_v4();
NewUser { Ok(NewUser {
username, username,
email, email,
password: hashed_password, password: hashed_password,
unique_id: uuid.to_string(), unique_id: uuid.to_string(),
} })
} }
} }
+3 -2
View File
@@ -14,10 +14,11 @@ pub struct User {
pub email: String, pub email: String,
pub password: String, pub password: String,
pub unique_id: String, pub unique_id: String,
pub token_version: i32,
} }
impl User { impl User {
pub fn verify(self, password: String) -> bool { pub fn verify(self, password: String) -> anyhow::Result<bool> {
verify(password.as_str(), &self.password).unwrap() Ok(verify(password.as_str(), &self.password)?)
} }
} }
+47 -9
View File
@@ -2,13 +2,17 @@ use actix_web::{web, HttpResponse};
use diesel::RunQueryDsl; use diesel::RunQueryDsl;
use crate::{ use crate::{
database::establish_connection, json_serialization::new_feed::NewFeedSchema, auth::extractor::AuthUser, database::establish_connection,
models::feed::new_feed::NewFeed, schema::feed, json_serialization::new_feed::NewFeedSchema, models::feed::new_feed::NewFeed, schema::feed,
}; };
use super::feeds; use super::feeds;
pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse { pub async fn add(new_feed: web::Json<NewFeedSchema>, auth_user: AuthUser) -> HttpResponse {
if auth_user.0 != new_feed.user_id {
return HttpResponse::Forbidden().finish();
}
let mut connection = establish_connection(); let mut connection = establish_connection();
let title: String = new_feed.title.clone(); let title: String = new_feed.title.clone();
let url: String = new_feed.url.clone(); let url: String = new_feed.url.clone();
@@ -19,12 +23,12 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
Ok(channel) => { Ok(channel) => {
log::info!("valid channel"); log::info!("valid channel");
if channel.items.is_empty() { if channel.items.is_empty() {
return HttpResponse::ServiceUnavailable().await.unwrap(); return HttpResponse::ServiceUnavailable().finish();
} }
} }
Err(e) => { Err(e) => {
log::error!("{:?}", e); log::error!("{:?}", e);
return HttpResponse::NotFound().await.unwrap(); return HttpResponse::NotFound().finish();
} }
} }
@@ -35,25 +39,35 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
.execute(&mut connection); .execute(&mut connection);
match insert_result { match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(), Ok(_) => HttpResponse::Created().finish(),
Err(e) => { Err(e) => {
log::error!("{e}"); log::error!("{e}");
HttpResponse::Conflict().await.unwrap() HttpResponse::Conflict().finish()
} }
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use actix_service::Service;
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::{test, web, App}; use actix_web::{test, web, App, HttpMessage};
use super::add; use super::add;
use crate::auth::extractor::AuthUser;
use crate::test_helpers::unique_suffix; use crate::test_helpers::unique_suffix;
#[actix_web::test] #[actix_web::test]
async fn add_fails_for_unfetchable_feed_url() { async fn add_fails_for_unfetchable_feed_url() {
let app = test::init_service(App::new().route("/add", web::post().to(add))).await; let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(1));
srv.call(req)
})
.route("/add", web::post().to(add)),
)
.await;
let req = test::TestRequest::post() let req = test::TestRequest::post()
.uri("/add") .uri("/add")
.set_json(serde_json::json!({ .set_json(serde_json::json!({
@@ -66,4 +80,28 @@ mod tests {
assert_eq!(StatusCode::NOT_FOUND, resp.status()); assert_eq!(StatusCode::NOT_FOUND, resp.status());
} }
#[actix_web::test]
async fn add_rejects_feed_for_another_user() {
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(1));
srv.call(req)
})
.route("/add", web::post().to(add)),
)
.await;
let req = test::TestRequest::post()
.uri("/add")
.set_json(serde_json::json!({
"title": "Someone else's feed",
"url": format!("https://example.test/feed/{}", unique_suffix()),
"user_id": 2
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::FORBIDDEN, resp.status());
}
} }
+183
View File
@@ -0,0 +1,183 @@
use actix_web::{web, HttpResponse};
use diesel::prelude::*;
use crate::{
auth::extractor::AuthUser,
database::establish_connection,
schema::{feed, feed_item},
};
pub async fn delete_feed(path: web::Path<i32>, auth_user: AuthUser) -> HttpResponse {
let feed_id = path.into_inner();
let mut connection = establish_connection();
let owner: Option<i32> = feed::table
.find(feed_id)
.select(feed::user_id)
.first(&mut connection)
.optional()
.unwrap_or(None);
// Treat "doesn't exist" and "not yours" the same, so callers can't probe
// for other users' feed ids.
if owner != Some(auth_user.0) {
return HttpResponse::NotFound().finish();
}
diesel::delete(feed_item::table.filter(feed_item::feed_id.eq(feed_id)))
.execute(&mut connection)
.ok();
diesel::delete(feed::table.filter(feed::id.eq(feed_id)))
.execute(&mut connection)
.ok();
HttpResponse::NoContent().finish()
}
#[cfg(test)]
mod tests {
use actix_service::Service;
use actix_web::http::StatusCode;
use actix_web::{test, web, App, HttpMessage};
use diesel::prelude::*;
use super::delete_feed;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection;
use crate::schema::{feed, feed_item};
use crate::test_helpers::{
delete_feed as cleanup_feed, delete_user, insert_feed, insert_feed_item, insert_user,
};
#[actix_web::test]
async fn delete_feed_removes_feed_and_items() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let f = insert_feed(&mut connection, user.id);
let item = insert_feed_item(&mut connection, f.id, false);
let user_id = user.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
)
.await;
let req = test::TestRequest::delete()
.uri(&format!("/feed/{}", f.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NO_CONTENT, resp.status());
let feed_exists: i64 = feed::table
.filter(feed::id.eq(f.id))
.count()
.get_result(&mut connection)
.unwrap();
assert_eq!(0, feed_exists);
let item_exists: i64 = feed_item::table
.filter(feed_item::id.eq(item.id))
.count()
.get_result(&mut connection)
.unwrap();
assert_eq!(0, item_exists);
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn delete_feed_returns_404_for_nonexistent_feed() {
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(1));
srv.call(req)
})
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
)
.await;
let req = test::TestRequest::delete()
.uri("/feed/999999999")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status());
}
#[actix_web::test]
async fn delete_feed_does_not_affect_other_feeds() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let feed_a = insert_feed(&mut connection, user.id);
let feed_b = insert_feed(&mut connection, user.id);
let user_id = user.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
)
.await;
let req = test::TestRequest::delete()
.uri(&format!("/feed/{}", feed_a.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NO_CONTENT, resp.status());
let feed_b_exists: i64 = feed::table
.filter(feed::id.eq(feed_b.id))
.count()
.get_result(&mut connection)
.unwrap();
assert_eq!(1, feed_b_exists);
cleanup_feed(&mut connection, feed_b.id);
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn delete_feed_rejects_other_users_feed() {
let mut connection = establish_connection();
let user_a = insert_user(&mut connection, "secret");
let user_b = insert_user(&mut connection, "secret");
let feed_b = insert_feed(&mut connection, user_b.id);
let user_a_id = user_a.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_a_id));
srv.call(req)
})
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
)
.await;
let req = test::TestRequest::delete()
.uri(&format!("/feed/{}", feed_b.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status());
let feed_b_exists: i64 = feed::table
.filter(feed::id.eq(feed_b.id))
.count()
.get_result(&mut connection)
.unwrap();
assert_eq!(1, feed_b_exists);
cleanup_feed(&mut connection, feed_b.id);
delete_user(&mut connection, user_a.id);
delete_user(&mut connection, user_b.id);
}
}
+5 -4
View File
@@ -1,9 +1,10 @@
use std::error::Error;
use rss::Channel; use rss::Channel;
pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> { use super::net::safe_fetch;
let content = reqwest::get(feed).await?.bytes().await?; use crate::error::AppError;
pub async fn get_feed(feed: &str) -> Result<Channel, AppError> {
let content = safe_fetch(feed).await?.bytes().await?;
let channel = Channel::read_from(&content[..])?; let channel = Channel::read_from(&content[..])?;
log::debug!("{:?}", channel); log::debug!("{:?}", channel);
Ok(channel) Ok(channel)
+54 -10
View File
@@ -1,3 +1,5 @@
use crate::auth::extractor::AuthUser;
use crate::error::AppError;
use crate::json_serialization::user::JsonUser; use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed; use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::rss_feed_item::FeedItem; use crate::models::feed_item::rss_feed_item::FeedItem;
@@ -9,22 +11,29 @@ use crate::{
schema::feed::{self, user_id}, schema::feed::{self, user_id},
schema::feed_item, schema::feed_item,
}; };
use actix_web::{web, HttpRequest, Responder}; use actix_web::{web, HttpRequest, HttpResponse, Responder};
use chrono::Local; use chrono::Local;
use diesel::prelude::*; use diesel::prelude::*;
use super::structs::article::Article; use super::structs::article::Article;
pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder { pub async fn get(
path: web::Path<JsonUser>,
req: HttpRequest,
auth_user: AuthUser,
) -> Result<HttpResponse, AppError> {
let request = req.clone(); let request = req.clone();
let req_user_id = path.user_id; let req_user_id = path.user_id;
log::info!("Received user_id: {}", req_user_id); log::info!("Received user_id: {}", req_user_id);
if auth_user.0 != req_user_id {
return Ok(HttpResponse::Forbidden().finish());
}
let mut connection: diesel::PgConnection = establish_connection(); let mut connection: diesel::PgConnection = establish_connection();
let feeds: Vec<Feed> = feed::table let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id)) .filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection) .load::<Feed>(&mut connection)?;
.unwrap();
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new(); let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
for feed in feeds { for feed in feeds {
@@ -32,8 +41,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
.filter(feed_id.eq(feed.id)) .filter(feed_id.eq(feed.id))
.filter(read.eq(false)) .filter(read.eq(false))
.order(id.asc()) .order(id.asc())
.load(&mut connection) .load(&mut connection)?;
.unwrap();
log::info!( log::info!(
"Load {} feed items for feed: {}", "Load {} feed items for feed: {}",
@@ -70,15 +78,17 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
feeds: feed_aggregates, feeds: feed_aggregates,
}; };
articles.respond_to(&request) Ok(articles.respond_to(&request).map_into_boxed_body())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use actix_service::Service;
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::{test, web, App}; use actix_web::{test, web, App, HttpMessage};
use super::get; use super::get;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection; use crate::database::establish_connection;
use crate::test_helpers::{ use crate::test_helpers::{
delete_feed, delete_feed_item, delete_user, insert_feed, insert_feed_item, insert_user, delete_feed, delete_feed_item, delete_user, insert_feed, insert_feed_item, insert_user,
@@ -92,8 +102,16 @@ mod tests {
let unread = insert_feed_item(&mut connection, feed.id, false); let unread = insert_feed_item(&mut connection, feed.id, false);
let read = insert_feed_item(&mut connection, feed.id, true); let read = insert_feed_item(&mut connection, feed.id, true);
let app = let user_id = user.id;
test::init_service(App::new().route("/get/{user_id}", web::get().to(get))).await; let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/get/{user_id}", web::get().to(get)),
)
.await;
let req = test::TestRequest::get() let req = test::TestRequest::get()
.uri(&format!("/get/{}", user.id)) .uri(&format!("/get/{}", user.id))
.to_request(); .to_request();
@@ -112,4 +130,30 @@ mod tests {
delete_feed(&mut connection, feed.id); delete_feed(&mut connection, feed.id);
delete_user(&mut connection, user.id); delete_user(&mut connection, user.id);
} }
#[actix_web::test]
async fn get_rejects_requests_for_another_user() {
let mut connection = establish_connection();
let user_a = insert_user(&mut connection, "secret");
let user_b = insert_user(&mut connection, "secret");
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_a.id));
srv.call(req)
})
.route("/get/{user_id}", web::get().to(get)),
)
.await;
let req = test::TestRequest::get()
.uri(&format!("/get/{}", user_b.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::FORBIDDEN, resp.status());
delete_user(&mut connection, user_a.id);
delete_user(&mut connection, user_b.id);
}
} }
+139
View File
@@ -0,0 +1,139 @@
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use diesel::prelude::*;
use crate::{
auth::extractor::AuthUser,
database::establish_connection,
error::AppError,
json_serialization::feed_info::{FeedInfo, FeedInfoList},
json_serialization::user::JsonUser,
schema::feed::{self, user_id},
};
pub async fn list_feeds(
path: web::Path<JsonUser>,
req: HttpRequest,
auth_user: AuthUser,
) -> Result<HttpResponse, AppError> {
let request = req.clone();
let req_user_id = path.user_id;
if auth_user.0 != req_user_id {
return Ok(HttpResponse::Forbidden().finish());
}
let mut connection = establish_connection();
let feeds = feed::table
.filter(user_id.eq(req_user_id))
.select((feed::id, feed::title, feed::url))
.load::<(i32, String, String)>(&mut connection)?;
let feed_list: Vec<FeedInfo> = feeds
.into_iter()
.map(|(id, title, url)| FeedInfo { id, title, url })
.collect();
Ok(FeedInfoList { feeds: feed_list }
.respond_to(&request)
.map_into_boxed_body())
}
#[cfg(test)]
mod tests {
use actix_service::Service;
use actix_web::http::StatusCode;
use actix_web::{test, web, App, HttpMessage};
use super::list_feeds;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection;
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user};
#[actix_web::test]
async fn list_feeds_returns_feeds_for_user() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let feed = insert_feed(&mut connection, user.id);
let user_id = user.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/feeds/{user_id}", web::get().to(list_feeds)),
)
.await;
let req = test::TestRequest::get()
.uri(&format!("/feeds/{}", user.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains(&feed.title));
assert!(body_str.contains(&feed.url));
delete_feed(&mut connection, feed.id);
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn list_feeds_returns_empty_list_for_user_with_no_feeds() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let user_id = user.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/feeds/{user_id}", web::get().to(list_feeds)),
)
.await;
let req = test::TestRequest::get()
.uri(&format!("/feeds/{}", user.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
let body = test::read_body(resp).await;
let body_str = String::from_utf8(body.to_vec()).unwrap();
assert!(body_str.contains("\"feeds\":[]"));
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn list_feeds_rejects_requests_for_another_user() {
let mut connection = establish_connection();
let user_a = insert_user(&mut connection, "secret");
let user_b = insert_user(&mut connection, "secret");
let feed_b = insert_feed(&mut connection, user_b.id);
let user_a_id = user_a.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_a_id));
srv.call(req)
})
.route("/feeds/{user_id}", web::get().to(list_feeds)),
)
.await;
let req = test::TestRequest::get()
.uri(&format!("/feeds/{}", user_b.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::FORBIDDEN, resp.status());
delete_feed(&mut connection, feed_b.id);
delete_user(&mut connection, user_a.id);
delete_user(&mut connection, user_b.id);
}
}
+86 -20
View File
@@ -1,42 +1,56 @@
use crate::auth::extractor::AuthUser;
use crate::error::AppError;
use crate::schema::feed_item::{id, read}; use crate::schema::feed_item::{id, read};
use crate::{ use crate::{
database::establish_connection, json_serialization::read_feed_item::ReadItem, database::establish_connection,
models::feed_item::rss_feed_item::FeedItem, schema::feed_item, json_serialization::read_feed_item::ReadItem,
models::feed_item::rss_feed_item::FeedItem,
schema::{feed, feed_item},
}; };
use actix_web::{web, HttpRequest, HttpResponse, Responder}; use actix_web::{web, HttpRequest, HttpResponse, Responder};
use diesel::RunQueryDsl; use diesel::RunQueryDsl;
use diesel::{ExpressionMethods, QueryDsl}; use diesel::{ExpressionMethods, OptionalExtension, QueryDsl};
pub async fn mark_read(_req: HttpRequest, path: web::Path<ReadItem>) -> impl Responder { pub async fn mark_read(
_req: HttpRequest,
path: web::Path<ReadItem>,
auth_user: AuthUser,
) -> Result<impl Responder, AppError> {
let mut connection = establish_connection(); let mut connection = establish_connection();
log::info!("Id: {}", path.id); log::info!("Id: {}", path.id);
let feed_items: Vec<FeedItem> = feed_item::table
// Join through to `feed` so we can confirm the item belongs to the caller
// before mutating it. "Doesn't exist" and "not yours" both return 404.
let owned_item: Option<(FeedItem, i32)> = feed_item::table
.inner_join(feed::table)
.filter(id.eq(path.id)) .filter(id.eq(path.id))
.load::<FeedItem>(&mut connection) .select((feed_item::all_columns, feed::user_id))
.unwrap(); .first(&mut connection)
.optional()?;
if feed_items.len() != 1 { let feed_item = match owned_item {
return HttpResponse::NotFound(); Some((feed_item, owner_id)) if owner_id == auth_user.0 => feed_item,
} _ => return Ok(HttpResponse::NotFound().finish()),
};
let feed_item: &FeedItem = feed_items.first().unwrap(); let result = diesel::update(&feed_item)
let result: Result<usize, diesel::result::Error> = diesel::update(feed_item)
.set(read.eq(true)) .set(read.eq(true))
.execute(&mut connection); .execute(&mut connection)?;
log::info!("Mark as read: {:?}", result); log::info!("Mark as read: {:?}", result);
HttpResponse::Ok() Ok(HttpResponse::Ok().finish())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use actix_service::Service;
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::{test, web, App}; use actix_web::{test, web, App, HttpMessage};
use diesel::prelude::*; use diesel::prelude::*;
use super::mark_read; use super::mark_read;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection; use crate::database::establish_connection;
use crate::models::feed_item::rss_feed_item::FeedItem; use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::schema::feed_item; use crate::schema::feed_item;
@@ -51,8 +65,16 @@ mod tests {
let feed = insert_feed(&mut connection, user.id); let feed = insert_feed(&mut connection, user.id);
let item = insert_feed_item(&mut connection, feed.id, false); let item = insert_feed_item(&mut connection, feed.id, false);
let app = let user_id = user.id;
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await; let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/read/{id}", web::put().to(mark_read)),
)
.await;
let req = test::TestRequest::put() let req = test::TestRequest::put()
.uri(&format!("/read/{}", item.id)) .uri(&format!("/read/{}", item.id))
.to_request(); .to_request();
@@ -73,11 +95,55 @@ mod tests {
#[actix_web::test] #[actix_web::test]
async fn mark_read_returns_not_found_for_unknown_id() { async fn mark_read_returns_not_found_for_unknown_id() {
let app = let app = test::init_service(
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await; App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(1));
srv.call(req)
})
.route("/read/{id}", web::put().to(mark_read)),
)
.await;
let req = test::TestRequest::put().uri("/read/999999999").to_request(); let req = test::TestRequest::put().uri("/read/999999999").to_request();
let resp = test::call_service(&app, req).await; let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status()); assert_eq!(StatusCode::NOT_FOUND, resp.status());
} }
#[actix_web::test]
async fn mark_read_rejects_other_users_item() {
let mut connection = establish_connection();
let user_a = insert_user(&mut connection, "secret");
let user_b = insert_user(&mut connection, "secret");
let feed_b = insert_feed(&mut connection, user_b.id);
let item_b = insert_feed_item(&mut connection, feed_b.id, false);
let user_a_id = user_a.id;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_a_id));
srv.call(req)
})
.route("/read/{id}", web::put().to(mark_read)),
)
.await;
let req = test::TestRequest::put()
.uri(&format!("/read/{}", item_b.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status());
let updated: FeedItem = feed_item::table
.find(item_b.id)
.first(&mut connection)
.unwrap();
assert!(!updated.read);
delete_feed_item(&mut connection, item_b.id);
delete_feed(&mut connection, feed_b.id);
delete_user(&mut connection, user_a.id);
delete_user(&mut connection, user_b.id);
}
} }
+1
View File
@@ -7,6 +7,7 @@ pub mod feeds;
mod get; mod get;
mod list_feeds; mod list_feeds;
mod mark_read; mod mark_read;
pub mod net;
mod read; mod read;
mod scraper; mod scraper;
pub mod structs; pub mod structs;
+145
View File
@@ -0,0 +1,145 @@
use std::net::IpAddr;
use anyhow::bail;
use reqwest::{redirect::Policy, Client, Response, Url};
use tokio::net::lookup_host;
use crate::error::AppError;
// Outbound requests for feed/article URLs are driven by user input (feed URLs,
// "read" links). Without these checks a user could point the server at
// internal services or cloud metadata endpoints (e.g. http://169.254.169.254/)
// and have it fetch them on their behalf (SSRF).
pub async fn safe_fetch(url: &str) -> Result<Response, AppError> {
safe_fetch_inner(url).await.map_err(AppError::from)
}
// Redirects are validated and followed manually (rather than via
// `redirect::Policy::default()`) so each hop's resolved address is checked
// against `is_globally_routable` before it's fetched — otherwise an allowed
// host could redirect to an internal address and bypass the checks below.
const MAX_REDIRECTS: u8 = 5;
async fn safe_fetch_inner(url: &str) -> anyhow::Result<Response> {
let client = Client::builder().redirect(Policy::none()).build()?;
let mut current = Url::parse(url)?;
for _ in 0..=MAX_REDIRECTS {
check_url_is_safe(&current).await?;
let response = client.get(current.clone()).send().await?;
if response.status().is_redirection() {
let location = response
.headers()
.get(reqwest::header::LOCATION)
.ok_or_else(|| {
anyhow::anyhow!("redirect response from {} has no Location header", current)
})?
.to_str()?;
current = current.join(location)?;
continue;
}
return Ok(response);
}
bail!("refusing to fetch {}: too many redirects", url);
}
async fn check_url_is_safe(url: &Url) -> anyhow::Result<()> {
if url.scheme() != "http" && url.scheme() != "https" {
bail!("refusing to fetch {}: unsupported URL scheme", url);
}
let host = url
.host_str()
.ok_or_else(|| anyhow::anyhow!("refusing to fetch {}: URL has no host", url))?;
let port = url.port_or_known_default().unwrap_or(80);
let mut resolved_any = false;
for addr in lookup_host((host, port)).await? {
resolved_any = true;
if !is_globally_routable(addr.ip()) {
bail!(
"refusing to fetch {}: resolves to non-public address {}",
url,
addr.ip()
);
}
}
if !resolved_any {
bail!("refusing to fetch {}: host did not resolve to any address", url);
}
Ok(())
}
fn is_globally_routable(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
!(v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_unspecified()
|| v4.is_broadcast()
|| v4.is_multicast())
}
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return is_globally_routable(IpAddr::V4(v4));
}
let segments = v6.segments();
let is_unique_local = (segments[0] & 0xfe00) == 0xfc00; // fc00::/7
let is_unicast_link_local = (segments[0] & 0xffc0) == 0xfe80; // fe80::/10
!(v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_multicast()
|| is_unique_local
|| is_unicast_link_local)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_loopback_and_private_addresses() {
assert!(!is_globally_routable("127.0.0.1".parse().unwrap()));
assert!(!is_globally_routable("10.0.0.5".parse().unwrap()));
assert!(!is_globally_routable("192.168.1.1".parse().unwrap()));
assert!(!is_globally_routable("169.254.169.254".parse().unwrap()));
assert!(!is_globally_routable("::1".parse().unwrap()));
assert!(!is_globally_routable("fc00::1".parse().unwrap()));
assert!(!is_globally_routable("fe80::1".parse().unwrap()));
assert!(!is_globally_routable("::ffff:127.0.0.1".parse().unwrap()));
}
#[test]
fn allows_public_addresses() {
assert!(is_globally_routable("93.184.216.34".parse().unwrap()));
assert!(is_globally_routable("2606:2800:220:1:248:1893:25c8:1946".parse().unwrap()));
}
#[actix_web::test]
async fn rejects_unsupported_schemes() {
let result = safe_fetch("ftp://example.test/file").await;
assert!(result.is_err());
}
#[actix_web::test]
async fn rejects_loopback_urls() {
let result = safe_fetch("http://127.0.0.1:8001/").await;
assert!(result.is_err());
}
#[actix_web::test]
async fn rejects_link_local_metadata_url() {
let result = safe_fetch("http://169.254.169.254/latest/meta-data/").await;
assert!(result.is_err());
}
}
+5 -4
View File
@@ -1,8 +1,9 @@
use reqwest::Error; use super::super::net::safe_fetch;
use crate::error::AppError;
// Do a request for the given URL, with a minimum time between requests // Do a request for the given URL, with a minimum time between requests
// to avoid overloading the server. // to avoid overloading the server.
pub async fn do_throttled_request(url: &str) -> Result<String, Error> { pub async fn do_throttled_request(url: &str) -> Result<String, AppError> {
let response = reqwest::get(url).await?; let response = safe_fetch(url).await?;
response.text().await Ok(response.text().await?)
} }
+292 -133
View File
@@ -1,9 +1,10 @@
use super::feeds; use super::feeds;
use crate::auth::extractor::AuthUser;
use crate::error::AppError;
use crate::json_serialization::user::JsonUser; use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed; use crate::models::feed::rss_feed::Feed;
use crate::models::feed_item::new_feed_item::NewFeedItem; use crate::models::feed_item::new_feed_item::NewFeedItem;
use crate::models::feed_item::rss_feed_item::FeedItem; use crate::schema::feed_item::{feed_id, url};
use crate::schema::feed_item::{feed_id, title};
use crate::{ use crate::{
database::establish_connection, database::establish_connection,
schema::{ schema::{
@@ -11,12 +12,13 @@ use crate::{
feed_item, feed_item,
}, },
}; };
use actix_web::{web, HttpRequest, HttpResponse, Responder}; use actix_web::{web, HttpRequest, HttpResponse};
use chrono::{DateTime, Duration, Local, NaiveDateTime}; use chrono::{DateTime, Local, NaiveDateTime};
use dateparser::parse; use dateparser::parse;
use diesel::prelude::*; use diesel::prelude::*;
use rss::Item; use rss::Item;
use scraper::{Html, Selector}; use scraper::{Html, Selector};
use std::collections::{HashMap, HashSet};
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> { fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
if let Ok(result) = parse(date_str) { if let Ok(result) = parse(date_str) {
@@ -46,6 +48,20 @@ fn escape_html_attr(value: &str) -> String {
.replace('>', "&gt;") .replace('>', "&gt;")
} }
// Feed-supplied `<img>` markup is rendered as-is (via `v-html`) in the frontend,
// so strip everything except a harmless image tag before it's stored — in
// particular event handlers like `onerror`/`onload` that a malicious feed
// could use for XSS.
fn sanitize_img_html(html: &str) -> String {
let allowed_attributes = HashSet::from(["src", "alt", "title"]);
ammonia::Builder::default()
.tags(HashSet::from(["img"]))
.tag_attributes(HashMap::from([("img", allowed_attributes)]))
.clean(html)
.to_string()
}
// Some feeds (e.g. Deutsche Welle) don't embed an <img> in the item content at // Some feeds (e.g. Deutsche Welle) don't embed an <img> in the item content at
// all — they carry the article image as an RSS <enclosure> instead. Build an // all — they carry the article image as an RSS <enclosure> instead. Build an
// <img> tag from it so those feeds get a preview image too. // <img> tag from it so those feeds get a preview image too.
@@ -60,14 +76,22 @@ fn enclosure_image_html(item: &Item) -> Option<String> {
)) ))
} }
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) { fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> anyhow::Result<()> {
let item_title = item.title.clone().unwrap(); // Items without a title or link are malformed/unusable — skip them rather
// than failing the whole sync over one bad entry from an external feed.
let Some(item_title) = item.title.clone() else {
log::warn!("Skipping feed item without a title.");
return Ok(());
};
if item.link.is_none() {
log::warn!("Skipping feed item without a link: {}", item_title);
return Ok(());
}
log::info!("Create feed item: {}", item_title); log::info!("Create feed item: {}", item_title);
// Resolve the publication date before any HTML parsing or DB work so we can // Items without a pub_date are treated as current (inserted unconditionally)
// bail out early for old articles. Items without a pub_date are treated as // — feeds that don't publish dates are typically small/curated enough that
// current (inserted unconditionally) — feeds that don't publish dates are // this is fine.
// typically small/curated enough that this is fine.
let mut time: NaiveDateTime = Local::now().naive_local(); let mut time: NaiveDateTime = Local::now().naive_local();
if let Some(pub_date) = item.pub_date() { if let Some(pub_date) = item.pub_date() {
time = match get_date(pub_date) { time = match get_date(pub_date) {
@@ -79,83 +103,91 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
}; };
} }
let cutoff = Local::now().naive_local() - Duration::days(14);
if time < cutoff {
log::info!("Skipping item {} (older than 2 weeks).", item_title);
return;
}
let base_content: &str = item.content().or(item.description()).unwrap_or_default(); let base_content: &str = item.content().or(item.description()).unwrap_or_default();
let frag = Html::parse_fragment(base_content); let frag = Html::parse_fragment(base_content);
let mut content = "".to_string(); let mut content = "".to_string();
let selector_img = Selector::parse("img").unwrap(); // Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget
// (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the
// article content. It's not part of the article and isn't present in the
// scraped/readable edition either, so skip its text when flattening below.
let selector_social_bar =
Selector::parse("#article-social-bar").expect("\"#article-social-bar\" is a valid CSS selector");
let excluded_node_ids: std::collections::HashSet<_> = frag
.select(&selector_social_bar)
.flat_map(|el| el.descendants().map(|node| node.id()))
.collect();
let selector_img = Selector::parse("img").expect("\"img\" is a valid CSS selector");
match frag.select(&selector_img).find(image_src_is_resolvable) { match frag.select(&selector_img).find(image_src_is_resolvable) {
Some(image) => { Some(image) => {
content.push_str(&image.html()); content.push_str(&sanitize_img_html(&image.html()));
content.push_str("<br>"); content.push_str("<br>");
} }
None => { None => {
if let Some(image_html) = enclosure_image_html(&item) { if let Some(image_html) = enclosure_image_html(&item) {
content.push_str(&image_html); content.push_str(&sanitize_img_html(&image_html));
content.push_str("<br>"); content.push_str("<br>");
} }
} }
} }
for node in frag.tree.nodes() { for node in frag.tree.nodes() {
if excluded_node_ids.contains(&node.id()) {
continue;
}
if let scraper::node::Node::Text(text) = node.value() { if let scraper::node::Node::Text(text) = node.value() {
content.push_str(&text.text); content.push_str(&text.text);
} }
} }
let existing_item: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.filter(title.eq(&item_title))
.load(connection)
.unwrap();
if existing_item.is_empty() {
let new_feed_item = NewFeedItem::new( let new_feed_item = NewFeedItem::new(
feed.id, feed.id,
content.clone(), content.clone(),
item_title.clone(), item_title.clone(),
item.link.unwrap(), item.link.expect("checked above"),
Some(time), Some(time),
); );
let insert_result = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.execute(connection);
log::info!("Insert Result: {:?}", insert_result); // `on_conflict` on the (feed_id, url) unique constraint makes this
// insert idempotent, so two syncs for the same feed running concurrently
// (e.g. a sync button press racing a page-reload sync) can't both pass a
// check-then-insert race and create duplicate items. Keying on the
// article's link rather than its title also handles feeds that edit a
// headline after publishing while keeping the same link — the link is
// the stable identity.
let inserted_rows = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.on_conflict((feed_id, url))
.do_nothing()
.execute(connection)?;
if inserted_rows > 0 {
log::info!("Inserted item: {}", item_title);
} else { } else {
log::info!("Item {} already exists.", item_title); log::info!("Item {} already exists.", item_title);
} }
Ok(())
} }
// Items without a `created_ts` (e.g. ones inserted before this column existed, pub async fn sync(
// or whose feed didn't provide a publish date) are left alone — `lt` never _req: HttpRequest,
// matches NULL, so there's nothing to special-case here. data: web::Json<JsonUser>,
fn delete_old_feed_items(connection: &mut PgConnection) { auth_user: AuthUser,
let cutoff = Local::now().naive_local() - Duration::days(14); ) -> Result<HttpResponse, AppError> {
let result = diesel::delete(feed_item::table.filter(feed_item::created_ts.lt(cutoff)))
.execute(connection);
log::info!("Deleted old feed items (older than 2 weeks): {:?}", result);
}
pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responder {
let mut connection: diesel::PgConnection = establish_connection();
delete_old_feed_items(&mut connection);
let req_user_id: i32 = data.user_id; let req_user_id: i32 = data.user_id;
if auth_user.0 != req_user_id {
return Ok(HttpResponse::Forbidden().finish());
}
let mut connection: diesel::PgConnection = establish_connection();
let feeds: Vec<Feed> = feed::table let feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id)) .filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection) .load::<Feed>(&mut connection)?;
.unwrap();
log::info!("Found {} feeds to sync.", feeds.len()); log::info!("Found {} feeds to sync.", feeds.len());
@@ -167,23 +199,27 @@ pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responde
Ok(channel) => { Ok(channel) => {
for item in channel.into_items() { for item in channel.into_items() {
log::info!("{:?}", item); log::info!("{:?}", item);
create_feed_item(item, &feed, &mut connection); if let Err(e) = create_feed_item(item, &feed, &mut connection) {
log::error!("Could not create feed item for {}: {:?}", feed.url, e);
}
} }
} }
Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e), Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e),
} }
} }
HttpResponse::Ok() Ok(HttpResponse::Ok().finish())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::models::feed::new_feed::NewFeed; use crate::models::feed::new_feed::NewFeed;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::models::user::new_user::NewUser; use crate::models::user::new_user::NewUser;
use crate::models::user::rss_user::User; use crate::models::user::rss_user::User;
use crate::schema::users; use crate::schema::users;
use crate::test_helpers::unique_suffix; use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user, unique_suffix};
use chrono::Duration;
use super::*; use super::*;
@@ -271,93 +307,46 @@ mod tests {
); );
} }
#[actix_web::test] #[test]
async fn delete_old_feed_items_removes_items_older_than_two_weeks_but_keeps_recent_ones() { fn sanitize_img_html_strips_event_handlers() {
let mut connection = establish_connection(); let sanitized = sanitize_img_html(r#"<img src="x" onerror="alert(1)">"#);
let suffix = unique_suffix();
let new_user = NewUser::new( assert!(!sanitized.contains("onerror"));
format!("cleanup_test_{suffix}"), assert!(!sanitized.contains("alert"));
format!("cleanup_{suffix}@example.test"), assert!(sanitized.contains(r#"src="x""#));
"secret".to_string(), }
#[test]
fn sanitize_img_html_keeps_only_allowed_attributes() {
let sanitized = sanitize_img_html(
r#"<img src="https://example.test/img.jpg" alt="desc" title="t" style="display:none" class="evil">"#,
); );
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
.unwrap();
let new_feed = NewFeed::new( assert!(sanitized.contains(r#"src="https://example.test/img.jpg""#));
format!("Cleanup test feed {suffix}"), assert!(sanitized.contains(r#"alt="desc""#));
format!("https://example.test/feed/{suffix}"), assert!(sanitized.contains(r#"title="t""#));
user.id, assert!(!sanitized.contains("style"));
); assert!(!sanitized.contains("class"));
let feed: Feed = diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(&mut connection)
.unwrap();
let now = Local::now().naive_local();
let old_item = NewFeedItem::new(
feed.id,
"old content".to_string(),
format!("Old article {suffix}"),
format!("https://example.test/article/old-{suffix}"),
Some(now - Duration::days(20)),
);
let recent_item = NewFeedItem::new(
feed.id,
"recent content".to_string(),
format!("Recent article {suffix}"),
format!("https://example.test/article/recent-{suffix}"),
Some(now - Duration::days(1)),
);
diesel::insert_into(feed_item::table)
.values(&old_item)
.execute(&mut connection)
.unwrap();
let recent: FeedItem = diesel::insert_into(feed_item::table)
.values(&recent_item)
.get_result(&mut connection)
.unwrap();
delete_old_feed_items(&mut connection);
let remaining: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(1, remaining.len(), "only the recent item should survive cleanup");
assert_eq!(recent.id, remaining[0].id);
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(users::table.filter(users::id.eq(user.id)))
.execute(&mut connection)
.ok();
} }
#[actix_web::test] #[actix_web::test]
async fn create_feed_item_skips_articles_older_than_two_weeks() { async fn create_feed_item_inserts_articles_older_than_two_weeks() {
let mut connection = establish_connection(); let mut connection = establish_connection();
let suffix = unique_suffix(); let suffix = unique_suffix();
let new_user = NewUser::new( let new_user = NewUser::new(
format!("age_skip_test_{suffix}"), format!("age_test_{suffix}"),
format!("age_skip_{suffix}@example.test"), format!("age_{suffix}@example.test"),
"secret".to_string(), "secret".to_string(),
); )
.unwrap();
let user: User = diesel::insert_into(users::table) let user: User = diesel::insert_into(users::table)
.values(&new_user) .values(&new_user)
.get_result(&mut connection) .get_result(&mut connection)
.unwrap(); .unwrap();
let new_feed = NewFeed::new( let new_feed = NewFeed::new(
format!("Age skip test feed {suffix}"), format!("Age test feed {suffix}"),
format!("https://example.test/feed/{suffix}"), format!("https://example.test/feed/{suffix}"),
user.id, user.id,
); );
@@ -366,7 +355,9 @@ mod tests {
.get_result(&mut connection) .get_result(&mut connection)
.unwrap(); .unwrap();
// Item with a pub_date 20 days ago — should be ignored by create_feed_item. // Item with a pub_date 20 days ago — should still be inserted, since
// infrequently-updated feeds (or infrequent syncs) must not lose
// articles the user hasn't seen yet.
let old_date = (Local::now() - Duration::days(20)) let old_date = (Local::now() - Duration::days(20))
.format("%a, %d %b %Y %H:%M:%S %z") .format("%a, %d %b %Y %H:%M:%S %z")
.to_string(); .to_string();
@@ -382,19 +373,15 @@ mod tests {
fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}"))); fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}")));
fresh_item.set_content(Some("<p>fresh</p>".to_string())); fresh_item.set_content(Some("<p>fresh</p>".to_string()));
create_feed_item(old_item, &feed, &mut connection); create_feed_item(old_item, &feed, &mut connection).unwrap();
create_feed_item(fresh_item, &feed, &mut connection); create_feed_item(fresh_item, &feed, &mut connection).unwrap();
let items: Vec<FeedItem> = feed_item::table let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id)) .filter(feed_id.eq(feed.id))
.load(&mut connection) .load(&mut connection)
.unwrap(); .unwrap();
assert_eq!(1, items.len(), "old item should have been skipped"); assert_eq!(2, items.len(), "both old and fresh items should be inserted");
assert!(
items[0].title.contains("Fresh article"),
"only the fresh item should be present"
);
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id))) diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection) .execute(&mut connection)
@@ -416,7 +403,8 @@ mod tests {
format!("sync_test_{suffix}"), format!("sync_test_{suffix}"),
format!("sync_{suffix}@example.test"), format!("sync_{suffix}@example.test"),
"secret".to_string(), "secret".to_string(),
); )
.unwrap();
let user: User = diesel::insert_into(users::table) let user: User = diesel::insert_into(users::table)
.values(&new_user) .values(&new_user)
.get_result(&mut connection) .get_result(&mut connection)
@@ -437,8 +425,8 @@ mod tests {
item.set_link(Some(format!("https://example.test/article/{suffix}"))); item.set_link(Some(format!("https://example.test/article/{suffix}")));
item.set_content(Some("<p>Hello world</p>".to_string())); item.set_content(Some("<p>Hello world</p>".to_string()));
create_feed_item(item.clone(), &feed, &mut connection); create_feed_item(item.clone(), &feed, &mut connection).unwrap();
create_feed_item(item, &feed, &mut connection); create_feed_item(item, &feed, &mut connection).unwrap();
let items: Vec<FeedItem> = feed_item::table let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id)) .filter(feed_id.eq(feed.id))
@@ -456,4 +444,175 @@ mod tests {
.execute(&mut connection) .execute(&mut connection)
.ok(); .ok();
} }
#[actix_web::test]
async fn create_feed_item_is_race_safe_under_concurrent_syncs() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let user = insert_user(&mut connection, "secret");
let feed = insert_feed(&mut connection, user.id);
let mut item = Item::default();
item.set_title(Some(format!("Race test article {suffix}")));
item.set_link(Some(format!("https://example.test/race/{suffix}")));
item.set_content(Some("<p>Hello world</p>".to_string()));
// Simulate two concurrent syncs for the same feed (e.g. a sync
// button press racing a page-reload sync) — each on its own
// connection/thread, released together so they genuinely overlap —
// both inserting the same item.
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let handles: Vec<_> = (0..2)
.map(|_| {
let feed = feed.clone();
let item = item.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
let mut connection = establish_connection();
barrier.wait();
create_feed_item(item, &feed, &mut connection)
})
})
.collect();
for handle in handles {
handle.join().unwrap().unwrap();
}
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(
1,
items.len(),
"concurrent syncs must not create duplicate feed items"
);
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
delete_feed(&mut connection, feed.id);
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn create_feed_item_strips_onerror_from_feed_image() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let new_user = NewUser::new(
format!("xss_test_{suffix}"),
format!("xss_{suffix}@example.test"),
"secret".to_string(),
)
.unwrap();
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
.unwrap();
let new_feed = NewFeed::new(
format!("XSS test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
user.id,
);
let feed: Feed = diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(&mut connection)
.unwrap();
let mut item = Item::default();
item.set_title(Some(format!("XSS article {suffix}")));
item.set_link(Some(format!("https://example.test/xss/{suffix}")));
item.set_content(Some(
r#"<img src="https://example.test/real.jpg" onerror="alert(1)"><p>text</p>"#
.to_string(),
));
create_feed_item(item, &feed, &mut connection).unwrap();
let stored: FeedItem = feed_item::table
.filter(feed_id.eq(feed.id))
.first(&mut connection)
.unwrap();
assert!(!stored.content.contains("onerror"));
assert!(!stored.content.contains("alert"));
assert!(stored.content.contains(r#"src="https://example.test/real.jpg""#));
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(users::table.filter(users::id.eq(user.id)))
.execute(&mut connection)
.ok();
}
#[actix_web::test]
async fn create_feed_item_strips_social_sharing_widget() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let new_user = NewUser::new(
format!("social_bar_test_{suffix}"),
format!("social_bar_{suffix}@example.test"),
"secret".to_string(),
)
.unwrap();
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
.unwrap();
let new_feed = NewFeed::new(
format!("Social bar test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
user.id,
);
let feed: Feed = diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(&mut connection)
.unwrap();
let mut item = Item::default();
item.set_title(Some(format!("Social bar article {suffix}")));
item.set_link(Some(format!("https://example.test/article/{suffix}")));
item.set_content(Some(
r#"<p>Article text</p>
<div id="article-social-bar" data-noprint="true">
<ul>
<li><a id="whatsapp" href="whatsapp://send?text=foo">&nbsp;</a></li>
<li><a id="link_copy" onclick="copyToClipboard()">&nbsp;</a>
<p>Link kopiert</p>
</li>
</ul>
</div>"#
.to_string(),
));
create_feed_item(item, &feed, &mut connection).unwrap();
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.load(&mut connection)
.unwrap();
assert_eq!(1, items.len());
assert!(items[0].content.contains("Article text"));
assert!(!items[0].content.contains("Link kopiert"));
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
.execute(&mut connection)
.ok();
diesel::delete(users::table.filter(users::id.eq(user.id)))
.execute(&mut connection)
.ok();
}
} }
+1
View File
@@ -28,6 +28,7 @@ diesel::table! {
email -> Varchar, email -> Varchar,
password -> Varchar, password -> Varchar,
unique_id -> Varchar, unique_id -> Varchar,
token_version -> Int4,
} }
} }
+2 -1
View File
@@ -28,7 +28,8 @@ pub fn insert_user(connection: &mut PgConnection, password: &str) -> User {
format!("test_user_{suffix}"), format!("test_user_{suffix}"),
format!("test_{suffix}@example.test"), format!("test_{suffix}@example.test"),
password.to_string(), password.to_string(),
); )
.expect("failed to hash test user password");
diesel::insert_into(users::table) diesel::insert_into(users::table)
.values(&new_user) .values(&new_user)
.get_result(connection) .get_result(connection)
+10 -11
View File
@@ -1,5 +1,6 @@
use crate::database::establish_connection; use crate::database::establish_connection;
use crate::diesel; use crate::diesel;
use crate::error::AppError;
use crate::json_serialization::login::Login; use crate::json_serialization::login::Login;
use crate::models::user::rss_user::User; use crate::models::user::rss_user::User;
use crate::schema::users; use crate::schema::users;
@@ -7,7 +8,7 @@ use crate::{auth::jwt::JwtToken, schema::users::username};
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use diesel::prelude::*; use diesel::prelude::*;
pub async fn login(credentials: web::Json<Login>) -> HttpResponse { pub async fn login(credentials: web::Json<Login>) -> Result<HttpResponse, AppError> {
let username_cred: String = credentials.username.clone(); let username_cred: String = credentials.username.clone();
let password: String = credentials.password.clone(); let password: String = credentials.password.clone();
@@ -15,32 +16,30 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
let users: Vec<User> = users::table let users: Vec<User> = users::table
.filter(username.eq(username_cred.as_str())) .filter(username.eq(username_cred.as_str()))
.load::<User>(&mut connection) .load::<User>(&mut connection)?;
.unwrap();
if users.is_empty() { if users.is_empty() {
return HttpResponse::NotFound().await.unwrap(); return Ok(HttpResponse::NotFound().finish());
} else if users.len() > 1 { } else if users.len() > 1 {
log::error!( log::error!(
"multiple user have the usernam: {}", "multiple user have the usernam: {}",
credentials.username.clone() credentials.username.clone()
); );
return HttpResponse::Conflict().await.unwrap(); return Ok(HttpResponse::Conflict().finish());
} }
let user: &User = &users[0]; let user: &User = &users[0];
match user.clone().verify(password) { match user.clone().verify(password)? {
true => { true => {
log::info!("verified password successfully for user {}", user.id); log::info!("verified password successfully for user {}", user.id);
let token: String = JwtToken::encode(user.clone().id); let token: String = JwtToken::encode(user.id, user.token_version);
HttpResponse::Ok() Ok(HttpResponse::Ok()
.insert_header(("token", token)) .insert_header(("token", token))
.insert_header(("user_id", user.id)) .insert_header(("user_id", user.id))
.await .finish())
.unwrap()
} }
false => HttpResponse::Unauthorized().await.unwrap(), false => Ok(HttpResponse::Unauthorized().finish()),
} }
} }
+45 -8
View File
@@ -1,25 +1,62 @@
use actix_web::HttpResponse; use actix_web::HttpResponse;
use diesel::prelude::*;
// JWT auth is stateless and there is no token blacklist, so logging out is use crate::auth::extractor::AuthUser;
// purely a client-side action (discarding the stored token). This endpoint use crate::database::establish_connection;
// exists so the frontend has something to call and gets a clean response. use crate::error::AppError;
pub async fn logout() -> HttpResponse { use crate::schema::users;
HttpResponse::Ok().finish()
/// Invalidates every token previously issued for this user by bumping
/// `token_version` — the auth middleware rejects tokens whose `tv` claim no
/// longer matches the stored value.
pub async fn logout(auth_user: AuthUser) -> Result<HttpResponse, AppError> {
let mut connection = establish_connection();
diesel::update(users::table.find(auth_user.0))
.set(users::token_version.eq(users::token_version + 1))
.execute(&mut connection)?;
Ok(HttpResponse::Ok().finish())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use actix_service::Service;
use actix_web::http::StatusCode; use actix_web::http::StatusCode;
use actix_web::{test, web, App}; use actix_web::{test, web, App, HttpMessage};
use diesel::prelude::*;
use super::logout; use super::logout;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection;
use crate::models::user::rss_user::User;
use crate::schema::users;
use crate::test_helpers::{delete_user, insert_user};
#[actix_web::test] #[actix_web::test]
async fn logout_returns_ok() { async fn logout_bumps_token_version() {
let app = test::init_service(App::new().route("/logout", web::post().to(logout))).await; let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let user_id = user.id;
let initial_version = user.token_version;
let app = test::init_service(
App::new()
.wrap_fn(move |req, srv| {
req.extensions_mut().insert(AuthUser(user_id));
srv.call(req)
})
.route("/logout", web::post().to(logout)),
)
.await;
let req = test::TestRequest::post().uri("/logout").to_request(); let req = test::TestRequest::post().uri("/logout").to_request();
let resp = test::call_service(&app, req).await; let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status()); assert_eq!(StatusCode::OK, resp.status());
let updated: User = users::table.find(user_id).first(&mut connection).unwrap();
assert_eq!(initial_version + 1, updated.token_version);
delete_user(&mut connection, user_id);
} }
} }
+14 -3
View File
@@ -1,3 +1,4 @@
use actix_governor::{Governor, GovernorConfigBuilder};
use actix_web::web; use actix_web::web;
use actix_web::web::ServiceConfig; use actix_web::web::ServiceConfig;
@@ -12,9 +13,19 @@ pub fn auth_factory(app: &mut ServiceConfig) {
backend: true, backend: true,
}; };
app.route( // Login is the only unauthenticated endpoint that checks a password, so
&base_path.define(String::from("/login")), // it's the only one worth rate-limiting against brute-force/credential
web::post().to(login::login), // stuffing. One request every 2s with a burst of 5 per IP.
let login_rate_limit = GovernorConfigBuilder::default()
.seconds_per_request(2)
.burst_size(5)
.finish()
.expect("valid governor rate-limit config");
app.service(
web::resource(base_path.define(String::from("/login")))
.wrap(Governor::new(&login_rate_limit))
.route(web::post().to(login::login)),
); );
app.route( app.route(
&base_path.define(String::from("/logout")), &base_path.define(String::from("/logout")),
+30 -7
View File
@@ -1,27 +1,32 @@
use crate::database::establish_connection; use crate::database::establish_connection;
use crate::diesel; use crate::diesel;
use crate::error::AppError;
use crate::json_serialization::new_user::NewUserSchema; use crate::json_serialization::new_user::NewUserSchema;
use crate::models::user::new_user::NewUser; use crate::models::user::new_user::{validate_password, NewUser};
use crate::schema::users; use crate::schema::users;
use actix_web::{web, HttpResponse}; use actix_web::{web, HttpResponse};
use diesel::prelude::*; use diesel::prelude::*;
pub async fn create(new_user: web::Json<NewUserSchema>) -> HttpResponse { pub async fn create(new_user: web::Json<NewUserSchema>) -> Result<HttpResponse, AppError> {
let mut connection = establish_connection(); let mut connection = establish_connection();
let name: String = new_user.name.clone(); let name: String = new_user.name.clone();
let email: String = new_user.email.clone(); let email: String = new_user.email.clone();
let new_password: String = new_user.password.clone(); let new_password: String = new_user.password.clone();
let new_user = NewUser::new(name, email, new_password); if let Err(message) = validate_password(&new_password) {
return Ok(HttpResponse::BadRequest().body(message));
}
let new_user = NewUser::new(name, email, new_password)?;
let insert_result = diesel::insert_into(users::table) let insert_result = diesel::insert_into(users::table)
.values(&new_user) .values(&new_user)
.execute(&mut connection); .execute(&mut connection);
match insert_result { Ok(match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(), Ok(_) => HttpResponse::Created().finish(),
Err(_) => HttpResponse::Conflict().await.unwrap(), Err(_) => HttpResponse::Conflict().finish(),
} })
} }
#[cfg(test)] #[cfg(test)]
@@ -60,6 +65,24 @@ mod tests {
.ok(); .ok();
} }
#[actix_web::test]
async fn create_fails_for_short_password() {
let suffix = unique_suffix();
let app = test::init_service(App::new().route("/create", web::post().to(create))).await;
let req = test::TestRequest::post()
.uri("/create")
.set_json(serde_json::json!({
"name": format!("short_pw_{suffix}"),
"email": format!("short_{suffix}@example.test"),
"password": "abc"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::BAD_REQUEST, resp.status());
}
#[actix_web::test] #[actix_web::test]
async fn create_fails_for_duplicate_user() { async fn create_fails_for_duplicate_user() {
let mut connection = establish_connection(); let mut connection = establish_connection();
+3 -1
View File
@@ -1,5 +1,7 @@
# --- builder --- # --- builder ---
FROM node:20-alpine AS builder # Node 24 (active LTS): Node 20 is past EOL (April 2026), and vue-router 5's
# Babel 8 toolchain requires ^22.18.0 || >=24.11.0 (EBADENGINE on node 20).
FROM node:24-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
+3
View File
@@ -7,6 +7,9 @@
<link rel="alternate icon" href="/favicon.ico"> <link rel="alternate icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RSS-Reader</title> <title>RSS-Reader</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Lora:ital,wght@0,400;0,700;1,400&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:wght@400;700&family=Raleway:wght@400;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,700;1,8..60,400&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
+50 -40
View File
@@ -1275,9 +1275,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.12.6", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
@@ -1427,9 +1427,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -1437,12 +1437,12 @@
} }
}, },
"node_modules/braces": { "node_modules/braces": {
"version": "3.0.2", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"fill-range": "^7.0.1" "fill-range": "^7.1.1"
}, },
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -2205,9 +2205,9 @@
} }
}, },
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.0.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@@ -2247,9 +2247,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.2.7", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true "dev": true
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
@@ -2300,15 +2300,15 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -2812,10 +2812,20 @@
"dev": true "dev": true
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
}, },
@@ -3224,9 +3234,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true "dev": true
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
@@ -3299,12 +3309,12 @@
} }
}, },
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.5", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"braces": "^3.0.2", "braces": "^3.0.3",
"picomatch": "^2.3.1" "picomatch": "^2.3.1"
}, },
"engines": { "engines": {
@@ -3343,9 +3353,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"brace-expansion": "^1.1.7" "brace-expansion": "^1.1.7"
@@ -3673,9 +3683,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "2.3.1", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=8.6" "node": ">=8.6"
@@ -4450,9 +4460,9 @@
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==" "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.27.2", "version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=20.18.1" "node": ">=20.18.1"
+4
View File
@@ -1,8 +1,12 @@
<script setup> <script setup>
import { onMounted } from 'vue'
import { RouterView, useRoute } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import AppNav from './components/AppNav.vue' import AppNav from './components/AppNav.vue'
import { useSettings } from './composables/useSettings.js'
const route = useRoute() const route = useRoute()
const { applySettings } = useSettings()
onMounted(applySettings)
</script> </script>
<template> <template>
+17
View File
@@ -23,6 +23,13 @@
/* semantic color variables for this project */ /* semantic color variables for this project */
:root { :root {
--headline-font-family: Glook, 'Courier New';
--content-font-family: Merriweather, Georgia, 'Times New Roman', Times, serif;
--headline-font-size-scale: 1;
--content-font-size-scale: 1;
--content-text-align: left;
--content-padding: 1rem;
--color-background: var(--vt-c-white); --color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft); --color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute); --color-background-mute: var(--vt-c-white-mute);
@@ -70,6 +77,16 @@
body { body {
min-height: 100vh; min-height: 100vh;
/* Full-bleed article images use a `100vw`-based breakout, which can be
wider than the visible content area (scrollbar) and would otherwise
introduce a horizontal scrollbar. */
overflow-x: hidden;
/* Without this, a horizontal touch drag on content with nothing left to
scroll (true here, given overflow-x: hidden above) is claimed by
Chrome-on-Android's native swipe-to-go-back/forward gesture before our
own touchend listener (article-view swipe navigation) ever sees it —
the gesture is silently swallowed with no error, no touchend fires. */
overscroll-behavior-x: contain;
color: var(--color-text); color: var(--color-text);
background: var(--color-background); background: var(--color-background);
transition: color 0.5s, background-color 0.5s; transition: color 0.5s, background-color 0.5s;
+9 -6
View File
@@ -4,6 +4,7 @@
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 0.5rem; padding: 0.5rem;
padding-top: var(--app-nav-height, 4.5rem);
font-weight: normal; font-weight: normal;
} }
@@ -68,8 +69,8 @@ a,
.feed-title { .feed-title {
cursor: pointer; cursor: pointer;
font-family: 'Courier New'; font-family: var(--headline-font-family);
font-size: clamp(1.25rem, 4.5vw, 1.6rem); font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
font-weight: bold; font-weight: bold;
color: var(--color-accent-2); color: var(--color-accent-2);
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
@@ -83,9 +84,10 @@ a,
} }
.feed-content { .feed-content {
font-family: Georgia, 'Times New Roman', Times, serif; font-family: var(--content-font-family);
font-size: clamp(1rem, 3.5vw, 1.25rem); font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
padding: 0 1em 1em; text-align: var(--content-text-align);
padding: 0 var(--content-padding) 1em;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
@@ -100,7 +102,7 @@ a,
.feed-content h3 { .feed-content h3 {
padding: 0.5em 0; padding: 0.5em 0;
font-size: clamp(1rem, 3vw, 1.3rem); font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
font-weight: bold; font-weight: bold;
} }
@@ -111,5 +113,6 @@ h3 {
@media (min-width: 768px) { @media (min-width: 768px) {
#app { #app {
padding: 0.75rem; padding: 0.75rem;
padding-top: var(--app-nav-height, 4.5rem);
} }
} }
+133
View File
@@ -0,0 +1,133 @@
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
const feeds = ref([])
const error = ref('')
function authHeaders() {
return {
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem('user-token'),
},
}
}
async function loadFeeds() {
const userId = localStorage.getItem('user-id')
try {
const response = await axios.get(`/api/v1/article/feeds/${userId}`, authHeaders())
feeds.value = response.data.feeds
} catch (e) {
error.value = 'Failed to load feeds.'
}
}
async function deleteFeed(feedId) {
if (!window.confirm('Delete this feed and all its articles?')) return
try {
await axios.delete(`/api/v1/article/feed/${feedId}`, authHeaders())
feeds.value = feeds.value.filter(f => f.id !== feedId)
} catch (e) {
error.value = 'Failed to delete feed.'
}
}
onMounted(loadFeeds)
</script>
<template>
<div class="admin">
<h1 class="admin__heading">Admin</h1>
<p v-if="error" class="admin__error">{{ error }}</p>
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
<ul v-else class="admin__list">
<li v-for="feed in feeds" :key="feed.id" class="admin__item">
<div class="admin__item-info">
<span class="admin__item-title">{{ feed.title }}</span>
<a :href="feed.url" class="admin__item-url" target="_blank" rel="noopener">{{ feed.url }}</a>
</div>
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
</li>
</ul>
</div>
</template>
<style scoped>
.admin {
padding: 1.5rem 1rem;
max-width: 720px;
margin: 0 auto;
}
.admin__heading {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 1.25rem;
}
.admin__error,
.admin__empty {
opacity: 0.6;
}
.admin__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.admin__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
}
.admin__item-info {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
.admin__item-title {
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin__item-url {
font-size: 0.8rem;
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: inherit;
}
.admin__delete {
flex-shrink: 0;
min-height: 36px;
padding: 0.3rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.admin__delete:hover {
border-color: var(--color-border-hover);
}
</style>
+193
View File
@@ -0,0 +1,193 @@
<script setup>
import { useSettings } from '../composables/useSettings.js'
const {
headlineSizeScale,
contentSizeScale,
headlineFontKey,
contentFontKey,
SIZE_STEPS,
SIZE_LABELS,
HEADLINE_FONT_OPTIONS,
CONTENT_FONT_OPTIONS,
setHeadlineSize,
setContentSize,
setHeadlineFont,
setContentFont,
textAlignKey,
contentPadding,
TEXT_ALIGN_OPTIONS,
PADDING_STEPS,
PADDING_LABELS,
setTextAlign,
setContentPadding,
} = useSettings()
</script>
<template>
<div class="settings">
<h1 class="settings__heading">Typography</h1>
<section class="settings__section">
<h2 class="settings__section-title">Headline Size</h2>
<div class="settings__strip">
<button
v-for="(step, i) in SIZE_STEPS"
:key="step"
class="settings__btn"
:class="{ 'settings__btn--active': headlineSizeScale === step }"
type="button"
@click="setHeadlineSize(step)"
>{{ SIZE_LABELS[i] }}</button>
</div>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Article Text Size</h2>
<div class="settings__strip">
<button
v-for="(step, i) in SIZE_STEPS"
:key="step"
class="settings__btn"
:class="{ 'settings__btn--active': contentSizeScale === step }"
type="button"
@click="setContentSize(step)"
>{{ SIZE_LABELS[i] }}</button>
</div>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Headline Font</h2>
<select
class="settings__select"
:value="headlineFontKey"
@change="setHeadlineFont($event.target.value)"
>
<option
v-for="opt in HEADLINE_FONT_OPTIONS"
:key="opt.key"
:value="opt.key"
>{{ opt.label }}</option>
</select>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Article Text Font</h2>
<select
class="settings__select"
:value="contentFontKey"
@change="setContentFont($event.target.value)"
>
<option
v-for="opt in CONTENT_FONT_OPTIONS"
:key="opt.key"
:value="opt.key"
>{{ opt.label }}</option>
</select>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Text Alignment</h2>
<div class="settings__strip">
<button
v-for="opt in TEXT_ALIGN_OPTIONS"
:key="opt.key"
class="settings__btn"
:class="{ 'settings__btn--active': textAlignKey === opt.key }"
type="button"
@click="setTextAlign(opt.key)"
>{{ opt.label }}</button>
</div>
</section>
<section class="settings__section">
<h2 class="settings__section-title">Content Padding</h2>
<div class="settings__strip">
<button
v-for="(step, i) in PADDING_STEPS"
:key="step"
class="settings__btn"
:class="{ 'settings__btn--active': contentPadding === step }"
type="button"
@click="setContentPadding(step)"
>{{ PADDING_LABELS[i] }}</button>
</div>
</section>
</div>
</template>
<style scoped>
.settings {
padding: 1.5rem 1rem 0.5rem;
max-width: 720px;
margin: 0 auto;
}
.settings__heading {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 1.25rem;
}
.settings__section {
margin-bottom: 1.25rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-background-soft);
}
.settings__section-title {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
margin-bottom: 0.6rem;
}
.settings__strip {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.settings__btn {
min-height: 36px;
padding: 0.3rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.settings__btn:hover {
border-color: var(--color-border-hover);
}
.settings__btn--active {
border-color: var(--color-accent);
background: var(--color-accent-hover);
color: var(--color-text);
}
.settings__select {
width: 100%;
min-height: 36px;
padding: 0.3rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
color: var(--color-text);
font: inherit;
cursor: pointer;
appearance: auto;
}
.settings__select:hover {
border-color: var(--color-border-hover);
}
</style>
+178 -14
View File
@@ -1,12 +1,112 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds' import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue'
const router = useRouter() const router = useRouter()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds() const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
const headerRef = ref(null)
// Native <select>/<option> has no "is open" state — the closed box just shows
// the selected option's text verbatim, so per-option unread counts can only be
// toggled based on focus, since the popup is only ever shown while focused.
// @focus ~ opening (shows counts); @change (picking an option closes the
// popup but keeps focus, so no blur fires) and @blur (focus left without
// picking) ~ closed (counts hidden, so the closed box just shows the name).
const filterOpen = ref(false)
function onFilterFocus() {
filterOpen.value = true
refreshUnreadDisplay()
}
function onFilterChange(event) {
setFeedFilter(event.target.value || null)
filterOpen.value = false
}
// Scroll-driven show/hide: the header slides out of view on scroll-down and
// back in only on scroll-up — never merely by proximity to the top. This is
// show/hide via `transform` (not the old resize behaviour) — the header is
// position:fixed, so translating it never reflows content. The app's
// programmatic scrolls resolve to sensible states without any top-of-page
// special case: scrollTo(0, 0) (article paging, layout toggle) is itself a
// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and
// reveals the header that way; the list-view read-correction scrollBy moves
// only a few px → stays under the threshold and leaves a hidden header hidden.
const hidden = ref(false)
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
// When the feed list mutates itself (read-correction scrollBy + array-splice
// scroll anchoring) the page jumps *upward* without user intent. For this long
// after such a jump we gate only the reveal direction, so the jump can't pop
// the header back into view mid-read. Hiding stays allowed the whole time
// (the jump never scrolls down), so scrolling down still hides normally even
// while articles are being marked read. See lastProgrammaticScroll in useFeeds.
const PROGRAMMATIC_SUPPRESS_MS = 300
let lastY = 0
let accumulated = 0
function onScroll() {
const y = Math.max(0, window.scrollY)
// Keep the header visible while the menu is open (the dropdown is anchored
// to the header, so hiding it would slide the open menu off-screen).
if (menuOpen.value) {
hidden.value = false
accumulated = 0
lastY = y
return
}
const delta = y - lastY
lastY = y
// Reset the accumulator whenever direction flips, so the threshold is
// measured from the last turning point (not from page load).
if ((delta > 0) !== (accumulated > 0)) accumulated = 0
accumulated += delta
// Hiding (scroll-down) is always allowed. Revealing (scroll-up) is gated for
// a short window after a programmatic list update, whose induced jump is
// upward and would otherwise pop the header back into view mid-read. A
// genuine scroll-up reveals once the window has elapsed.
const afterProgrammatic = performance.now() - lastProgrammaticScroll.value < PROGRAMMATIC_SUPPRESS_MS
if (accumulated > REVEAL_THRESHOLD) hidden.value = true // scrolling down
else if (accumulated < -REVEAL_THRESHOLD && !afterProgrammatic) hidden.value = false // scrolling up
}
let ticking = false
function onScrollRaf() {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
onScroll()
ticking = false
})
}
onMounted(() => {
// Drives #app's padding-top / RssFeeds' scroll-margin-top so content below
// the fixed header isn't hidden behind it at scroll position 0. The header is
// a fixed size, so this is measured once on mount and never changes.
const h = headerRef.value?.getBoundingClientRect().height ?? 0
document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
lastY = Math.max(0, window.scrollY)
window.addEventListener('scroll', onScrollRaf, { passive: true })
document.addEventListener('click', onDocumentClick)
refreshUnreadDisplay()
})
onUnmounted(() => {
window.removeEventListener('scroll', onScrollRaf)
document.removeEventListener('click', onDocumentClick)
})
const onFeedsPage = computed(() => route.path === '/feeds')
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const menuOpen = ref(false) const menuOpen = ref(false)
@@ -18,9 +118,21 @@ function closeMenu() {
menuOpen.value = false menuOpen.value = false
} }
function logout() { // The open menu is a thin absolutely-positioned strip under the header, so its
localStorage.removeItem('user-token') // `@click.self` only catches clicks on that strip — a click anywhere else on
localStorage.removeItem('user-id') // the page never reaches it. This document-level listener closes the menu on
// any outside click. The hamburger is excluded (it has its own toggle, so an
// opening click mustn't immediately re-close), and clicks inside the menu are
// left to the menu items' own handlers.
function onDocumentClick(event) {
if (!menuOpen.value) return
const target = event.target
if (target.closest?.('.app-nav__hamburger') || target.closest?.('.app-nav__menu')) return
closeMenu()
}
async function logout() {
await logoutSession()
closeMenu() closeMenu()
router.push({ name: 'login' }) router.push({ name: 'login' })
} }
@@ -52,9 +164,21 @@ function handleToggleLayout() {
</script> </script>
<template> <template>
<header class="app-nav"> <header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
<div class="app-nav__wrapper"> <div class="app-nav__wrapper">
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span> <span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
<select
v-if="onFeedsPage && feedTitles.length"
class="app-nav__filter"
:value="feedFilter ?? ''"
aria-label="Filter by feed"
@focus="onFilterFocus"
@change="onFilterChange"
@blur="filterOpen = false"
>
<option value="">All feeds</option>
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
</select>
<button <button
class="app-nav__hamburger" class="app-nav__hamburger"
type="button" type="button"
@@ -78,26 +202,48 @@ function handleToggleLayout() {
> >
<div class="app-nav__menu-panel"> <div class="app-nav__menu-panel">
<RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink> <RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink>
<template v-if="onFeedsPage">
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode"> <button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
{{ viewMode === 'list' ? 'Article view' : 'List view' }} {{ viewMode === 'list' ? 'Article view' : 'List view' }}
</button> </button>
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout"> <button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
{{ layout === 'list' ? 'Card layout' : 'List layout' }} {{ layout === 'list' ? 'Card layout' : 'List layout' }}
</button> </button>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button> <button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
</template>
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button> <button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink> <RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button> <button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div> </div>
</nav> </nav>
</Transition> </Transition>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</header> </header>
</template> </template>
<style scoped> <style scoped>
.app-nav { .app-nav {
position: relative; position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: var(--color-background);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
transition: transform 0.25s ease;
will-change: transform;
}
.app-nav--hidden {
transform: translateY(-100%);
} }
.app-nav__wrapper { .app-nav__wrapper {
@@ -106,12 +252,13 @@ function handleToggleLayout() {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem 1rem; padding: 0.375rem 1rem;
} }
.app-nav__title { .app-nav__title {
margin-right: auto;
font-weight: bold; font-weight: bold;
font-size: clamp(1.1rem, 4vw, 1.4rem); font-size: clamp(0.95rem, 3.5vw, 1.1rem);
} }
.app-nav__unread { .app-nav__unread {
@@ -119,6 +266,23 @@ function handleToggleLayout() {
opacity: 0.6; opacity: 0.6;
} }
.app-nav__filter {
min-height: 44px;
max-width: clamp(120px, 40vw, 220px);
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
text-overflow: ellipsis;
}
.app-nav__filter:hover {
border-color: var(--color-border-hover);
}
.app-nav__hamburger { .app-nav__hamburger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -203,7 +367,7 @@ function handleToggleLayout() {
@media (min-width: 768px) { @media (min-width: 768px) {
.app-nav__wrapper { .app-nav__wrapper {
padding: 1rem 2rem; padding: 0.5rem 2rem;
} }
} }
</style> </style>
+317 -39
View File
@@ -1,27 +1,111 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import Modal from './modal/AddUrl.vue';
import { useFeeds } from '@/composables/useFeeds'; import { useFeeds } from '@/composables/useFeeds';
const { const {
feeds, feeds,
showMessage, showMessage,
message, message,
showModal,
viewMode, viewMode,
currentIndex, currentIndex,
leaveArticleView,
layout, layout,
nextArticle, nextArticle,
prevArticle, prevArticle,
fetchData, fetchData,
sync,
getReadable, getReadable,
disconnectObserver,
setInitialLoad, setInitialLoad,
showMessageForXSeconds, showMessageForXSeconds,
markCurrentArticleRead,
} = useFeeds() } = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const shareLabel = navigator.share ? 'Share' : 'Copy link' const shareLabel = navigator.share ? 'Share' : 'Copy link'
function scrollToNextArticle() {
const articles = document.querySelectorAll('#article .observe')
const threshold = window.scrollY + 1
for (const el of articles) {
if (el.offsetTop > threshold) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
}
}
// Swipe-to-navigate in article view. Transient per-gesture state, not shared
// app state, so it's kept as plain component-local variables rather than in
// useFeeds() — nothing outside this component needs it.
let touchStartX = null
let touchStartY = null
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
function onArticleTouchStart(event) {
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
// horizontally-scrollable code block (.article-feature__content :deep(pre))
// — those should scroll the block, not page the article.
if (event.touches.length !== 1 || event.target.closest('pre')) {
touchStartX = null
touchStartY = null
return
}
touchStartX = event.touches[0].clientX
touchStartY = event.touches[0].clientY
}
function onArticleTouchEnd(event) {
if (touchStartX === null) return
const touch = event.changedTouches[0]
const deltaX = touch.clientX - touchStartX
const deltaY = touch.clientY - touchStartY
touchStartX = null
touchStartY = null
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
if (deltaX < 0) {
nextArticle()
} else {
prevArticle()
}
}
// Small images (icons, logos, ...) look bad stretched to the full-bleed
// width used for readable article images — leave them at their natural size
// instead. Intrinsic size is only known once the image has loaded, so check
// on load (or immediately if it's already cached/complete).
const SMALL_IMAGE_THRESHOLD = 200
function markSmallImages() {
document.querySelectorAll('.article-feature__content--readable img, .feed-content--readable img').forEach(img => {
const checkSize = () => {
if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) {
img.classList.add('article-feature__image--small')
}
}
if (img.complete) {
checkSize()
} else {
img.addEventListener('load', checkSize, { once: true })
}
})
}
watch(() => feeds.value[currentIndex.value]?.content, async () => {
await nextTick()
markSmallImages()
})
async function loadReadable(feed, index) {
await getReadable(feed, index)
await nextTick()
markSmallImages()
}
async function shareUrl(url) { async function shareUrl(url) {
if (navigator.share) { if (navigator.share) {
await navigator.share({ url }) await navigator.share({ url })
@@ -31,9 +115,20 @@ async function shareUrl(url) {
} }
} }
onBeforeUnmount(() => {
disconnectObserver()
setInitialLoad(false)
})
onMounted(async () => { onMounted(async () => {
setInitialLoad(false) setInitialLoad(false)
await fetchData() await fetchData()
// viewMode persists across reloads (see useFeeds.js) — if we're booting
// straight into article view, the article now on screen needs the same
// immediate mark-read that toggleViewMode()/nextArticle()/prevArticle()
// give it when reached by in-session navigation.
if (viewMode.value === 'article') markCurrentArticleRead()
sync(true)
setTimeout(function () { setTimeout(function () {
setInitialLoad(true) setInitialLoad(true)
console.log('set to true') console.log('set to true')
@@ -42,13 +137,6 @@ onMounted(async () => {
</script> </script>
<template> <template>
<Teleport to="body">
<modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</modal>
</Teleport>
<div> <div>
<div v-if="showMessage" class="message">{{ message }}</div> <div v-if="showMessage" class="message">{{ message }}</div>
@@ -60,24 +148,30 @@ onMounted(async () => {
</svg> </svg>
<p class="empty-state__label">All caught up</p> <p class="empty-state__label">All caught up</p>
</div> </div>
<template v-for="( feed, index ) in feeds "> <template v-for="( feed, index ) in feeds " :key="feed.id">
<div v-bind:id="index" class="observe"> <div v-bind:id="index" class="observe">
<p class="feed-source">{{ feed.feedTitle }}</p> <p class="feed-source">{{ feed.feedTitle }}</p>
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2> <h2 @click="loadReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
<h3>{{ feed.timestamp }}</h3> <h3>{{ feed.timestamp }}</h3>
<p v-if="!feed.readable" class="feed-original-link"> <p class="feed-original-link">
<a :href="feed.url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a> <a :href="feed.url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feed.url)" :aria-label="shareLabel"> <button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feed.url)" :aria-label="shareLabel">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
</button> </button>
</p> </p>
<p class="feed-content" v-html='feed.content'></p> <p class="feed-content" :class="{ 'feed-content--readable': feed.readable }" v-html='feed.content'></p>
</div> </div>
</template> </template>
<button
v-if="feeds.length"
type="button"
class="article-nav__btn list-skip-btn"
aria-label="Skip to next article"
@click="scrollToNextArticle"
>&darr;</button>
</div> </div>
<div v-else class="article-single"> <div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
<button type="button" class="article-single__back" @click="leaveArticleView">&larr; Back to list</button>
<div v-if="feeds.length == 0" class="empty-state"> <div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
@@ -85,16 +179,22 @@ onMounted(async () => {
</svg> </svg>
<p class="empty-state__label">All caught up</p> <p class="empty-state__label">All caught up</p>
</div> </div>
<template v-else> <!-- Deliberately no enter/leave transition here: Vue's <Transition>
<p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p> component forces a synchronous whole-document reflow
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="feed-title">{{ feeds[currentIndex].title }}</h2> (document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
<h3>{{ feeds[currentIndex].timestamp }}</h3> every enter/leave, regardless of the app's own CSS — confirmed (via
<p v-if="!feeds[currentIndex].readable" class="feed-original-link"> a DevTools performance trace) to be what was corrupting the header's
native <select>/<button> rendering during article paging. -->
<article v-else class="article-feature" :key="currentIndex">
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
<p class="feed-original-link">
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a> <a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article &#8599;</a>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button> <button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p> </p>
<p class="feed-content" v-html="feeds[currentIndex].content"></p> <p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</template> </article>
<div class="article-nav"> <div class="article-nav">
<button <button
@@ -117,6 +217,17 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
.list-skip-btn {
position: fixed;
right: 1rem;
bottom: 1.5rem;
z-index: 20;
}
.observe {
scroll-margin-top: var(--app-nav-height, 4.5rem);
}
/* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and /* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
with no truncation/max-height: normal block flow lets each card grow to fit with no truncation/max-height: normal block flow lets each card grow to fit
its own full content (images included), with no cross-element interaction. */ its own full content (images included), with no cross-element interaction. */
@@ -170,6 +281,38 @@ onMounted(async () => {
width: auto; width: auto;
} }
.feed-content--readable :deep(img),
.feed-content--readable :deep(video) {
display: block;
width: 100vw;
max-width: 100vw;
height: auto;
margin-top: 1.5em;
margin-bottom: 1.5em;
margin-left: 50%;
transform: translateX(-50%);
}
.feed-content--readable :deep(img.article-feature__image--small) {
display: block;
width: auto;
max-width: 100%;
margin: 1.5em auto;
transform: none;
}
@media (min-width: 720px) {
.feed-content--readable :deep(img),
.feed-content--readable :deep(video) {
display: block;
width: auto;
max-width: 100%;
height: auto;
margin: 1.5em auto;
transform: none;
}
}
.feed-original-link { .feed-original-link {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -211,24 +354,159 @@ onMounted(async () => {
.article-single { .article-single {
position: relative; position: relative;
padding-bottom: 5rem; display: flex;
} flex-direction: column;
.article-single__back {
display: inline-flex;
align-items: center; align-items: center;
min-height: 44px; padding-top: 1em;
padding: 0.5rem 0.9rem; padding-bottom: 5rem;
margin-bottom: 1rem; /* Fill the viewport below the fixed header even when the article content
border: 1px solid var(--color-border); is short, so swipe-to-navigate works anywhere on screen instead of only
border-radius: 4px; over the (possibly small) content area. dvh is the accurate unit on
background: transparent; mobile, where this matters most; vh is the fallback for browsers
color: var(--color-text); without dvh support. */
cursor: pointer; min-height: calc(100vh - var(--app-nav-height, 4.5rem));
min-height: calc(100dvh - var(--app-nav-height, 4.5rem));
} }
.article-single__back:hover { .article-feature {
border-color: var(--color-border-hover); width: 100%;
max-width: 720px;
}
.article-feature__source {
margin: 0 0 0.5em;
padding: 0 1rem;
font-size: clamp(0.75rem, 2vw, 0.85rem);
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-accent);
}
.article-feature__title {
cursor: pointer;
margin: 0;
padding: 0 1rem;
font-family: var(--headline-font-family);
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
font-weight: bold;
line-height: 1.15;
color: var(--color-accent-2);
transition: color 0.2s;
}
.article-feature__title:hover {
color: var(--color-accent-2-hover);
}
.article-feature__meta {
margin: 0.75em 0 1.5em;
padding: 0 1rem 1.5em;
font-size: clamp(0.85rem, 2.5vw, 1rem);
font-weight: normal;
opacity: 0.55;
border-bottom: 1px solid var(--color-border);
}
.article-feature .feed-original-link {
margin-bottom: 1.5em;
padding: 0 1rem;
}
.article-feature__content {
padding: 0 var(--content-padding);
text-align: var(--content-text-align);
font-family: var(--content-font-family);
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
line-height: 1.75;
overflow-wrap: break-word;
}
.article-feature__content :deep(p) {
padding: 0.5em 0;
}
.article-feature__content :deep(h3) {
padding: 0.5em 0;
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
font-weight: bold;
}
.article-feature__content :deep(img),
.article-feature__content :deep(video) {
max-width: 100%;
height: auto;
}
.article-feature__content--readable :deep(img),
.article-feature__content--readable :deep(video) {
display: block;
width: 100vw;
max-width: 100vw;
height: auto;
margin-top: 1.5em;
margin-bottom: 1.5em;
margin-left: 50%;
transform: translateX(-50%);
}
/* Small images (icons, logos, ...) keep their natural size instead of being
stretched to the full-bleed width. */
.article-feature__content--readable :deep(img.article-feature__image--small) {
display: block;
width: auto;
max-width: 100%;
margin: 1.5em auto;
transform: none;
}
/* On desktop the viewport is much wider than the article column, so the
full-bleed 100vw treatment above would blow images up far beyond their
natural resolution. Keep them at natural size, centered in the text. */
@media (min-width: 720px) {
.article-feature__content--readable :deep(img),
.article-feature__content--readable :deep(video) {
display: block;
width: auto;
max-width: 100%;
height: auto;
margin: 1.5em auto;
transform: none;
}
}
.article-feature__content :deep(a) {
color: var(--color-accent);
text-decoration-color: var(--color-accent-hover);
}
.article-feature__content :deep(blockquote) {
margin: 1.5em 0;
padding: 1em 0;
border-top: 1px solid var(--color-border);
border-bottom: 1px solid var(--color-border);
font-family: var(--content-font-family);
font-size: 1.25em;
font-style: italic;
text-align: center;
}
.article-feature__content :deep(pre) {
overflow-x: auto;
padding: 1em;
background: var(--color-background-soft);
}
.article-feature__content :deep(code) {
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
.article-feature__content :deep(figcaption) {
margin-top: 0.5em;
font-size: 0.85em;
text-align: center;
opacity: 0.65;
} }
.article-nav { .article-nav {
+282 -11
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { nextTick } from 'vue'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios' import axios from 'axios'
import AppNav from '../AppNav.vue' import AppNav from '../AppNav.vue'
@@ -7,6 +8,15 @@ import { useFeeds } from '../../composables/useFeeds'
vi.mock('axios') vi.mock('axios')
// jsdom does not implement IntersectionObserver, but AppNav sets one up on mount
// to track whether the list view's title is scrolled into view.
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('AppNav', () => { describe('AppNav', () => {
let router let router
@@ -15,8 +25,13 @@ describe('AppNav', () => {
localStorage.setItem('user-id', '7') localStorage.setItem('user-id', '7')
vi.clearAllMocks() vi.clearAllMocks()
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds() const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
feeds.value = [] feeds.value = []
allItems.value = []
feedFilter.value = null
// Module-singleton state: a prior test may have stamped this via a mocked
// performance.now(); reset it so the scroll-driven reveal gate starts clean.
lastProgrammaticScroll.value = 0
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false showModal.value = false
@@ -35,15 +50,35 @@ describe('AppNav', () => {
await router.isReady() await router.isReady()
}) })
// Unmount every AppNav mounted via mountNav() after each test so mounted
// instances (and their router/menu listeners) don't pile up across the file.
let mountedWrappers = []
function mountNav(options = { global: { plugins: [router] } }) {
const wrapper = mount(AppNav, options)
mountedWrappers.push(wrapper)
return wrapper
}
afterEach(() => {
for (const wrapper of mountedWrappers) {
try {
wrapper.unmount()
} catch {
// already unmounted by the test itself — fine
}
}
mountedWrappers = []
})
async function mountWithMenuOpen() { async function mountWithMenuOpen() {
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mountNav()
await wrapper.find('.app-nav__hamburger').trigger('click') await wrapper.find('.app-nav__hamburger').trigger('click')
await flushPromises() await flushPromises()
return wrapper return wrapper
} }
it('toggles the menu open and closed via the hamburger button', async () => { it('toggles the menu open and closed via the hamburger button', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mountNav()
expect(wrapper.find('.app-nav__menu').exists()).toBe(false) expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
@@ -54,6 +89,17 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__menu').exists()).toBe(false) expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
}) })
it('closes the menu on a click outside of it', async () => {
const wrapper = await mountWithMenuOpen()
expect(wrapper.find('.app-nav__menu').exists()).toBe(true)
// A click anywhere outside the menu strip (e.g. on page content) closes it.
document.body.dispatchEvent(new Event('click', { bubbles: true }))
await nextTick()
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('clears stored credentials and redirects to login on logout', async () => { it('clears stored credentials and redirects to login on logout', async () => {
const wrapper = await mountWithMenuOpen() const wrapper = await mountWithMenuOpen()
@@ -149,38 +195,262 @@ describe('AppNav', () => {
}) })
it('shows the unread count in the title when there are articles', async () => { it('shows the unread count in the title when there are articles', async () => {
const { feeds } = useFeeds() // The badge is the global unread total, sourced from the master list
feeds.value = [ // (allItems) so it stays correct regardless of any active feed filter.
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' }, { id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' }, { id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
] ]
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mountNav()
await flushPromises() await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(2)') expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
}) })
it('excludes already-read articles from the counter while in article view', async () => { it('excludes already-read articles from the counter while in article view', async () => {
const { feeds } = useFeeds() const { allItems } = useFeeds()
feeds.value = [ allItems.value = [
{ id: 1, title: 'Article one', read: true, content: '', url: 'https://example.test/1', timestamp: '2026-01-01' }, { id: 1, title: 'Article one', read: true, content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' }, { id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
] ]
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mountNav()
await flushPromises() await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(1)') expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
}) })
it('hides the unread count when there are no articles', async () => { it('hides the unread count when there are no articles', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } }) const wrapper = mountNav()
await flushPromises() await flushPromises()
expect(wrapper.find('.app-nav__unread').exists()).toBe(false) expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
}) })
it('renders a feed filter with an "All feeds" option plus one per distinct feed', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' },
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-01' },
{ id: 3, feedTitle: 'Feed A', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
]
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
// Counts only show while the dropdown is open (focused) — see AppNav.vue.
await select.trigger('focus')
const options = select.findAll('option').map(o => o.text())
// "All feeds" first, then distinct titles sorted alphabetically, each with
// its unread count.
expect(options).toEqual(['All feeds', 'Feed A (2)', 'Feed B (1)'])
})
it('hides the per-option unread count once the feed filter is closed', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
await select.trigger('focus')
await select.setValue('Feed A') // fires change, which closes the popup but keeps focus
await flushPromises()
const feedAOption = select.findAll('option').find(o => o.element.value === 'Feed A')
expect(feedAOption.text()).toBe('Feed A')
})
it('does not render the feed filter when there are no feeds', async () => {
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
})
it('applies the selected feed to the displayed list, and "All feeds" restores it', async () => {
const { allItems, feeds, feedFilter } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
await select.setValue('Feed A')
await flushPromises()
expect(feedFilter.value).toBe('Feed A')
expect(feeds.value.map(f => f.id)).toEqual([1])
await select.setValue('')
await flushPromises()
expect(feedFilter.value).toBeNull()
expect(feeds.value.map(f => f.id)).toEqual([1, 2])
})
it('shows the selected feed\'s unread count in the title when a filter is active', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
{ id: 3, feedTitle: 'Feed B', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
]
const wrapper = mountNav()
await flushPromises()
// No filter → global total (3).
expect(wrapper.find('.app-nav__title').text()).toContain('(3)')
await wrapper.find('.app-nav__filter').setValue('Feed A')
await flushPromises()
// Filtered → unread in Feed A only (2).
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
describe('scroll-driven show/hide', () => {
// The scroll handler is rAF-throttled; run rAF synchronously so a single
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
// Vitest gotcha, avoid bare fake timers here — they'd clobber this stub.
beforeEach(() => {
// Reset scroll position so each mount's lastY baseline starts at 0.
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
// getBoundingClientRect() is all-zero in jsdom; give the header a real
// height so onMounted()'s one-time measurement (used for the
// --app-nav-height CSS var — see AppNav.vue) has a realistic value.
// This is a prototype-wide spy (scoped to this describe block only, restored in
// afterEach), so it returns a full DOMRect-shaped object rather than a
// partial one — no test here happens to call getBoundingClientRect() on
// anything but the header, but a bare `{ height: 50 }` would silently
// hand back `undefined` for top/left/width/etc. to any code that did.
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {},
})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
function scrollTo(y) {
Object.defineProperty(window, 'scrollY', { value: y, configurable: true, writable: true })
window.dispatchEvent(new Event('scroll'))
}
it('hides the header when scrolling down past the threshold', async () => {
const wrapper = mountNav()
scrollTo(200)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
})
it('reveals the header again when scrolling back up past the threshold', async () => {
const wrapper = mountNav()
scrollTo(200)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
scrollTo(150)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('reveals the header on a large upward jump that lands near the top', async () => {
const wrapper = mountNav()
scrollTo(400)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// A jump this large (e.g. article paging's scrollTo(0, 0)) crosses
// REVEAL_THRESHOLD on its own — no top-of-page special case needed.
scrollTo(10)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('does not reveal the header merely for landing near the top', async () => {
const wrapper = mountNav()
// Hide it with a jump just past the header's own height (50px, mocked
// above) plus the reveal threshold.
scrollTo(62)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Move back to right at the header's own height — within the "near the
// top" zone the old code always revealed for — but the upward delta
// (12px) doesn't exceed REVEAL_THRESHOLD, so a hidden header stays
// hidden: proximity to the top is no longer sufficient on its own.
scrollTo(50)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
})
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
const { markProgrammaticScroll } = useFeeds()
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
const wrapper = mountNav()
// Hide it first via a normal scroll-down (no programmatic flag active).
scrollTo(400)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// A read-correction flags a programmatic scroll, then the page jumps
// upward. Within the window that upward jump must NOT reveal the header.
markProgrammaticScroll() // records lastProgrammaticScroll = 1000
nowSpy.mockReturnValue(1100) // 100ms later — inside the 300ms window
scrollTo(200)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Still allows hiding on scroll-down even while the flag is active.
scrollTo(500)
await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
// Once the window elapses, a genuine scroll-up reveals it again.
nowSpy.mockReturnValue(1500) // 500ms after the flag — outside the window
scrollTo(450)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
it('does not toggle on sub-threshold jitter', async () => {
const wrapper = mountNav()
// Start well below the top so the "near the top" guard doesn't apply.
scrollTo(300)
await nextTick()
// Reveal first so we're testing that small moves don't hide it.
scrollTo(260)
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
scrollTo(268) // +8px, under the 12px threshold
await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
})
})
it('does not mark articles as read when the confirmation is dismissed', async () => { it('does not mark articles as read when the confirmation is dismissed', async () => {
const { feeds } = useFeeds() const { feeds } = useFeeds()
feeds.value = [ feeds.value = [
@@ -199,4 +469,5 @@ describe('AppNav', () => {
confirmSpy.mockRestore() confirmSpy.mockRestore()
}) })
}) })
+216 -13
View File
@@ -15,6 +15,20 @@ class FakeIntersectionObserver {
} }
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
// Shared by the swipe-navigation tests below, which all start from the same
// two-article article view.
function twoArticleFeed() {
return [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<p>one</p>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
],
},
]
}
describe('RssFeeds', () => { describe('RssFeeds', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -119,7 +133,14 @@ describe('RssFeeds', () => {
], ],
}, },
}) })
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } }) // axios.post is also hit by the sync triggered on mount, so branch on the
// URL rather than relying on call order via `mockResolvedValueOnce`.
axios.post.mockImplementation((url) => {
if (url === '/api/v1/article/sync') {
return Promise.resolve({ status: 200 })
}
return Promise.resolve({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
})
const { layout } = useFeeds() const { layout } = useFeeds()
layout.value = 'cards' layout.value = 'cards'
@@ -177,10 +198,7 @@ describe('RssFeeds', () => {
expect(titles).toEqual(['Newer article', 'Older article']) expect(titles).toEqual(['Newer article', 'Older article'])
}) })
it('shows a link to the original article until the readable version is loaded', async () => { it('keeps a link to the original article visible after the readable version is loaded', async () => {
// The API returns each item with a short summary already in `content` —
// the link must key off the `readable` flag (set once Readability has
// parsed the full article), not off `content` truthiness.
axios.get.mockResolvedValueOnce({ axios.get.mockResolvedValueOnce({
data: { data: {
feeds: [ feeds: [
@@ -199,7 +217,14 @@ describe('RssFeeds', () => {
], ],
}, },
}) })
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } }) // axios.post is also hit by the sync triggered on mount, so branch on the
// URL rather than relying on call order via `mockResolvedValueOnce`.
axios.post.mockImplementation((url) => {
if (url === '/api/v1/article/sync') {
return Promise.resolve({ status: 200 })
}
return Promise.resolve({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
})
const wrapper = mount(RssFeeds) const wrapper = mount(RssFeeds)
await flushPromises() await flushPromises()
@@ -212,7 +237,9 @@ describe('RssFeeds', () => {
await wrapper.find('.feed-title').trigger('click') await wrapper.find('.feed-title').trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.find('.feed-original-link a').exists()).toBe(false) const linkAfter = wrapper.find('.feed-original-link a')
expect(linkAfter.exists()).toBe(true)
expect(linkAfter.attributes('href')).toBe('https://example.test/1')
}) })
it('switches to article view and navigates between articles', async () => { it('switches to article view and navigates between articles', async () => {
@@ -252,30 +279,31 @@ describe('RssFeeds', () => {
useFeeds().toggleViewMode() useFeeds().toggleViewMode()
await flushPromises() await flushPromises()
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one') expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
// Same as in list view: the readable content is loaded on demand by // Same as in list view: the readable content is loaded on demand by
// clicking the headline, not fetched automatically on entering the view. // clicking the headline, not fetched automatically on entering the view.
expect(axios.post).not.toHaveBeenCalled() // (axios.post is also hit by the sync triggered on mount.)
expect(axios.post).not.toHaveBeenCalledWith('/api/v1/article/read', expect.anything(), expect.anything())
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true) expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
await wrapper.find('.article-single .feed-title').trigger('click') await wrapper.find('.article-single .article-feature__title').trigger('click')
await flushPromises() await flushPromises()
expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything()) expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything())
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(false) expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined() expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
await wrapper.findAll('.article-nav__btn')[1].trigger('click') await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article two') expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined() expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
await wrapper.findAll('.article-nav__btn')[0].trigger('click') await wrapper.findAll('.article-nav__btn')[0].trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one') expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
}) })
it('drops articles read while paging through article view once back in the list', async () => { it('drops articles read while paging through article view once back in the list', async () => {
@@ -331,4 +359,179 @@ describe('RssFeeds', () => {
const titles = wrapper.findAll('.feed-title').map(el => el.text()) const titles = wrapper.findAll('.feed-title').map(el => el.text())
expect(titles).toEqual(['Article three']) expect(titles).toEqual(['Article three'])
}) })
it('swipes left to navigate to the next article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
wrapper.unmount()
})
it('swipes right to navigate to the previous article', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a swipe shorter than the horizontal threshold', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a mostly-vertical drag', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('ignores a multi-touch gesture (pinch-zoom)', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } })
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', {
touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }],
})
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not navigate when a swipe starts inside a code block', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
wrapper.unmount()
})
it('does not throw when swiping with an empty feed list', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mount(RssFeeds)
await flushPromises()
useFeeds().toggleViewMode()
await flushPromises()
const article = wrapper.find('.article-single')
await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] })
await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] })
await flushPromises()
expect(wrapper.text()).toContain('All caught up')
wrapper.unmount()
})
}) })
+443 -3
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import axios from 'axios' import axios from 'axios'
import { useFeeds } from '../useFeeds' import { useFeeds } from '../useFeeds'
@@ -12,7 +12,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('useFeeds', () => { describe('useFeeds', () => {
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable } = useFeeds() const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, setupIntersectionObserver } = useFeeds()
beforeEach(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -20,6 +20,8 @@ describe('useFeeds', () => {
vi.clearAllMocks() vi.clearAllMocks()
feeds.value = [] feeds.value = []
allItems.value = []
feedFilter.value = null
showMessage.value = false showMessage.value = false
message.value = '' message.value = ''
showModal.value = false showModal.value = false
@@ -91,6 +93,441 @@ describe('useFeeds', () => {
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything()) expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
}) })
it('marks the correct articles read when several scroll out of view in one batch', async () => {
feeds.value = [
{ id: 101, title: 'First' },
{ id: 102, title: 'Second' },
{ id: 103, title: 'Third' },
]
setInitialLoad(true)
axios.put.mockResolvedValue({ status: 200 })
// Both the first and second articles scrolled above the viewport in the
// same IntersectionObserver callback — their `target.id` reflects their
// original render-time indices (0 and 1).
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
{ isIntersecting: false, boundingClientRect: { y: -5 }, target: { id: '1' } },
])
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/101', null, expect.anything())
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/102', null, expect.anything())
expect(axios.put).not.toHaveBeenCalledWith('/api/v1/article/read/103', null, expect.anything())
expect(feeds.value).toEqual([{ id: 103, title: 'Third' }])
setInitialLoad(false)
})
it('refreshes the filter dropdown\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
// displayedFeedUnreadCounts is a frozen snapshot of feedUnreadCounts (see
// useFeeds.js) — AppNav's filter <select> renders it instead of the live
// computed so paging through article view doesn't force it to repaint.
// List-view scrolling is a different code path (handleIntersection) and
// should keep it live regardless.
allItems.value = [
{ id: 301, feedTitle: 'Feed A', read: false },
{ id: 302, feedTitle: 'Feed A', read: false },
]
feeds.value = [...allItems.value]
refreshUnreadDisplay()
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 2 })
setInitialLoad(true)
axios.put.mockResolvedValue({ status: 200 })
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 1 })
setInitialLoad(false)
})
it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => {
feeds.value = [
{ id: 201, title: 'First' },
{ id: 202, title: 'Second' },
]
setInitialLoad(true)
axios.put.mockResolvedValue({ status: 200 })
// A .observe node must exist for setupIntersectionObserver to arm the skip.
const node = document.createElement('div')
node.className = 'observe'
document.body.appendChild(node)
setupIntersectionObserver()
// First callback after connect is the initial snapshot — must be dropped
// even though both entries look "scrolled past" (above the topbar).
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
{ isIntersecting: false, boundingClientRect: { y: -5 }, target: { id: '1' } },
])
expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value).toHaveLength(2)
// A genuine later scroll-past still marks read.
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/201', null, expect.anything())
document.body.removeChild(node)
setInitialLoad(false)
})
describe('markRead resilience', () => {
// Mirrors useFeeds.js's MARK_READ_RETRY_DELAYS_MS (not exported). Kept in
// sync via this comment: if that backoff changes, update this too — too
// small a value just makes the affected test's `await` hang until it
// times out, rather than pass incorrectly.
const RETRY_BACKOFF_SUM_MS = 300 + 1000
// A failed assertion before a test's own vi.useRealTimers() call would
// otherwise leak fake timers into every later test in this file.
afterEach(() => {
vi.useRealTimers()
})
it('retries a failed mark-read PUT before giving up, with a request timeout set', async () => {
vi.useFakeTimers()
axios.put
.mockRejectedValueOnce(new Error('network'))
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce({ status: 200 })
const done = markRead(601)
await vi.runAllTimersAsync()
expect(await done).toBe(true)
expect(axios.put).toHaveBeenCalledTimes(3)
expect(axios.put).toHaveBeenCalledWith(
'/api/v1/article/read/601',
null,
expect.objectContaining({ timeout: expect.any(Number) }),
)
expect(showMessage.value).toBe(false)
})
it('reports failure once retries are exhausted, and stops withholding the item from a refetch', async () => {
vi.useFakeTimers()
axios.put.mockRejectedValue(new Error('network'))
const done = markRead(602)
// Advance past just the two retry backoffs — not runAllTimersAsync(),
// which would also fire showMessageForXSeconds's own 5s auto-hide timer
// and reset showMessage before the assertion below.
await vi.advanceTimersByTimeAsync(RETRY_BACKOFF_SUM_MS)
expect(await done).toBe(false)
expect(axios.put).toHaveBeenCalledTimes(3)
expect(showMessage.value).toBe(true)
expect(message.value).toMatch(/could not mark/i)
// The mark never committed, so the item genuinely is still unread
// server-side — a refetch must be free to show it again, not hide it
// forever as "pending".
axios.get.mockResolvedValueOnce({
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 602, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchData()
expect(feeds.value.map(f => f.id)).toEqual([602])
})
it('does not retry a non-retryable 4xx mark-read failure', async () => {
const error = new Error('Not Found')
error.response = { status: 404 }
axios.put.mockRejectedValueOnce(error)
const result = await markRead(605)
expect(result).toBe(false)
expect(axios.put).toHaveBeenCalledTimes(1)
expect(showMessage.value).toBe(true)
})
it('keeps a still-unconfirmed read article out of a concurrent background refetch', async () => {
let resolvePut
axios.put.mockReturnValueOnce(new Promise(resolve => { resolvePut = resolve }))
const done = markRead(603) // simulates a page-turn's fire-and-forget mark-read, still in flight
// A background sync's trailing fetchData() resolves while that PUT is
// still pending — the server hasn't committed the read yet, so it's
// still in the response.
axios.get.mockResolvedValueOnce({
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 603, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchData()
expect(feeds.value).toEqual([])
resolvePut({ status: 200 })
await done
})
it('excludes an item whose mark-read PUT settles between a refetch\'s request and its response', async () => {
// The PUT commits (and clears the pending id) while the GET below is
// still in flight — the GET's response nonetheless reflects the DB as
// of before that commit, still reporting the item unread. A snapshot
// taken only once the GET resolves would miss this; one taken before
// it's dispatched (unioned with the live state) catches it too.
axios.put.mockResolvedValueOnce({ status: 200 })
const markDone = markRead(604)
let resolveGet
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
const fetchPromise = fetchData()
await markDone // the PUT (and pending-id cleanup) settles first...
resolveGet({ // ...then the already-in-flight GET resolves, still unread.
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 604, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchPromise
expect(feeds.value).toEqual([])
})
it('keeps an item withheld until every overlapping markRead() call for it settles', async () => {
// nextArticle()/prevArticle() can both re-mark the same article (page
// forward, back, forward again) — two overlapping calls for one id.
let resolveFirst
let resolveSecond
axios.put
.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve }))
.mockReturnValueOnce(new Promise(resolve => { resolveSecond = resolve }))
const first = markRead(606)
const second = markRead(606)
resolveSecond({ status: 200 }) // the second (later) call settles first
await second
// The first call is still in flight — a refetch must still withhold it.
axios.get.mockResolvedValueOnce({
data: {
feeds: [{
title: 'Feed A',
items: [{ id: 606, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' }],
}],
},
})
await fetchData()
expect(feeds.value).toEqual([])
resolveFirst({ status: 200 })
await first
})
})
describe('markAllRead resilience', () => {
const RETRY_BACKOFF_SUM_MS = 300 + 1000
afterEach(() => {
vi.useRealTimers()
})
it('reports a partial failure instead of claiming full success', async () => {
feeds.value = [
{ id: 701, title: 'First' },
{ id: 702, title: 'Second' },
]
vi.spyOn(window, 'confirm').mockReturnValue(true)
axios.put
.mockResolvedValueOnce({ status: 200 }) // 701 succeeds
.mockRejectedValue(new Error('network')) // 702 fails every attempt
vi.useFakeTimers()
const done = markAllRead()
await vi.advanceTimersByTimeAsync(RETRY_BACKOFF_SUM_MS)
await done
expect(message.value).toContain('1 of 2')
expect(message.value).not.toBe('All articles marked as read.')
})
})
describe('feed filter', () => {
const twoFeedsResponse = {
data: {
feeds: [
{
title: 'Feed A',
items: [
{ id: 1, title: 'A1', content: '', url: 'https://example.test/a1', timestamp: '2026-01-01 10:00:00' },
{ id: 3, title: 'A2', content: '', url: 'https://example.test/a2', timestamp: '2026-01-03 10:00:00' },
],
},
{
title: 'Feed B',
items: [
{ id: 2, title: 'B1', content: '', url: 'https://example.test/b1', timestamp: '2026-01-02 10:00:00' },
],
},
],
},
}
it('narrows the displayed list to a single feed and restores it on "All feeds"', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
await fetchData()
// Options are the distinct feed titles, sorted.
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
// Unfiltered: everything, newest first (Jan 3, Jan 2, Jan 1).
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
await setFeedFilter('Feed A')
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
await setFeedFilter(null)
expect(feeds.value.map(f => f.id)).toEqual([3, 2, 1])
})
it('does not resurrect read articles when the filter is cleared', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
axios.put.mockResolvedValue({ status: 200 })
await fetchData()
await setFeedFilter('Feed A')
setInitialLoad(true)
// The first Feed A article scrolls above the viewport → marked read and
// dropped from both the filtered list and the master.
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
setInitialLoad(false)
expect(feeds.value.map(f => f.id)).toEqual([1])
await setFeedFilter(null)
// Article 3 stays gone; the still-unread articles remain, newest first.
expect(feeds.value.map(f => f.id)).toEqual([2, 1])
})
it('keeps an emptied filtered feed selected and selectable, showing "All caught up"', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
axios.put.mockResolvedValue({ status: 200 })
await fetchData()
await setFeedFilter('Feed B')
expect(feeds.value.map(f => f.id)).toEqual([2])
// Mark all (visible = just Feed B) read — Feed B has no unread items left.
vi.spyOn(window, 'confirm').mockReturnValue(true)
await markAllRead()
// The list empties, but the filter stays put and the feed remains a
// selectable option (so the <select> never dangles) even though it no
// longer has unread items.
expect(feeds.value).toEqual([])
expect(feedFilter.value).toBe('Feed B')
expect(feedTitles.value).toEqual(['Feed A', 'Feed B'])
// Switching away drops the now-empty feed from the options.
await setFeedFilter(null)
expect(feedTitles.value).toEqual(['Feed A'])
expect(feeds.value.map(f => f.id)).toEqual([3, 1])
})
it('counts unread items in the selected feed when a filter is active, else the global total', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
await fetchData()
// No filter: global unread total across both feeds.
expect(unreadCount.value).toBe(3)
// Filtered: only the selected feed's unread items.
await setFeedFilter('Feed B')
expect(unreadCount.value).toBe(1)
await setFeedFilter('Feed A')
expect(unreadCount.value).toBe(2)
await setFeedFilter(null)
expect(unreadCount.value).toBe(3)
})
it('exposes per-feed unread counts, excluding items marked read in place', async () => {
axios.get.mockResolvedValueOnce(twoFeedsResponse)
await fetchData()
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 2, 'Feed B': 1 })
// Article view marks items read in place (feed.read = true) without
// removing them from the master — the counts must not include those.
allItems.value.find(i => i.id === 3).read = true
expect(feedUnreadCounts.value).toEqual({ 'Feed A': 1, 'Feed B': 1 })
})
})
it('strips leftover embedded-video placeholder headings', async () => {
feeds.value = [{
id: 1,
title: 'Article one',
url: 'https://www.dw.com/en/article-one/a-1',
content: '',
}]
axios.post.mockResolvedValueOnce({
data: {
content: `<html><body><article>
<h2 aria-label="Eingebettetes Video — Iran-Krieg belastet Wirtschaft und Märkte in Deutschland">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><g fill-rule="evenodd"><path d="M14.114 7.599H13.5l.002 4.706h.601l4.582 3.25-.005-11.11zM11.084 4.444l-9.007.002-1.336.797.002 9.514 1.334.793 9.007.006 1.509-.799-.004-9.516z"></path></g></svg>
Iran-Krieg belastet Wirtschaft und Märkte in Deutschland
</h2>
<p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).not.toContain('Eingebettetes Video')
expect(feeds.value[0].content).not.toContain('<svg')
})
it('strips leftover embedded-audio placeholder headings', async () => {
feeds.value = [{
id: 1,
title: 'Article one',
url: 'https://www.dw.com/en/article-one/a-1',
content: '',
}]
axios.post.mockResolvedValueOnce({
data: {
content: `<html><body><article>
<h2 aria-label="Eingebetteter Audio-Beitrag — Der Gender Pay Gap existiert noch immer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><g fill-rule="evenodd"><path d="M14.114 7.599H13.5l.002 4.706h.601l4.582 3.25-.005-11.11zM11.084 4.444l-9.007.002-1.336.797.002 9.514 1.334.793 9.007.006 1.509-.799-.004-9.516z"></path></g></svg>
Der Gender Pay Gap existiert noch immer
</h2>
<p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p>
</article></body></html>`,
},
})
await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).not.toContain('Eingebetteter Audio-Beitrag')
expect(feeds.value[0].content).not.toContain('<svg')
})
it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => { it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => {
feeds.value = [{ feeds.value = [{
id: 1, id: 1,
@@ -112,7 +549,10 @@ describe('useFeeds', () => {
await getReadable(feeds.value[0], 0) await getReadable(feeds.value[0], 0)
expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_MASTER_LANDSCAPE.jpg"') // "MASTER_LANDSCAPE" is a symbolic name from DW's CMS, not a valid value
// for the CDN's numeric `formatId` — it must be mapped to "6" or the
// resulting URL 400s and the image fails to load.
expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_6.jpg"')
// The rendered `src` is what matters — `data-url` retaining the raw // The rendered `src` is what matters — `data-url` retaining the raw
// template is harmless since browsers don't load images from data-* attrs. // template is harmless since browsers don't load images from data-* attrs.
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/) expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
@@ -0,0 +1,76 @@
// Reproduces the "reload the tab while in article view" boot sequence:
// module-scope init must pick up the persisted viewMode, and RssFeeds'
// mount flow (fetchData + sync) must not reset it back to 'list'.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
vi.mock('axios')
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('viewMode boot persistence', () => {
beforeEach(() => {
vi.resetModules()
localStorage.clear()
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
})
afterEach(() => {
localStorage.clear()
})
it('boots into article view when localStorage says so, surviving the mount fetch/sync', async () => {
localStorage.setItem('viewMode', 'article')
const axios = (await import('axios')).default
axios.get.mockResolvedValue({
data: {
feeds: [
{
title: 'My Feed',
items: [
{ id: 1, title: 'Article one', content: '<p>hi</p>', url: 'https://example.test/1', timestamp: '2026-01-01 10:00:00' },
],
},
],
},
})
axios.post.mockResolvedValue({ status: 200 })
axios.put.mockResolvedValue({ status: 200 })
// Import AFTER setting storage — module-scope refs initialize at import.
const { useFeeds } = await import('../useFeeds')
const { viewMode } = useFeeds()
expect(viewMode.value).toBe('article')
const RssFeeds = (await import('../../components/RssFeeds.vue')).default
const wrapper = mount(RssFeeds)
await flushPromises()
expect(viewMode.value).toBe('article')
expect(wrapper.find('.article-single').exists()).toBe(true)
expect(wrapper.find('#article').exists()).toBe(false)
// The article shown right after reload must be marked read immediately —
// not only once the user pages forward/back at least once.
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
wrapper.unmount()
})
it('boots into list view when the key is absent', async () => {
const axios = (await import('axios')).default
axios.get.mockResolvedValue({ data: { feeds: [] } })
axios.post.mockResolvedValue({ status: 200 })
const { useFeeds } = await import('../useFeeds')
const { viewMode } = useFeeds()
expect(viewMode.value).toBe('list')
})
})
+379 -34
View File
@@ -1,21 +1,92 @@
import { ref, unref, nextTick } from 'vue'; import { ref, computed, nextTick } from 'vue';
import axios from 'axios'; import axios from 'axios';
import { Readability } from '@mozilla/readability'; import { Readability } from '@mozilla/readability';
// Module-level state — declared outside useFeeds() so every caller shares the // Module-level state — declared outside useFeeds() so every caller shares the
// same singleton refs (a Pinia-free "store" for the feed list and its UI state). // same singleton refs (a Pinia-free "store" for the feed list and its UI state).
const showMessage = ref(false) const showMessage = ref(false)
// `allItems` is the full flattened+sorted stream from the last fetch (the
// master); `feeds` is the currently displayed projection of it (all items, or a
// single feed when a filter is active). Every view/observer consumer operates on
// `feeds` — the filter layer only ever swaps what `feeds` points at, so none of
// the index-based observer / currentIndex logic has to know a filter exists.
const allItems = ref([]);
const feeds = ref([]); const feeds = ref([]);
const feedFilter = ref(null) // selected feedTitle, or null = all feeds (not persisted)
// Distinct feed titles present in the loaded (unread) items — the filter options.
// The active filter is always kept in the list even once its feed runs out of
// unread items, so the <select> never binds to a value that isn't an option
// (it just shows "All caught up") and the feed stays re-selectable.
const feedTitles = computed(() => {
const titles = new Set(allItems.value.map(i => i.feedTitle))
if (feedFilter.value) titles.add(feedFilter.value)
return [...titles].sort((a, b) => a.localeCompare(b))
})
// Unread count per feed title, shown next to each option in the filter
// <select>. Derived from the master (allItems) so article view's
// read-but-still-shown items are excluded. A title kept in feedTitles with no
// unread items left has no key here — render it as 0.
const feedUnreadCounts = computed(() => {
const counts = {}
for (const item of allItems.value) {
if (!item.read) counts[item.feedTitle] = (counts[item.feedTitle] ?? 0) + 1
}
return counts
})
// The header badge count: unread items in the selected feed when a filter is
// active, otherwise the global unread total. Derived from the master (allItems)
// rather than the displayed `feeds` so article view's read-but-still-shown
// items are excluded and the count matches the filter regardless of view.
const unreadCount = computed(() => {
const items = feedFilter.value
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
: allItems.value
return items.filter(i => !i.read).length
})
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
// for the filter <select>'s per-feed option counts, instead of binding
// feedUnreadCounts directly — by design, this stays frozen while paging
// through article view (markCurrentArticleRead marks one article read per
// page turn, which would otherwise recompute it live on every single swipe).
// The header's own unread badge stays bound to the live unreadCount (it
// should update in real time regardless of view). Everywhere else that marks
// articles read — list-view scrolling (handleIntersection), opening/changing
// the filter, syncing, mark-all-read, leaving article view — refreshes this
// snapshot immediately, so it only ever looks stale during article-view
// paging itself.
const displayedFeedUnreadCounts = ref({})
function refreshUnreadDisplay() {
displayedFeedUnreadCounts.value = feedUnreadCounts.value
}
const message = ref('') const message = ref('')
const showModal = ref(false) const showModal = ref(false)
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
const currentIndex = ref(0) const currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
let observer; // Declare observer outside the setup function let observer; // Declare observer outside the setup function
let initialLoad = false let initialLoad = false
// An IntersectionObserver always delivers an initial snapshot for every element
// it starts observing. If the observer (re)connects while the page is scrolled
// down — e.g. leaving a scrolled article view, an HMR module reload, or the
// post-splice re-setup in handleIntersection — that snapshot reports every
// article above the viewport as "not intersecting / scrolled past" and would
// mark them all read at once. So each setupIntersectionObserver() sets this and
// handleIntersection() drops exactly the first callback after a connect; only
// genuine scroll-driven exits mark read after that.
let skipNextObservation = false
function authHeaders() { // Timestamp (performance.now()) of the most recent programmatic scroll / list
// mutation that moves the page without user intent — currently the list-view
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
// (instead of treating the induced jump as a user scroll) for a short window
// after this, so removing read articles can't pop the header in/out mid-read.
const lastProgrammaticScroll = ref(0)
function markProgrammaticScroll() {
lastProgrammaticScroll.value = performance.now()
}
export function authHeaders() {
return { return {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -24,6 +95,20 @@ function authHeaders() {
} }
} }
// Tells the server to revoke the current token (bumps token_version, so any
// other outstanding tokens for this account are invalidated too) before
// clearing the local session. Best-effort: if the request fails (e.g. the
// token already expired) the local session is cleared regardless.
export async function logout() {
try {
await axios.post('/api/v1/auth/logout', null, authHeaders())
} catch (error) {
console.error('Error logging out', error)
}
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
}
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various // Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
// lazy-load attributes (`data-url`, `data-src`, `srcset`, ...) contain an // lazy-load attributes (`data-url`, `data-src`, `srcset`, ...) contain an
// unresolved `${placeholderName}` template — or its URL-encoded `%7B...%7D` // unresolved `${placeholderName}` template — or its URL-encoded `%7B...%7D`
@@ -35,8 +120,19 @@ function authHeaders() {
const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/ const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/
const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g
// `data-format` holds a symbolic name from DW's CMS (e.g. "MASTER_LANDSCAPE"),
// but their image CDN only accepts numeric format ids in the URL — the
// template's `${formatId}` literally means a number. Substituting the
// symbolic name verbatim produces a 400 (image fails to load). DW generates
// the same fixed set of numeric variants for every image, so map the
// symbolic names we've seen to their numeric equivalent.
const DW_FORMAT_IDS = {
MASTER_LANDSCAPE: '6', // 940x529, 16:9 — matches DW's `16/9` aspect ratio
}
function resolveTemplatedImage(img) { function resolveTemplatedImage(img) {
const format = img.getAttribute('data-format') const rawFormat = img.getAttribute('data-format')
const format = rawFormat && (DW_FORMAT_IDS[rawFormat] ?? (/^\d+$/.test(rawFormat) ? rawFormat : null))
const dataUrl = img.getAttribute('data-url') const dataUrl = img.getAttribute('data-url')
if (format) { if (format) {
@@ -81,7 +177,49 @@ async function getReadable(feed, index) {
doc.head.prepend(base); doc.head.prepend(base);
doc.querySelectorAll('img').forEach(resolveTemplatedImage); doc.querySelectorAll('img').forEach(resolveTemplatedImage);
doc.querySelectorAll('video, audio').forEach(el => el.remove()); doc.querySelectorAll('video, audio').forEach(el => el.remove());
// Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget
// (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the
// article body. It's not part of the article, so strip it before Readability
// pulls it into the parsed content.
doc.querySelectorAll('#article-social-bar').forEach(el => el.remove())
// Some feeds (e.g. Deutsche Welle) leave behind a heading + play-icon SVG
// for an embedded video/audio player whose actual <video>/<audio>/<iframe>
// we already stripped — without it, the heading is just a giant orphaned
// icon that takes up space and links nowhere.
doc.querySelectorAll('[aria-label]').forEach(el => {
if (/^(Eingebettete[rs]?|Embedded) (Video|Audio)/i.test(el.getAttribute('aria-label'))) {
el.remove()
}
})
// Alpine.js widget overlays: x-cloak marks elements that should be hidden
// until Alpine.js initialises (prevents FOUC). These are always widget
// containers (e.g. taz's "taz schneller googeln" promo), never article
// content, so they're safe to remove unconditionally.
doc.querySelectorAll('[x-cloak]').forEach(el => el.remove())
// taz subscription promo blocks: a standalone <section> whose link(s) point
// to an /abo/ subscription page. Only climb to <section>, not <article>,
// to avoid accidentally removing the main article body.
doc.querySelectorAll('a[href*="/abo/"]').forEach(el => {
const container = el.closest('section')
if (container) container.remove()
})
// taz "Mehr zum Thema" related-articles teaser section.
doc.querySelectorAll('#articleTeaser').forEach(el => el.remove())
// taz subsidiary magazine promo blocks (e.g. taz FUTURZWEI): either the
// <article> itself or its direct <a> child carries an aria-label containing "Abo".
doc.querySelectorAll('article[aria-label*="Abo"]').forEach(el => {
const container = el.closest('section') ?? el
container.remove()
})
doc.querySelectorAll('article > a[aria-label*="Abo"]').forEach(el => {
const container = el.closest('section') ?? el.closest('article')
if (container) container.remove()
})
const article = new Readability(doc).parse(); const article = new Readability(doc).parse();
if (!article) {
showMessageForXSeconds('Could not extract readable content.', 5)
return
}
feeds.value[index].content = article.content; feeds.value[index].content = article.content;
feeds.value[index].readable = true; feeds.value[index].readable = true;
} catch (error) { } catch (error) {
@@ -90,27 +228,129 @@ async function getReadable(feed, index) {
} }
} }
async function markRead(id) { // Ids with a mark-read PUT in flight (or being retried) — see markRead() and
try { // fetchData() below. A refcount rather than a plain Set: nextArticle()/
const response = await axios.put("/api/v1/article/read/" + id, null, authHeaders()) // prevArticle() can both re-mark the same article (page forward, back,
console.log(response.status) // forward again), so two overlapping markRead() calls for one id must both
} catch (error) { // finish before fetchData() is allowed to trust the server's word on it —
console.log(error) // otherwise the first call to settle would clear the id out from under the
// second, still-in-flight one.
const pendingReadCounts = new Map()
function addPendingRead(id) {
pendingReadCounts.set(id, (pendingReadCounts.get(id) ?? 0) + 1)
}
function removePendingRead(id) {
const count = pendingReadCounts.get(id) ?? 0
if (count <= 1) {
pendingReadCounts.delete(id)
} else {
pendingReadCounts.set(id, count - 1)
} }
} }
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
// Short backoff between retries of a failed mark-read PUT — each entry is the
// wait before that retry attempt.
const MARK_READ_RETRY_DELAYS_MS = [300, 1000]
// Bounds how long a hung request (dead connection, captive portal — axios has
// no default timeout) can keep an id "pending": without this, a request that
// never settles would never leave pendingReadCounts, silently withholding
// that article from every fetchData() for the rest of the session.
const MARK_READ_TIMEOUT_MS = 10000
// 4xx responses (expired token, item not found/not owned — see
// src/reader/mark_read.rs) mean the request itself is wrong and won't
// succeed on retry; only a missing response (network error, timeout) or a
// server-side/rate-limit status is worth retrying.
function isRetryableMarkReadError(error) {
const status = error.response?.status
return status === undefined || status >= 500 || status === 429
}
// Resolves to true once the article is confirmed read server-side, or false
// once retries (if any) are exhausted / the error isn't retryable.
async function markRead(id) {
addPendingRead(id)
try {
for (let attempt = 0; ; attempt++) {
try {
await axios.put("/api/v1/article/read/" + id, null, { ...authHeaders(), timeout: MARK_READ_TIMEOUT_MS })
return true
} catch (error) {
console.error('Error marking article read:', error)
if (attempt >= MARK_READ_RETRY_DELAYS_MS.length || !isRetryableMarkReadError(error)) {
// Out of retries, or a retry can't help — this mark never committed
// server-side, so the item genuinely is still unread there. Surface
// it rather than letting it silently vanish from the local list now
// only to mysteriously reappear as unread later.
showMessageForXSeconds('Could not mark an article as read. It may reappear as unread.', 5)
return false
}
await delay(MARK_READ_RETRY_DELAYS_MS[attempt])
}
}
} finally {
removePendingRead(id)
}
}
// Projects the master list onto `feeds` through the active filter. A filtered
// feed with no remaining unread items projects to an empty list ("All caught
// up") while staying selected — see feedTitles.
function applyFilter() {
feeds.value = feedFilter.value
? allItems.value.filter(i => i.feedTitle === feedFilter.value)
: allItems.value.slice()
}
// Changes the active feed filter and re-projects. Mirrors toggleLayout's
// observer/scroll-safe pattern: disconnect first, scroll to top, then re-point
// the observer at the new .observe nodes after Vue has re-rendered. Works in
// article view too — there are no .observe nodes there so the setup is a no-op,
// and resetting currentIndex keeps paging valid against the new list.
async function setFeedFilter(title) {
disconnectObserver()
window.scrollTo(0, 0)
feedFilter.value = title // null for "All feeds"
currentIndex.value = 0
applyFilter()
refreshUnreadDisplay()
await nextTick()
setupIntersectionObserver()
}
const fetchData = async () => { const fetchData = async () => {
const user_id = localStorage.getItem("user-id") const user_id = localStorage.getItem("user-id")
try { try {
// Snapshot ids pending *before* the GET goes out, not just when its
// response comes back. A markRead() PUT that commits while this GET is
// in flight is invisible to the check below (its id has already been
// cleared by the time the response arrives) even though the server read
// the DB before that commit and so still reported the item unread — this
// snapshot catches that case too.
const pendingBeforeFetch = new Set(pendingReadCounts.keys())
const response = await axios.get("/api/v1/article/get/" + user_id, authHeaders()); const response = await axios.get("/api/v1/article/get/" + user_id, authHeaders());
const items = []; const items = [];
response.data.feeds.forEach(feed => { response.data.feeds.forEach(feed => {
feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title })); feed.items.forEach(item => items.push({ ...item, feedTitle: feed.title }));
}); });
// An item pending at either end of this request may not have committed
// server-side yet, so the server can still report it unread here. Drop it
// from this snapshot too — otherwise a concurrent background sync's
// refetch (see sync()) would resurrect it as unread out from under the
// user while markRead() is still confirming or retrying it.
const freshItems = items.filter(item => !pendingBeforeFetch.has(item.id) && !pendingReadCounts.has(item.id));
// timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain // timestamps are zero-padded "YYYY-MM-DD HH:MM:SS" strings, so a plain
// lexicographic comparison sorts them chronologically. // lexicographic comparison sorts them chronologically.
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
feeds.value = items; allItems.value = freshItems;
applyFilter();
refreshUnreadDisplay();
await nextTick(); await nextTick();
setupIntersectionObserver(); setupIntersectionObserver();
} catch (error) { } catch (error) {
@@ -119,20 +359,22 @@ const fetchData = async () => {
} }
}; };
async function sync() { async function sync(silent = false) {
try { try {
const response = await axios.post('/api/v1/article/sync', { const response = await axios.post('/api/v1/article/sync', {
user_id: parseInt(localStorage.getItem("user-id")) user_id: parseInt(localStorage.getItem("user-id"))
}, authHeaders()) }, authHeaders())
if (response.status == 200) { if (response.status == 200 && !silent) {
showMessageForXSeconds('Sync successful.', 5) showMessageForXSeconds('Sync successful.', 5)
} }
fetchData(); fetchData();
} catch (error) { } catch (error) {
console.error('Error sync', error) console.error('Error sync', error)
if (!silent) {
showMessageForXSeconds(error, 5) showMessageForXSeconds(error, 5)
} }
}
} }
function setupIntersectionObserver() { function setupIntersectionObserver() {
@@ -140,36 +382,92 @@ function setupIntersectionObserver() {
observer.disconnect(); observer.disconnect();
} }
observer = new IntersectionObserver(handleIntersection, { // The sticky topbar overlays the top of the viewport, so an article fully
// hidden behind it should already count as "scrolled past" — shrink the
// observer's root by that height so it stops intersecting at that point.
const topbarHeight = document.querySelector('.app-nav')?.getBoundingClientRect().height ?? 0;
observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), {
root: null, // Use the viewport as the root root: null, // Use the viewport as the root
rootMargin: '0px', rootMargin: `-${topbarHeight}px 0px 0px 0px`,
// threshold: 0.5, // Fire the callback when at least 50% of the element is visible // threshold: 0.5, // Fire the callback when at least 50% of the element is visible
}); });
const observedDivs = document.querySelectorAll(".observe"); const observedDivs = document.querySelectorAll(".observe");
if (observedDivs.length > 0) { // Arm the skip only when we actually observe nodes: an observe() batch fires
// an initial snapshot to drop (a connect while scrolled down would otherwise
// mass-mark everything above the viewport), but with nothing observed there's
// no snapshot — and assigning unconditionally clears any stale flag from a
// prior no-op setup (e.g. one run in article view, which has no .observe nodes).
skipNextObservation = observedDivs.length > 0
observedDivs.forEach(observedDiv => { observedDivs.forEach(observedDiv => {
observer.observe(observedDiv); observer.observe(observedDiv);
}) })
}
} }
async function handleIntersection(entries) { function handleIntersection(entries, topbarHeight = 0) {
// The callback function for when the target element enters or exits the viewport // Drop the initial snapshot fired on (re)connect — see skipNextObservation.
for (const entry of entries) { // The old observer is disconnected before this new one observes, so only the
// An article that has scrolled above the viewport (not intersecting, // latest observer's initial callback reaches here; one skip is enough.
// bounding box above the top edge) has been read — mark it and remove it. if (skipNextObservation) {
if (initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0) { skipNextObservation = false
await markRead(feeds.value[entry.target.id].id) return
removeFeed(entry.target.id) }
document.getElementById(0)?.scrollIntoView() // Resolve all affected feeds before touching feeds.value — the target.id
// indices are render-time positions that shift once we splice the array.
const readFeeds = entries
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight)
.map(entry => feeds.value[entry.target.id])
.filter(Boolean)
if (readFeeds.length === 0) return
// Disconnect before the DOM mutation. In card layout the cards are short
// enough that the shift caused by removing one can push the next card above
// the header, which the observer would immediately treat as another read —
// cascading until many articles disappear at once.
if (observer) {
observer.disconnect()
observer = null
}
// Both the array splice (via scroll anchoring) and the scrollBy correction
// below move the page — flag it so AppNav's header auto-hide ignores the jump.
markProgrammaticScroll()
const readIds = new Set(readFeeds.map(feed => feed.id))
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
// Mirror into the master so cleared filters don't resurrect read articles.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
refreshUnreadDisplay()
for (const feed of readFeeds) {
markRead(feed.id)
}
nextTick().then(() => {
// If scroll anchoring didn't compensate for the removed content (common
// with position:fixed headers and overflow-x:hidden on body), the first
// remaining article will have drifted above the header. Correct the scroll
// position so it sits exactly at the header bottom before reconnecting —
// otherwise the initial observation would immediately mark everything above
// the topbar as read and cascade until the list is empty.
const first = document.querySelector('.observe')
if (first) {
const top = first.getBoundingClientRect().top
if (top < topbarHeight) {
markProgrammaticScroll()
window.scrollBy(0, top - topbarHeight)
} }
} }
setupIntersectionObserver()
})
} }
function removeFeed(index) { function disconnectObserver() {
const array = unref(feeds); if (observer) {
array.splice(index, 1); observer.disconnect()
observer = null
}
} }
function setInitialLoad(value) { function setInitialLoad(value) {
@@ -181,11 +479,25 @@ async function markAllRead() {
if (!window.confirm('Mark all articles as read?')) return if (!window.confirm('Mark all articles as read?')) return
const ids = feeds.value.map(feed => feed.id) const ids = feeds.value.map(feed => feed.id)
const readIds = new Set(ids)
feeds.value = [] feeds.value = []
// markAllRead operates on the visible subset (only the filtered feed, if one
// is active) — drop exactly those from the master too.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
currentIndex.value = 0 currentIndex.value = 0
// markRead swallows its own errors, so Promise.all can't reject here. refreshUnreadDisplay()
await Promise.all(ids.map(id => markRead(id))) // markRead() resolves to true/false rather than rejecting, so Promise.all
// can't reject here — but a false means that article is still unread
// server-side, which the message below must not contradict.
const results = await Promise.all(ids.map(id => markRead(id)))
const failedCount = results.filter(ok => !ok).length
if (failedCount === 0) {
showMessageForXSeconds('All articles marked as read.', 5) showMessageForXSeconds('All articles marked as read.', 5)
} else {
// markRead() already surfaced each individual failure — this summarizes
// the batch outcome instead of claiming full success over it.
showMessageForXSeconds(`Marked ${ids.length - failedCount} of ${ids.length} as read; ${failedCount} failed and may reappear.`, 5)
}
} }
function markCurrentArticleRead() { function markCurrentArticleRead() {
@@ -204,8 +516,12 @@ async function leaveArticleView() {
// in place so currentIndex stayed valid — drop them now so they don't keep // in place so currentIndex stayed valid — drop them now so they don't keep
// showing up in the list view. // showing up in the list view.
feeds.value = feeds.value.filter(feed => !feed.read) feeds.value = feeds.value.filter(feed => !feed.read)
// Shared references — the paged-past objects carry .read on the master too.
allItems.value = allItems.value.filter(feed => !feed.read)
currentIndex.value = 0 currentIndex.value = 0
viewMode.value = 'list' viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value)
refreshUnreadDisplay()
// The v-if on the list container tears down and recreates all .observe DOM // The v-if on the list container tears down and recreates all .observe DOM
// nodes when switching views, so the intersection observer must be // nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering. // re-pointed at the new elements after Vue has finished rendering.
@@ -217,21 +533,37 @@ function toggleViewMode() {
if (viewMode.value === 'article') { if (viewMode.value === 'article') {
leaveArticleView() leaveArticleView()
} else { } else {
// Disconnect first: the v-if switch is about to unmount all .observe
// elements, which would otherwise fire intersection callbacks reporting
// them as no-longer-intersecting and mark every visible article read.
if (observer) {
observer.disconnect()
observer = null
}
viewMode.value = 'article' viewMode.value = 'article'
localStorage.setItem('viewMode', viewMode.value)
currentIndex.value = 0 currentIndex.value = 0
markCurrentArticleRead() markCurrentArticleRead()
} }
} }
function toggleLayout() { async function toggleLayout() {
if (observer) {
observer.disconnect()
observer = null
}
window.scrollTo(0, 0)
layout.value = layout.value === 'list' ? 'cards' : 'list' layout.value = layout.value === 'list' ? 'cards' : 'list'
localStorage.setItem('layout', layout.value) localStorage.setItem('layout', layout.value)
await nextTick()
setupIntersectionObserver()
} }
function nextArticle() { function nextArticle() {
if (currentIndex.value < feeds.value.length - 1) { if (currentIndex.value < feeds.value.length - 1) {
currentIndex.value += 1 currentIndex.value += 1
markCurrentArticleRead() markCurrentArticleRead()
window.scrollTo(0, 0)
} }
} }
@@ -239,12 +571,21 @@ function prevArticle() {
if (currentIndex.value > 0) { if (currentIndex.value > 0) {
currentIndex.value -= 1 currentIndex.value -= 1
markCurrentArticleRead() markCurrentArticleRead()
window.scrollTo(0, 0)
} }
} }
export function useFeeds() { export function useFeeds() {
return { return {
feeds, feeds,
allItems,
feedFilter,
feedTitles,
feedUnreadCounts,
unreadCount,
displayedFeedUnreadCounts,
refreshUnreadDisplay,
setFeedFilter,
showMessage, showMessage,
message, message,
showModal, showModal,
@@ -252,6 +593,7 @@ export function useFeeds() {
currentIndex, currentIndex,
toggleViewMode, toggleViewMode,
leaveArticleView, leaveArticleView,
markCurrentArticleRead,
layout, layout,
toggleLayout, toggleLayout,
nextArticle, nextArticle,
@@ -263,7 +605,10 @@ export function useFeeds() {
markAllRead, markAllRead,
showMessageForXSeconds, showMessageForXSeconds,
setupIntersectionObserver, setupIntersectionObserver,
removeFeed, disconnectObserver,
setInitialLoad, setInitialLoad,
handleIntersection,
lastProgrammaticScroll,
markProgrammaticScroll,
} }
} }
+110
View File
@@ -0,0 +1,110 @@
import { ref } from 'vue'
const HEADLINE_FONT_OPTIONS = [
{ key: 'default', label: 'Default (Glook)', value: "Glook, 'Courier New'" },
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
{ key: 'raleway', label: 'Raleway', value: "Raleway, -apple-system, sans-serif" },
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
]
const CONTENT_FONT_OPTIONS = [
{ key: 'default', label: 'Default (Merriweather)', value: "Merriweather, Georgia, 'Times New Roman', Times, serif" },
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
{ key: 'source-serif', label: 'Source Serif 4', value: "'Source Serif 4', Georgia, serif" },
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
]
const SIZE_STEPS = [0.85, 1, 1.2, 1.45]
const SIZE_LABELS = ['S', 'M', 'L', 'XL']
const TEXT_ALIGN_OPTIONS = [
{ key: 'left', label: 'Left' },
{ key: 'justify', label: 'Justified' },
]
const PADDING_STEPS = [1, 0.5, 0.15]
const PADDING_LABELS = ['Default', 'Compact', 'Minimal']
const headlineSizeScale = ref(parseFloat(localStorage.getItem('s-headline-size') ?? '1'))
const contentSizeScale = ref(parseFloat(localStorage.getItem('s-content-size') ?? '1'))
const headlineFontKey = ref(localStorage.getItem('s-headline-font') ?? 'default')
const contentFontKey = ref(localStorage.getItem('s-content-font') ?? 'default')
const textAlignKey = ref(localStorage.getItem('s-text-align') ?? 'left')
const contentPadding = ref(parseFloat(localStorage.getItem('s-content-padding') ?? '1'))
function fontValue(options, key) {
return (options.find(o => o.key === key) ?? options[0]).value
}
function applySettings() {
const s = document.documentElement.style
s.setProperty('--headline-font-size-scale', headlineSizeScale.value)
s.setProperty('--content-font-size-scale', contentSizeScale.value)
s.setProperty('--headline-font-family', fontValue(HEADLINE_FONT_OPTIONS, headlineFontKey.value))
s.setProperty('--content-font-family', fontValue(CONTENT_FONT_OPTIONS, contentFontKey.value))
s.setProperty('--content-text-align', textAlignKey.value)
s.setProperty('--content-padding', contentPadding.value + 'rem')
}
function setHeadlineSize(scale) {
headlineSizeScale.value = scale
localStorage.setItem('s-headline-size', scale)
applySettings()
}
function setContentSize(scale) {
contentSizeScale.value = scale
localStorage.setItem('s-content-size', scale)
applySettings()
}
function setHeadlineFont(key) {
headlineFontKey.value = key
localStorage.setItem('s-headline-font', key)
applySettings()
}
function setContentFont(key) {
contentFontKey.value = key
localStorage.setItem('s-content-font', key)
applySettings()
}
function setTextAlign(key) {
textAlignKey.value = key
localStorage.setItem('s-text-align', key)
applySettings()
}
function setContentPadding(step) {
contentPadding.value = step
localStorage.setItem('s-content-padding', step)
applySettings()
}
export function useSettings() {
return {
headlineSizeScale,
contentSizeScale,
headlineFontKey,
contentFontKey,
SIZE_STEPS,
SIZE_LABELS,
HEADLINE_FONT_OPTIONS,
CONTENT_FONT_OPTIONS,
TEXT_ALIGN_OPTIONS,
PADDING_STEPS,
PADDING_LABELS,
applySettings,
setHeadlineSize,
setContentSize,
setHeadlineFont,
setContentFont,
setTextAlign,
setContentPadding,
textAlignKey,
contentPadding,
}
}
+20
View File
@@ -1,8 +1,28 @@
import './assets/main.css' import './assets/main.css'
import axios from 'axios'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
// A 401 means the server has rejected the token (missing, expired, or
// revoked via logout/token_version bump elsewhere). Drop the stale session
// and send the user back to login rather than leaving them on a page where
// every request silently fails.
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
if (router.currentRoute.value.name !== 'login') {
router.push({ name: 'login' })
}
}
return Promise.reject(error)
}
)
const app = createApp(App) const app = createApp(App)
app.use(router) app.use(router)
+8 -17
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior: () => ({ top: 0, behavior: 'instant' }),
routes: [ routes: [
{ {
path: '/', path: '/',
@@ -30,23 +31,13 @@ const router = createRouter({
] ]
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to) => {
if (to.meta.requiresAuth) { const isAuthenticated = localStorage.getItem("user-token") != null;
let isAuthenticated = false; // Redirect unauthenticated users hitting a protected route to login;
if (localStorage.getItem("user-token") != null){ // returning a value (instead of the deprecated next() callback) is the
isAuthenticated = true; // modern vue-router guard API. Returning nothing lets navigation proceed.
} if (to.meta.requiresAuth && !isAuthenticated) {
return '/login';
if (!isAuthenticated) {
// Redirect to the login page
next('/login');
} else {
// Proceed to the protected route
next();
}
} else {
// For routes that don't require authentication, proceed without checking
next();
} }
}); });
export default router export default router
+11
View File
@@ -0,0 +1,11 @@
<script setup>
import AdminFeeds from '../components/AdminFeeds.vue'
import AdminSettings from '../components/AdminSettings.vue'
</script>
<template>
<main>
<AdminSettings />
<AdminFeeds />
</main>
</template>