Author SHA1 Message Date
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 4107 additions and 515 deletions
+4
View File
@@ -1,3 +1,7 @@
/target
.env
.claude
CLAUDE.md
LEARNINGS.md
PLAN.md
/memory
Generated
+221 -31
View File
@@ -34,6 +34,18 @@ dependencies = [
"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]]
name = "actix-http"
version = "3.12.1"
@@ -52,7 +64,7 @@ dependencies = [
"derive_more",
"encoding_rs",
"flate2",
"foldhash",
"foldhash 0.1.5",
"futures-core",
"h2 0.3.27",
"http 0.2.12",
@@ -167,7 +179,7 @@ dependencies = [
"cookie",
"derive_more",
"encoding_rs",
"foldhash",
"foldhash 0.1.5",
"futures-core",
"futures-util",
"impl-more",
@@ -231,6 +243,24 @@ dependencies = [
"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]]
name = "android_system_properties"
version = "0.1.5"
@@ -292,15 +322,15 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.102"
version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "atom_syndication"
version = "0.12.8"
version = "0.12.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39d8b8ef99e33deb2a51504888222225797f48bace2f0cab01975746a39c05bb"
checksum = "d48d93a31c932d58b9fd68664a904ae5b74bea37f06c5f68d5bec92f0cdd106d"
dependencies = [
"chrono",
"derive_builder",
@@ -355,12 +385,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bcrypt"
version = "0.19.1"
name = "base64"
version = "0.23.1"
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 = [
"base64 0.22.1",
"base64 0.23.1",
"blowfish",
"getrandom 0.4.2",
"subtle",
@@ -475,9 +511,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.10.0"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
@@ -612,6 +648,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -724,6 +766,20 @@ dependencies = [
"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]]
name = "dateparser"
version = "0.3.1"
@@ -889,10 +945,10 @@ dependencies = [
]
[[package]]
name = "dotenv"
version = "0.15.0"
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
@@ -1029,6 +1085,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1115,6 +1177,12 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-timer"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
version = "0.3.32"
@@ -1185,11 +1253,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"rand_core 0.10.1",
"wasip2",
"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]]
@@ -1213,9 +1306,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.14"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
@@ -1230,13 +1323,30 @@ dependencies = [
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
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]]
@@ -1345,7 +1455,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.14",
"h2 0.4.19",
"http 1.4.1",
"http-body",
"httparse",
@@ -1752,6 +1862,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.39.0"
@@ -1824,6 +1940,12 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nonzero_ext"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -2027,10 +2149,25 @@ dependencies = [
]
[[package]]
name = "quick-xml"
version = "0.39.4"
name = "quanta"
version = "0.12.6"
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 = [
"encoding_rs",
"memchr",
@@ -2058,15 +2195,16 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.14"
version = "0.11.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83"
dependencies = [
"aws-lc-rs",
"bytes",
"getrandom 0.3.4",
"getrandom 0.4.2",
"lru-slab",
"rand 0.9.4",
"rand 0.10.1",
"rand_pcg",
"ring",
"rustc-hash",
"rustls",
@@ -2159,6 +2297,24 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "redox_syscall"
version = "0.5.18"
@@ -2215,7 +2371,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.14",
"h2 0.4.19",
"http 1.4.1",
"http-body",
"http-body-util",
@@ -2261,9 +2417,9 @@ dependencies = [
[[package]]
name = "rss"
version = "2.0.13"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e38781082c53bdde56e6081698c06831f69a27eac2593ed6b6cb2511424ad5"
checksum = "dc13570823abc675c60d7837f1fe7daaf18dd181a3cacb3c6ba7a062eef581c0"
dependencies = [
"atom_syndication",
"derive_builder",
@@ -2272,18 +2428,21 @@ dependencies = [
[[package]]
name = "rss-reader"
version = "0.1.0"
version = "0.9.1"
dependencies = [
"actix-cors",
"actix-governor",
"actix-rt",
"actix-service",
"actix-web",
"ammonia",
"anyhow",
"bcrypt",
"chrono",
"dateparser",
"diesel",
"diesel_migrations",
"dotenv",
"dotenvy",
"env_logger",
"futures",
"hmac",
@@ -2660,6 +2819,15 @@ dependencies = [
"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]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -3268,6 +3436,22 @@ dependencies = [
"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]]
name = "winapi-util"
version = "0.1.11"
@@ -3277,6 +3461,12 @@ dependencies = [
"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]]
name = "windows-core"
version = "0.62.2"
+5 -2
View File
@@ -1,11 +1,12 @@
[package]
name = "rss-reader"
version = "0.1.0"
version = "0.9.1"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1"
reqwest = { version = "0.13", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.13" }
@@ -17,7 +18,7 @@ serde_derive = "1.0.228"
actix-service = "2.0.3"
diesel = { version = "2.3", features = ["postgres", "chrono"] }
diesel_migrations = "2.3"
dotenv = "0.15.0"
dotenvy = "0.15"
bcrypt = "0.19"
uuid = {version = "1.23", features=["serde", "v4"]}
jwt = "0.16.0"
@@ -29,6 +30,8 @@ scraper = "0.27"
actix-cors = "0.7"
chrono = { version = "0.4.45", features = ["serde"] }
dateparser = "0.3"
ammonia = "4.1.2"
actix-governor = "0.10.0"
[dependencies.serde_json]
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
COPY . .
RUN cargo build --release
RUN cargo build --release && \
cp target/release/rss-reader /usr/local/bin/rss-reader && \
rm -rf target
# --- runtime ---
FROM debian:bookworm-slim
@@ -16,7 +18,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 ca-certificates \
&& 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
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
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
```
Passwords must be at least 6 characters.
### Useful commands during development
```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)
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 -v # stop and wipe all data — careful!
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
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
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`):
+3
View File
@@ -2,6 +2,7 @@ services:
postgres:
container_name: "rss-postgres"
image: "postgres:18"
restart: unless-stopped
ports:
- "5432:5432"
environment:
@@ -16,6 +17,7 @@ services:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- postgres
environment:
@@ -31,6 +33,7 @@ services:
build:
context: ./vue
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- backend
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 sha2;
use std::collections::BTreeMap;
use std::env;
use actix_web::HttpRequest;
use dotenv::dotenv;
use chrono::{Duration, Utc};
use dotenvy::dotenv;
use hmac::{Hmac, Mac};
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
/// How long a freshly issued token remains valid for.
const TOKEN_LIFETIME_HOURS: i64 = 730;
pub struct JwtToken {
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>;
@@ -20,29 +35,39 @@ type HmacSha256 = Hmac<Sha256>;
fn signing_key() -> HmacSha256 {
dotenv().ok();
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 {
pub fn encode(user_id: i32) -> String {
pub fn encode(user_id: i32, token_version: i32) -> String {
let key: HmacSha256 = signing_key();
let mut claims = BTreeMap::new();
claims.insert("user_id", user_id);
claims.sign_with_key(&key).unwrap()
let claims = Claims {
user_id,
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> {
let key: HmacSha256 = signing_key();
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);
match token {
Ok(token) => {
let _header = token.header();
let claims = token.claims();
if claims.exp < Utc::now().timestamp() {
return Err("token has expired");
}
Ok(JwtToken {
user_id: claims["user_id"],
user_id: claims.user_id,
token_version: claims.tv,
})
}
Err(_err) => Err("could not decode token"),
@@ -52,7 +77,10 @@ impl JwtToken {
#[allow(dead_code)]
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
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"),
}
}
@@ -61,14 +89,19 @@ impl JwtToken {
#[cfg(test)]
mod jwt_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]
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();
assert_eq!(32, decoded_token.user_id);
assert_eq!(0, decoded_token.token_version);
}
#[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]
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()
.insert_header(header::ContentType::json())
.insert_header(("user-token", encoded_token))
+14 -8
View File
@@ -1,12 +1,13 @@
use actix_web::dev::ServiceRequest;
pub mod extractor;
pub mod jwt;
pub mod processes;
use crate::auth::processes::check_password;
use crate::auth::processes::check_token;
use crate::auth::processes::extract_header_token;
pub fn process_token(request: &ServiceRequest) -> Result<i32, &'static str> {
match extract_header_token(request) {
Ok(token) => check_password(token),
Ok(token) => check_token(token),
Err(message) => Err(message),
}
}
@@ -16,19 +17,24 @@ mod mod_test {
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]
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()
.insert_header(("user-token", token))
.to_srv_request();
match process_token(&request) {
Ok(user_id) => assert_eq!(32, user_id),
Err(_) => panic!("process token failed"),
}
assert_eq!(Ok(user.id), process_token(&request));
delete_user(&mut connection, user.id);
}
#[actix_web::test]
+55 -24
View File
@@ -1,22 +1,33 @@
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 diesel::prelude::*;
pub fn check_password(password: String) -> Result<i32, &'static str> {
match jwt::JwtToken::decode(password) {
Ok(token) => Ok(token.user_id),
Err(message) => Err(message),
/// Decodes the token and confirms it hasn't been revoked, i.e. its `token_version`
/// still matches the one stored on the user (bumped on logout / password change).
pub fn check_token(token: String) -> Result<i32, &'static str> {
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> {
log::info!("Request: {:?}", request);
match request.headers().get("user-token") {
Some(token) => match token.to_str() {
Ok(processed_password) => {
log::info!("Token provided: {}", processed_password);
Ok(String::from(processed_password))
}
Err(_processed_password) => Err("there was an error processing token"),
Ok(processed_token) => Ok(String::from(processed_token)),
Err(_) => Err("there was an error processing token"),
},
None => Err("there is no token"),
}
@@ -25,31 +36,51 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static
#[cfg(test)]
mod processes_test {
use actix_web::test::TestRequest;
use diesel::prelude::*;
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]
fn check_correct_password() {
let password_string: String = JwtToken::encode(32);
fn check_correct_token() {
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 {
Ok(user_id) => assert_eq!(32, user_id),
_ => panic!("Check correct password failed."),
}
let result = check_token(token);
assert_eq!(Ok(user.id), result);
delete_user(&mut connection, user.id);
}
#[test]
fn incorrect_check_password() {
let password: String = String::from("test");
fn revoked_token_is_rejected() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
match check_password(password) {
Err(message) => assert_eq!("could not decode token", message),
_ => panic!("check password should not be able to be decoded"),
}
// Token signed with the user's current version, then the version is bumped
// (as logout would do), which must invalidate the previously issued token.
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]
+1 -1
View File
@@ -1,7 +1,7 @@
use diesel::pg::PgConnection;
use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenv::dotenv;
use dotenvy::dotenv;
use std::env;
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;
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
let body = serde_json::to_string(&self).unwrap();
HttpResponse::with_body(StatusCode::OK, 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())
}
}
}
}
+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;
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
let body = serde_json::to_string(&self).unwrap();
HttpResponse::with_body(StatusCode::OK, 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())
}
}
}
}
+21 -17
View File
@@ -1,14 +1,14 @@
extern crate diesel;
extern crate dotenv;
use actix_cors::Cors;
use actix_service::Service;
use actix_web::{App, HttpResponse, HttpServer};
use dotenv::dotenv;
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
use dotenvy::dotenv;
use futures::future::{ok, Either};
use std::env;
mod auth;
mod database;
mod error;
mod json_serialization;
mod models;
mod reader;
@@ -36,28 +36,32 @@ async fn main() -> std::io::Result<()> {
App::new()
.wrap_fn(|req, srv| {
let mut passed: bool;
let request_url: String = String::from(req.uri().path());
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) {
Ok(user_id) => {
log::info!("Authenticated user {} for {}", user_id, request_url);
passed = true;
req.extensions_mut().insert(auth::extractor::AuthUser(user_id));
true
}
Err(message) => {
log::warn!("Rejected request to {}: {}", request_url, message);
false
}
Err(_message) => passed = 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 {
true => Either::Left(srv.call(req)),
+18 -4
View File
@@ -6,6 +6,19 @@ use uuid::Uuid;
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)]
#[diesel(table_name=users)]
pub struct NewUser {
@@ -16,14 +29,15 @@ pub struct NewUser {
}
impl NewUser {
pub fn new(username: String, email: String, password: String) -> NewUser {
let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap();
pub fn new(username: String, email: String, password: String) -> anyhow::Result<NewUser> {
validate_password(&password).map_err(anyhow::Error::msg)?;
let hashed_password: String = hash(password.as_str(), DEFAULT_COST)?;
let uuid = Uuid::new_v4();
NewUser {
Ok(NewUser {
username,
email,
password: hashed_password,
unique_id: uuid.to_string(),
}
})
}
}
+3 -2
View File
@@ -14,10 +14,11 @@ pub struct User {
pub email: String,
pub password: String,
pub unique_id: String,
pub token_version: i32,
}
impl User {
pub fn verify(self, password: String) -> bool {
verify(password.as_str(), &self.password).unwrap()
pub fn verify(self, password: String) -> anyhow::Result<bool> {
Ok(verify(password.as_str(), &self.password)?)
}
}
+47 -9
View File
@@ -2,13 +2,17 @@ use actix_web::{web, HttpResponse};
use diesel::RunQueryDsl;
use crate::{
database::establish_connection, json_serialization::new_feed::NewFeedSchema,
models::feed::new_feed::NewFeed, schema::feed,
auth::extractor::AuthUser, database::establish_connection,
json_serialization::new_feed::NewFeedSchema, models::feed::new_feed::NewFeed, schema::feed,
};
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 title: String = new_feed.title.clone();
let url: String = new_feed.url.clone();
@@ -19,12 +23,12 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
Ok(channel) => {
log::info!("valid channel");
if channel.items.is_empty() {
return HttpResponse::ServiceUnavailable().await.unwrap();
return HttpResponse::ServiceUnavailable().finish();
}
}
Err(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);
match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(),
Ok(_) => HttpResponse::Created().finish(),
Err(e) => {
log::error!("{e}");
HttpResponse::Conflict().await.unwrap()
HttpResponse::Conflict().finish()
}
}
}
#[cfg(test)]
mod tests {
use actix_service::Service;
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use actix_web::{test, web, App, HttpMessage};
use super::add;
use crate::auth::extractor::AuthUser;
use crate::test_helpers::unique_suffix;
#[actix_web::test]
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()
.uri("/add")
.set_json(serde_json::json!({
@@ -66,4 +80,28 @@ mod tests {
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;
pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> {
let content = reqwest::get(feed).await?.bytes().await?;
use super::net::safe_fetch;
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[..])?;
log::debug!("{:?}", 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::models::feed::rss_feed::Feed;
use crate::models::feed_item::rss_feed_item::FeedItem;
@@ -9,22 +11,29 @@ use crate::{
schema::feed::{self, user_id},
schema::feed_item,
};
use actix_web::{web, HttpRequest, Responder};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use chrono::Local;
use diesel::prelude::*;
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 req_user_id = path.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 feeds: Vec<Feed> = feed::table
.filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection)
.unwrap();
.load::<Feed>(&mut connection)?;
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
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(read.eq(false))
.order(id.asc())
.load(&mut connection)
.unwrap();
.load(&mut connection)?;
log::info!(
"Load {} feed items for feed: {}",
@@ -70,15 +78,17 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
feeds: feed_aggregates,
};
articles.respond_to(&request)
Ok(articles.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};
use actix_web::{test, web, App, HttpMessage};
use super::get;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection;
use crate::test_helpers::{
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 read = insert_feed_item(&mut connection, feed.id, true);
let app =
test::init_service(App::new().route("/get/{user_id}", web::get().to(get))).await;
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("/get/{user_id}", web::get().to(get)),
)
.await;
let req = test::TestRequest::get()
.uri(&format!("/get/{}", user.id))
.to_request();
@@ -112,4 +130,30 @@ mod tests {
delete_feed(&mut connection, feed.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::{
database::establish_connection, json_serialization::read_feed_item::ReadItem,
models::feed_item::rss_feed_item::FeedItem, schema::feed_item,
database::establish_connection,
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 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();
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))
.load::<FeedItem>(&mut connection)
.unwrap();
.select((feed_item::all_columns, feed::user_id))
.first(&mut connection)
.optional()?;
if feed_items.len() != 1 {
return HttpResponse::NotFound();
}
let feed_item = match owned_item {
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: Result<usize, diesel::result::Error> = diesel::update(feed_item)
let result = diesel::update(&feed_item)
.set(read.eq(true))
.execute(&mut connection);
.execute(&mut connection)?;
log::info!("Mark as read: {:?}", result);
HttpResponse::Ok()
Ok(HttpResponse::Ok().finish())
}
#[cfg(test)]
mod tests {
use actix_service::Service;
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use actix_web::{test, web, App, HttpMessage};
use diesel::prelude::*;
use super::mark_read;
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection;
use crate::models::feed_item::rss_feed_item::FeedItem;
use crate::schema::feed_item;
@@ -51,8 +65,16 @@ mod tests {
let feed = insert_feed(&mut connection, user.id);
let item = insert_feed_item(&mut connection, feed.id, false);
let app =
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await;
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("/read/{id}", web::put().to(mark_read)),
)
.await;
let req = test::TestRequest::put()
.uri(&format!("/read/{}", item.id))
.to_request();
@@ -73,11 +95,55 @@ mod tests {
#[actix_web::test]
async fn mark_read_returns_not_found_for_unknown_id() {
let app =
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(1));
srv.call(req)
})
.route("/read/{id}", web::put().to(mark_read)),
)
.await;
let req = test::TestRequest::put().uri("/read/999999999").to_request();
let resp = test::call_service(&app, req).await;
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 list_feeds;
mod mark_read;
pub mod net;
mod read;
mod scraper;
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
// to avoid overloading the server.
pub async fn do_throttled_request(url: &str) -> Result<String, Error> {
let response = reqwest::get(url).await?;
response.text().await
pub async fn do_throttled_request(url: &str) -> Result<String, AppError> {
let response = safe_fetch(url).await?;
Ok(response.text().await?)
}
+297 -138
View File
@@ -1,9 +1,10 @@
use super::feeds;
use crate::auth::extractor::AuthUser;
use crate::error::AppError;
use crate::json_serialization::user::JsonUser;
use crate::models::feed::rss_feed::Feed;
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, title};
use crate::schema::feed_item::{feed_id, url};
use crate::{
database::establish_connection,
schema::{
@@ -11,12 +12,13 @@ use crate::{
feed_item,
},
};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use chrono::{DateTime, Duration, Local, NaiveDateTime};
use actix_web::{web, HttpRequest, HttpResponse};
use chrono::{DateTime, Local, NaiveDateTime};
use dateparser::parse;
use diesel::prelude::*;
use rss::Item;
use scraper::{Html, Selector};
use std::collections::{HashMap, HashSet};
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
if let Ok(result) = parse(date_str) {
@@ -46,6 +48,20 @@ fn escape_html_attr(value: &str) -> String {
.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
// 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.
@@ -60,14 +76,22 @@ fn enclosure_image_html(item: &Item) -> Option<String> {
))
}
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
let item_title = item.title.clone().unwrap();
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> anyhow::Result<()> {
// 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);
// Resolve the publication date before any HTML parsing or DB work so we can
// bail out early for old articles. Items without a pub_date are treated as
// current (inserted unconditionally) — feeds that don't publish dates are
// typically small/curated enough that this is fine.
// Items without a pub_date are treated as current (inserted unconditionally)
// — feeds that don't publish dates are typically small/curated enough that
// this is fine.
let mut time: NaiveDateTime = Local::now().naive_local();
if let Some(pub_date) = item.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 frag = Html::parse_fragment(base_content);
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) {
Some(image) => {
content.push_str(&image.html());
content.push_str(&sanitize_img_html(&image.html()));
content.push_str("<br>");
}
None => {
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>");
}
}
}
for node in frag.tree.nodes() {
if excluded_node_ids.contains(&node.id()) {
continue;
}
if let scraper::node::Node::Text(text) = node.value() {
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();
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
item_title.clone(),
item.link.expect("checked above"),
Some(time),
);
if existing_item.is_empty() {
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
item_title.clone(),
item.link.unwrap(),
Some(time),
);
let insert_result = diesel::insert_into(feed_item::table)
.values(&new_feed_item)
.execute(connection);
// `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)?;
log::info!("Insert Result: {:?}", insert_result);
if inserted_rows > 0 {
log::info!("Inserted item: {}", item_title);
} else {
log::info!("Item {} already exists.", item_title);
}
Ok(())
}
// Items without a `created_ts` (e.g. ones inserted before this column existed,
// or whose feed didn't provide a publish date) are left alone — `lt` never
// matches NULL, so there's nothing to special-case here.
fn delete_old_feed_items(connection: &mut PgConnection) {
let cutoff = Local::now().naive_local() - Duration::days(14);
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);
pub async fn sync(
_req: HttpRequest,
data: web::Json<JsonUser>,
auth_user: AuthUser,
) -> Result<HttpResponse, AppError> {
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
.filter(user_id.eq(req_user_id))
.load::<Feed>(&mut connection)
.unwrap();
.load::<Feed>(&mut connection)?;
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) => {
for item in channel.into_items() {
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),
}
}
HttpResponse::Ok()
Ok(HttpResponse::Ok().finish())
}
#[cfg(test)]
mod tests {
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::rss_user::User;
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::*;
@@ -271,93 +307,46 @@ mod tests {
);
}
#[actix_web::test]
async fn delete_old_feed_items_removes_items_older_than_two_weeks_but_keeps_recent_ones() {
let mut connection = establish_connection();
let suffix = unique_suffix();
#[test]
fn sanitize_img_html_strips_event_handlers() {
let sanitized = sanitize_img_html(r#"<img src="x" onerror="alert(1)">"#);
let new_user = NewUser::new(
format!("cleanup_test_{suffix}"),
format!("cleanup_{suffix}@example.test"),
"secret".to_string(),
assert!(!sanitized.contains("onerror"));
assert!(!sanitized.contains("alert"));
assert!(sanitized.contains(r#"src="x""#));
}
#[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(
format!("Cleanup 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 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();
assert!(sanitized.contains(r#"src="https://example.test/img.jpg""#));
assert!(sanitized.contains(r#"alt="desc""#));
assert!(sanitized.contains(r#"title="t""#));
assert!(!sanitized.contains("style"));
assert!(!sanitized.contains("class"));
}
#[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 suffix = unique_suffix();
let new_user = NewUser::new(
format!("age_skip_test_{suffix}"),
format!("age_skip_{suffix}@example.test"),
format!("age_test_{suffix}"),
format!("age_{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!("Age skip test feed {suffix}"),
format!("Age test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
user.id,
);
@@ -366,7 +355,9 @@ mod tests {
.get_result(&mut connection)
.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))
.format("%a, %d %b %Y %H:%M:%S %z")
.to_string();
@@ -382,19 +373,15 @@ mod tests {
fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}")));
fresh_item.set_content(Some("<p>fresh</p>".to_string()));
create_feed_item(old_item, &feed, &mut connection);
create_feed_item(fresh_item, &feed, &mut connection);
create_feed_item(old_item, &feed, &mut connection).unwrap();
create_feed_item(fresh_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(), "old item should have been skipped");
assert!(
items[0].title.contains("Fresh article"),
"only the fresh item should be present"
);
assert_eq!(2, items.len(), "both old and fresh items should be inserted");
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
.execute(&mut connection)
@@ -416,7 +403,8 @@ mod tests {
format!("sync_test_{suffix}"),
format!("sync_{suffix}@example.test"),
"secret".to_string(),
);
)
.unwrap();
let user: User = diesel::insert_into(users::table)
.values(&new_user)
.get_result(&mut connection)
@@ -437,8 +425,8 @@ mod tests {
item.set_link(Some(format!("https://example.test/article/{suffix}")));
item.set_content(Some("<p>Hello world</p>".to_string()));
create_feed_item(item.clone(), &feed, &mut connection);
create_feed_item(item, &feed, &mut connection);
create_feed_item(item.clone(), &feed, &mut connection).unwrap();
create_feed_item(item, &feed, &mut connection).unwrap();
let items: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
@@ -456,4 +444,175 @@ mod tests {
.execute(&mut connection)
.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,
password -> 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_{suffix}@example.test"),
password.to_string(),
);
)
.expect("failed to hash test user password");
diesel::insert_into(users::table)
.values(&new_user)
.get_result(connection)
+10 -11
View File
@@ -1,5 +1,6 @@
use crate::database::establish_connection;
use crate::diesel;
use crate::error::AppError;
use crate::json_serialization::login::Login;
use crate::models::user::rss_user::User;
use crate::schema::users;
@@ -7,7 +8,7 @@ use crate::{auth::jwt::JwtToken, schema::users::username};
use actix_web::{web, HttpResponse};
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 password: String = credentials.password.clone();
@@ -15,32 +16,30 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
let users: Vec<User> = users::table
.filter(username.eq(username_cred.as_str()))
.load::<User>(&mut connection)
.unwrap();
.load::<User>(&mut connection)?;
if users.is_empty() {
return HttpResponse::NotFound().await.unwrap();
return Ok(HttpResponse::NotFound().finish());
} else if users.len() > 1 {
log::error!(
"multiple user have the usernam: {}",
credentials.username.clone()
);
return HttpResponse::Conflict().await.unwrap();
return Ok(HttpResponse::Conflict().finish());
}
let user: &User = &users[0];
match user.clone().verify(password) {
match user.clone().verify(password)? {
true => {
log::info!("verified password successfully for user {}", user.id);
let token: String = JwtToken::encode(user.clone().id);
HttpResponse::Ok()
let token: String = JwtToken::encode(user.id, user.token_version);
Ok(HttpResponse::Ok()
.insert_header(("token", token))
.insert_header(("user_id", user.id))
.await
.unwrap()
.finish())
}
false => HttpResponse::Unauthorized().await.unwrap(),
false => Ok(HttpResponse::Unauthorized().finish()),
}
}
+45 -8
View File
@@ -1,25 +1,62 @@
use actix_web::HttpResponse;
use diesel::prelude::*;
// JWT auth is stateless and there is no token blacklist, so logging out is
// purely a client-side action (discarding the stored token). This endpoint
// exists so the frontend has something to call and gets a clean response.
pub async fn logout() -> HttpResponse {
HttpResponse::Ok().finish()
use crate::auth::extractor::AuthUser;
use crate::database::establish_connection;
use crate::error::AppError;
use crate::schema::users;
/// 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)]
mod tests {
use actix_service::Service;
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 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]
async fn logout_returns_ok() {
let app = test::init_service(App::new().route("/logout", web::post().to(logout))).await;
async fn logout_bumps_token_version() {
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 resp = test::call_service(&app, req).await;
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::ServiceConfig;
@@ -12,9 +13,19 @@ pub fn auth_factory(app: &mut ServiceConfig) {
backend: true,
};
app.route(
&base_path.define(String::from("/login")),
web::post().to(login::login),
// Login is the only unauthenticated endpoint that checks a password, so
// it's the only one worth rate-limiting against brute-force/credential
// 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(
&base_path.define(String::from("/logout")),
+30 -7
View File
@@ -1,27 +1,32 @@
use crate::database::establish_connection;
use crate::diesel;
use crate::error::AppError;
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 actix_web::{web, HttpResponse};
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 name: String = new_user.name.clone();
let email: String = new_user.email.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)
.values(&new_user)
.execute(&mut connection);
match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(),
Err(_) => HttpResponse::Conflict().await.unwrap(),
}
Ok(match insert_result {
Ok(_) => HttpResponse::Created().finish(),
Err(_) => HttpResponse::Conflict().finish(),
})
}
#[cfg(test)]
@@ -60,6 +65,24 @@ mod tests {
.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]
async fn create_fails_for_duplicate_user() {
let mut connection = establish_connection();
+3 -1
View File
@@ -1,5 +1,7 @@
# --- 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
COPY package.json package-lock.json ./
+3
View File
@@ -7,6 +7,9 @@
<link rel="alternate icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
<body>
+50 -40
View File
@@ -1275,9 +1275,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
@@ -1427,9 +1427,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0",
@@ -1437,12 +1437,12 @@
}
},
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"dependencies": {
"fill-range": "^7.0.1"
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
@@ -2205,9 +2205,9 @@
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -2247,9 +2247,9 @@
}
},
"node_modules/flatted": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true
},
"node_modules/follow-redirects": {
@@ -2300,15 +2300,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
"hasown": "^2.0.4",
"mime-types": "^2.1.35"
},
"engines": {
"node": ">= 6"
@@ -2812,10 +2812,20 @@
"dev": true
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"dependencies": {
"argparse": "^2.0.1"
},
@@ -3224,9 +3234,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true
},
"node_modules/lodash.merge": {
@@ -3299,12 +3309,12 @@
}
},
"node_modules/micromatch": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"dependencies": {
"braces": "^3.0.2",
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
@@ -3343,9 +3353,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -3673,9 +3683,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"engines": {
"node": ">=8.6"
@@ -4450,9 +4460,9 @@
"integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="
},
"node_modules/undici": {
"version": "7.27.2",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz",
"integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==",
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
"dev": true,
"engines": {
"node": ">=20.18.1"
+4
View File
@@ -1,8 +1,12 @@
<script setup>
import { onMounted } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import AppNav from './components/AppNav.vue'
import { useSettings } from './composables/useSettings.js'
const route = useRoute()
const { applySettings } = useSettings()
onMounted(applySettings)
</script>
<template>
+17
View File
@@ -23,6 +23,13 @@
/* semantic color variables for this project */
: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-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
@@ -70,6 +77,16 @@
body {
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);
background: var(--color-background);
transition: color 0.5s, background-color 0.5s;
+9 -6
View File
@@ -4,6 +4,7 @@
max-width: 1280px;
margin: 0 auto;
padding: 0.5rem;
padding-top: var(--app-nav-height, 4.5rem);
font-weight: normal;
}
@@ -68,8 +69,8 @@ a,
.feed-title {
cursor: pointer;
font-family: 'Courier New';
font-size: clamp(1.25rem, 4.5vw, 1.6rem);
font-family: var(--headline-font-family);
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
font-weight: bold;
color: var(--color-accent-2);
border-bottom: 1px solid #ccc;
@@ -83,9 +84,10 @@ a,
}
.feed-content {
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: clamp(1rem, 3.5vw, 1.25rem);
padding: 0 1em 1em;
font-family: var(--content-font-family);
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
text-align: var(--content-text-align);
padding: 0 var(--content-padding) 1em;
overflow-wrap: break-word;
}
@@ -100,7 +102,7 @@ a,
.feed-content h3 {
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;
}
@@ -111,5 +113,6 @@ h3 {
@media (min-width: 768px) {
#app {
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>
+184 -20
View File
@@ -1,12 +1,112 @@
<script setup>
import { ref, computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useFeeds } from '@/composables/useFeeds'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue'
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)
@@ -18,9 +118,21 @@ function closeMenu() {
menuOpen.value = false
}
function logout() {
localStorage.removeItem('user-token')
localStorage.removeItem('user-id')
// The open menu is a thin absolutely-positioned strip under the header, so its
// `@click.self` only catches clicks on that strip — a click anywhere else on
// 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()
router.push({ name: 'login' })
}
@@ -52,9 +164,21 @@ function handleToggleLayout() {
</script>
<template>
<header class="app-nav">
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
<div class="app-nav__wrapper">
<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
class="app-nav__hamburger"
type="button"
@@ -78,26 +202,48 @@ function handleToggleLayout() {
>
<div class="app-nav__menu-panel">
<RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink>
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
</button>
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
</button>
<template v-if="onFeedsPage">
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
</button>
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
</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="handleMarkAllRead">Mark all as read</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>
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
</div>
</nav>
</Transition>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</header>
</template>
<style scoped>
.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 {
@@ -106,12 +252,13 @@ function handleToggleLayout() {
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.75rem 1rem;
padding: 0.375rem 1rem;
}
.app-nav__title {
margin-right: auto;
font-weight: bold;
font-size: clamp(1.1rem, 4vw, 1.4rem);
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
}
.app-nav__unread {
@@ -119,6 +266,23 @@ function handleToggleLayout() {
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 {
display: inline-flex;
align-items: center;
@@ -203,7 +367,7 @@ function handleToggleLayout() {
@media (min-width: 768px) {
.app-nav__wrapper {
padding: 1rem 2rem;
padding: 0.5rem 2rem;
}
}
</style>
+311 -39
View File
@@ -1,27 +1,110 @@
<script setup>
import { onMounted } from 'vue';
import Modal from './modal/AddUrl.vue';
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds';
const {
feeds,
showMessage,
message,
showModal,
viewMode,
currentIndex,
leaveArticleView,
layout,
nextArticle,
prevArticle,
fetchData,
sync,
getReadable,
disconnectObserver,
setInitialLoad,
showMessageForXSeconds,
} = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
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) {
if (navigator.share) {
await navigator.share({ url })
@@ -31,9 +114,15 @@ async function shareUrl(url) {
}
}
onBeforeUnmount(() => {
disconnectObserver()
setInitialLoad(false)
})
onMounted(async () => {
setInitialLoad(false)
await fetchData()
sync(true)
setTimeout(function () {
setInitialLoad(true)
console.log('set to true')
@@ -42,13 +131,6 @@ onMounted(async () => {
</script>
<template>
<Teleport to="body">
<modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</modal>
</Teleport>
<div>
<div v-if="showMessage" class="message">{{ message }}</div>
@@ -60,24 +142,30 @@ onMounted(async () => {
</svg>
<p class="empty-state__label">All caught up</p>
</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">
<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>
<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>
<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>
</button>
</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>
</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 v-else class="article-single">
<button type="button" class="article-single__back" @click="leaveArticleView">&larr; Back to list</button>
<div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @touchend.passive="onArticleTouchEnd">
<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">
<circle cx="12" cy="12" r="10"/>
@@ -85,16 +173,22 @@ onMounted(async () => {
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<template v-else>
<p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="feed-title">{{ feeds[currentIndex].title }}</h2>
<h3>{{ feeds[currentIndex].timestamp }}</h3>
<p v-if="!feeds[currentIndex].readable" class="feed-original-link">
<!-- Deliberately no enter/leave transition here: Vue's <Transition>
component forces a synchronous whole-document reflow
(document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
every enter/leave, regardless of the app's own CSS — confirmed (via
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>
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
</p>
<p class="feed-content" v-html="feeds[currentIndex].content"></p>
</template>
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article>
<div class="article-nav">
<button
@@ -117,6 +211,17 @@ onMounted(async () => {
</template>
<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
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. */
@@ -170,6 +275,38 @@ onMounted(async () => {
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 {
display: flex;
flex-wrap: wrap;
@@ -211,24 +348,159 @@ onMounted(async () => {
.article-single {
position: relative;
padding-bottom: 5rem;
}
.article-single__back {
display: inline-flex;
display: flex;
flex-direction: column;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
margin-bottom: 1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
cursor: pointer;
padding-top: 1em;
padding-bottom: 5rem;
/* Fill the viewport below the fixed header even when the article content
is short, so swipe-to-navigate works anywhere on screen instead of only
over the (possibly small) content area. dvh is the accurate unit on
mobile, where this matters most; vh is the fallback for browsers
without dvh support. */
min-height: calc(100vh - var(--app-nav-height, 4.5rem));
min-height: calc(100dvh - var(--app-nav-height, 4.5rem));
}
.article-single__back:hover {
border-color: var(--color-border-hover);
.article-feature {
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 {
+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 { nextTick } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios'
import AppNav from '../AppNav.vue'
@@ -7,6 +8,15 @@ import { useFeeds } from '../../composables/useFeeds'
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', () => {
let router
@@ -15,8 +25,13 @@ describe('AppNav', () => {
localStorage.setItem('user-id', '7')
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 = []
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
message.value = ''
showModal.value = false
@@ -35,15 +50,35 @@ describe('AppNav', () => {
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() {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
const wrapper = mountNav()
await wrapper.find('.app-nav__hamburger').trigger('click')
await flushPromises()
return wrapper
}
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)
@@ -54,6 +89,17 @@ describe('AppNav', () => {
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 () => {
const wrapper = await mountWithMenuOpen()
@@ -149,38 +195,262 @@ describe('AppNav', () => {
})
it('shows the unread count in the title when there are articles', async () => {
const { feeds } = useFeeds()
feeds.value = [
// The badge is the global unread total, sourced from the master list
// (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: 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()
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
it('excludes already-read articles from the counter while in article view', async () => {
const { feeds } = useFeeds()
feeds.value = [
const { allItems } = useFeeds()
allItems.value = [
{ 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' },
]
const wrapper = mount(AppNav, { global: { plugins: [router] } })
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
})
it('hides the unread count when there are no articles', async () => {
const wrapper = mount(AppNav, { global: { plugins: [router] } })
const wrapper = mountNav()
await flushPromises()
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 () => {
const { feeds } = useFeeds()
feeds.value = [
@@ -199,4 +469,5 @@ describe('AppNav', () => {
confirmSpy.mockRestore()
})
})
+216 -13
View File
@@ -15,6 +15,20 @@ class 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', () => {
beforeEach(() => {
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()
layout.value = 'cards'
@@ -177,10 +198,7 @@ describe('RssFeeds', () => {
expect(titles).toEqual(['Newer article', 'Older article'])
})
it('shows a link to the original article until 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.
it('keeps a link to the original article visible after the readable version is loaded', async () => {
axios.get.mockResolvedValueOnce({
data: {
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)
await flushPromises()
@@ -212,7 +237,9 @@ describe('RssFeeds', () => {
await wrapper.find('.feed-title').trigger('click')
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 () => {
@@ -252,30 +279,31 @@ describe('RssFeeds', () => {
useFeeds().toggleViewMode()
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
// 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)
await wrapper.find('.article-single .feed-title').trigger('click')
await wrapper.find('.article-single .article-feature__title').trigger('click')
await flushPromises()
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()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
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()
await wrapper.findAll('.article-nav__btn')[0].trigger('click')
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 () => {
@@ -331,4 +359,179 @@ describe('RssFeeds', () => {
const titles = wrapper.findAll('.feed-title').map(el => el.text())
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 { useFeeds } from '../useFeeds'
@@ -12,7 +12,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
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(() => {
localStorage.setItem('user-token', 'test-token')
@@ -20,6 +20,8 @@ describe('useFeeds', () => {
vi.clearAllMocks()
feeds.value = []
allItems.value = []
feedFilter.value = null
showMessage.value = false
message.value = ''
showModal.value = false
@@ -91,6 +93,441 @@ describe('useFeeds', () => {
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 () => {
feeds.value = [{
id: 1,
@@ -112,7 +549,10 @@ describe('useFeeds', () => {
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
// template is harmless since browsers don't load images from data-* attrs.
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
@@ -0,0 +1,72 @@
// 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)
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')
})
})
+384 -40
View File
@@ -1,21 +1,92 @@
import { ref, unref, nextTick } from 'vue';
import { ref, computed, nextTick } from 'vue';
import axios from 'axios';
import { Readability } from '@mozilla/readability';
// 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).
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 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 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 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 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 {
headers: {
'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
// lazy-load attributes (`data-url`, `data-src`, `srcset`, ...) contain an
// 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_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) {
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')
if (format) {
@@ -81,7 +177,49 @@ async function getReadable(feed, index) {
doc.head.prepend(base);
doc.querySelectorAll('img').forEach(resolveTemplatedImage);
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();
if (!article) {
showMessageForXSeconds('Could not extract readable content.', 5)
return
}
feeds.value[index].content = article.content;
feeds.value[index].readable = true;
} catch (error) {
@@ -90,27 +228,129 @@ async function getReadable(feed, index) {
}
}
async function markRead(id) {
try {
const response = await axios.put("/api/v1/article/read/" + id, null, authHeaders())
console.log(response.status)
} catch (error) {
console.log(error)
// Ids with a mark-read PUT in flight (or being retried) — see markRead() and
// fetchData() below. A refcount rather than a plain Set: nextArticle()/
// prevArticle() can both re-mark the same article (page forward, back,
// forward again), so two overlapping markRead() calls for one id must both
// finish before fetchData() is allowed to trust the server's word on it —
// 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 user_id = localStorage.getItem("user-id")
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 items = [];
response.data.feeds.forEach(feed => {
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
// lexicographic comparison sorts them chronologically.
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
feeds.value = items;
freshItems.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
allItems.value = freshItems;
applyFilter();
refreshUnreadDisplay();
await nextTick();
setupIntersectionObserver();
} catch (error) {
@@ -119,19 +359,21 @@ const fetchData = async () => {
}
};
async function sync() {
async function sync(silent = false) {
try {
const response = await axios.post('/api/v1/article/sync', {
user_id: parseInt(localStorage.getItem("user-id"))
}, authHeaders())
if (response.status == 200) {
if (response.status == 200 && !silent) {
showMessageForXSeconds('Sync successful.', 5)
}
fetchData();
} catch (error) {
console.error('Error sync', error)
showMessageForXSeconds(error, 5)
if (!silent) {
showMessageForXSeconds(error, 5)
}
}
}
@@ -140,36 +382,92 @@ function setupIntersectionObserver() {
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
rootMargin: '0px',
rootMargin: `-${topbarHeight}px 0px 0px 0px`,
// threshold: 0.5, // Fire the callback when at least 50% of the element is visible
});
const observedDivs = document.querySelectorAll(".observe");
if (observedDivs.length > 0) {
observedDivs.forEach(observedDiv => {
observer.observe(observedDiv);
})
}
// 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 => {
observer.observe(observedDiv);
})
}
async function handleIntersection(entries) {
// The callback function for when the target element enters or exits the viewport
for (const entry of entries) {
// An article that has scrolled above the viewport (not intersecting,
// bounding box above the top edge) has been read — mark it and remove it.
if (initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0) {
await markRead(feeds.value[entry.target.id].id)
removeFeed(entry.target.id)
document.getElementById(0)?.scrollIntoView()
function handleIntersection(entries, topbarHeight = 0) {
// Drop the initial snapshot fired on (re)connect — see skipNextObservation.
// The old observer is disconnected before this new one observes, so only the
// latest observer's initial callback reaches here; one skip is enough.
if (skipNextObservation) {
skipNextObservation = false
return
}
// 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) {
const array = unref(feeds);
array.splice(index, 1);
function disconnectObserver() {
if (observer) {
observer.disconnect()
observer = null
}
}
function setInitialLoad(value) {
@@ -181,11 +479,25 @@ async function markAllRead() {
if (!window.confirm('Mark all articles as read?')) return
const ids = feeds.value.map(feed => feed.id)
const readIds = new Set(ids)
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
// markRead swallows its own errors, so Promise.all can't reject here.
await Promise.all(ids.map(id => markRead(id)))
showMessageForXSeconds('All articles marked as read.', 5)
refreshUnreadDisplay()
// 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)
} 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() {
@@ -204,8 +516,12 @@ async function leaveArticleView() {
// in place so currentIndex stayed valid — drop them now so they don't keep
// showing up in the list view.
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
viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value)
refreshUnreadDisplay()
// The v-if on the list container tears down and recreates all .observe DOM
// nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering.
@@ -217,21 +533,37 @@ function toggleViewMode() {
if (viewMode.value === 'article') {
leaveArticleView()
} 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'
localStorage.setItem('viewMode', viewMode.value)
currentIndex.value = 0
markCurrentArticleRead()
}
}
function toggleLayout() {
async function toggleLayout() {
if (observer) {
observer.disconnect()
observer = null
}
window.scrollTo(0, 0)
layout.value = layout.value === 'list' ? 'cards' : 'list'
localStorage.setItem('layout', layout.value)
await nextTick()
setupIntersectionObserver()
}
function nextArticle() {
if (currentIndex.value < feeds.value.length - 1) {
currentIndex.value += 1
markCurrentArticleRead()
window.scrollTo(0, 0)
}
}
@@ -239,12 +571,21 @@ function prevArticle() {
if (currentIndex.value > 0) {
currentIndex.value -= 1
markCurrentArticleRead()
window.scrollTo(0, 0)
}
}
export function useFeeds() {
return {
feeds,
allItems,
feedFilter,
feedTitles,
feedUnreadCounts,
unreadCount,
displayedFeedUnreadCounts,
refreshUnreadDisplay,
setFeedFilter,
showMessage,
message,
showModal,
@@ -263,7 +604,10 @@ export function useFeeds() {
markAllRead,
showMessageForXSeconds,
setupIntersectionObserver,
removeFeed,
disconnectObserver,
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 axios from 'axios'
import { createApp } from 'vue'
import App from './App.vue'
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)
app.use(router)
+8 -17
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior: () => ({ top: 0, behavior: 'instant' }),
routes: [
{
path: '/',
@@ -30,23 +31,13 @@ const router = createRouter({
]
})
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth) {
let isAuthenticated = false;
if (localStorage.getItem("user-token") != null){
isAuthenticated = true;
}
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();
router.beforeEach((to) => {
const isAuthenticated = localStorage.getItem("user-token") != null;
// Redirect unauthenticated users hitting a protected route to login;
// returning a value (instead of the deprecated next() callback) is the
// modern vue-router guard API. Returning nothing lets navigation proceed.
if (to.meta.requiresAuth && !isAuthenticated) {
return '/login';
}
});
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>