Author SHA1 Message Date
mathias 43269bf1d7 Merge pull request 'Replace the feed select with a multi-feed filter modal' (#4) from feature/feed-filter-modal into master
Reviewed-on: #4
2026-09-27 11:31:27 +02:00
mathias 5ed780baa3 Replace the feed select with a multi-feed filter modal
The header's feed <select> is now a funnel button that opens a modal with
an "All feeds" checkbox and one checkbox per feed with unread items. The
selection is either "All feeds" or at least one feed, is applied once when
the modal closes, and is persisted in localStorage.

- feedFilter holds string[] | null; setFeedFilter is a no-op when the
  selection is unchanged (order-insensitive), sparing callers the
  scroll/mark-read side effects.
- Selected feeds stay listed at 0 unread so they can be unchecked; titles
  whose feed no longer exists are pruned after each fetch.
- The persisted filter is cleared on logout and on 401 (handler extracted
  to httpAuthInterceptor.js so it is unit-testable).
- The modal locks body scroll while open so scrolling behind it can't
  mark articles read.
2026-09-27 11:28:48 +02:00
mathias 8f7ddb77c4 Stop reserving layout space for the idle sync spinner
visibility:hidden still occupied the header's flex row, squeezing the
title on narrow screens and clipping the unread counter. Render the
spinner with v-if instead so it takes no space when not syncing.
2026-09-25 13:55:23 +02:00
mathias e51bce4244 added pwa ap manifest 2026-09-23 17:57:44 +02:00
mathias f31fccdd16 fix break header 2026-09-14 19:59:22 +02:00
mathias a7cfd267c6 Strip Al Jazeera's Recommended Stories section from readable articles
Al Jazeera's article pages embed a <section class="more-on"> block (a
'Recommended Stories' heading plus a list of unrelated article teasers)
that was getting pulled into the parsed content by Readability. Remove
it from the fetched DOM before Readability runs, same as the existing
social-bar/abo/teaser stripping in getReadable().
2026-09-14 18:40:34 +02:00
mathias c584ac4f60 Merge pull request 'Show a sync-in-progress spinner in the header next to the feed filter' (#3) from feature/sync-spinner into master
Reviewed-on: #3
2026-09-14 18:08:44 +02:00
mathias 9c042411fb Show a sync-in-progress spinner in the header next to the feed filter
activeSyncCount/isSyncing() in useFeeds.js already refcounted both boot
reload (fetchData) and manual sync() but wasn't reactive. Make it a ref
+ computed and expose it so AppNav can render a small spinner next to
the feed filter whenever a sync is in flight.

The spinner sits outside the filter's own v-if so it also shows during
a manual sync from pages other than /feeds, and its box is always
reserved (visibility toggle, not v-if) so the one-time --app-nav-height
measurement stays correct.
2026-09-14 18:05:24 +02:00
mathias 4d9e899519 Disable mark-as-read while a sync or reload is in progress
useFeeds.js had no concept of a sync/reload being in flight, so two
passive mark-as-read paths could race a wholesale fetchData()/sync()
list replacement:

- handleIntersection() (list-view scroll marking)
- markCurrentArticleRead() (article-view display/paging marking)

Both are now gated by a refcount (activeSyncCount/beginSync/endSync/
isSyncing), mirroring the existing pendingReadCounts pattern for the
same class of overlapping-async problem. A refcount rather than a
boolean because sync() nests fetchData() inside it, and fetchData()
can also run independently while a sync() elsewhere is still in
flight.

Reads suppressed during a sync/reload aren't dropped: their ids are
queued and flushed (marked read, and swept out of the list) once
every in-flight fetchData()/sync() call has finished. The explicit
"Mark all read" action is left ungated, since it's a deliberate,
already-confirmed action rather than a passive side effect.

87/87 frontend tests passing.
2026-09-13 12:11:14 +02:00
mathias c290513ed1 Fix read-article handling across feed filter switches in article view
Article view marks the displayed article read in place (feed.read =
true) without removing it, so currentIndex stays valid while paging.
leaveArticleView() already dropped those afterward, but switching the
feed filter while still in article view never did — so an
already-read article could resurface (re-selecting its feed
re-projected it from allItems as if still unread). Extracted the drop
into a shared dropReadArticles() and call it from setFeedFilter() too.

Also, switching the filter while in article view immediately displays
the new feed's article in full, which is a genuine view — same as
paging or toggling into article view — but nothing marked it read, so
a single-article feed could never be marked read that way at all. This
was deliberately excluded (see commit 169c294) after a generic watch
proved too broad; setFeedFilter() now marks it explicitly, scoped to
article view only, so list view's scroll-driven read-marking is
unaffected.
2026-09-12 19:42:22 +02:00
mathias 41a17eba6d Move Add Feed button from hamburger menu to admin area
The Add RSS modal is now opened from a button directly above the feed
list in AdminFeeds.vue instead of the hamburger menu, so feed creation
lives alongside the rest of feed management. AppNav.vue no longer
renders it.

AddUrl.vue emits an `added` event on a successful save; AdminFeeds.vue
listens for it and reloads its feed list, so a newly added feed shows
up immediately without leaving the admin page.

Also fixes a bug this move surfaced: the modal's "Close" button had no
`type`, so inside the form it defaulted to type="submit" — clicking
Close on a filled-in form silently created the feed.
2026-09-12 19:42:08 +02:00
mathiasandClaude Sonnet 5 169c294e62 Mark article read on display in article view, including on boot
markCurrentArticleRead() already marked the displayed article read
immediately at each deliberate navigation point (entering article view,
paging next/prev), but reloading the page while viewMode was already
'article' (persisted in localStorage) booted straight into article view
without marking anything read — the article on screen stayed unread
until the user paged forward/back at least once.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:14:52 +02:00
mathias 01f8a30505 autorestrat images 2026-08-03 14:58:45 +02:00
mathias a898e7ac33 counter on feed select 2026-07-13 17:46:49 +02:00
mathias 671ed86b48 added test 2026-07-13 09:47:10 +02:00
mathias af5e2b8137 upgrade packages 2026-07-12 17:27:16 +02:00
mathias 2b1d1c57ff keep state of aritcle/list view 2026-07-12 17:22:30 +02:00
mathias 31991ea1f8 Feed filter implementation 2026-07-12 12:28:32 +02:00
mathias 0e3142bac9 remove deprecations, remove header when scrolling 2026-07-04 17:06:45 +02:00
mathias a73a1b57de padding for article view 2026-07-03 19:38:53 +02:00
mathias 3642635b20 remove variable header size 2026-07-03 11:10:14 +02:00
mathias b3cf5e4787 variable header size 2026-07-02 22:24:01 +02:00
mathias fe0adcf68e added shadow to header 2026-07-02 21:51:58 +02:00
mathias 3f9de099aa new textadjustment options 2026-06-23 18:03:11 +02:00
mathias b9c0951f2b Filter comercials in readable 2026-06-22 20:30:00 +02:00
mathias 7a24980101 Fix article get read when navigation to admin area. 2026-06-19 16:41:16 +02:00
mathias dfc2e29e36 Change version number 2026-06-19 13:44:05 +02:00
mathias a90d10368e New feauter, change font family and font size 2026-06-19 13:42:57 +02:00
mathias 5417176dd4 add some filters for unwanted content 2026-06-16 19:08:31 +02:00
mathias 4d3f5d3285 fix autoscroll 2026-06-16 16:26:33 +02:00
mathias 967803c326 bugfixes 2026-06-16 12:34:08 +02:00
mathias e9c865a254 Improved sticky header 2026-06-16 12:04:42 +02:00
mathias 570db2d948 keep original article link in readable 2026-06-15 19:58:13 +02:00
mathias a37d845875 Fix stuttering list view 2026-06-15 19:53:18 +02:00
mathias 8e57e2f02a Added sync on reload 2026-06-14 17:13:41 +02:00
mathias 3671b90b81 Fix fontend tests, move next button in list view 2026-06-14 17:04:37 +02:00
mathias a399ede401 Fonts, Docker fixes 2026-06-14 09:03:06 +02:00
mathias 82ec6ea902 fix article read after switching to article view 2026-06-13 11:59:33 +02:00
mathias fbf3597984 improve desktop readable for article view 2026-06-13 11:48:45 +02:00
mathias e9580037ef increase token lifetime to one month 2026-06-12 19:34:08 +02:00
mathias b457b8abaa Improve security 2026-06-12 19:22:07 +02:00
mathias 0820ce6ef7 fix some frontend issues 2026-06-12 10:57:48 +02:00
mathias ed1241490d fix scroll to top in article view 2026-06-12 09:19:15 +02:00
mathias 177d975b4d frontend improvement 2026-06-10 19:22:24 +02:00
mathias 52ea84747a added anyhow, improve hamburger menu, improve dw articles 2026-06-10 18:51:55 +02:00
mathias 0420cf0dd5 fix sync missing articles 2026-06-10 06:08:17 +02:00
mathias 972e967432 fix sync missing articles 2026-06-10 05:35:46 +02:00
mathias 400648c3d1 added admin area to delete feeds 2026-06-09 21:55:07 +02:00
mathias 6ae6490dec Merge pull request 'added admin area to delete feeds' (#1) from feature/admin-area into master
Reviewed-on: #1
2026-06-09 21:48:28 +02:00
mathias f3af42c3c5 added admin area to delete feeds 2026-06-09 21:45:13 +02:00
mathias 8b56174856 fix scroll to mark read bug 2026-06-09 21:30:26 +02:00
mathias 039e0b448c all caught up icon, counter dynamic 2026-06-09 20:20:17 +02:00
mathias b851e0257c fix sync issue, frontend improvement 2026-06-09 19:50:47 +02:00
mathias d826a8f3dc cleanup when syncing 2026-06-08 17:31:10 +02:00
mathias 83fdb464af fix card layout 2026-06-08 16:59:30 +02:00
mathias 0ee9e22109 added docker update to readme 2026-06-08 14:31:06 +02:00
mathias a98a8ba9e6 card view, minor css bugfixes 2026-06-08 14:22:04 +02:00
mathias 3c42ebb972 mark all as read 2026-06-08 07:06:38 +02:00
mathias b4fc86302f hamburger menu, article view 2026-06-08 06:39:47 +02:00
mathias 39f08c7218 added favicon, improve layout 2026-06-07 20:18:31 +02:00
mathias b865fe982e upgraded packages 2026-06-07 17:58:03 +02:00
mathias 6eeb3384fe fixed rootless docker setup 2026-06-07 17:31:46 +02:00
mathias 675bf15828 fixed rootless docker setup 2026-06-07 17:12:17 +02:00
mathias c5e5e463f9 fixed rootless docker setup 2026-06-07 17:07:26 +02:00
mathias 997fc2989a added rootless docker setup 2026-06-07 16:57:12 +02:00
mathias 09eae69041 upgrade postgres 2026-06-07 16:50:14 +02:00
mathias e9d7f63ff3 remove .env 2026-06-07 16:29:52 +02:00
mathias 841e8419b0 updated rust version, minor fixes 2026-06-07 16:26:42 +02:00
mathias b4874ad318 claude rework 2026-06-07 15:43:43 +02:00
114 changed files with 13494 additions and 3667 deletions
+5 -6
View File
@@ -1,6 +1,5 @@
target/
tests/
Dockerfile
scripts/
migrations/
target
vue/node_modules
vue/dist
.git
.env
+5
View File
@@ -0,0 +1,5 @@
DATABASE_URL=postgres://admin:changeme@localhost/rss
JWT_SECRET=change-this-to-a-long-random-string
FRONTEND_ORIGIN=http://localhost:5173
RUST_LOG=info
POSTGRES_PASSWORD=changeme
+6
View File
@@ -1 +1,7 @@
/target
.env
.claude
CLAUDE.md
LEARNINGS.md
PLAN.md
/memory
Generated
+2112 -1503
View File
File diff suppressed because it is too large Load Diff
Regular → Executable
+27 -38
View File
@@ -1,51 +1,40 @@
[package]
name = "rss-reader"
version = "0.1.0"
edition = "2021"
[lib]
path = "src/lib.rs"
[[bin]]
path = "src/main.rs"
name = "rss-reader"
version = "0.9.1"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
reqwest = { version = "0.11", features = ["json", "blocking"] }
anyhow = "1"
reqwest = { version = "0.13", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] }
rss = { version = "2.0.1" }
actix-web = "4.1.0"
actix-rt = "2.7.0"
futures = "0.3.24"
serde = { version = "1.0.144", features = ["alloc", "derive", "serde_derive"] }
serde_derive = "1.0.145"
actix-service = "2.0.2"
diesel = { version = "2.0.2", features = ["postgres", "chrono", "r2d2"] }
dotenv = "0.15.0"
bcrypt = "0.13.0"
uuid = {version = "1.2.1", features=["serde", "v4"]}
rss = { version = "2.0.13" }
actix-web = "4.13"
actix-rt = "2.10"
futures = "0.3.31"
serde = { version = "1.0.228", features = ["alloc", "derive", "serde_derive"] }
serde_derive = "1.0.228"
actix-service = "2.0.3"
diesel = { version = "2.3", features = ["postgres", "chrono"] }
diesel_migrations = "2.3"
dotenvy = "0.15"
bcrypt = "0.19"
uuid = {version = "1.23", features=["serde", "v4"]}
jwt = "0.16.0"
hmac = "0.12.1"
sha2 = "0.10.6"
scraper = "0.14.0"
actix-cors = "0.6.4"
chrono = { version = "0.4.31", features = ["serde"] }
dateparser = "0.2.0"
tracing-appender = "0.2.3"
once_cell = "1.19.0"
secrecy = { version = "0.8.0", features = ["serde"] }
tracing-actix-web = "0.7.10"
tracing-subscriber = { version = "0.3.18", features = ["registry", "env-filter"] }
tracing-log = "0.2.0"
config = "0.14.0"
diesel-connection = "4.1.0"
tracing = { version = "0.1.40", features = ["log"] }
tracing-bunyan-formatter = "0.3.9"
hmac = "0.12"
sha2 = "0.10"
log = "0.4.32"
env_logger = "0.11"
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.86"
version = "1.0.150"
default-features = false
features = ["alloc"]
+18 -35
View File
@@ -1,41 +1,24 @@
FROM lukemathwalker/cargo-chef:latest-rust-1 AS chef
WORKDIR /app
# --- builder ---
FROM rust:1-slim-bookworm AS builder
RUN apt update && apt install lld clang -y
FROM chef as planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef as builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
COPY . .
RUN cargo build --release --bin rss-reader
RUN cargo install diesel_cli --no-default-features --features postgres
# Runtime stage
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# Install OpenSSL - it is dynamically linked by some of our dependencies
# Install ca-certificates - it is needed to verify TLS certificates
# when establishing HTTPS connections
RUN apt-get update -y \
&& apt-get install -y openssl ca-certificates pkg-config\
&& apt-get install -y libpq5 \
&& apt-get autoremove -y \
&& apt-get clean -y \
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq-dev pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy diesel_cli from builder to runtime
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/cargo/bin/diesel
WORKDIR /app
COPY . .
RUN cargo build --release && \
cp target/release/rss-reader /usr/local/bin/rss-reader && \
rm -rf target
COPY --from=builder /app/target/release/rss-reader rss-reader
# --- runtime ---
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /usr/local/bin/rss-reader /usr/local/bin/rss-reader
EXPOSE 8001
# COPY configuration configuration
# ENV APP_ENVIRONMENT production
# ENTRYPOINT ["./rss-reader"]
ENTRYPOINT ["sh", "-c", "/app/rss-reader && diesel migration run"]
CMD ["rss-reader"]
+347 -12
View File
@@ -1,24 +1,359 @@
## RSS-Reader [WIP]
# RSS Reader
# Diesel Setup
A self-hosted RSS reader: a Rust/actix-web + Diesel/PostgreSQL backend with a Vue 3 single-page-app frontend. Sync feeds, read articles, and mark them as read — from your desktop or your phone.
setup, first step, or when docker been and DB not found.
## Stack
`diesel setup`
- **Backend**: Rust, actix-web, Diesel ORM, PostgreSQL, JWT auth
- **Frontend**: Vue 3, Vite, axios
- **Database**: PostgreSQL 18
generate table
---
`diesel migration generate create_to_do_items`
## Development setup
fill up and down
### Prerequisites
`diesel migration run`
- [Rust](https://rustup.rs/) (stable toolchain)
- [Node.js](https://nodejs.org/) 20+ and npm
- PostgreSQL (locally, or via Docker — see below)
- The Diesel CLI: `cargo install diesel_cli --no-default-features --features postgres`
(requires `libpq`; on Debian/Ubuntu: `sudo apt install libpq-dev`)
# docker
### 1. Configure environment variables
`docker exec -it 59ff8bad10c0 psql -d rss -U admin`
Copy the example file and fill in your own values:
```sh
cp .env.example .env
```
# Create user
curl -X POST -H "Content-Type: application/json" -d '{"name": "mace", "email": "safemind@posteo.de", "password": "secret"}' http://localhost:8001/api/v1/user/create
`.env` is read by both the backend (via `dotenv`) and the `diesel` CLI, and is gitignored — never commit it.
| Variable | Purpose |
|---|---|
| `DATABASE_URL` | Postgres connection string, e.g. `postgres://admin:changeme@localhost/rss` |
| `JWT_SECRET` | Secret used to sign/verify auth tokens — use a long random string |
| `FRONTEND_ORIGIN` | Origin allowed by CORS, e.g. `http://localhost:5173` for the Vite dev server |
| `RUST_LOG` | Log level for `env_logger`, e.g. `info` |
| `POSTGRES_PASSWORD` | Password for the `admin` Postgres user (used by `docker-compose.yml`) |
### 2. Start PostgreSQL
The simplest way during development is to run just the database container:
```sh
docker compose up postgres -d
```
This starts Postgres on `localhost:5432` with the credentials from `.env`. Alternatively, point `DATABASE_URL` at any Postgres instance you already have running.
### 3. Run database migrations
Migrations are embedded in the backend binary and also run automatically on startup, but you can apply them manually with the Diesel CLI (useful when generating new migrations during development):
```sh
diesel setup # creates the database and runs existing migrations
diesel migration run # applies any pending migrations
diesel migration generate <name> # scaffolds a new up.sql/down.sql pair
```
### 4. Run the backend
```sh
cargo run
```
The API listens on `http://0.0.0.0:8001`. Backend logs respect `RUST_LOG`.
### 5. Run the frontend
```sh
cd vue
npm install
npm run dev -- --host
```
The Vite dev server runs on `http://localhost:5173` (the `--host` flag also exposes it on your LAN so you can test from a phone) and proxies `/api` requests to `http://localhost:8001` (configured in `vue/vite.config.js`).
### 6. Try it out
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": "secret1"}' \
http://localhost:8001/api/v1/user/create
```
Passwords must be at least 6 characters.
### Useful commands during development
```sh
# Backend
cargo build # compile
cargo test # run the test suite (needs a reachable Postgres + DATABASE_URL)
cargo clippy # lint
cargo fmt # format
```
```sh
# Frontend (run from vue/)
npm run test # run Vitest component tests
npm run lint # eslint
npm run format # prettier
```
```sh
# Inspect the dockerized database directly
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.
### 1. Configure environment variables
Same as in development — create a root-level `.env` from `.env.example` and fill in **strong, unique** values for `POSTGRES_PASSWORD` and `JWT_SECRET`. Set `FRONTEND_ORIGIN` to the URL the frontend will actually be served from (e.g. `http://<host-ip>:8080` or your domain), since the backend's CORS policy only allows that origin.
### 2. Build and start everything
```sh
docker compose up --build -d
```
This builds three images and starts them on a shared network:
- **`postgres`** — PostgreSQL 18, data persisted in the `postgres_data` volume, reachable on `localhost:5432`
- **`backend`** — multi-stage build (compiles the Rust binary in a `rust:slim` builder, runs it in a slim `debian` runtime image); runs embedded Diesel migrations automatically on startup; listens on `0.0.0.0:8001`
- **`frontend`** — multi-stage build (compiles the Vue app with `node:20-alpine`, serves the static bundle with `nginx:alpine`); listens on `0.0.0.0:8080` and proxies `/api/` to the `backend` service over Docker's internal network
### 3. Use it
- From the host machine: `http://localhost:8080`
- From another device on the same network (e.g. a phone): `http://<host-LAN-IP>:8080`
### Operating the stack
```sh
docker compose ps # check container status
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.
Rootless Docker runs as a completely independent daemon — separate socket and storage (`~/.local/share/docker` vs. `/var/lib/docker`) — so it coexists fine with an existing system-wide Docker install on the same host. Just make sure `DOCKER_HOST`/`PATH` point at the rootless daemon when operating on this stack, and that the ports you publish in step 4 below aren't already in use elsewhere.
**1. Create the user:**
```sh
sudo adduser --disabled-password --gecos "" rss-svc
```
> The "normal" rootless Docker setup runs the daemon as a **per-user systemd service** kept alive via `loginctl enable-linger`, which depends on `systemd-logind`/D-Bus. Minimal headless images (DietPi included) often disable or strip those out — and whether re-enabling them survives the distro's own update mechanism is genuinely unclear. Rather than depend on that, the steps below run rootless Docker as an ordinary **system-level** unit with `User=rss-svc` — no logind, no D-Bus, no lingering, nothing that can be reset out from under you.
**2. Install rootless Docker for that user:**
```sh
sudo apt install -y uidmap
sudo -u rss-svc -H curl -fsSL https://get.docker.com/rootless -o /tmp/install-rootless.sh
sudo -u rss-svc -H sh /tmp/install-rootless.sh
```
The installer detects there's no active systemd **user session** for `rss-svc` (we're not logging in interactively), so instead of wiring up a per-user service it falls back to a runtime directory under the user's home — and prints the exact paths to use, e.g.:
```sh
export XDG_RUNTIME_DIR=/home/rss-svc/.docker/run
export PATH=/home/rss-svc/bin:$PATH
export DOCKER_HOST=unix:///home/rss-svc/.docker/run/docker.sock
```
Use the values **the installer prints for you** (add them to `~/.bashrc` as `rss-svc` — e.g. `sudo -u rss-svc -H bash`) — this is actually convenient for us, since `~/.docker/run` is a regular on-disk directory that persists across reboots without any `tmpfiles`/`RuntimeDirectory=` trickery.
Now create the system unit at `/etc/systemd/system/docker-rss-svc.service`, pointing `XDG_RUNTIME_DIR` at that same directory:
```ini
[Unit]
Description=Rootless Docker daemon (rss-svc)
After=network.target
[Service]
User=rss-svc
Group=rss-svc
Environment=PATH=/home/rss-svc/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=XDG_RUNTIME_DIR=/home/rss-svc/.docker/run
ExecStart=/home/rss-svc/bin/dockerd-rootless.sh
Restart=always
RestartSec=2
Delegate=yes
Type=notify
NotifyAccess=all
KillMode=mixed
TasksMax=infinity
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target
```
Then enable and start it:
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now docker-rss-svc
```
Verify it came up (as `rss-svc`, with the `.bashrc` exports loaded):
```sh
docker info
```
> **cgroup driver note**: rootless Docker defaults to the **systemd** cgroup driver, which expects a per-user slice (`user-<uid>.slice`) created by a logind session — something we don't have here by design. If `docker compose up --build` later fails with `open /sys/fs/cgroup/user.slice/user-<uid>.slice/cgroup.controllers: no such file or directory`, switch dockerd to manage cgroups itself instead. As `rss-svc`:
> ```sh
> mkdir -p ~/.config/docker
> cat > ~/.config/docker/daemon.json << 'EOF'
> {
> "exec-opts": ["native.cgroupdriver=cgroupfs"]
> }
> EOF
> ```
> then, as your sudo-capable user, `sudo systemctl restart docker-rss-svc`.
**Updating rootless Docker:** the daemon and CLI live in `/home/rss-svc/bin` as a private, self-contained copy of the static binaries — entirely decoupled from the system package manager, so `apt upgrade` never touches them. To update, stop the daemon, re-run the same installer (it just re-downloads the current stable bundle for your architecture and overwrites the files in `~/bin` in place), then start it back up:
```sh
sudo systemctl stop docker-rss-svc
sudo -u rss-svc -H curl -fsSL https://get.docker.com/rootless -o /tmp/install-rootless.sh
sudo -u rss-svc -H sh /tmp/install-rootless.sh
sudo systemctl start docker-rss-svc
sudo -u rss-svc -H docker info # confirm the new version
```
**3. Deploy the stack as `rss-svc`:**
```sh
git clone <your-repo-url> ~/rss-reader
cd ~/rss-reader
cp .env.example .env
chmod 600 .env
```
Fill in `.env` with strong, unique secrets — `openssl rand -hex 32` is a convenient way to generate `JWT_SECRET`/`POSTGRES_PASSWORD`.
**4. Bind published ports to localhost only.** The only thing that needs to reach this stack from outside is the reverse proxy below, and it runs on the same host. Edit `docker-compose.yml`:
```yaml
postgres:
ports:
- "127.0.0.1:5432:5432"
backend:
ports:
- "127.0.0.1:8001:8001"
frontend:
ports:
- "127.0.0.1:8080:80"
```
**5. Bring it up:**
```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`):
```sh
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
```
With this in place: only SSH and HTTPS are reachable from the network, the reverse proxy is the sole entry point into the app, the whole stack runs in its own user/container/network namespaces with no elevated privileges, and secrets live in a `chmod 600` `.env` owned by an account that can't do anything else on the system.
### Optional: Apache reverse proxy (TLS termination)
If you want to expose the app under a domain with HTTPS, put Apache in front of the `frontend` container (which keeps listening on `localhost:8080`) and let Apache handle TLS. Enable the required modules first:
```sh
sudo a2enmod proxy proxy_http proxy_wstunnel ssl headers
```
Then a vhost like this proxies everything — including the WebSocket-capable Vite/axios traffic and the `/api/` calls the frontend's nginx already forwards to the backend — to the container:
```apache
<VirtualHost *:443>
ServerName rss.example.com
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/rss.example.com/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/rss.example.com/privkey.pem
ProxyPreserveHost On
ProxyPass / http://127.0.0.1:8080/
ProxyPassReverse / http://127.0.0.1:8080/
RequestHeader set X-Forwarded-Proto "https"
ErrorLog ${APACHE_LOG_DIR}/rss-error.log
CustomLog ${APACHE_LOG_DIR}/rss-access.log combined
</VirtualHost>
# Redirect plain HTTP to HTTPS
<VirtualHost *:80>
ServerName rss.example.com
Redirect permanent / https://rss.example.com/
</VirtualHost>
```
Notes for this setup:
- Set `FRONTEND_ORIGIN=https://rss.example.com` in your root `.env` so the backend's CORS check allows the proxied origin, then `docker compose up --build -d backend`.
- You no longer need to publish port `8080` to the LAN — change the `frontend` service's port mapping in `docker-compose.yml` to `"127.0.0.1:8080:80"` so only Apache (on the same host) can reach it.
- Obtain the certificate with `certbot --apache -d rss.example.com` (via the [Certbot](https://certbot.eff.org/) Apache plugin), which can also write the vhost and set up auto-renewal for you.
### Notes
- Migrations run automatically at backend startup — no manual `diesel` step needed in production.
- Secrets (`POSTGRES_PASSWORD`, `JWT_SECRET`, `FRONTEND_ORIGIN`) come from the root `.env`, which is gitignored and interpolated into `docker-compose.yml` via `${VAR}` — never commit real secrets.
- If you need the app reachable from multiple origins (e.g. `localhost:8080` on desktop and `<lan-ip>:8080` on a phone), the current single-origin CORS check (`FRONTEND_ORIGIN`) won't allow both — pick the one you'll actually use, or relax the CORS policy in `src/main.rs` for a self-hosted, trusted-network setup.
-8
View File
@@ -1,8 +0,0 @@
application:
port: 8001
database:
host: "localhost"
port: 5432
username: "admin"
password: "secret+123"
database_name: "rss"
-2
View File
@@ -1,2 +0,0 @@
application:
host: 127.0.0.1
-2
View File
@@ -1,2 +0,0 @@
application:
host: 0.0.0.0
+29 -30
View File
@@ -1,44 +1,43 @@
version: "3.7"
services:
# vue-app:
# build:
# context: ./vue/
# dockerfile: Dockerfile
# ports:
# - "8080:8080" # Adjust the port as needed for your Rust application
# networks:
# - app-network
postgres:
restart: always
container_name: "rss-postgres"
image: "postgres:15"
image: "postgres:18"
restart: unless-stopped
ports:
- "5432:5432"
environment:
- "POSTGRES_USER=admin"
- "POSTGRES_DB=rss"
- "POSTGRES_PASSWORD=secret+123"
- "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-network
- postgres_data:/var/lib/postgresql
# rust-app:
# build:
# context: . # Specify the path to your Rust application's Dockerfile
# dockerfile: Dockerfile
# ports:
# - "8001:8001" # Adjust the port as needed for your Rust application
# depends_on:
# - postgres
# networks:
# - app-network
backend:
container_name: "rss-backend"
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- postgres
environment:
- "DATABASE_URL=postgres://admin:${POSTGRES_PASSWORD}@postgres/rss"
- "JWT_SECRET=${JWT_SECRET}"
- "FRONTEND_ORIGIN=${FRONTEND_ORIGIN}"
- "RUST_LOG=${RUST_LOG}"
ports:
- "0.0.0.0:8001:8001"
networks:
app-network:
driver: bridge
frontend:
container_name: "rss-frontend"
build:
context: ./vue
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- backend
ports:
- "0.0.0.0:8080:80"
volumes:
postgres_data:
-36
View File
@@ -1,36 +0,0 @@
const loginButton = document.getElementById('loginButton');
const username = document.getElementById(
'defaultLoginFormUsername');
const password = document.getElementById(
'defaultLoginFormPassword');
const message = document.getElementById("loginMessage");
loginButton.addEventListener("click", () => {
let xhr = new XMLHttpRequest();
xhr.open("POST", "/api/v1/auth/login", true);
xhr.setRequestHeader("Content-Type",
"application/json");
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
let token = xhr.getResponseHeader("token");
localStorage.setItem("user-token", token);
console.log("status 200: " + document.location.origin);
window.location.replace(
document.location.origin);
} else {
message.innerText =
"login failed please try again";
}
}
};
let data = JSON.stringify({
"username": username.value,
"password": password.value
});
xhr.send(data);
message.innerText = "logging in";
})
-59
View File
@@ -1,59 +0,0 @@
if (localStorage.getItem("user-token") == null) {
window.location.replace(document.location.origin + "/login");
} else {
getArticles();
}
function apiCall(url, method) {
let xhr = new XMLHttpRequest();
xhr.withCredentials = true;
xhr.addEventListener("readystatechange", function () {
if (this.readyState === this.DONE) {
if (this.status === 401) {
window.location.replace(document.location.origin + "/login/");
} else {
runRenderProcess(JSON.parse(this.responseText));
localStorage.setItem("item-cache-date", new Date());
localStorage.setItem("item-cache-data", this.responseText);
}
}
});
xhr.open(method, "/api/v1" + url);
xhr.setRequestHeader("content-type", "application/json");
xhr.setRequestHeader("user-token", localStorage.getItem("user-token"));
return xhr;
}
function runRenderProcess(data) {
params = renderArticle(data["feeds"]);
// document.getElementById("mainContainer").innerHtml = params;
}
function renderArticle(feeds) {
let placeholder = "<div>";
for (i = 0; i < feeds.length; i++) {
let title = feeds[i]["title"];
placeholder += '<div class="itemContainer">' + "<p>" + title + "</p>";
let items = feeds[i]["items"];
for (t = 0; t < items.length; t++) {
placeholder +=
'<div class="article">' +
"<h2>" +
items[t].title +
"</h2>" +
"<p>" +
items[t].content +
"</p>";
}
placeholder += "</div>" + "</div>";
}
placeholder += "</div>";
document.getElementById("mainContainer").innerHTML = placeholder;
}
function getArticles() {
let call = apiCall("/article/get", "GET");
call.send();
}
@@ -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"))),
}
}
}
+71 -16
View File
@@ -2,41 +2,72 @@ extern crate hmac;
extern crate jwt;
extern crate sha2;
use std::collections::BTreeMap;
use std::env;
use actix_web::HttpRequest;
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 body: String,
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>;
fn signing_key() -> HmacSha256 {
dotenv().ok();
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
// 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 {
let key: HmacSha256 = HmacSha256::new_from_slice(b"secret").unwrap();
let mut claims = BTreeMap::new();
claims.insert("user_id", user_id);
claims.sign_with_key(&key).unwrap()
pub fn encode(user_id: i32, token_version: i32) -> String {
let key: HmacSha256 = signing_key();
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 = HmacSha256::new_from_slice(b"secret").unwrap();
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"],
body: encoded_token,
user_id: claims.user_id,
token_version: claims.tv,
})
}
Err(_err) => Err("could not decode token"),
@@ -46,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"),
}
}
@@ -55,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]
@@ -75,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))
+15 -10
View File
@@ -1,13 +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;
#[tracing::instrument(name = "Process token")]
pub fn process_token(request: &ServiceRequest) -> Result<String, &'static str> {
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),
}
}
@@ -17,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(message) => assert_eq!("passed", message),
Err(_) => panic!("process token failed"),
}
assert_eq!(Ok(user.id), process_token(&request));
delete_user(&mut connection, user.id);
}
#[actix_web::test]
+56 -24
View File
@@ -1,20 +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 secrecy::{ExposeSecret, Secret};
use diesel::prelude::*;
pub fn check_password(password: Secret<String>) -> Result<String, &'static str> {
match jwt::JwtToken::decode(password.expose_secret().to_string()) {
Ok(_token) => Ok(String::from("passed")),
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)
}
#[tracing::instrument(name = "Extract Header Token")]
pub fn extract_header_token(request: &ServiceRequest) -> Result<Secret<String>, &'static str> {
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
match request.headers().get("user-token") {
Some(token) => match token.to_str() {
Ok(processed_password) => Ok(Secret::new(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"),
}
@@ -23,32 +36,51 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<Secret<String>,
#[cfg(test)]
mod processes_test {
use actix_web::test::TestRequest;
use secrecy::{ExposeSecret, Secret};
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: Secret<String> = Secret::new(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(check) => assert_eq!("passed", check),
_ => 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: Secret<String> = Secret::new(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]
@@ -58,7 +90,7 @@ mod processes_test {
.to_srv_request();
match super::extract_header_token(&request) {
Ok(processed_password) => assert_eq!("token", processed_password.expose_secret()),
Ok(processed_password) => assert_eq!("token", processed_password),
_ => panic!("failed extract_header_token"),
}
}
-118
View File
@@ -1,118 +0,0 @@
use config::{Config, ConfigError};
use secrecy::{ExposeSecret, Secret};
#[derive(serde::Deserialize, Debug)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
}
#[derive(serde::Deserialize, Debug)]
pub struct ApplicationSettings {
pub port: u16,
pub host: String,
}
#[derive(serde::Deserialize, Debug)]
pub struct DatabaseSettings {
pub username: String,
pub password: Secret<String>,
pub port: u16,
pub host: String,
pub database_name: String,
}
impl TryFrom<Config> for Settings {
type Error = ConfigError;
fn try_from(builder: config::Config) -> Result<Self, Self::Error> {
// Extract values from the builder and construct Settings
let database = builder.get::<DatabaseSettings>("database")?;
let application = builder.get::<ApplicationSettings>("application")?;
Ok(Settings {
database,
application,
})
}
}
impl DatabaseSettings {
pub fn connection_string(&self) -> Secret<String> {
Secret::new(format!(
"postgres://{}:{}@{}:{}/{}",
self.username,
self.password.expose_secret(),
self.host,
self.port,
self.database_name
))
}
pub fn connection_string_without_db(&self) -> Secret<String> {
Secret::new(format!(
"postgres://{}:{}@{}:{}",
self.username,
self.password.expose_secret(),
self.host,
self.port
))
}
}
pub fn get_configuration() -> Result<Settings, ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory.");
let configuration_directory = base_path.join("configuration");
// Detect the running environment
// Default to `local`
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "local".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT.");
let environment_filename = format!("{}.yaml", environment.as_str());
// Initialise our configuration reader
let settings = config::Config::builder()
// Add configuration values from a file named `configuration.yaml`.
.add_source(config::File::from(
configuration_directory.join("base.yaml"),
))
.add_source(config::File::from(
configuration_directory.join(environment_filename),
))
.build()?;
// Try to convert the configuration values it read into
// our Settings type
settings.try_deserialize::<Settings>()
}
pub enum Environment {
Local,
Production,
}
impl Environment {
pub fn as_str(&self) -> &'static str {
match self {
Environment::Local => "local",
Environment::Production => "production",
}
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
match s.to_lowercase().as_str() {
"local" => Ok(Self::Local),
"production" => Ok(Self::Production),
other => Err(format!(
"{} is not a supported environement. \
Use either 'local' or 'production'.",
other
)),
}
}
}
-9
View File
@@ -1,9 +0,0 @@
application:
port: 8000
host: 127.0.0.1
database:
host: "127.0.0.1"
port: 5432
username: "postgres"
password: "password"
database_name: "newsletter"
+18 -9
View File
@@ -1,12 +1,21 @@
use diesel::pg::PgConnection;
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::prelude::*;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use dotenvy::dotenv;
use std::env;
pub fn get_connection_pool(url: &str) -> Pool<ConnectionManager<PgConnection>> {
let manager = ConnectionManager::<PgConnection>::new(url);
// Refer to the `r2d2` documentation for more methods to use
// when building a connection pool
Pool::builder()
.test_on_check_out(true)
.build(manager)
.expect("Could not build connection pool")
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
pub fn establish_connection() -> PgConnection {
dotenv().ok();
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
PgConnection::establish(&database_url)
.unwrap_or_else(|e| panic!("Error connecting to database {}: {}", database_url, e))
}
pub fn run_migrations(connection: &mut PgConnection) {
connection
.run_pending_migrations(MIGRATIONS)
.expect("Failed to run database 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())
}
}
+8 -3
View File
@@ -1,5 +1,5 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, Responder};
use reqwest::StatusCode;
use serde::Serialize;
use crate::reader::structs::feed::FeedAggregate;
@@ -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())
}
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
pub mod articles;
pub mod feed_info;
pub mod login;
pub mod new_feed;
pub mod new_feed_item;
pub mod new_user;
pub mod read_feed_item;
pub mod readable;
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[derive(Deserialize)]
pub struct NewFeedSchema {
pub title: String,
pub url: String,
-9
View File
@@ -1,9 +0,0 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct NewFeedItemSchema {
pub content: String,
pub feed_id: i32,
pub url: String,
pub title: String,
}
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[derive(Deserialize)]
pub struct NewUserSchema {
pub name: String,
pub email: String,
+1 -1
View File
@@ -1,6 +1,6 @@
use serde_derive::Deserialize;
#[derive(Deserialize, Debug)]
#[derive(Deserialize)]
pub struct ReadItem {
pub id: i32,
}
+8 -3
View File
@@ -1,5 +1,5 @@
use actix_web::http::StatusCode;
use actix_web::{HttpResponse, Responder};
use reqwest::StatusCode;
use serde::Serialize;
#[derive(Serialize)]
@@ -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())
}
}
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use serde::Deserialize;
#[derive(Deserialize, Debug)]
#[derive(Deserialize)]
pub struct UrlJson {
pub url: String,
}
+1 -1
View File
@@ -1,6 +1,6 @@
use serde_derive::Deserialize;
#[derive(Deserialize, Debug)]
#[derive(Deserialize)]
pub struct JsonUser {
pub user_id: i32,
}
-13
View File
@@ -1,13 +0,0 @@
extern crate diesel;
extern crate dotenv;
pub mod auth;
pub mod configuration;
pub mod database;
pub mod json_serialization;
pub mod models;
pub mod reader;
pub mod schema;
pub mod startup;
pub mod telemetry;
pub mod views;
+75 -22
View File
@@ -1,32 +1,85 @@
use std::net::TcpListener;
extern crate diesel;
use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection,
};
use rss_reader::{
configuration::get_configuration,
database::get_connection_pool,
startup::run,
telemetry::{get_subscriber, init_subscriber},
};
use secrecy::ExposeSecret;
use actix_cors::Cors;
use actix_service::Service;
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;
mod schema;
#[cfg(test)]
mod test_helpers;
mod views;
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);
init_subscriber(subscriber);
dotenv().ok();
env_logger::init();
let configuration = get_configuration().expect("Failed to read configuration.");
database::run_migrations(&mut database::establish_connection());
let connection_pool: Pool<ConnectionManager<PgConnection>> =
get_connection_pool(configuration.database.connection_string().expose_secret());
let frontend_origin =
env::var("FRONTEND_ORIGIN").unwrap_or_else(|_| String::from("http://localhost:5173"));
let address = format!(
"{}:{}",
configuration.application.host, configuration.application.port
HttpServer::new(move || {
let cors = Cors::default()
.allowed_origin(&frontend_origin)
.allow_any_method()
.allow_any_header()
.supports_credentials();
App::new()
.wrap_fn(|req, srv| {
let request_url: String = String::from(req.uri().path());
log::info!("Request Url: {}", request_url);
// 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 listener = TcpListener::bind(address)?;
run(listener, connection_pool)?.await
let passed = if is_public {
true
} else {
match auth::process_token(&req) {
Ok(user_id) => {
log::info!("Authenticated user {} for {}", user_id, request_url);
req.extensions_mut().insert(auth::extractor::AuthUser(user_id));
true
}
Err(message) => {
log::warn!("Rejected request to {}: {}", request_url, message);
false
}
}
};
let end_result = match passed {
true => Either::Left(srv.call(req)),
false => Either::Right(ok(req.into_response(
HttpResponse::Unauthorized().finish().map_into_boxed_body(),
))),
};
async move {
let result = end_result.await?;
log::info!("{} -> {}", request_url, &result.status());
Ok(result)
}
})
.wrap(cors)
.configure(views::views_factory)
})
.bind("0.0.0.0:8001")?
.run()
.await
}
+18 -6
View File
@@ -2,11 +2,23 @@ extern crate bcrypt;
use bcrypt::{hash, DEFAULT_COST};
use diesel::Insertable;
use secrecy::{ExposeSecret, Secret};
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 {
@@ -17,15 +29,15 @@ pub struct NewUser {
}
impl NewUser {
pub fn new(username: String, email: String, password: Secret<String>) -> NewUser {
let hashed_password: String =
hash(password.expose_secret().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 {
return verify(password.as_str(), &self.password).unwrap();
pub fn verify(self, password: String) -> anyhow::Result<bool> {
Ok(verify(password.as_str(), &self.password)?)
}
}
+76 -16
View File
@@ -1,23 +1,19 @@
use actix_web::{web, HttpResponse};
use diesel::{
r2d2::{ConnectionManager, Pool},
PgConnection, RunQueryDsl,
};
use diesel::RunQueryDsl;
use crate::{
auth::extractor::AuthUser, database::establish_connection,
json_serialization::new_feed::NewFeedSchema, models::feed::new_feed::NewFeed, schema::feed,
};
use super::feeds;
#[tracing::instrument(name = "Add new feed", skip(pool))]
pub async fn add(
new_feed: web::Json<NewFeedSchema>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
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();
let user_id: i32 = new_feed.user_id;
@@ -25,12 +21,14 @@ pub async fn add(
let result = feeds::get_feed(&url).await;
match result {
Ok(channel) => {
log::info!("valid channel");
if channel.items.is_empty() {
return HttpResponse::ServiceUnavailable().await.unwrap();
return HttpResponse::ServiceUnavailable().finish();
}
}
Err(_) => {
return HttpResponse::NotFound().await.unwrap();
Err(e) => {
log::error!("{:?}", e);
return HttpResponse::NotFound().finish();
}
}
@@ -41,7 +39,69 @@ pub async fn add(
.execute(&mut connection);
match insert_result {
Ok(_) => HttpResponse::Created().await.unwrap(),
Err(_) => HttpResponse::Conflict().await.unwrap(),
Ok(_) => HttpResponse::Created().finish(),
Err(e) => {
log::error!("{e}");
HttpResponse::Conflict().finish()
}
}
}
#[cfg(test)]
mod tests {
use actix_service::Service;
use actix_web::http::StatusCode;
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()
.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": "Bad feed",
"url": format!("not-a-valid-url-{}", unique_suffix()),
"user_id": 1
}))
.to_request();
let resp = test::call_service(&app, req).await;
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);
}
}
+6 -5
View File
@@ -1,10 +1,11 @@
use std::error::Error;
use rss::Channel;
#[tracing::instrument(name = "Get Channel Feed")]
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)
}
+108 -30
View File
@@ -1,61 +1,53 @@
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;
use crate::reader::structs::feed::FeedAggregate;
use crate::schema::feed_item::{feed_id, id, read};
use crate::{
database::establish_connection,
json_serialization::articles::Articles,
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::r2d2::{ConnectionManager, Pool};
use diesel::{prelude::*, r2d2};
use diesel::prelude::*;
use super::structs::article::Article;
#[tracing::instrument(name = "Get feeds", skip(pool))]
pub async fn get(
path: web::Path<JsonUser>,
req: HttpRequest,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> impl Responder {
auth_user: AuthUser,
) -> Result<HttpResponse, AppError> {
let request = req.clone();
let req_user_id = path.user_id;
// Clone the Arc containing the connection pool
let pool_arc = pool.get_ref().clone();
// Acquire a connection from the pool
let mut connection = pool_arc.get().expect("Failed to get database connection");
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 {
feed_aggregates.push(get_feed_aggregate(feed, &mut connection))
}
let articles: Articles = Articles {
feeds: feed_aggregates,
};
articles.respond_to(&request)
}
#[tracing::instrument(name = "Get feed aggregate", skip(connection))]
pub fn get_feed_aggregate(
feed: Feed,
connection: &mut r2d2::PooledConnection<ConnectionManager<PgConnection>>,
) -> FeedAggregate {
let existing_item: Vec<FeedItem> = feed_item::table
.filter(feed_id.eq(feed.id))
.filter(read.eq(false))
.order(id.asc())
.load(connection)
.unwrap();
.load(&mut connection)?;
log::info!(
"Load {} feed items for feed: {}",
existing_item.len(),
feed.url
);
let article_list: Vec<Article> = existing_item
.into_iter()
@@ -74,8 +66,94 @@ pub fn get_feed_aggregate(
})
.collect();
FeedAggregate {
log::info!("article list with {} items generated.", article_list.len());
feed_aggregates.push(FeedAggregate {
title: feed.title,
items: article_list,
})
}
let articles: Articles = Articles {
feeds: feed_aggregates,
};
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, 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,
};
#[actix_web::test]
async fn get_returns_only_unread_items() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let feed = insert_feed(&mut connection, user.id);
let unread = insert_feed_item(&mut connection, feed.id, false);
let read = insert_feed_item(&mut connection, feed.id, true);
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();
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(&unread.title));
assert!(!body_str.contains(&read.title));
delete_feed_item(&mut connection, unread.id);
delete_feed_item(&mut connection, read.id);
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);
}
}
+134 -21
View File
@@ -1,36 +1,149 @@
use crate::auth::extractor::AuthUser;
use crate::error::AppError;
use crate::schema::feed_item::{id, read};
use crate::{
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};
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::{ExpressionMethods, QueryDsl};
use diesel::{PgConnection, RunQueryDsl};
use actix_web::{web, HttpRequest, HttpResponse, Responder};
use diesel::RunQueryDsl;
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl};
#[tracing::instrument(name = "Mark as read", skip(pool))]
pub async fn mark_read(
_req: HttpRequest,
path: web::Path<ReadItem>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
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().await.unwrap();
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 result = diesel::update(&feed_item)
.set(read.eq(true))
.execute(&mut connection)?;
log::info!("Mark as read: {:?}", result);
Ok(HttpResponse::Ok().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::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;
use crate::test_helpers::{
delete_feed, delete_feed_item, delete_user, insert_feed, insert_feed_item, insert_user,
};
#[actix_web::test]
async fn mark_read_flips_the_read_flag() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "secret");
let feed = insert_feed(&mut connection, user.id);
let item = insert_feed_item(&mut connection, feed.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("/read/{id}", web::put().to(mark_read)),
)
.await;
let req = test::TestRequest::put()
.uri(&format!("/read/{}", item.id))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
let updated: FeedItem = feed_item::table
.find(item.id)
.first(&mut connection)
.unwrap();
assert!(updated.read);
delete_feed_item(&mut connection, item.id);
delete_feed(&mut connection, feed.id);
delete_user(&mut connection, user.id);
}
let feed_item: &FeedItem = feed_items.first().unwrap();
#[actix_web::test]
async fn mark_read_returns_not_found_for_unknown_id() {
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;
let _result: Result<usize, diesel::result::Error> = diesel::update(feed_item)
.set(read.eq(true))
.execute(&mut connection);
assert_eq!(StatusCode::NOT_FOUND, resp.status());
}
HttpResponse::Ok().await.unwrap()
#[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);
}
}
+11
View File
@@ -2,9 +2,12 @@ use actix_web::web;
use crate::views::path::Path;
mod add;
mod delete_feed;
pub mod feeds;
mod get;
mod list_feeds;
mod mark_read;
pub mod net;
mod read;
mod scraper;
pub mod structs;
@@ -19,6 +22,14 @@ pub fn feed_factory(app: &mut web::ServiceConfig) {
&base_path.define(String::from("/get/{user_id}")),
web::get().to(get::get),
);
app.route(
&base_path.define(String::from("/feeds/{user_id}")),
web::get().to(list_feeds::list_feeds),
);
app.route(
&base_path.define(String::from("/feed/{feed_id}")),
web::delete().to(delete_feed::delete_feed),
);
app.route(
&base_path.define(String::from("/add")),
web::post().to(add::add),
+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());
}
}
+4 -2
View File
@@ -4,13 +4,15 @@ use crate::json_serialization::{readable::Readable, url::UrlJson};
use super::scraper::content::do_throttled_request;
#[tracing::instrument(name = "Read Feed")]
pub async fn read(_req: HttpRequest, data: web::Json<UrlJson>) -> impl Responder {
let result = do_throttled_request(&data.url);
let content = match result.await {
Ok(cont) => cont,
Err(e) => e.to_string(),
Err(e) => {
log::error!("Could not scrap url {}", data.url);
e.to_string()
}
};
Readable { content }
+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?)
}
+558 -67
View File
@@ -1,127 +1,618 @@
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::{
use crate::schema::feed_item::{feed_id, url};
use crate::{
database::establish_connection,
schema::{
feed::{self, user_id},
feed_item,
},
};
use actix_web::{web, HttpRequest, HttpResponse};
use chrono::{DateTime, Local, NaiveDateTime};
use dateparser::parse;
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
use rss::Item;
use scraper::{Html, Selector};
use std::collections::{HashMap, HashSet};
#[tracing::instrument(name = "Get Date")]
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
// let format_string = "%a, %d %b %Y %H:%M:%S %z";
let format_string = "%Y-%m-%dT%H:%M:%S%Z";
let result = parse(date_str).unwrap();
match NaiveDateTime::parse_from_str(&result.to_string(), format_string) {
Ok(r) => Ok(r),
Err(_) => {
let datetime = DateTime::parse_from_rfc2822(date_str);
match datetime {
Ok(r) => NaiveDateTime::parse_from_str(&r.to_rfc3339(), format_string),
Err(_) => match DateTime::parse_from_rfc2822(date_str) {
Ok(r) => NaiveDateTime::parse_from_str(&r.to_rfc3339(), format_string),
Err(e) => Err(e),
},
}
if let Ok(result) = parse(date_str) {
log::info!("Date: {:?}", result);
return Ok(result.with_timezone(&Local).naive_local());
}
DateTime::parse_from_rfc2822(date_str).map(|dt| dt.with_timezone(&Local).naive_local())
}
// Some feeds (e.g. Deutsche Welle) embed responsive-image templates such as
// `src="https://example.com/img_${formatId}.jpg"` that their own frontend
// JavaScript fills in before loading — verbatim, they 404. Skip those and
// pick the first <img> with a real, directly loadable URL instead.
fn image_src_is_resolvable(element: &scraper::ElementRef) -> bool {
match element.value().attr("src") {
Some(src) => !src.contains('{') && !src.to_lowercase().contains("%7b"),
None => false,
}
}
#[tracing::instrument(name = "Create Feed Item", skip(connection))]
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
let item_title = item.title.clone().unwrap();
fn escape_html_attr(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
let base_content: &str = match item.content() {
Some(c) => c,
None => match item.description() {
Some(c) => c,
None => "",
},
// 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.
fn enclosure_image_html(item: &Item) -> Option<String> {
let enclosure = item.enclosure()?;
if !enclosure.mime_type().to_lowercase().starts_with("image/") {
return None;
}
Some(format!(
r#"<img src="{}">"#,
escape_html_attr(enclosure.url())
))
}
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);
// 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) {
Ok(date) => date,
Err(err) => {
log::error!("could not parse pub date: {}", err);
time
}
};
}
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 frag_clone = frag.clone();
frag.tree.into_iter().for_each(|node| {
let selector_img = Selector::parse("img").unwrap();
for element in frag_clone.select(&selector_img) {
if !content.starts_with("<img") {
content.push_str(&element.html());
content.push_str("<br>")
// 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(&sanitize_img_html(&image.html()));
content.push_str("<br>");
}
None => {
if let Some(image_html) = enclosure_image_html(&item) {
content.push_str(&sanitize_img_html(&image_html));
content.push_str("<br>");
}
}
if let scraper::node::Node::Text(text) = node {
}
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();
if existing_item.is_empty() {
let mut time: NaiveDateTime = Local::now().naive_local();
if item.pub_date().is_some() {
time = match get_date(item.pub_date().unwrap()) {
Ok(date) => date,
Err(_err) => time,
};
}
let new_feed_item = NewFeedItem::new(
feed.id,
content.clone(),
item_title.clone(),
item.link.unwrap(),
item.link.expect("checked above"),
Some(time),
);
let _insert_result = diesel::insert_into(feed_item::table)
// `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)
.execute(connection);
.on_conflict((feed_id, url))
.do_nothing()
.execute(connection)?;
if inserted_rows > 0 {
log::info!("Inserted item: {}", item_title);
} else {
log::info!("Item {} already exists.", item_title);
}
Ok(())
}
#[tracing::instrument(name = "sync", skip(pool))]
pub async fn sync(
_req: HttpRequest,
data: web::Json<JsonUser>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
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());
for feed in feeds {
log::info!("Try to get url: {}", feed.url);
let result = feeds::get_feed(&feed.url).await;
match result {
Ok(channel) => {
for item in channel.into_items() {
create_feed_item(item, &feed, &mut connection);
log::info!("{:?}", item);
if let Err(e) = create_feed_item(item, &feed, &mut connection) {
log::error!("Could not create feed item for {}: {:?}", feed.url, e);
}
}
Err(_e) => return HttpResponse::InternalServerError().await.unwrap(),
}
Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e),
}
}
HttpResponse::Ok().await.unwrap()
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::{delete_feed, delete_user, insert_feed, insert_user, unique_suffix};
use chrono::Duration;
use super::*;
#[test]
fn get_date_parses_iso8601_dates() {
assert!(get_date("2024-01-01T12:00:00Z").is_ok());
}
#[test]
fn get_date_parses_rfc2822_dates() {
assert!(get_date("Tue, 03 Jun 2025 10:00:00 GMT").is_ok());
}
#[test]
fn get_date_returns_err_for_unparseable_dates() {
assert!(get_date("not-a-date").is_err());
}
#[test]
fn create_feed_item_skips_template_placeholder_images() {
let html = Html::parse_fragment(
r#"<img src="https://example.test/img_${formatId}.jpg"><p>placeholder</p>
<img src="https://example.test/real.jpg"><p>real image</p>"#,
);
let selector = Selector::parse("img").unwrap();
let chosen = html
.select(&selector)
.find(image_src_is_resolvable)
.expect("should find a resolvable image");
assert_eq!(
Some("https://example.test/real.jpg"),
chosen.value().attr("src")
);
}
#[test]
fn create_feed_item_finds_no_image_when_all_are_templated() {
let html = Html::parse_fragment(r#"<img src="https://example.test/img_${formatId}.jpg">"#);
let selector = Selector::parse("img").unwrap();
assert!(html.select(&selector).find(image_src_is_resolvable).is_none());
}
#[test]
fn enclosure_image_html_builds_img_for_image_enclosures() {
let mut item = Item::default();
item.set_enclosure(rss::Enclosure {
url: "https://static.dw.com/image/73880499_302.jpg".to_string(),
length: "2000".to_string(),
mime_type: "image/jpeg".to_string(),
});
assert_eq!(
Some(r#"<img src="https://static.dw.com/image/73880499_302.jpg">"#.to_string()),
enclosure_image_html(&item)
);
}
#[test]
fn enclosure_image_html_ignores_non_image_enclosures() {
let mut item = Item::default();
item.set_enclosure(rss::Enclosure {
url: "https://example.test/episode.mp3".to_string(),
length: "2000".to_string(),
mime_type: "audio/mpeg".to_string(),
});
assert_eq!(None, enclosure_image_html(&item));
}
#[test]
fn enclosure_image_html_escapes_url_attribute() {
let mut item = Item::default();
item.set_enclosure(rss::Enclosure {
url: "https://example.test/img.jpg?a=1&b=\"x\"".to_string(),
length: "2000".to_string(),
mime_type: "image/jpeg".to_string(),
});
assert_eq!(
Some(r#"<img src="https://example.test/img.jpg?a=1&amp;b=&quot;x&quot;">"#.to_string()),
enclosure_image_html(&item)
);
}
#[test]
fn sanitize_img_html_strips_event_handlers() {
let sanitized = sanitize_img_html(r#"<img src="x" onerror="alert(1)">"#);
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">"#,
);
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_inserts_articles_older_than_two_weeks() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let new_user = NewUser::new(
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 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();
// 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();
let mut old_item = Item::default();
old_item.set_title(Some(format!("Old article {suffix}")));
old_item.set_link(Some(format!("https://example.test/old/{suffix}")));
old_item.set_pub_date(Some(old_date));
old_item.set_content(Some("<p>old</p>".to_string()));
// Item without a pub_date — treated as current, should be inserted.
let mut fresh_item = Item::default();
fresh_item.set_title(Some(format!("Fresh article {suffix}")));
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).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!(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)
.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_does_not_duplicate_existing_items() {
let mut connection = establish_connection();
let suffix = unique_suffix();
let new_user = NewUser::new(
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)
.unwrap();
let new_feed = NewFeed::new(
format!("Sync 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!("Sync test article {suffix}")));
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).unwrap();
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(), "duplicate feed items should not be created");
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_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,
}
}
-56
View File
@@ -1,56 +0,0 @@
use std::net::TcpListener;
use actix_service::Service;
use actix_web::web;
use actix_web::{dev::Server, App, HttpResponse, HttpServer};
use diesel::r2d2::{ConnectionManager, Pool};
use diesel::PgConnection;
use futures::future::{ok, Either};
use crate::auth;
use crate::views;
#[tracing::instrument(name = "Run application", skip(connection, listener))]
pub fn run(
listener: TcpListener,
connection: Pool<ConnectionManager<PgConnection>>,
) -> Result<Server, std::io::Error> {
let wrapper = web::Data::new(connection);
let server = HttpServer::new(move || {
App::new()
.wrap_fn(|req, srv| {
let mut passed: bool;
if req.path().contains("/article/") {
match auth::process_token(&req) {
Ok(_token) => passed = true,
Err(_message) => passed = false,
}
} else {
passed = true;
}
if req.path().contains("user/create") {
passed = true;
}
let end_result = match passed {
true => Either::Left(srv.call(req)),
false => Either::Right(ok(req.into_response(
HttpResponse::Unauthorized().finish().map_into_boxed_body(),
))),
};
async move {
let result = end_result.await?;
Ok(result)
}
})
.app_data(wrapper.clone())
.configure(views::views_factory)
})
.listen(listener)?
.run();
Ok(server)
}
-27
View File
@@ -1,27 +0,0 @@
use tracing::{dispatcher::set_global_default, Subscriber};
use tracing_bunyan_formatter::{BunyanFormattingLayer, JsonStorageLayer};
use tracing_log::LogTracer;
use tracing_subscriber::{fmt::MakeWriter, layer::SubscriberExt, EnvFilter, Registry};
pub fn get_subscriber<Sink>(
name: String,
env_filter: String,
sink: Sink,
) -> impl Subscriber + Send + Sync
where
Sink: for<'a> MakeWriter<'a> + Send + Sync + 'static,
{
let env_filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(env_filter));
let formatting_layer = BunyanFormattingLayer::new(name, sink);
Registry::default()
.with(env_filter)
.with(JsonStorageLayer)
.with(formatting_layer)
}
pub fn init_subscriber(subscriber: impl Subscriber + Send + Sync) {
LogTracer::init().expect("Failed to set logger.");
set_global_default(subscriber.into()).expect("Failed to set subscriber.");
}
+92
View File
@@ -0,0 +1,92 @@
#![cfg(test)]
use diesel::prelude::*;
use uuid::Uuid;
use crate::models::feed::new_feed::NewFeed;
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::models::user::new_user::NewUser;
use crate::models::user::rss_user::User;
use crate::schema::{feed, feed_item, users};
// Test fixtures are written through the same models/schema as the app and
// cleaned up explicitly afterwards (rather than wrapped in a rolled-back
// transaction), because every handler opens its own DB connection via
// `establish_connection`, so a transaction held by the test would not be
// visible to the handler under test. Random suffixes keep parallel test runs
// from colliding on the unique username/email/url constraints.
pub fn unique_suffix() -> String {
Uuid::new_v4().to_string()
}
pub fn insert_user(connection: &mut PgConnection, password: &str) -> User {
let suffix = unique_suffix();
let new_user = NewUser::new(
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)
.expect("failed to insert test user")
}
pub fn delete_user(connection: &mut PgConnection, user_id: i32) {
diesel::delete(users::table.filter(users::id.eq(user_id)))
.execute(connection)
.ok();
}
pub fn insert_feed(connection: &mut PgConnection, owner_id: i32) -> Feed {
let suffix = unique_suffix();
let new_feed = NewFeed::new(
format!("Test feed {suffix}"),
format!("https://example.test/feed/{suffix}"),
owner_id,
);
diesel::insert_into(feed::table)
.values(&new_feed)
.get_result(connection)
.expect("failed to insert test feed")
}
pub fn delete_feed(connection: &mut PgConnection, id: i32) {
diesel::delete(feed::table.filter(feed::id.eq(id)))
.execute(connection)
.ok();
}
pub fn insert_feed_item(connection: &mut PgConnection, owning_feed_id: i32, read: bool) -> FeedItem {
let suffix = unique_suffix();
let new_item = NewFeedItem::new(
owning_feed_id,
format!("Content {suffix}"),
format!("Title {suffix}"),
format!("https://example.test/article/{suffix}"),
None,
);
let item: FeedItem = diesel::insert_into(feed_item::table)
.values(&new_item)
.get_result(connection)
.expect("failed to insert test feed item");
if read {
diesel::update(&item)
.set(feed_item::read.eq(true))
.execute(connection)
.expect("failed to mark test feed item as read");
}
item
}
pub fn delete_feed_item(connection: &mut PgConnection, id: i32) {
diesel::delete(feed_item::table.filter(feed_item::id.eq(id)))
.execute(connection)
.ok();
}
+84 -18
View File
@@ -1,44 +1,110 @@
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;
use crate::{auth::jwt::JwtToken, schema::users::username};
use actix_web::{web, HttpResponse};
use diesel::prelude::*;
use diesel::r2d2::{ConnectionManager, Pool};
pub async fn login(
credentials: web::Json<Login>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
pub async fn login(credentials: web::Json<Login>) -> Result<HttpResponse, AppError> {
let username_cred: String = credentials.username.clone();
let password: String = credentials.password.clone();
let mut connection = establish_connection();
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 {
return HttpResponse::Conflict().await.unwrap();
log::error!(
"multiple user have the usernam: {}",
credentials.username.clone()
);
return Ok(HttpResponse::Conflict().finish());
}
let user: &User = &users[0];
match user.clone().verify(password) {
match user.clone().verify(password)? {
true => {
let token: String = JwtToken::encode(user.clone().id);
HttpResponse::Ok()
log::info!("verified password successfully for user {}", user.id);
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()),
}
}
#[cfg(test)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use super::login;
use crate::database::establish_connection;
use crate::test_helpers::{delete_user, insert_user, unique_suffix};
#[actix_web::test]
async fn login_succeeds_with_correct_credentials() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "correct-password");
let app = test::init_service(App::new().route("/login", web::post().to(login))).await;
let req = test::TestRequest::post()
.uri("/login")
.set_json(serde_json::json!({
"username": user.username,
"password": "correct-password"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::OK, resp.status());
assert!(resp.headers().contains_key("token"));
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn login_fails_with_wrong_password() {
let mut connection = establish_connection();
let user = insert_user(&mut connection, "correct-password");
let app = test::init_service(App::new().route("/login", web::post().to(login))).await;
let req = test::TestRequest::post()
.uri("/login")
.set_json(serde_json::json!({
"username": user.username,
"password": "wrong-password"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::UNAUTHORIZED, resp.status());
delete_user(&mut connection, user.id);
}
#[actix_web::test]
async fn login_fails_for_unknown_user() {
let app = test::init_service(App::new().route("/login", web::post().to(login))).await;
let req = test::TestRequest::post()
.uri("/login")
.set_json(serde_json::json!({
"username": format!("does-not-exist-{}", unique_suffix()),
"password": "whatever"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::NOT_FOUND, resp.status());
}
}
+61 -2
View File
@@ -1,3 +1,62 @@
pub async fn logout() -> String {
"logout view".to_string()
use actix_web::HttpResponse;
use diesel::prelude::*;
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, 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_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")),
+91 -19
View File
@@ -1,34 +1,106 @@
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::*,
r2d2::{ConnectionManager, Pool},
};
use secrecy::Secret;
#[tracing::instrument(name = "Create new User", skip(pool))]
pub async fn create(
new_user: web::Json<NewUserSchema>,
pool: web::Data<Pool<ConnectionManager<PgConnection>>>,
) -> HttpResponse {
let pool_arc = pool.get_ref().clone();
let mut connection = pool_arc.get().expect("Failed to get database connection");
use diesel::prelude::*;
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: Secret<String> = Secret::new(new_user.password.clone());
let new_password: String = new_user.password.clone();
let new_user = NewUser::new(name, email, new_password);
if let Err(message) = validate_password(&new_password) {
return Ok(HttpResponse::BadRequest().body(message));
}
let new_user = NewUser::new(name, email, new_password)?;
let insert_result = diesel::insert_into(users::table)
.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)]
mod tests {
use actix_web::http::StatusCode;
use actix_web::{test, web, App};
use diesel::prelude::*;
use super::create;
use crate::database::establish_connection;
use crate::schema::users;
use crate::test_helpers::{delete_user, insert_user, unique_suffix};
#[actix_web::test]
async fn create_succeeds_for_new_user() {
let suffix = unique_suffix();
let username = format!("new_user_{suffix}");
let email = format!("new_{suffix}@example.test");
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": username,
"email": email,
"password": "secret"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::CREATED, resp.status());
let mut connection = establish_connection();
diesel::delete(users::table.filter(users::username.eq(&username)))
.execute(&mut connection)
.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();
let user = insert_user(&mut connection, "secret");
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": user.username,
"email": user.email,
"password": "secret"
}))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(StatusCode::CONFLICT, resp.status());
delete_user(&mut connection, user.id);
}
}
-12
View File
@@ -1,12 +0,0 @@
.header {
background: #034f84;
margin-bottom: 0.3rem;
}
.header p {
color: white;
display: inline-block;
margin: 0.5rem;
margin-right: 0.4rem;
margin-left: 0.4rem;
}
-4
View File
@@ -1,4 +0,0 @@
<div class="header">
<p>complete tasks: </p><p id="completeNum"></p>
<p>pending tasks: </p><p id="pendingNum"></p>
</div>
-51
View File
@@ -1,51 +0,0 @@
<html>
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width,
initial-scale=1.0" />
<meta name="description" content="This is a simple to do app" />
<meta httpEquiv="X-UA-Compatiable" content="ie=edge" />
<title>Login</title>
</head>
<style>
{{BASE_CSS}}
{{CSS}}
.loginButtonStyle {
display: inline-block;
background: #f7786b;
border: none;
padding: 0.5rem;
padding-left: 2rem;
padding-right: 2rem;
color: white;
}
.loginButtonStyle:hover {
background: #f7686b;
color: black;
}
</style>
<body>
<div class="mainContainer">
<h2 class="ContainerTitle" style="text-align:center;">Login</h2>
<p id="loginMessage" class="FeedbackMessage" style="text-align:center;"></p>
<form style="text-align:center;" action="submit">
<input type="text" value="" placeholder="Username" class="formInputContainer"
id="defaultLoginFormUsername"><br>
<p></p>
<input type="password" value="" placeholder="Password" class="formInputContainer"
id="defaultLoginFormPassword"><br><br>
<input type="button" value="Submit" class="loginButtonStyle" id="loginButton" style="text-align:center;">
</form>
</div>
</body>
<script>
{{JAVASCRIPT}}
</script>
</html>
-26
View File
@@ -1,26 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width,
initial-scale=1.0"
/>
<meta httpEquiv="X-UA-Compatible" content="ie=edge" />
<meta name="description" content="This is a simple to do app" />
<title>ToDo App</title>
</head>
<style>
BASE_CSS
CSS
HEADER_CSS
</style>
<body>
<div id="mainContainer" class="mainContainer"></div>
</body>
<script>
JAVASCRIPT;
</script>
</html>
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
-2
View File
@@ -1,2 +0,0 @@
VITE_API_BASE_URL=http://localhost:8001
-2
View File
@@ -1,2 +0,0 @@
VITE_API_BASE_URL=http://rust-app:8001
+13 -17
View File
@@ -1,22 +1,18 @@
FROM node:lts-alpine
# --- 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
# install simple http server for serving static content
RUN npm install -g http-server
# make the 'app' folder the current working directory
WORKDIR /app
# copy both 'package.json' and 'package-lock.json' (if available)
COPY package*.json ./
# install project dependencies
RUN npm install
# copy project files and folders to the current working directory (i.e. 'app' folder)
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# build app for production with minification
RUN npm run build
EXPOSE 8080
CMD [ "http-server", "dist" ]
# --- runtime ---
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+8 -1
View File
@@ -3,9 +3,16 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="alternate icon" href="/favicon.ico">
<link rel="manifest" href="/manifest.json">
<link rel="apple-touch-icon" href="/icon-192.png">
<meta name="theme-color" content="#1a8f5e">
<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>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8001/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+2916 -574
View File
File diff suppressed because it is too large Load Diff
+12 -10
View File
@@ -6,24 +6,26 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore",
"format": "prettier --write src/"
},
"dependencies": {
"@mozilla/readability": "^0.4.4",
"axios": "^1.5.0",
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"vue-sessionstorage": "^1.0.0"
"@mozilla/readability": "^0.6.0",
"axios": "^1.17.0",
"vue": "^3.5.35",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.2",
"@vitejs/plugin-vue": "^4.3.1",
"@rushstack/eslint-patch": "^1.16.1",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/eslint-config-prettier": "^8.0.0",
"dotenv": "^16.4.5",
"@vue/test-utils": "^2.4.11",
"eslint": "^8.46.0",
"eslint-plugin-vue": "^9.16.1",
"prettier": "^3.0.0",
"vite": "^4.4.9"
"jsdom": "^29.1.1",
"prettier": "^3.8.3",
"vite": "^8.0.16",
"vitest": "^4.1.8"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#1a8f5e"/>
<circle cx="20" cy="44" r="6" fill="#ffffff"/>
<path d="M14 28a22 22 0 0 1 22 22h-8a14 14 0 0 0-14-14z" fill="#ffffff"/>
<path d="M14 14a36 36 0 0 1 36 36h-8a28 28 0 0 0-28-28z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 325 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

+20
View File
@@ -0,0 +1,20 @@
{
"name": "RSS Reader",
"short_name": "RSS Reader",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#1a8f5e",
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+9 -72
View File
@@ -1,78 +1,15 @@
<script setup>
import { RouterLink, RouterView } from 'vue-router'
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>
<!-- <header> -->
<!-- <div class="wrapper"> -->
<!-- <nav> -->
<!-- <p onclick="$refs.sync">Sync</p> -->
<!-- <p>Login</p> -->
<!-- <RouterLink to="/">Home</RouterLink> -->
<!-- <RouterLink to="/about">About</RouterLink> -->
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
<!-- </nav> -->
<!-- </div> -->
<!-- </header> -->
<AppNav v-if="route.meta.requiresAuth" />
<RouterView />
</template>
<style>
header {
line-height: 1.5;
max-height: 100vh;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
}
nav p.router-link-exact-active {
color: var(--color-text);
}
nav p.router-link-exact-active:hover {
background-color: transparent;
}
nav p {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
cursor: pointer;
}
nav p:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
nav {
text-align: left;
margin-left: -1rem;
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
}
</style>
@@ -0,0 +1,72 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFeeds } from '../composables/useFeeds'
// router/index.js's route definitions lazily import(...) the view components,
// which importing the real module would still be safe to do (the imports
// themselves aren't eagerly evaluated) — but mocking it keeps this test from
// depending on the app's actual routes and lets currentRoute/push be
// controlled directly.
vi.mock('../router', () => ({
default: {
currentRoute: { value: { name: 'feeds' } },
push: vi.fn(),
},
}))
import router from '../router'
import { handleUnauthorizedResponse } from '../httpAuthInterceptor'
describe('handleUnauthorizedResponse', () => {
const { feedFilter } = useFeeds()
beforeEach(() => {
localStorage.setItem('user-token', 'abc123')
localStorage.setItem('user-id', '7')
localStorage.setItem('feedFilter', JSON.stringify(['Feed A']))
feedFilter.value = ['Feed A']
router.currentRoute.value.name = 'feeds'
router.push.mockClear()
})
it('clears the session and the feed filter, and redirects to login, on a 401 from a non-login route', async () => {
const error = { response: { status: 401 } }
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBeNull()
expect(localStorage.getItem('user-id')).toBeNull()
expect(localStorage.getItem('feedFilter')).toBeNull()
expect(feedFilter.value).toBeNull()
expect(router.push).toHaveBeenCalledWith({ name: 'login' })
})
it('still clears the session on a 401 but does not push again when already on the login route', async () => {
router.currentRoute.value.name = 'login'
const error = { response: { status: 401 } }
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBeNull()
expect(router.push).not.toHaveBeenCalled()
})
it('leaves the session and filter untouched, and still rejects, on a non-401 error response', async () => {
const error = { response: { status: 500 } }
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBe('abc123')
expect(localStorage.getItem('feedFilter')).toBe(JSON.stringify(['Feed A']))
expect(feedFilter.value).toEqual(['Feed A'])
expect(router.push).not.toHaveBeenCalled()
})
it('leaves the session untouched and still rejects on an error with no response (e.g. a network error)', async () => {
const error = new Error('network')
await expect(handleUnauthorizedResponse(error)).rejects.toBe(error)
expect(localStorage.getItem('user-token')).toBe('abc123')
expect(router.push).not.toHaveBeenCalled()
})
})
+28 -1
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);
@@ -33,6 +40,13 @@
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--color-accent: hsla(160, 100%, 37%, 1);
--color-accent-hover: hsla(160, 100%, 37%, 0.2);
--color-accent-2: hsla(200, 90%, 45%, 1);
--color-accent-2-hover: hsla(200, 90%, 35%, 1);
--color-info: #3498db;
--color-info-text: white;
--section-gap: 160px;
}
@@ -47,6 +61,9 @@
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
--color-accent-2: hsla(200, 90%, 65%, 1);
--color-accent-2-hover: hsla(200, 90%, 75%, 1);
}
}
@@ -60,13 +77,23 @@
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;
line-height: 1.6;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-size: 15px;
font-size: clamp(14px, 2.5vw, 16px);
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
+73 -67
View File
@@ -3,7 +3,8 @@
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
padding: 0.5rem;
padding-top: var(--app-nav-height, 4.5rem);
font-weight: normal;
}
@@ -11,102 +12,107 @@
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
color: var(--color-accent);
transition: 0.4s;
}
.feed-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.feed-actions p {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0.5rem 1rem;
margin: 0;
border: 1px solid var(--color-border);
border-radius: 4px;
cursor: pointer;
}
.feed-actions p:hover {
border-color: var(--color-border-hover);
}
.message {
background-color: #3498db;
color: white;
background-color: var(--color-info);
color: var(--color-info-text);
padding: 10px;
border-radius: 4px;
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
max-width: min(90vw, 28rem);
overflow-wrap: break-word;
z-index: 9999;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
background-color: var(--color-accent-hover);
}
}
.feed-source {
margin: 0;
padding: 1em 1em 0;
font-size: clamp(0.75rem, 2vw, 0.85rem);
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: var(--color-accent);
}
.feed-title {
cursor: pointer;
font-family: 'Courier New';
font-size: 22px;
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;
padding: 1em;
padding: 0.25em 1em 1em;
min-height: 44px;
transition: color 0.2s;
}
.feed-title:hover {
color: var(--color-accent-2-hover);
}
.feed-content {
font-family: Georgia, 'Times New Roman', Times, serif;
font-size: 20px;
padding: 1em;
display: flex;
flex-direction: column;
align-items: left;
text-align: left;
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;
}
.feed-content p {
padding: 1em;
}
.feed-content h2,
h3,
h4,
h5,
h6 {
padding: 1em;
font-size: 21px;
font-weight: bold;
}
.feed-content img {
max-width: 100%;
margin-bottom: 10px;
/* Adjust spacing between image and text */
height: auto;
}
.feed-content p {
padding: 0.5em 0;
}
.feed-content h3 {
padding: 0.5em 0;
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
font-weight: bold;
}
h3 {
font-size: 14px;
font-size: clamp(0.85rem, 2.5vw, 1rem);
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
}
input[type="text"],
input[type="password"] {
/* width: 100%; */
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #45a049;
}
.error {
color: red;
@media (min-width: 768px) {
#app {
padding: 0.75rem;
padding-top: var(--app-nav-height, 4.5rem);
}
}
+25 -6
View File
@@ -1,6 +1,11 @@
input {
margin: 15px;
display: block;
width: 100%;
min-height: 44px;
margin: 0.5rem 0 1rem;
padding: 0.5rem;
font-size: 1rem;
}
.modal-mask {
@@ -16,10 +21,13 @@ input {
}
.modal-container {
width: 300px;
width: clamp(280px, 90vw, 420px);
max-height: 90vh;
overflow-y: auto;
margin: auto;
padding: 20px 30px;
background-color: #fff;
background-color: var(--color-background-soft);
color: var(--color-text);
border-radius: 2px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
transition: all 0.3s ease;
@@ -27,15 +35,26 @@ input {
.modal-header h3 {
margin-top: 0;
color: #42b983;
color: var(--color-heading);
}
.modal-body {
margin: 20px 0;
}
.modal-default-button {
float: right;
.modal-footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.modal-footer button {
flex: 1 1 auto;
min-height: 44px;
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
}
/*
+162
View File
@@ -0,0 +1,162 @@
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
import Modal from './modal/AddUrl.vue'
const showModal = ref(false)
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>
<button class="admin__add" type="button" @click="showModal = true">Add RSS</button>
<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>
<Teleport to="body">
<Modal :show="showModal" @close="showModal = false" @added="loadFeeds">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</Modal>
</Teleport>
</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__add {
display: inline-flex;
align-items: center;
min-height: 44px;
margin-bottom: 1.25rem;
padding: 0.5rem 1.1rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.admin__add:hover {
border-color: var(--color-border-hover);
}
.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>
+416
View File
@@ -0,0 +1,416 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import FeedFilter from './modal/FeedFilter.vue'
const router = useRouter()
const route = useRoute()
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
const headerRef = ref(null)
const filterBtnRef = ref(null)
const filterModalOpen = ref(false)
function openFilterModal() {
refreshUnreadDisplay()
filterModalOpen.value = true
}
// setFeedFilter() itself no-ops (order-insensitively) an unchanged selection,
// so there's nothing to check here before applying it.
async function handleFilterClose(selection) {
filterModalOpen.value = false
await setFeedFilter(selection)
filterBtnRef.value?.focus()
}
// 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 menuOpen = ref(false)
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
function closeMenu() {
menuOpen.value = false
}
// 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' })
}
function handleSync() {
sync()
closeMenu()
}
function handleMarkAllRead() {
markAllRead()
closeMenu()
}
function handleToggleViewMode() {
toggleViewMode()
closeMenu()
}
function handleToggleLayout() {
toggleLayout()
closeMenu()
}
</script>
<template>
<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>
<span
v-if="isSyncing"
class="app-nav__sync-spinner"
aria-hidden="true"
></span>
<button
v-if="onFeedsPage && feedTitles.length"
ref="filterBtnRef"
class="app-nav__filter-btn"
:class="{ 'app-nav__filter-btn--active': feedFilter !== null }"
type="button"
aria-haspopup="dialog"
:aria-expanded="filterModalOpen"
:aria-label="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
:title="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
@click="openFilterModal"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
</button>
<Teleport to="body">
<FeedFilter
:show="filterModalOpen"
:titles="feedTitles"
:counts="displayedFeedUnreadCounts"
:selected="feedFilter"
@close="handleFilterClose"
/>
</Teleport>
<button
class="app-nav__hamburger"
type="button"
:aria-expanded="menuOpen"
aria-controls="app-nav-menu"
:aria-label="menuOpen ? 'Close menu' : 'Open menu'"
@click="toggleMenu"
>
<span class="app-nav__hamburger-icon" aria-hidden="true">
<span></span><span></span><span></span>
</span>
</button>
</div>
<Transition name="app-nav-menu">
<nav
v-if="menuOpen"
id="app-nav-menu"
class="app-nav__menu"
@click.self="closeMenu"
>
<div class="app-nav__menu-panel">
<RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink>
<template v-if="onFeedsPage">
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
{{ 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>
<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>
</header>
</template>
<style scoped>
.app-nav {
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 {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.375rem 1rem;
}
.app-nav__title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: bold;
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
}
.app-nav__unread {
font-weight: normal;
opacity: 0.6;
}
.app-nav__sync-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--color-border);
border-top-color: var(--color-text);
border-radius: 50%;
opacity: 0.6;
flex-shrink: 0;
animation: app-nav-spin 0.8s linear infinite;
}
@keyframes app-nav-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.app-nav__sync-spinner {
animation: none;
}
}
.app-nav__filter-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.app-nav__filter-btn:hover {
border-color: var(--color-border-hover);
}
.app-nav__filter-btn--active::after {
content: '';
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent);
}
.app-nav__hamburger {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-height: 44px;
min-width: 44px;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
cursor: pointer;
}
.app-nav__hamburger-icon {
display: inline-flex;
flex-direction: column;
justify-content: space-between;
width: 22px;
height: 16px;
}
.app-nav__hamburger-icon span {
display: block;
height: 2px;
border-radius: 1px;
background: var(--color-text);
}
.app-nav__menu {
position: absolute;
inset: 100% 0 auto 0;
z-index: 30;
display: flex;
justify-content: flex-end;
padding: 0 1rem 1rem;
}
.app-nav__menu-panel {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: clamp(220px, 60vw, 280px);
padding: 0.75rem;
background: var(--color-background-soft);
border: 1px solid var(--color-border);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.app-nav__menu-item {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
text-decoration: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.app-nav__menu-item:hover {
border-color: var(--color-border-hover);
}
.app-nav__menu-item.router-link-exact-active {
font-weight: bold;
}
.app-nav-menu-enter-active,
.app-nav-menu-leave-active {
transition: opacity 0.2s ease;
}
.app-nav-menu-enter-from,
.app-nav-menu-leave-to {
opacity: 0;
}
@media (min-width: 768px) {
.app-nav__wrapper {
padding: 0.5rem 2rem;
}
}
</style>
-44
View File
@@ -1,44 +0,0 @@
<script setup>
defineProps({
msg: {
type: String,
required: true
}
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
You’ve successfully created a project with
<a href="https://vitejs.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
+47 -32
View File
@@ -2,57 +2,40 @@
import axios from 'axios'
import { ref } from 'vue'
import { useRouter } from 'vue-router';
const username = ref('')
const password = ref('')
const error = ref('')
const router = useRouter()
async function login() {
error.value = ''
const loginData = {
"username": username.value,
"password": password.value,
}
const jsonData = JSON.stringify(loginData)
console.log('test')
try {
const response = await axios.post('login/rss', jsonData, {
const response = await axios.post('/api/v1/auth/login', {
username: username.value,
password: password.value,
}, {
headers: {
'Content-Type': 'application/json', // Set the content type to JSON
'crossDomain': true,
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Credentials': true,
'strict-origin-when-cross-origin': false
'Content-Type': 'application/json',
},
});
// Handle the response data here
console.log('Response:', response.data);
// You can also access the HTTP status code
console.log('HTTP Status Code:', response.status);
if (response.status == 200) {
let token = response.headers.token
let user_id = response.headers.user_id
localStorage.setItem("user-token", token)
localStorage.setItem("user-id", user_id)
sessionStorage.setItem("user-id", user_id)
sessionStorage.setItem("user-token", token)
localStorage.setItem("user-token", response.headers.token)
localStorage.setItem("user-id", response.headers.user_id)
router.push({ name: 'feeds' })
}
// Handle success
} catch (error) {
// Handle any errors here
console.error('Error:', error);
} catch (err) {
console.error('Login failed:', err)
error.value = 'Login failed. Please check your username and password.'
}
// Implement your login logic here (e.g., send a request to your backend)
// If login is successful, you can redirect the user to the dashboard:
}
</script>
<template>
<div>
<h1>Login Page</h1>
<div class="login-page">
<h1>Login</h1>
<form @submit.prevent="login">
<div class="form-group">
<label for="username">Username/Email:</label>
@@ -62,7 +45,39 @@ async function login() {
<label for="password">Password:</label>
<input v-model="password" type="password" id="password" name="password" required />
</div>
<p v-if="error" class="login-error">{{ error }}</p>
<button type="submit">Login</button>
</form>
</div>
</template>
<style scoped>
.login-page {
max-width: 420px;
margin: 2rem auto;
padding: 0 1rem;
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.form-group input {
min-height: 44px;
padding: 0.5rem;
font-size: 1rem;
}
.login-page button {
min-height: 44px;
padding: 0.5rem 1.5rem;
font-size: 1rem;
cursor: pointer;
}
.login-error {
color: #c0392b;
}
</style>
+511 -155
View File
@@ -1,191 +1,547 @@
<script setup>
import { ref, unref, onMounted, nextTick } from 'vue';
import axios from 'axios';
import { Readability } from '@mozilla/readability';
import Modal from './modal/AddUrl.vue';
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds';
const showMessage = ref(false)
const feeds = ref([]);
const message = ref('')
const showModal = ref(false)
const {
feeds,
showMessage,
message,
viewMode,
currentIndex,
layout,
nextArticle,
prevArticle,
fetchData,
sync,
getReadable,
disconnectObserver,
setInitialLoad,
showMessageForXSeconds,
markCurrentArticleRead,
} = useFeeds()
async function getReadable(feed, index) {
try {
const response = await axios.post("feeds/read", {
url: feed.url
},
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
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
}
})
const doc = new DOMParser().parseFromString(response.data.content, 'text/html');
const article = new Readability(doc).parse();
feeds.value[index].content = article.content;
} catch (error) {
console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
}
}
async function markRead(id) {
try {
const response = await axios.put("feeds/read/" + id,
null,
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
// 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
}
}
)
console.log(response.status)
} catch (error) {
console.log(error)
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()
}
}
function showMessageForXSeconds(text, seconds) {
message.value = text;
showMessage.value = true;
// 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
// Set a timeout to hide the message after x seconds
setTimeout(() => {
showMessage.value = false;
message.value = '';
}, seconds * 1000); // Convert seconds to milliseconds
}
const fetchData = async () => {
const user_id = localStorage.getItem("user-id")
try {
const response = await axios.get("feeds/get/" + user_id, {
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
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')
}
});
const sortedItems = response.data.feeds.flatMap(feed => feed.items)
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
feeds.value.push(...sortedItems);
await nextTick();
setupIntersectionObserver();
} catch (error) {
console.error('Error fetching data:', error)
showMessageForXSeconds(error, 5)
}
};
async function sync() {
try {
const response = await axios.post('feeds/sync', {
user_id: parseInt(localStorage.getItem("user-id"))
},
{
headers: {
'Content-Type': 'application/json',
'user-token': localStorage.getItem("user-token")
}
})
if (response.status == 200) {
showMessageForXSeconds('Sync successful.', 5)
}
fetchData();
} catch (error) {
console.error('Error sync', error)
showMessageForXSeconds(error, 5)
}
}
let observer; // Declare observer outside the setup function
function setupIntersectionObserver() {
observer = new IntersectionObserver(handleIntersection, {
root: null, // Use the viewport as the root
rootMargin: '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);
})
}
}
async function handleIntersection(entries) {
// The callback function for when the target element enters or exits the viewport
entries.forEach(entry => {
if (entry.isIntersecting) {
console.log('Element is in sight');
} else if (initialLoad === true) {
console.log(entry.isIntersecting)
// Element is out of sight
if (entry.isVisible === false && entry.boundingClientRect.y < 0) {
console.log('Element is out of sight ' + entry.intersectionRatio);
//console.log(feeds.value[entry.target.id])
markRead(feeds.value[entry.target.id].id).await
removeFeed(entry.target.id)
document.getElementById(0).scrollIntoView()
}
if (img.complete) {
checkSize()
} else {
img.addEventListener('load', checkSize, { once: true })
}
})
}
function removeFeed(index) {
const array = unref(feeds);
array.splice(index, 1);
watch(() => feeds.value[currentIndex.value]?.content, async () => {
await nextTick()
markSmallImages()
})
async function loadReadable(feed, index) {
await getReadable(feed, index)
await nextTick()
markSmallImages()
}
let initialLoad = false
onMounted(() => {
initialLoad = false
fetchData().await
async function shareUrl(url) {
if (navigator.share) {
await navigator.share({ url })
} else {
await navigator.clipboard.writeText(url)
showMessageForXSeconds('Link copied.', 2)
}
}
onBeforeUnmount(() => {
disconnectObserver()
setInitialLoad(false)
})
onMounted(async () => {
setInitialLoad(false)
await fetchData()
// viewMode persists across reloads (see useFeeds.js) — if we're booting
// straight into article view, the article now on screen needs the same
// immediate mark-read that toggleViewMode()/nextArticle()/prevArticle()
// give it when reached by in-session navigation.
if (viewMode.value === 'article') markCurrentArticleRead()
sync(true)
setTimeout(function () {
initialLoad = true
setInitialLoad(true)
console.log('set to true')
}, 2000);
});
</script>
<template>
<header>
<div class="wrapper">
<nav>
<p @click="sync">Sync</p>
<!-- <p @click="updateShow(true)">Add RSS</p> -->
<p @click="showModal = true">Add RSS</p>
<!-- <RouterLink to="/">Home</RouterLink> -->
<!-- <RouterLink to="/about">About</RouterLink> -->
<!-- <RouterLink to="/feeds">Feeds</RouterLink> -->
</nav>
</div>
</header>
<Teleport to="body">
<!-- use the modal component, pass in the prop -->
<modal :show="showModal" @close="showModal = false">
<template #header>
<h3>Add RSS Feed</h3>
</template>
</modal>
</Teleport>
<div>
<h1>Feeds</h1> <!-- <button @click="sync">{{ buttonText }}</button> -->
<div v-if="showMessage" class="message">{{ message }}</div>
<div id='article' class='article'>
<p v-if="feeds.length == 0">No unread articles.</p>
<template v-for="( feed, index ) in feeds ">
<div v-if="viewMode === 'list'" id='article' class='article' :class="{ 'article--cards': layout === 'cards' }">
<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"/>
<polyline points="7 12.5 10.5 16 17 9"/>
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<template v-for="( feed, index ) in feeds " :key="feed.id">
<div v-bind:id="index" class="observe">
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
<p class="feed-source">{{ feed.feedTitle }}</p>
<h2 @click="loadReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
<h3>{{ feed.timestamp }}</h3>
<p class="feed-content" v-html='feed.content'></p>
<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" :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" @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"/>
<polyline points="7 12.5 10.5 16 17 9"/>
</svg>
<p class="empty-state__label">All caught up</p>
</div>
<!-- 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="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article>
<div class="article-nav">
<button
type="button"
class="article-nav__btn"
:disabled="currentIndex === 0"
aria-label="Previous article"
@click="prevArticle"
>&uarr;</button>
<button
type="button"
class="article-nav__btn"
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
aria-label="Next article"
@click="nextArticle"
>&darr;</button>
</div>
</div>
</div>
</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. */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 4rem 1rem;
gap: 1rem;
opacity: 0.4;
}
.empty-state__icon {
width: 64px;
height: 64px;
}
.empty-state__label {
font-size: 1.1rem;
margin: 0;
}
.article--cards .observe {
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
background: var(--color-background-soft);
}
.article--cards .observe + .observe {
margin-top: 1rem;
}
.article--cards .feed-title {
border-bottom: none;
}
.article--cards h3 {
margin: 0;
padding: 0 1em 0.5em;
}
/* `v-html` content isn't part of the component's render output, so it never
gets the scoped `data-v-*` attribute — `:deep()` is required for this rule
to actually reach the injected <img> tags (without it, the selector silently
never matches). Cap the height so a large article photo reads as a tidy
preview thumbnail; the card itself is left to grow to whatever height its
content (image included) naturally needs — no clamping, no max-height. */
.article--cards .feed-content :deep(img) {
max-height: 220px;
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;
align-items: center;
gap: 0.5rem;
}
.feed-original-link a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.25em 1em;
color: var(--color-accent);
text-decoration: none;
}
.feed-original-link a:hover {
text-decoration: underline;
}
.feed-share-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0;
border: none;
background: transparent;
color: var(--color-text);
opacity: 0.45;
cursor: pointer;
transition: opacity 0.15s;
}
.feed-share-btn:hover {
opacity: 1;
}
.article-single {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
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-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 {
position: fixed;
right: 1rem;
bottom: 1.5rem;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.article-nav__btn {
min-width: 56px;
min-height: 56px;
border-radius: 50%;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
color: var(--color-text);
font-size: 1.5rem;
line-height: 1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
cursor: pointer;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.article-nav__btn:hover:not(:disabled),
.article-nav__btn:focus-visible {
border-color: var(--color-border-hover);
opacity: 1;
}
.article-nav__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>
-86
View File
@@ -1,86 +0,0 @@
<script setup>
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vue’s
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vitejs.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a> +
<a href="https://github.com/johnsoncodehk/volar" target="_blank" rel="noopener">Volar</a>. If
you need to test your components and web pages, check out
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a> and
<a href="https://on.cypress.io/component" target="_blank">Cypress Component Testing</a>.
<br />
More instructions are available in <code>README.md</code>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>, our official
Discord server, or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also subscribe to
<a href="https://news.vuejs.org" target="_blank" rel="noopener">our mailing list</a> and follow
the official
<a href="https://twitter.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
twitter account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
-86
View File
@@ -1,86 +0,0 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
@@ -0,0 +1,112 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import AdminFeeds from '../AdminFeeds.vue'
import AddUrl from '../modal/AddUrl.vue'
vi.mock('axios')
describe('AdminFeeds', () => {
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
})
// Each mount renders the AddUrl modal via <Teleport to="body">, which
// survives outside the wrapper's own element — unmount every mounted
// instance so a leftover doesn't bleed into a later test's document.body.
let mountedWrappers = []
function mountAdmin() {
const wrapper = mount(AdminFeeds)
mountedWrappers.push(wrapper)
return wrapper
}
afterEach(() => {
for (const wrapper of mountedWrappers) {
try {
wrapper.unmount()
} catch {
// already unmounted by the test itself — fine
}
}
mountedWrappers = []
})
it('fetches and renders the feed list on mount', async () => {
axios.get.mockResolvedValueOnce({
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
})
const wrapper = mountAdmin()
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/feeds/7', expect.anything())
expect(wrapper.text()).toContain('Feed A')
})
it('shows the empty state when there are no feeds', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('No feeds added yet.')
})
it('shows an error message when the feed list fails to load', async () => {
axios.get.mockRejectedValueOnce(new Error('Network Error'))
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('Failed to load feeds.')
})
it('deletes a feed after confirmation', async () => {
axios.get.mockResolvedValueOnce({
data: { feeds: [{ id: 1, title: 'Feed A', url: 'https://example.test/a.xml' }] },
})
axios.delete.mockResolvedValueOnce({ status: 204 })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = mountAdmin()
await flushPromises()
await wrapper.find('.admin__delete').trigger('click')
await flushPromises()
expect(axios.delete).toHaveBeenCalledWith('/api/v1/article/feed/1', expect.anything())
expect(wrapper.text()).toContain('No feeds added yet.')
confirmSpy.mockRestore()
})
it('opens the add-feed modal from the Add RSS button', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mountAdmin()
await flushPromises()
await wrapper.find('.admin__add').trigger('click')
expect(wrapper.findComponent(AddUrl).props('show')).toBe(true)
})
it('reloads the feed list when the modal reports a feed was added', async () => {
axios.get
.mockResolvedValueOnce({ data: { feeds: [] } })
.mockResolvedValueOnce({ data: { feeds: [{ id: 2, title: 'Feed B', url: 'https://example.test/b.xml' }] } })
const wrapper = mountAdmin()
await flushPromises()
expect(wrapper.text()).toContain('No feeds added yet.')
await wrapper.findComponent(AddUrl).vm.$emit('added')
await flushPromises()
expect(axios.get).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('Feed B')
})
})
+610
View File
@@ -0,0 +1,610 @@
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'
import FeedFilter from '../modal/FeedFilter.vue'
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
beforeEach(async () => {
localStorage.setItem('user-token', 'abc123')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, 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 = ''
viewMode.value = 'list'
currentIndex.value = 0
layout.value = 'list'
router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: { template: '<div />' } },
{ path: '/feeds', name: 'feeds', component: { template: '<div />' } },
],
})
router.push('/feeds')
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 = 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 = mountNav()
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
await wrapper.find('.app-nav__hamburger').trigger('click')
expect(wrapper.find('.app-nav__menu').exists()).toBe(true)
await wrapper.find('.app-nav__hamburger').trigger('click')
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()
await wrapper.find('.app-nav__logout').trigger('click')
await flushPromises()
expect(localStorage.getItem('user-token')).toBeNull()
expect(localStorage.getItem('user-id')).toBeNull()
expect(router.currentRoute.value.name).toBe('login')
})
it('triggers a sync from the menu', async () => {
axios.get.mockResolvedValue({ data: { feeds: [] } })
axios.post.mockResolvedValueOnce({ status: 200 })
const wrapper = await mountWithMenuOpen()
const syncButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Sync')
await syncButton.trigger('click')
await flushPromises()
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/article/sync',
{ user_id: 7 },
expect.anything(),
)
// Menu auto-closes after an action
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('switches the view mode from the menu and closes it', async () => {
const wrapper = await mountWithMenuOpen()
const { viewMode } = useFeeds()
const viewButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Article view')
await viewButton.trigger('click')
expect(viewMode.value).toBe('article')
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('switches the list layout from the menu and closes it', async () => {
const wrapper = await mountWithMenuOpen()
const { layout } = useFeeds()
const layoutButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Card layout')
await layoutButton.trigger('click')
expect(layout.value).toBe('cards')
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('hides the layout toggle while in article view', async () => {
const { viewMode } = useFeeds()
viewMode.value = 'article'
const wrapper = await mountWithMenuOpen()
expect(wrapper.findAll('.app-nav__menu-item').find(el => el.text().includes('layout'))).toBeUndefined()
})
it('marks all articles as read from the menu after confirmation', async () => {
const { feeds } = useFeeds()
feeds.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' },
]
axios.put.mockResolvedValue({ status: 200 })
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true)
const wrapper = await mountWithMenuOpen()
const markAllButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Mark all as read')
await markAllButton.trigger('click')
await flushPromises()
expect(confirmSpy).toHaveBeenCalled()
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/1', null, expect.anything())
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/2', null, expect.anything())
expect(feeds.value).toHaveLength(0)
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
confirmSpy.mockRestore()
})
it('shows the unread count in the title when there are articles', async () => {
// 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 = 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 { 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 = 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 = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
})
it('opens a feed filter modal listing "All feeds" plus one entry 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()
await wrapper.find('.app-nav__filter-btn').trigger('click')
const modal = wrapper.findComponent(FeedFilter)
expect(modal.props('show')).toBe(true)
// Distinct titles, sorted alphabetically, with per-feed unread counts.
expect(modal.props('titles')).toEqual(['Feed A', 'Feed B'])
expect(modal.props('counts')).toEqual({ 'Feed A': 2, 'Feed B': 1 })
})
it('does not render the feed filter when there are no feeds', async () => {
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__filter-btn').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()
await wrapper.find('.app-nav__filter-btn').trigger('click')
const modal = wrapper.findComponent(FeedFilter)
modal.vm.$emit('close', ['Feed A'])
await flushPromises()
expect(feedFilter.value).toEqual(['Feed A'])
expect(feeds.value.map(f => f.id)).toEqual([1])
await wrapper.find('.app-nav__filter-btn').trigger('click')
modal.vm.$emit('close', null)
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-btn').trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
await flushPromises()
// Filtered → unread in Feed A only (2).
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
it('sums the unread count across every selected feed in the title badge', async () => {
const { allItems, 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' },
{ id: 3, feedTitle: 'Feed C', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
]
feedFilter.value = ['Feed A', 'Feed B']
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
it('marks the filter button active and swaps its label/title when a filter is set', async () => {
const { allItems, feedFilter } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav()
await flushPromises()
const button = wrapper.find('.app-nav__filter-btn')
expect(button.classes()).not.toContain('app-nav__filter-btn--active')
expect(button.attributes('aria-label')).toBe('Filter feeds')
expect(button.attributes('title')).toBe('Filter feeds')
feedFilter.value = ['Feed A']
await nextTick()
expect(button.classes()).toContain('app-nav__filter-btn--active')
expect(button.attributes('aria-label')).toBe('Filter feeds (active)')
expect(button.attributes('title')).toBe('Filter feeds (active)')
})
it('tracks the modal\'s open state via aria-expanded on the filter button', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav()
await flushPromises()
const button = wrapper.find('.app-nav__filter-btn')
expect(button.attributes('aria-expanded')).toBe('false')
await button.trigger('click')
expect(button.attributes('aria-expanded')).toBe('true')
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
await flushPromises()
expect(button.attributes('aria-expanded')).toBe('false')
})
it('does not re-apply the filter when the emitted selection is order-insensitively unchanged', async () => {
const { allItems, 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' },
]
feedFilter.value = ['Feed A', 'Feed B']
const wrapper = mountNav()
await flushPromises()
const scrollToSpy = vi.spyOn(window, 'scrollTo')
await wrapper.find('.app-nav__filter-btn').trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed B', 'Feed A'])
await flushPromises()
// setFeedFilter() always scrollTo(0, 0)s — its absence proves the
// no-op path (sameFeedSelection) was taken instead.
expect(scrollToSpy).not.toHaveBeenCalled()
expect(feedFilter.value).toEqual(['Feed A', 'Feed B'])
})
it('does not re-apply the filter when null is emitted while already unfiltered', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav()
await flushPromises()
const scrollToSpy = vi.spyOn(window, 'scrollTo')
await wrapper.find('.app-nav__filter-btn').trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', null)
await flushPromises()
expect(scrollToSpy).not.toHaveBeenCalled()
})
it('returns focus to the filter button once the modal closes', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const wrapper = mountNav({ attachTo: document.body, global: { plugins: [router] } })
await flushPromises()
const button = wrapper.find('.app-nav__filter-btn')
await button.trigger('click')
wrapper.findComponent(FeedFilter).vm.$emit('close', ['Feed A'])
await flushPromises()
expect(document.activeElement).toBe(button.element)
})
describe('sync spinner', () => {
it('does not render the spinner, and reserves no space for it, when idle', async () => {
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
})
it('renders while a sync triggered through the composable is in flight, and disappears once it resolves', async () => {
const wrapper = mountNav()
await flushPromises()
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const { sync } = useFeeds()
const syncPromise = sync(true)
await nextTick()
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
await nextTick()
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(false)
})
it('renders while syncing even on a page where the feed filter itself does not render', async () => {
await router.push('/login')
await router.isReady()
const wrapper = mountNav()
await flushPromises()
let resolvePost
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
const { sync } = useFeeds()
const syncPromise = sync(true)
await nextTick()
expect(wrapper.find('.app-nav__filter-btn').exists()).toBe(false)
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
resolvePost({ status: 200 })
await syncPromise
})
})
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 = [
{ id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
]
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false)
const wrapper = await mountWithMenuOpen()
const markAllButton = wrapper.findAll('.app-nav__menu-item').find(el => el.text() === 'Mark all as read')
await markAllButton.trigger('click')
await flushPromises()
expect(confirmSpy).toHaveBeenCalled()
expect(axios.put).not.toHaveBeenCalled()
expect(feeds.value).toHaveLength(1)
confirmSpy.mockRestore()
})
})
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createRouter, createWebHistory } from 'vue-router'
import axios from 'axios'
import LoginPage from '../LoginPage.vue'
vi.mock('axios')
describe('LoginPage', () => {
let router
beforeEach(async () => {
localStorage.clear()
vi.clearAllMocks()
router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: { template: '<div />' } },
{ path: '/feeds', name: 'feeds', component: { template: '<div />' } },
],
})
router.push('/login')
await router.isReady()
})
it('stores the token and redirects to feeds on successful login', async () => {
axios.post.mockResolvedValueOnce({
status: 200,
headers: { token: 'abc123', user_id: '7' },
})
const wrapper = mount(LoginPage, { global: { plugins: [router] } })
await wrapper.find('#username').setValue('alice')
await wrapper.find('#password').setValue('secret')
await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/auth/login',
{ username: 'alice', password: 'secret' },
expect.anything(),
)
expect(localStorage.getItem('user-token')).toBe('abc123')
expect(localStorage.getItem('user-id')).toBe('7')
expect(router.currentRoute.value.name).toBe('feeds')
})
it('shows an error message and does not redirect when login fails', async () => {
axios.post.mockRejectedValueOnce(new Error('Request failed'))
const wrapper = mount(LoginPage, { global: { plugins: [router] } })
await wrapper.find('#username').setValue('alice')
await wrapper.find('#password').setValue('wrong')
await wrapper.find('form').trigger('submit.prevent')
await flushPromises()
expect(wrapper.text()).toContain('Login failed')
expect(localStorage.getItem('user-token')).toBeNull()
expect(router.currentRoute.value.name).toBe('login')
})
})
@@ -0,0 +1,536 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import RssFeeds from '../RssFeeds.vue'
import { useFeeds } from '../../composables/useFeeds'
vi.mock('axios')
// jsdom does not implement IntersectionObserver, but the component sets one up
// once the feed list has rendered.
class FakeIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
}
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')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
// useFeeds() returns module-level singleton refs shared across the whole
// app (and this spec file) — reset them so state doesn't leak between tests.
const { feeds, showMessage, message, viewMode, currentIndex, layout } = useFeeds()
feeds.value = []
showMessage.value = false
message.value = ''
viewMode.value = 'list'
currentIndex.value = 0
layout.value = 'list'
})
it('fetches the current user articles and shows the empty state', async () => {
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
const wrapper = mount(RssFeeds)
await flushPromises()
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
expect(wrapper.text()).toContain('All caught up')
})
it('renders the fetched feed items', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<p>hello</p>',
url: 'https://example.test/1',
timestamp: '2026-01-01',
},
],
},
],
},
})
const wrapper = mount(RssFeeds)
await flushPromises()
expect(wrapper.text()).toContain('Article one')
expect(wrapper.text()).toContain('My Feed')
expect(wrapper.text()).not.toContain('All caught up')
})
it('renders the list as cards when the card layout is selected', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<p>hello</p>',
url: 'https://example.test/1',
timestamp: '2026-01-01',
},
],
},
],
},
})
const { layout } = useFeeds()
layout.value = 'cards'
const wrapper = mount(RssFeeds)
await flushPromises()
expect(wrapper.find('.article').classes()).toContain('article--cards')
})
it('shows the full article content in cards with no truncation, growing the card to fit', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<img src="https://example.test/photo.jpg" alt="photo"><br>short summary',
url: 'https://example.test/1',
timestamp: '2026-01-01',
},
],
},
],
},
})
// 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'
const wrapper = mount(RssFeeds)
await flushPromises()
// Preview images are shown (not hidden/truncated) and the snippet isn't clamped.
expect(wrapper.find('.feed-content img').exists()).toBe(true)
expect(wrapper.find('.feed-content').classes()).not.toContain('feed-content--clamped')
expect(wrapper.text()).toContain('short summary')
await wrapper.find('.feed-title').trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('full text')
})
it('sorts articles by date across feeds, newest first', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'Old Feed',
items: [
{
id: 1,
title: 'Older article',
content: '<p>old</p>',
url: 'https://example.test/1',
timestamp: '2026-01-01 10:00:00',
},
],
},
{
title: 'New Feed',
items: [
{
id: 2,
title: 'Newer article',
content: '<p>new</p>',
url: 'https://example.test/2',
timestamp: '2026-02-01 10:00:00',
},
],
},
],
},
})
const wrapper = mount(RssFeeds)
await flushPromises()
const titles = wrapper.findAll('.feed-title').map(el => el.text())
expect(titles).toEqual(['Newer article', 'Older article'])
})
it('keeps a link to the original article visible after the readable version is loaded', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<p>short summary</p>',
url: 'https://example.test/1',
timestamp: '2026-01-01',
},
],
},
],
},
})
// 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()
const link = wrapper.find('.feed-original-link a')
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toBe('https://example.test/1')
expect(link.attributes('target')).toBe('_blank')
await wrapper.find('.feed-title').trigger('click')
await flushPromises()
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 () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
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',
},
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
axios.post.mockResolvedValue({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
const wrapper = mount(RssFeeds)
await flushPromises()
// The view-toggle button now lives in AppNav's hamburger menu, not here —
// switch modes directly through the shared composable, as AppNav would.
useFeeds().toggleViewMode()
await flushPromises()
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.
// (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 .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(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 .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 .article-feature__title').text()).toBe('Article one')
})
it('drops articles read while paging through article view once back in the list', async () => {
axios.get.mockResolvedValueOnce({
data: {
feeds: [
{
title: 'My Feed',
items: [
{
id: 1,
title: 'Article one',
content: '<p>one</p>',
url: 'https://example.test/1',
timestamp: '2026-03-01 10:00:00',
},
{
id: 2,
title: 'Article two',
content: '<p>two</p>',
url: 'https://example.test/2',
timestamp: '2026-02-01 10:00:00',
},
{
id: 3,
title: 'Article three',
content: '<p>three</p>',
url: 'https://example.test/3',
timestamp: '2026-01-01 10:00:00',
},
],
},
],
},
})
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mount(RssFeeds)
await flushPromises()
const { toggleViewMode, leaveArticleView } = useFeeds()
// Enter article view (marks "Article one" read), page forward to "Article
// two" (marks it read too), then leave without visiting "Article three".
toggleViewMode()
await flushPromises()
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
await flushPromises()
leaveArticleView()
await flushPromises()
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()
})
})
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
-7
View File
@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
-19
View File
@@ -1,19 +0,0 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
+7 -5
View File
@@ -2,9 +2,10 @@
import '@/assets/modal.css';
import { ref } from 'vue';
import axios from 'axios';
const props = defineProps({
defineProps({
show: Boolean
})
const emit = defineEmits(['close', 'added'])
const submitted = ref(false)
const url = ref('')
const title = ref('')
@@ -15,7 +16,7 @@ async function save() {
submitted.value = true;
console.log('saved ' + url.value)
try {
const response = await axios.post("feeds/add", {
const response = await axios.post("/api/v1/article/add", {
url: url.value,
title: title.value,
user_id: parseInt(localStorage.getItem("user-id"))
@@ -30,6 +31,7 @@ async function save() {
console.log(response)
output.value = 'saved successfully'
emit('added')
} catch (error) {
console.error(error.message)
output.value = error.message
@@ -44,7 +46,7 @@ async function save() {
<div class="modal-header">
<slot name="header">Add RSS Feed</slot>
</div>
<form @submit.prevent="submitForm">
<form @submit.prevent="save">
<label for="name">URL:</label>
<input v-model="url" id="url" type="text" required />
<label for="name">Title:</label>
@@ -56,8 +58,8 @@ async function save() {
<div class="modal-footer">
<slot name="footer">
<button type="submit" @click="save">Save</button>
<button class="modal-default-button" @click="$emit('close')">Close</button>
<button type="submit">Save</button>
<button class="modal-default-button" type="button" @click="$emit('close')">Close</button>
</slot>
</div>
</form>
+145
View File
@@ -0,0 +1,145 @@
<script setup>
import '@/assets/modal.css';
import { ref, watch, onUnmounted, nextTick } from 'vue'
const props = defineProps({
show: Boolean,
titles: { type: Array, default: () => [] },
counts: { type: Object, default: () => ({}) },
selected: { type: Array, default: null },
})
const emit = defineEmits(['close'])
// Component-local draft: seeded from `selected` each time the modal opens
// (including on initial mount if it's already open — see the `immediate`
// watcher below) and only reported back (via `close`) once it's dismissed —
// see AppNav.vue for how the emitted selection is applied.
const draft = ref(null)
const allFeedsCheckboxRef = ref(null)
function isChecked(title) {
return draft.value !== null && draft.value.includes(title)
}
function onToggleAll(event) {
if (event.target.checked) {
draft.value = null
} else {
// The selection is never allowed to be empty — "All feeds" is the
// fallback state, so it can't be unchecked without checking a feed first.
event.target.checked = true
}
}
function onToggleFeed(title, checked) {
if (checked) {
draft.value = draft.value === null ? [title] : [...draft.value, title]
} else {
const remaining = (draft.value ?? []).filter(t => t !== title)
// Unchecking the last selected feed falls back to "All feeds" — see
// onToggleAll, the selection is never allowed to be empty.
draft.value = remaining.length > 0 ? remaining : null
}
}
function handleClose() {
emit('close', draft.value)
}
function onKeydown(event) {
if (event.key === 'Escape') handleClose()
}
// While the modal is open, scrolling the page behind it would run through
// list view's IntersectionObserver and mark articles read out from under the
// user — lock it, and restore whatever it was before. `''` is a legitimate
// saved overflow value, so `isLocked` (not a null-check on the saved value)
// tracks whether there's actually anything to restore — otherwise unlocking
// on unmount without ever having locked would stomp a value this component
// never touched.
let previousBodyOverflow = ''
let isLocked = false
function lockBodyScroll() {
previousBodyOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
isLocked = true
}
function unlockBodyScroll() {
if (!isLocked) return
document.body.style.overflow = previousBodyOverflow
isLocked = false
}
watch(() => props.show, async (isOpen) => {
if (isOpen) {
draft.value = props.selected ? [...props.selected] : null
lockBodyScroll()
window.addEventListener('keydown', onKeydown)
await nextTick()
allFeedsCheckboxRef.value?.focus()
} else {
unlockBodyScroll()
window.removeEventListener('keydown', onKeydown)
}
}, { immediate: true })
onUnmounted(() => {
unlockBodyScroll()
window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<Transition name="modal">
<div v-if="show" class="modal-mask" @click.self="handleClose">
<div class="modal-container" role="dialog" aria-modal="true" aria-labelledby="feed-filter-heading">
<div class="modal-header">
<h3 id="feed-filter-heading">Filter feeds</h3>
</div>
<div class="modal-body">
<label class="feed-filter__option">
<input
ref="allFeedsCheckboxRef"
type="checkbox"
:checked="draft === null"
@change="onToggleAll"
/>
All feeds
</label>
<label v-for="title in titles" :key="title" class="feed-filter__option">
<input
type="checkbox"
:checked="isChecked(title)"
@change="onToggleFeed(title, $event.target.checked)"
/>
{{ title }} ({{ counts[title] ?? 0 }})
</label>
</div>
<div class="modal-footer">
<button type="button" @click="handleClose">Done</button>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.feed-filter__option {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
cursor: pointer;
}
/* modal.css's global `input` rule (display: block, width: 100%, min-height:
44px, ...) is meant for the text inputs in AddUrl.vue's form — reset it
here so checkboxes don't render full-width and oversized. */
.feed-filter__option input[type='checkbox'] {
display: inline-block;
width: auto;
min-height: 0;
margin: 0;
padding: 0;
}
</style>
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import axios from 'axios'
import AddUrl from '../AddUrl.vue'
vi.mock('axios')
describe('AddUrl', () => {
beforeEach(() => {
localStorage.setItem('user-token', 'test-token')
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
})
it('posts the entered url and title and shows a success message', async () => {
axios.post.mockResolvedValueOnce({ status: 201 })
const wrapper = mount(AddUrl, { props: { show: true } })
await wrapper.find('#url').setValue('https://example.test/feed.xml')
await wrapper.find('#title').setValue('Example feed')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/article/add',
{ url: 'https://example.test/feed.xml', title: 'Example feed', user_id: 7 },
expect.anything(),
)
expect(wrapper.text()).toContain('saved successfully')
expect(wrapper.emitted('added')).toHaveLength(1)
})
it('surfaces the error message when the request fails', async () => {
axios.post.mockRejectedValueOnce({ message: 'Network Error' })
const wrapper = mount(AddUrl, { props: { show: true } })
await wrapper.find('#url').setValue('https://example.test/feed.xml')
await wrapper.find('#title').setValue('Example feed')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Network Error')
expect(wrapper.emitted('added')).toBeUndefined()
})
it('closes without submitting when Close is clicked, even with fields filled in', async () => {
const wrapper = mount(AddUrl, { props: { show: true } })
await wrapper.find('#url').setValue('https://example.test/feed.xml')
await wrapper.find('#title').setValue('Example feed')
await wrapper.find('.modal-default-button').trigger('click')
expect(axios.post).not.toHaveBeenCalled()
expect(wrapper.emitted('close')).toHaveLength(1)
})
})

Some files were not shown because too many files have changed in this diff Show More