Compare commits
30 Commits
6ae6490dec
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e3142bac9 | |||
| a73a1b57de | |||
| 3642635b20 | |||
| b3cf5e4787 | |||
| fe0adcf68e | |||
| 3f9de099aa | |||
| b9c0951f2b | |||
| 7a24980101 | |||
| dfc2e29e36 | |||
| a90d10368e | |||
| 5417176dd4 | |||
| 4d3f5d3285 | |||
| 967803c326 | |||
| e9c865a254 | |||
| 570db2d948 | |||
| a37d845875 | |||
| 8e57e2f02a | |||
| 3671b90b81 | |||
| a399ede401 | |||
| 82ec6ea902 | |||
| fbf3597984 | |||
| e9580037ef | |||
| b457b8abaa | |||
| 0820ce6ef7 | |||
| ed1241490d | |||
| 177d975b4d | |||
| 52ea84747a | |||
| 0420cf0dd5 | |||
| 972e967432 | |||
| 400648c3d1 |
@@ -1,3 +1,7 @@
|
|||||||
/target
|
/target
|
||||||
.env
|
.env
|
||||||
.claude
|
.claude
|
||||||
|
CLAUDE.md
|
||||||
|
LEARNINGS.md
|
||||||
|
PLAN.md
|
||||||
|
/memory
|
||||||
|
|||||||
Generated
+389
-29
@@ -34,6 +34,18 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "actix-governor"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a7ffa43d3e1e92518355ffbc82c146b5f0fe24fba87f19f405270da7a7b3c1e"
|
||||||
|
dependencies = [
|
||||||
|
"actix-http",
|
||||||
|
"actix-web",
|
||||||
|
"futures",
|
||||||
|
"governor",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "actix-http"
|
name = "actix-http"
|
||||||
version = "3.12.1"
|
version = "3.12.1"
|
||||||
@@ -52,7 +64,7 @@ dependencies = [
|
|||||||
"derive_more",
|
"derive_more",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"flate2",
|
"flate2",
|
||||||
"foldhash",
|
"foldhash 0.1.5",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"h2 0.3.27",
|
"h2 0.3.27",
|
||||||
"http 0.2.12",
|
"http 0.2.12",
|
||||||
@@ -167,7 +179,7 @@ dependencies = [
|
|||||||
"cookie",
|
"cookie",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"foldhash",
|
"foldhash 0.1.5",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"impl-more",
|
"impl-more",
|
||||||
@@ -231,6 +243,25 @@ dependencies = [
|
|||||||
"alloc-no-stdlib",
|
"alloc-no-stdlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "allocator-api2"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ammonia"
|
||||||
|
version = "4.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
|
||||||
|
dependencies = [
|
||||||
|
"cssparser 0.35.0",
|
||||||
|
"html5ever 0.35.0",
|
||||||
|
"maplit",
|
||||||
|
"tendril 0.4.3",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -612,6 +643,12 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crossbeam-utils"
|
||||||
|
version = "0.8.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
@@ -631,19 +668,42 @@ dependencies = [
|
|||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssparser"
|
||||||
|
version = "0.35.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
|
||||||
|
dependencies = [
|
||||||
|
"cssparser-macros 0.6.1",
|
||||||
|
"dtoa-short",
|
||||||
|
"itoa",
|
||||||
|
"phf 0.11.3",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser-macros",
|
"cssparser-macros 0.7.0",
|
||||||
"dtoa-short",
|
"dtoa-short",
|
||||||
"itoa",
|
"itoa",
|
||||||
"phf",
|
"phf 0.13.1",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cssparser-macros"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser-macros"
|
name = "cssparser-macros"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -724,6 +784,20 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dashmap"
|
||||||
|
version = "6.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"crossbeam-utils",
|
||||||
|
"hashbrown 0.14.5",
|
||||||
|
"lock_api",
|
||||||
|
"once_cell",
|
||||||
|
"parking_lot_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dateparser"
|
name = "dateparser"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1029,6 +1103,12 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foldhash"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@@ -1044,6 +1124,16 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futf"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
||||||
|
dependencies = [
|
||||||
|
"mac",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1115,6 +1205,12 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "futures-timer"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -1192,6 +1288,29 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "governor"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9efcab3c1958580ff1f25a2a41be1668f7603d849bb63af523b208a3cc1223b8"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"dashmap",
|
||||||
|
"futures-sink",
|
||||||
|
"futures-timer",
|
||||||
|
"futures-util",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"hashbrown 0.16.1",
|
||||||
|
"nonzero_ext",
|
||||||
|
"parking_lot",
|
||||||
|
"portable-atomic",
|
||||||
|
"quanta",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"smallvec",
|
||||||
|
"spinning_top",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.3.27"
|
version = "0.3.27"
|
||||||
@@ -1230,13 +1349,30 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foldhash",
|
"foldhash 0.1.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.16.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1260,6 +1396,17 @@ dependencies = [
|
|||||||
"digest 0.10.7",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "html5ever"
|
||||||
|
version = "0.35.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"markup5ever 0.35.0",
|
||||||
|
"match_token",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "html5ever"
|
name = "html5ever"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@@ -1267,7 +1414,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"markup5ever",
|
"markup5ever 0.39.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1752,6 +1899,29 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mac"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maplit"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markup5ever"
|
||||||
|
version = "0.35.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"tendril 0.4.3",
|
||||||
|
"web_atoms 0.1.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markup5ever"
|
name = "markup5ever"
|
||||||
version = "0.39.0"
|
version = "0.39.0"
|
||||||
@@ -1759,8 +1929,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"tendril",
|
"tendril 0.5.0",
|
||||||
"web_atoms",
|
"web_atoms 0.2.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "match_token"
|
||||||
|
version = "0.35.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1824,6 +2005,12 @@ version = "1.0.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nonzero_ext"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -1886,25 +2073,55 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||||
|
dependencies = [
|
||||||
|
"phf_macros 0.11.3",
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_macros",
|
"phf_macros 0.13.1",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_codegen"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator 0.11.3",
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_codegen"
|
name = "phf_codegen"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator 0.13.1",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_generator"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||||
|
dependencies = [
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
"rand 0.8.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1914,7 +2131,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_macros"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator 0.11.3",
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1923,13 +2153,22 @@ version = "0.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator 0.13.1",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phf_shared"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||||
|
dependencies = [
|
||||||
|
"siphasher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_shared"
|
name = "phf_shared"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@@ -2026,6 +2265,21 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quanta"
|
||||||
|
version = "0.12.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7"
|
||||||
|
dependencies = [
|
||||||
|
"crossbeam-utils",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"raw-cpuid",
|
||||||
|
"wasi",
|
||||||
|
"web-sys",
|
||||||
|
"winapi",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
@@ -2113,6 +2367,15 @@ version = "6.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand"
|
name = "rand"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -2144,6 +2407,12 @@ dependencies = [
|
|||||||
"rand_core 0.9.5",
|
"rand_core 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rand_core"
|
name = "rand_core"
|
||||||
version = "0.9.5"
|
version = "0.9.5"
|
||||||
@@ -2159,6 +2428,15 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "raw-cpuid"
|
||||||
|
version = "11.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -2272,12 +2550,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rss-reader"
|
name = "rss-reader"
|
||||||
version = "0.1.0"
|
version = "0.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix-cors",
|
"actix-cors",
|
||||||
|
"actix-governor",
|
||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-service",
|
"actix-service",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
|
"ammonia",
|
||||||
|
"anyhow",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dateparser",
|
"dateparser",
|
||||||
@@ -2432,13 +2713,13 @@ version = "0.27.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser",
|
"cssparser 0.37.0",
|
||||||
"ego-tree",
|
"ego-tree",
|
||||||
"getopts",
|
"getopts",
|
||||||
"html5ever",
|
"html5ever 0.39.0",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
"selectors",
|
"selectors",
|
||||||
"tendril",
|
"tendril 0.5.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2471,12 +2752,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"cssparser",
|
"cssparser 0.37.0",
|
||||||
"derive_more",
|
"derive_more",
|
||||||
"log",
|
"log",
|
||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
"phf",
|
"phf 0.13.1",
|
||||||
"phf_codegen",
|
"phf_codegen 0.13.1",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"servo_arc",
|
"servo_arc",
|
||||||
@@ -2660,12 +2941,34 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spinning_top"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300"
|
||||||
|
dependencies = [
|
||||||
|
"lock_api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "string_cache"
|
||||||
|
version = "0.8.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
||||||
|
dependencies = [
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"parking_lot",
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
"precomputed-hash",
|
||||||
|
"serde",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache"
|
name = "string_cache"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2674,18 +2977,30 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"new_debug_unreachable",
|
"new_debug_unreachable",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
"precomputed-hash",
|
"precomputed-hash",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "string_cache_codegen"
|
||||||
|
version = "0.5.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
||||||
|
dependencies = [
|
||||||
|
"phf_generator 0.11.3",
|
||||||
|
"phf_shared 0.11.3",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "string_cache_codegen"
|
name = "string_cache_codegen"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator 0.13.1",
|
||||||
"phf_shared",
|
"phf_shared 0.13.1",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
@@ -2754,6 +3069,17 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tendril"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
||||||
|
dependencies = [
|
||||||
|
"futf",
|
||||||
|
"mac",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tendril"
|
name = "tendril"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -3247,16 +3573,28 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web_atoms"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
|
||||||
|
dependencies = [
|
||||||
|
"phf 0.11.3",
|
||||||
|
"phf_codegen 0.11.3",
|
||||||
|
"string_cache 0.8.9",
|
||||||
|
"string_cache_codegen 0.5.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web_atoms"
|
name = "web_atoms"
|
||||||
version = "0.2.4"
|
version = "0.2.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf",
|
"phf 0.13.1",
|
||||||
"phf_codegen",
|
"phf_codegen 0.13.1",
|
||||||
"string_cache",
|
"string_cache 0.9.0",
|
||||||
"string_cache_codegen",
|
"string_cache_codegen 0.6.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3268,6 +3606,22 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||||
|
dependencies = [
|
||||||
|
"winapi-i686-pc-windows-gnu",
|
||||||
|
"winapi-x86_64-pc-windows-gnu",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-i686-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
@@ -3277,6 +3631,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winapi-x86_64-pc-windows-gnu"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
|
|||||||
+4
-1
@@ -1,11 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rss-reader"
|
name = "rss-reader"
|
||||||
version = "0.1.0"
|
version = "0.9.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "blocking"] }
|
reqwest = { version = "0.13", features = ["json", "blocking"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
rss = { version = "2.0.13" }
|
rss = { version = "2.0.13" }
|
||||||
@@ -29,6 +30,8 @@ scraper = "0.27"
|
|||||||
actix-cors = "0.7"
|
actix-cors = "0.7"
|
||||||
chrono = { version = "0.4.45", features = ["serde"] }
|
chrono = { version = "0.4.45", features = ["serde"] }
|
||||||
dateparser = "0.3"
|
dateparser = "0.3"
|
||||||
|
ammonia = "4.1.2"
|
||||||
|
actix-governor = "0.10.0"
|
||||||
|
|
||||||
[dependencies.serde_json]
|
[dependencies.serde_json]
|
||||||
version = "1.0.150"
|
version = "1.0.150"
|
||||||
|
|||||||
+4
-2
@@ -7,7 +7,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN cargo build --release
|
RUN cargo build --release && \
|
||||||
|
cp target/release/rss-reader /usr/local/bin/rss-reader && \
|
||||||
|
rm -rf target
|
||||||
|
|
||||||
# --- runtime ---
|
# --- runtime ---
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
@@ -16,7 +18,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libpq5 ca-certificates \
|
libpq5 ca-certificates \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/rss-reader /usr/local/bin/rss-reader
|
COPY --from=builder /usr/local/bin/rss-reader /usr/local/bin/rss-reader
|
||||||
|
|
||||||
EXPOSE 8001
|
EXPOSE 8001
|
||||||
CMD ["rss-reader"]
|
CMD ["rss-reader"]
|
||||||
|
|||||||
@@ -82,10 +82,12 @@ Create a user, then log in through the UI at `http://localhost:5173`:
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -X POST -H "Content-Type: application/json" \
|
curl -X POST -H "Content-Type: application/json" \
|
||||||
-d '{"name": "mace", "email": "you@example.com", "password": "secret"}' \
|
-d '{"name": "mace", "email": "you@example.com", "password": "secret1"}' \
|
||||||
http://localhost:8001/api/v1/user/create
|
http://localhost:8001/api/v1/user/create
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Passwords must be at least 6 characters.
|
||||||
|
|
||||||
### Useful commands during development
|
### Useful commands during development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -110,6 +112,30 @@ docker exec -it rss-postgres psql -d rss -U admin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- **Sessions**: login returns a JWT (`token` header) valid for 24 hours. Logging out
|
||||||
|
(`POST /api/v1/auth/logout`, requires the current token) bumps the user's
|
||||||
|
`token_version`, which immediately invalidates *all* outstanding tokens for that
|
||||||
|
account — there's no per-session revocation, so logging out on one device logs out
|
||||||
|
every device.
|
||||||
|
- **Login rate limiting**: `POST /api/v1/auth/login` is limited to a burst of 5 requests
|
||||||
|
per IP, replenishing one every 2 seconds (`actix-governor`).
|
||||||
|
- **Outbound fetches**: feed syncs and the article-reader endpoint only fetch
|
||||||
|
`http`/`https` URLs that resolve to public IP addresses (no loopback/private/
|
||||||
|
link-local, e.g. `127.0.0.1` or the `169.254.169.254` cloud metadata address).
|
||||||
|
Redirects are followed (up to 5 hops), but each redirect target is checked against the
|
||||||
|
same rules before being fetched, so a redirect can't be used to reach an internal
|
||||||
|
address.
|
||||||
|
- **Stored feed content**: `<img>` tags from synced feed content are sanitized
|
||||||
|
(`ammonia`) down to `src`/`alt`/`title` before being stored, since they're later
|
||||||
|
rendered with `v-html` in the frontend.
|
||||||
|
- **If `JWT_SECRET` or `POSTGRES_PASSWORD` are ever leaked** (e.g. committed to git),
|
||||||
|
rotate them in `.env` and restart the backend — rotating `JWT_SECRET` invalidates every
|
||||||
|
outstanding token as a side effect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Production setup (Docker)
|
## Production setup (Docker)
|
||||||
|
|
||||||
The whole stack — Postgres, backend, and frontend — runs via Docker Compose. The frontend is built as a static Vue bundle and served by nginx, which also reverse-proxies `/api/` to the backend container.
|
The whole stack — Postgres, backend, and frontend — runs via Docker Compose. The frontend is built as a static Vue bundle and served by nginx, which also reverse-proxies `/api/` to the backend container.
|
||||||
@@ -143,8 +169,13 @@ docker compose logs -f backend # follow backend logs
|
|||||||
docker compose down # stop everything (keeps the postgres_data volume)
|
docker compose down # stop everything (keeps the postgres_data volume)
|
||||||
docker compose down -v # stop and wipe all data — careful!
|
docker compose down -v # stop and wipe all data — careful!
|
||||||
docker compose up --build -d # rebuild after pulling code changes
|
docker compose up --build -d # rebuild after pulling code changes
|
||||||
|
docker builder prune -af && docker image prune -af # reclaim disk used by old build layers/images
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Each `docker compose up --build` leaves the previous build's cache layers and images
|
||||||
|
> behind, which adds up quickly given how much disk `cargo build` needs. Run the prune
|
||||||
|
> command above after each rebuild (or on a cron job) to reclaim that space.
|
||||||
|
|
||||||
### Optional: hardened deployment — isolated user + rootless Docker
|
### Optional: hardened deployment — isolated user + rootless Docker
|
||||||
|
|
||||||
Anyone who can run `docker` commands effectively has root on the host (container volume mounts can reach the whole filesystem) — being in the `docker` group is root-equivalent. For a production server, it's worth confining this stack to a dedicated, unprivileged system user running its own **rootless Docker** daemon, instead of using a system-wide install or adding the user to the `docker` group.
|
Anyone who can run `docker` commands effectively has root on the host (container volume mounts can reach the whole filesystem) — being in the `docker` group is root-equivalent. For a production server, it's worth confining this stack to a dedicated, unprivileged system user running its own **rootless Docker** daemon, instead of using a system-wide install or adding the user to the `docker` group.
|
||||||
@@ -266,6 +297,7 @@ Fill in `.env` with strong, unique secrets — `openssl rand -hex 32` is a conve
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose up --build -d
|
docker compose up --build -d
|
||||||
|
docker builder prune -af && docker image prune -af # reclaim disk used by old build layers/images
|
||||||
```
|
```
|
||||||
|
|
||||||
**6. Firewall** (run as your normal sudo-capable user — not `rss-svc`):
|
**6. Firewall** (run as your normal sudo-capable user — not `rss-svc`):
|
||||||
|
|||||||
@@ -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,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"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
-13
@@ -2,17 +2,32 @@ extern crate hmac;
|
|||||||
extern crate jwt;
|
extern crate jwt;
|
||||||
extern crate sha2;
|
extern crate sha2;
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
use actix_web::HttpRequest;
|
use actix_web::HttpRequest;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
use dotenv::dotenv;
|
use dotenv::dotenv;
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
use jwt::{Header, SignWithKey, Token, VerifyWithKey};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
/// How long a freshly issued token remains valid for.
|
||||||
|
const TOKEN_LIFETIME_HOURS: i64 = 730;
|
||||||
|
|
||||||
pub struct JwtToken {
|
pub struct JwtToken {
|
||||||
pub user_id: i32,
|
pub user_id: i32,
|
||||||
|
pub token_version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
user_id: i32,
|
||||||
|
/// Must match `users.token_version` for the token to be accepted; bumping
|
||||||
|
/// the column (e.g. on logout) revokes every token issued before that point.
|
||||||
|
tv: i32,
|
||||||
|
/// Unix timestamp after which the token is rejected, independent of signature validity.
|
||||||
|
exp: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
type HmacSha256 = Hmac<Sha256>;
|
type HmacSha256 = Hmac<Sha256>;
|
||||||
@@ -20,29 +35,39 @@ type HmacSha256 = Hmac<Sha256>;
|
|||||||
fn signing_key() -> HmacSha256 {
|
fn signing_key() -> HmacSha256 {
|
||||||
dotenv().ok();
|
dotenv().ok();
|
||||||
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
HmacSha256::new_from_slice(secret.as_bytes()).unwrap()
|
// HMAC-SHA256 accepts a key of any length, so this cannot fail.
|
||||||
|
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts a key of any length")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JwtToken {
|
impl JwtToken {
|
||||||
pub fn encode(user_id: i32) -> String {
|
pub fn encode(user_id: i32, token_version: i32) -> String {
|
||||||
let key: HmacSha256 = signing_key();
|
let key: HmacSha256 = signing_key();
|
||||||
let mut claims = BTreeMap::new();
|
let claims = Claims {
|
||||||
claims.insert("user_id", user_id);
|
user_id,
|
||||||
claims.sign_with_key(&key).unwrap()
|
tv: token_version,
|
||||||
|
exp: (Utc::now() + Duration::hours(TOKEN_LIFETIME_HOURS)).timestamp(),
|
||||||
|
};
|
||||||
|
// Signing claims with a valid HMAC key cannot fail.
|
||||||
|
claims
|
||||||
|
.sign_with_key(&key)
|
||||||
|
.expect("signing claims with a valid HMAC key cannot fail")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
|
pub fn decode(encoded_token: String) -> Result<JwtToken, &'static str> {
|
||||||
let key: HmacSha256 = signing_key();
|
let key: HmacSha256 = signing_key();
|
||||||
let token_str: &str = encoded_token.as_str();
|
let token_str: &str = encoded_token.as_str();
|
||||||
let token: Result<Token<Header, BTreeMap<String, i32>, jwt::Verified>, jwt::Error> =
|
let token: Result<Token<Header, Claims, jwt::Verified>, jwt::Error> =
|
||||||
VerifyWithKey::verify_with_key(token_str, &key);
|
VerifyWithKey::verify_with_key(token_str, &key);
|
||||||
|
|
||||||
match token {
|
match token {
|
||||||
Ok(token) => {
|
Ok(token) => {
|
||||||
let _header = token.header();
|
|
||||||
let claims = token.claims();
|
let claims = token.claims();
|
||||||
|
if claims.exp < Utc::now().timestamp() {
|
||||||
|
return Err("token has expired");
|
||||||
|
}
|
||||||
Ok(JwtToken {
|
Ok(JwtToken {
|
||||||
user_id: claims["user_id"],
|
user_id: claims.user_id,
|
||||||
|
token_version: claims.tv,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Err(_err) => Err("could not decode token"),
|
Err(_err) => Err("could not decode token"),
|
||||||
@@ -52,7 +77,10 @@ impl JwtToken {
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
|
pub fn decode_from_request(request: HttpRequest) -> Result<JwtToken, &'static str> {
|
||||||
match request.headers().get("user-token") {
|
match request.headers().get("user-token") {
|
||||||
Some(token) => JwtToken::decode(String::from(token.to_str().unwrap())),
|
Some(token) => match token.to_str() {
|
||||||
|
Ok(token_str) => JwtToken::decode(String::from(token_str)),
|
||||||
|
Err(_) => Err("token header is not valid text"),
|
||||||
|
},
|
||||||
None => Err("There is no token"),
|
None => Err("There is no token"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,14 +89,19 @@ impl JwtToken {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod jwt_test {
|
mod jwt_test {
|
||||||
use actix_web::{http::header, test};
|
use actix_web::{http::header, test};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use hmac::Hmac;
|
||||||
|
use jwt::SignWithKey;
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
use super::JwtToken;
|
use super::{Claims, JwtToken};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
async fn encode_decode() {
|
async fn encode_decode() {
|
||||||
let encoded_token: String = JwtToken::encode(32);
|
let encoded_token: String = JwtToken::encode(32, 0);
|
||||||
let decoded_token: JwtToken = JwtToken::decode(encoded_token).unwrap();
|
let decoded_token: JwtToken = JwtToken::decode(encoded_token).unwrap();
|
||||||
assert_eq!(32, decoded_token.user_id);
|
assert_eq!(32, decoded_token.user_id);
|
||||||
|
assert_eq!(0, decoded_token.token_version);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -81,9 +114,25 @@ mod jwt_test {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
async fn decode_expired_token() {
|
||||||
|
let key: Hmac<Sha256> = super::signing_key();
|
||||||
|
let claims = Claims {
|
||||||
|
user_id: 32,
|
||||||
|
tv: 0,
|
||||||
|
exp: (Utc::now() - Duration::hours(1)).timestamp(),
|
||||||
|
};
|
||||||
|
let expired_token: String = claims.sign_with_key(&key).unwrap();
|
||||||
|
|
||||||
|
match JwtToken::decode(expired_token) {
|
||||||
|
Err(message) => assert_eq!(message, "token has expired"),
|
||||||
|
_ => panic!("Expired token should not be accepted."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn decode_from_request_with_correct_token() {
|
async fn decode_from_request_with_correct_token() {
|
||||||
let encoded_token: String = JwtToken::encode(32);
|
let encoded_token: String = JwtToken::encode(32, 0);
|
||||||
let request = test::TestRequest::default()
|
let request = test::TestRequest::default()
|
||||||
.insert_header(header::ContentType::json())
|
.insert_header(header::ContentType::json())
|
||||||
.insert_header(("user-token", encoded_token))
|
.insert_header(("user-token", encoded_token))
|
||||||
|
|||||||
+14
-8
@@ -1,12 +1,13 @@
|
|||||||
use actix_web::dev::ServiceRequest;
|
use actix_web::dev::ServiceRequest;
|
||||||
|
pub mod extractor;
|
||||||
pub mod jwt;
|
pub mod jwt;
|
||||||
pub mod processes;
|
pub mod processes;
|
||||||
use crate::auth::processes::check_password;
|
use crate::auth::processes::check_token;
|
||||||
use crate::auth::processes::extract_header_token;
|
use crate::auth::processes::extract_header_token;
|
||||||
|
|
||||||
pub fn process_token(request: &ServiceRequest) -> Result<i32, &'static str> {
|
pub fn process_token(request: &ServiceRequest) -> Result<i32, &'static str> {
|
||||||
match extract_header_token(request) {
|
match extract_header_token(request) {
|
||||||
Ok(token) => check_password(token),
|
Ok(token) => check_token(token),
|
||||||
Err(message) => Err(message),
|
Err(message) => Err(message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,19 +17,24 @@ mod mod_test {
|
|||||||
|
|
||||||
use actix_web::test::TestRequest;
|
use actix_web::test::TestRequest;
|
||||||
|
|
||||||
use super::{jwt::JwtToken, process_token};
|
use super::process_token;
|
||||||
|
use crate::auth::jwt::JwtToken;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::test_helpers::{delete_user, insert_user};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn process_token_test() {
|
fn process_token_test() {
|
||||||
let token = JwtToken::encode(32);
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
|
let token = JwtToken::encode(user.id, user.token_version);
|
||||||
let request = TestRequest::delete()
|
let request = TestRequest::delete()
|
||||||
.insert_header(("user-token", token))
|
.insert_header(("user-token", token))
|
||||||
.to_srv_request();
|
.to_srv_request();
|
||||||
|
|
||||||
match process_token(&request) {
|
assert_eq!(Ok(user.id), process_token(&request));
|
||||||
Ok(user_id) => assert_eq!(32, user_id),
|
|
||||||
Err(_) => panic!("process token failed"),
|
delete_user(&mut connection, user.id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
|
|||||||
+54
-23
@@ -1,22 +1,33 @@
|
|||||||
use super::jwt;
|
use super::jwt;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::models::user::rss_user::User;
|
||||||
|
use crate::schema::users;
|
||||||
use actix_web::dev::ServiceRequest;
|
use actix_web::dev::ServiceRequest;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
pub fn check_password(password: String) -> Result<i32, &'static str> {
|
/// Decodes the token and confirms it hasn't been revoked, i.e. its `token_version`
|
||||||
match jwt::JwtToken::decode(password) {
|
/// still matches the one stored on the user (bumped on logout / password change).
|
||||||
Ok(token) => Ok(token.user_id),
|
pub fn check_token(token: String) -> Result<i32, &'static str> {
|
||||||
Err(message) => Err(message),
|
let decoded = jwt::JwtToken::decode(token)?;
|
||||||
|
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user: User = users::table
|
||||||
|
.find(decoded.user_id)
|
||||||
|
.first(&mut connection)
|
||||||
|
.map_err(|_| "could not decode token")?;
|
||||||
|
|
||||||
|
if user.token_version != decoded.token_version {
|
||||||
|
return Err("token has been revoked");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(decoded.user_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
|
pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static str> {
|
||||||
log::info!("Request: {:?}", request);
|
|
||||||
match request.headers().get("user-token") {
|
match request.headers().get("user-token") {
|
||||||
Some(token) => match token.to_str() {
|
Some(token) => match token.to_str() {
|
||||||
Ok(processed_password) => {
|
Ok(processed_token) => Ok(String::from(processed_token)),
|
||||||
log::info!("Token provided: {}", processed_password);
|
Err(_) => Err("there was an error processing token"),
|
||||||
Ok(String::from(processed_password))
|
|
||||||
}
|
|
||||||
Err(_processed_password) => Err("there was an error processing token"),
|
|
||||||
},
|
},
|
||||||
None => Err("there is no token"),
|
None => Err("there is no token"),
|
||||||
}
|
}
|
||||||
@@ -25,31 +36,51 @@ pub fn extract_header_token(request: &ServiceRequest) -> Result<String, &'static
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod processes_test {
|
mod processes_test {
|
||||||
use actix_web::test::TestRequest;
|
use actix_web::test::TestRequest;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use crate::auth::jwt::JwtToken;
|
use crate::auth::jwt::JwtToken;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::test_helpers::{delete_user, insert_user};
|
||||||
|
|
||||||
use super::check_password;
|
use super::check_token;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn check_correct_password() {
|
fn check_correct_token() {
|
||||||
let password_string: String = JwtToken::encode(32);
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
let result = check_password(password_string);
|
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||||
|
|
||||||
match result {
|
let result = check_token(token);
|
||||||
Ok(user_id) => assert_eq!(32, user_id),
|
|
||||||
_ => panic!("Check correct password failed."),
|
assert_eq!(Ok(user.id), result);
|
||||||
}
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn incorrect_check_password() {
|
fn revoked_token_is_rejected() {
|
||||||
let password: String = String::from("test");
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
match check_password(password) {
|
// Token signed with the user's current version, then the version is bumped
|
||||||
Err(message) => assert_eq!("could not decode token", message),
|
// (as logout would do), which must invalidate the previously issued token.
|
||||||
_ => panic!("check password should not be able to be decoded"),
|
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||||
|
diesel::update(crate::schema::users::table.find(user.id))
|
||||||
|
.set(crate::schema::users::token_version.eq(user.token_version + 1))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(Err("token has been revoked"), check_token(token));
|
||||||
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn incorrect_check_token() {
|
||||||
|
let token: String = String::from("test");
|
||||||
|
|
||||||
|
assert_eq!(Err("could not decode token"), check_token(token));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,12 @@ impl Responder for Articles {
|
|||||||
type Body = String;
|
type Body = String;
|
||||||
|
|
||||||
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
||||||
let body = serde_json::to_string(&self).unwrap();
|
match serde_json::to_string(&self) {
|
||||||
HttpResponse::with_body(StatusCode::OK, body)
|
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Failed to serialize response: {}", err);
|
||||||
|
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,12 @@ impl Responder for Readable {
|
|||||||
type Body = String;
|
type Body = String;
|
||||||
|
|
||||||
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
fn respond_to(self, _req: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> {
|
||||||
let body = serde_json::to_string(&self).unwrap();
|
match serde_json::to_string(&self) {
|
||||||
HttpResponse::with_body(StatusCode::OK, body)
|
Ok(body) => HttpResponse::with_body(StatusCode::OK, body),
|
||||||
|
Err(err) => {
|
||||||
|
log::error!("Failed to serialize response: {}", err);
|
||||||
|
HttpResponse::with_body(StatusCode::INTERNAL_SERVER_ERROR, String::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-14
@@ -3,12 +3,13 @@ extern crate dotenv;
|
|||||||
|
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_service::Service;
|
use actix_service::Service;
|
||||||
use actix_web::{App, HttpResponse, HttpServer};
|
use actix_web::{App, HttpMessage, HttpResponse, HttpServer};
|
||||||
use dotenv::dotenv;
|
use dotenv::dotenv;
|
||||||
use futures::future::{ok, Either};
|
use futures::future::{ok, Either};
|
||||||
use std::env;
|
use std::env;
|
||||||
mod auth;
|
mod auth;
|
||||||
mod database;
|
mod database;
|
||||||
|
mod error;
|
||||||
mod json_serialization;
|
mod json_serialization;
|
||||||
mod models;
|
mod models;
|
||||||
mod reader;
|
mod reader;
|
||||||
@@ -36,28 +37,32 @@ async fn main() -> std::io::Result<()> {
|
|||||||
|
|
||||||
App::new()
|
App::new()
|
||||||
.wrap_fn(|req, srv| {
|
.wrap_fn(|req, srv| {
|
||||||
let mut passed: bool;
|
|
||||||
let request_url: String = String::from(req.uri().path());
|
let request_url: String = String::from(req.uri().path());
|
||||||
|
|
||||||
log::info!("Request Url: {}", request_url);
|
log::info!("Request Url: {}", request_url);
|
||||||
if req.path().contains("/article/") {
|
|
||||||
|
// Only these endpoints are reachable without a valid token. Everything
|
||||||
|
// else (in particular all `/article/*` endpoints) requires one.
|
||||||
|
let is_public = matches!(
|
||||||
|
request_url.as_str(),
|
||||||
|
"/api/v1/auth/login" | "/api/v1/user/create"
|
||||||
|
);
|
||||||
|
|
||||||
|
let passed = if is_public {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
match auth::process_token(&req) {
|
match auth::process_token(&req) {
|
||||||
Ok(user_id) => {
|
Ok(user_id) => {
|
||||||
log::info!("Authenticated user {} for {}", user_id, request_url);
|
log::info!("Authenticated user {} for {}", user_id, request_url);
|
||||||
passed = true;
|
req.extensions_mut().insert(auth::extractor::AuthUser(user_id));
|
||||||
|
true
|
||||||
}
|
}
|
||||||
Err(_message) => passed = false,
|
Err(message) => {
|
||||||
|
log::warn!("Rejected request to {}: {}", request_url, message);
|
||||||
|
false
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
log::warn!("No auth check done.");
|
|
||||||
passed = true;
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
if req.path().contains("user/create") {
|
|
||||||
passed = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
log::info!("passed: {:?}", passed);
|
|
||||||
|
|
||||||
let end_result = match passed {
|
let end_result = match passed {
|
||||||
true => Either::Left(srv.call(req)),
|
true => Either::Left(srv.call(req)),
|
||||||
|
|||||||
@@ -6,6 +6,19 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::schema::users;
|
use crate::schema::users;
|
||||||
|
|
||||||
|
pub const MIN_PASSWORD_LENGTH: usize = 6;
|
||||||
|
|
||||||
|
/// Rejects empty/trivial passwords. Kept as a standalone function so callers
|
||||||
|
/// (e.g. the user-creation handler) can return a `400` for this case
|
||||||
|
/// specifically, rather than the `500` that `NewUser::new`'s `anyhow::Result`
|
||||||
|
/// would otherwise map to.
|
||||||
|
pub fn validate_password(password: &str) -> Result<(), &'static str> {
|
||||||
|
if password.trim().len() < MIN_PASSWORD_LENGTH {
|
||||||
|
return Err("password must be at least 6 characters long");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Insertable, Clone)]
|
#[derive(Insertable, Clone)]
|
||||||
#[diesel(table_name=users)]
|
#[diesel(table_name=users)]
|
||||||
pub struct NewUser {
|
pub struct NewUser {
|
||||||
@@ -16,14 +29,15 @@ pub struct NewUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl NewUser {
|
impl NewUser {
|
||||||
pub fn new(username: String, email: String, password: String) -> NewUser {
|
pub fn new(username: String, email: String, password: String) -> anyhow::Result<NewUser> {
|
||||||
let hashed_password: String = hash(password.as_str(), DEFAULT_COST).unwrap();
|
validate_password(&password).map_err(anyhow::Error::msg)?;
|
||||||
|
let hashed_password: String = hash(password.as_str(), DEFAULT_COST)?;
|
||||||
let uuid = Uuid::new_v4();
|
let uuid = Uuid::new_v4();
|
||||||
NewUser {
|
Ok(NewUser {
|
||||||
username,
|
username,
|
||||||
email,
|
email,
|
||||||
password: hashed_password,
|
password: hashed_password,
|
||||||
unique_id: uuid.to_string(),
|
unique_id: uuid.to_string(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,11 @@ pub struct User {
|
|||||||
pub email: String,
|
pub email: String,
|
||||||
pub password: String,
|
pub password: String,
|
||||||
pub unique_id: String,
|
pub unique_id: String,
|
||||||
|
pub token_version: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
pub fn verify(self, password: String) -> bool {
|
pub fn verify(self, password: String) -> anyhow::Result<bool> {
|
||||||
verify(password.as_str(), &self.password).unwrap()
|
Ok(verify(password.as_str(), &self.password)?)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-9
@@ -2,13 +2,17 @@ use actix_web::{web, HttpResponse};
|
|||||||
use diesel::RunQueryDsl;
|
use diesel::RunQueryDsl;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
database::establish_connection, json_serialization::new_feed::NewFeedSchema,
|
auth::extractor::AuthUser, database::establish_connection,
|
||||||
models::feed::new_feed::NewFeed, schema::feed,
|
json_serialization::new_feed::NewFeedSchema, models::feed::new_feed::NewFeed, schema::feed,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::feeds;
|
use super::feeds;
|
||||||
|
|
||||||
pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
|
pub async fn add(new_feed: web::Json<NewFeedSchema>, auth_user: AuthUser) -> HttpResponse {
|
||||||
|
if auth_user.0 != new_feed.user_id {
|
||||||
|
return HttpResponse::Forbidden().finish();
|
||||||
|
}
|
||||||
|
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
let title: String = new_feed.title.clone();
|
let title: String = new_feed.title.clone();
|
||||||
let url: String = new_feed.url.clone();
|
let url: String = new_feed.url.clone();
|
||||||
@@ -19,12 +23,12 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
|
|||||||
Ok(channel) => {
|
Ok(channel) => {
|
||||||
log::info!("valid channel");
|
log::info!("valid channel");
|
||||||
if channel.items.is_empty() {
|
if channel.items.is_empty() {
|
||||||
return HttpResponse::ServiceUnavailable().await.unwrap();
|
return HttpResponse::ServiceUnavailable().finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("{:?}", e);
|
log::error!("{:?}", e);
|
||||||
return HttpResponse::NotFound().await.unwrap();
|
return HttpResponse::NotFound().finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,25 +39,35 @@ pub async fn add(new_feed: web::Json<NewFeedSchema>) -> HttpResponse {
|
|||||||
.execute(&mut connection);
|
.execute(&mut connection);
|
||||||
|
|
||||||
match insert_result {
|
match insert_result {
|
||||||
Ok(_) => HttpResponse::Created().await.unwrap(),
|
Ok(_) => HttpResponse::Created().finish(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("{e}");
|
log::error!("{e}");
|
||||||
HttpResponse::Conflict().await.unwrap()
|
HttpResponse::Conflict().finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
use actix_web::http::StatusCode;
|
use actix_web::http::StatusCode;
|
||||||
use actix_web::{test, web, App};
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
|
||||||
use super::add;
|
use super::add;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
use crate::test_helpers::unique_suffix;
|
use crate::test_helpers::unique_suffix;
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn add_fails_for_unfetchable_feed_url() {
|
async fn add_fails_for_unfetchable_feed_url() {
|
||||||
let app = test::init_service(App::new().route("/add", web::post().to(add))).await;
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(1));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/add", web::post().to(add)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let req = test::TestRequest::post()
|
let req = test::TestRequest::post()
|
||||||
.uri("/add")
|
.uri("/add")
|
||||||
.set_json(serde_json::json!({
|
.set_json(serde_json::json!({
|
||||||
@@ -66,4 +80,28 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn add_rejects_feed_for_another_user() {
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(1));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/add", web::post().to(add)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::post()
|
||||||
|
.uri("/add")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"title": "Someone else's feed",
|
||||||
|
"url": format!("https://example.test/feed/{}", unique_suffix()),
|
||||||
|
"user_id": 2
|
||||||
|
}))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::FORBIDDEN, resp.status());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
use actix_web::{web, HttpResponse};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::extractor::AuthUser,
|
||||||
|
database::establish_connection,
|
||||||
|
schema::{feed, feed_item},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn delete_feed(path: web::Path<i32>, auth_user: AuthUser) -> HttpResponse {
|
||||||
|
let feed_id = path.into_inner();
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
|
||||||
|
let owner: Option<i32> = feed::table
|
||||||
|
.find(feed_id)
|
||||||
|
.select(feed::user_id)
|
||||||
|
.first(&mut connection)
|
||||||
|
.optional()
|
||||||
|
.unwrap_or(None);
|
||||||
|
|
||||||
|
// Treat "doesn't exist" and "not yours" the same, so callers can't probe
|
||||||
|
// for other users' feed ids.
|
||||||
|
if owner != Some(auth_user.0) {
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::delete(feed_item::table.filter(feed_item::feed_id.eq(feed_id)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
diesel::delete(feed::table.filter(feed::id.eq(feed_id)))
|
||||||
|
.execute(&mut connection)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
HttpResponse::NoContent().finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use super::delete_feed;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::schema::{feed, feed_item};
|
||||||
|
use crate::test_helpers::{
|
||||||
|
delete_feed as cleanup_feed, delete_user, insert_feed, insert_feed_item, insert_user,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn delete_feed_removes_feed_and_items() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let f = insert_feed(&mut connection, user.id);
|
||||||
|
let item = insert_feed_item(&mut connection, f.id, false);
|
||||||
|
|
||||||
|
let user_id = user.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::delete()
|
||||||
|
.uri(&format!("/feed/{}", f.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NO_CONTENT, resp.status());
|
||||||
|
|
||||||
|
let feed_exists: i64 = feed::table
|
||||||
|
.filter(feed::id.eq(f.id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(0, feed_exists);
|
||||||
|
|
||||||
|
let item_exists: i64 = feed_item::table
|
||||||
|
.filter(feed_item::id.eq(item.id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(0, item_exists);
|
||||||
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn delete_feed_returns_404_for_nonexistent_feed() {
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(1));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::delete()
|
||||||
|
.uri("/feed/999999999")
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn delete_feed_does_not_affect_other_feeds() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let feed_a = insert_feed(&mut connection, user.id);
|
||||||
|
let feed_b = insert_feed(&mut connection, user.id);
|
||||||
|
|
||||||
|
let user_id = user.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::delete()
|
||||||
|
.uri(&format!("/feed/{}", feed_a.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NO_CONTENT, resp.status());
|
||||||
|
|
||||||
|
let feed_b_exists: i64 = feed::table
|
||||||
|
.filter(feed::id.eq(feed_b.id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(1, feed_b_exists);
|
||||||
|
|
||||||
|
cleanup_feed(&mut connection, feed_b.id);
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn delete_feed_rejects_other_users_feed() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user_a = insert_user(&mut connection, "secret");
|
||||||
|
let user_b = insert_user(&mut connection, "secret");
|
||||||
|
let feed_b = insert_feed(&mut connection, user_b.id);
|
||||||
|
|
||||||
|
let user_a_id = user_a.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_a_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feed/{feed_id}", web::delete().to(delete_feed)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::delete()
|
||||||
|
.uri(&format!("/feed/{}", feed_b.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
|
|
||||||
|
let feed_b_exists: i64 = feed::table
|
||||||
|
.filter(feed::id.eq(feed_b.id))
|
||||||
|
.count()
|
||||||
|
.get_result(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(1, feed_b_exists);
|
||||||
|
|
||||||
|
cleanup_feed(&mut connection, feed_b.id);
|
||||||
|
delete_user(&mut connection, user_a.id);
|
||||||
|
delete_user(&mut connection, user_b.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-4
@@ -1,9 +1,10 @@
|
|||||||
use std::error::Error;
|
|
||||||
|
|
||||||
use rss::Channel;
|
use rss::Channel;
|
||||||
|
|
||||||
pub async fn get_feed(feed: &str) -> Result<Channel, Box<dyn Error>> {
|
use super::net::safe_fetch;
|
||||||
let content = reqwest::get(feed).await?.bytes().await?;
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
pub async fn get_feed(feed: &str) -> Result<Channel, AppError> {
|
||||||
|
let content = safe_fetch(feed).await?.bytes().await?;
|
||||||
let channel = Channel::read_from(&content[..])?;
|
let channel = Channel::read_from(&content[..])?;
|
||||||
log::debug!("{:?}", channel);
|
log::debug!("{:?}", channel);
|
||||||
Ok(channel)
|
Ok(channel)
|
||||||
|
|||||||
+54
-10
@@ -1,3 +1,5 @@
|
|||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::json_serialization::user::JsonUser;
|
use crate::json_serialization::user::JsonUser;
|
||||||
use crate::models::feed::rss_feed::Feed;
|
use crate::models::feed::rss_feed::Feed;
|
||||||
use crate::models::feed_item::rss_feed_item::FeedItem;
|
use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||||
@@ -9,22 +11,29 @@ use crate::{
|
|||||||
schema::feed::{self, user_id},
|
schema::feed::{self, user_id},
|
||||||
schema::feed_item,
|
schema::feed_item,
|
||||||
};
|
};
|
||||||
use actix_web::{web, HttpRequest, Responder};
|
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use super::structs::article::Article;
|
use super::structs::article::Article;
|
||||||
|
|
||||||
pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder {
|
pub async fn get(
|
||||||
|
path: web::Path<JsonUser>,
|
||||||
|
req: HttpRequest,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
let request = req.clone();
|
let request = req.clone();
|
||||||
let req_user_id = path.user_id;
|
let req_user_id = path.user_id;
|
||||||
log::info!("Received user_id: {}", req_user_id);
|
log::info!("Received user_id: {}", req_user_id);
|
||||||
|
|
||||||
|
if auth_user.0 != req_user_id {
|
||||||
|
return Ok(HttpResponse::Forbidden().finish());
|
||||||
|
}
|
||||||
|
|
||||||
let mut connection: diesel::PgConnection = establish_connection();
|
let mut connection: diesel::PgConnection = establish_connection();
|
||||||
let feeds: Vec<Feed> = feed::table
|
let feeds: Vec<Feed> = feed::table
|
||||||
.filter(user_id.eq(req_user_id))
|
.filter(user_id.eq(req_user_id))
|
||||||
.load::<Feed>(&mut connection)
|
.load::<Feed>(&mut connection)?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
|
let mut feed_aggregates: Vec<FeedAggregate> = Vec::new();
|
||||||
for feed in feeds {
|
for feed in feeds {
|
||||||
@@ -32,8 +41,7 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
|
|||||||
.filter(feed_id.eq(feed.id))
|
.filter(feed_id.eq(feed.id))
|
||||||
.filter(read.eq(false))
|
.filter(read.eq(false))
|
||||||
.order(id.asc())
|
.order(id.asc())
|
||||||
.load(&mut connection)
|
.load(&mut connection)?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Load {} feed items for feed: {}",
|
"Load {} feed items for feed: {}",
|
||||||
@@ -70,15 +78,17 @@ pub async fn get(path: web::Path<JsonUser>, req: HttpRequest) -> impl Responder
|
|||||||
feeds: feed_aggregates,
|
feeds: feed_aggregates,
|
||||||
};
|
};
|
||||||
|
|
||||||
articles.respond_to(&request)
|
Ok(articles.respond_to(&request).map_into_boxed_body())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
use actix_web::http::StatusCode;
|
use actix_web::http::StatusCode;
|
||||||
use actix_web::{test, web, App};
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
|
||||||
use super::get;
|
use super::get;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
use crate::database::establish_connection;
|
use crate::database::establish_connection;
|
||||||
use crate::test_helpers::{
|
use crate::test_helpers::{
|
||||||
delete_feed, delete_feed_item, delete_user, insert_feed, insert_feed_item, insert_user,
|
delete_feed, delete_feed_item, delete_user, insert_feed, insert_feed_item, insert_user,
|
||||||
@@ -92,8 +102,16 @@ mod tests {
|
|||||||
let unread = insert_feed_item(&mut connection, feed.id, false);
|
let unread = insert_feed_item(&mut connection, feed.id, false);
|
||||||
let read = insert_feed_item(&mut connection, feed.id, true);
|
let read = insert_feed_item(&mut connection, feed.id, true);
|
||||||
|
|
||||||
let app =
|
let user_id = user.id;
|
||||||
test::init_service(App::new().route("/get/{user_id}", web::get().to(get))).await;
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/get/{user_id}", web::get().to(get)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let req = test::TestRequest::get()
|
let req = test::TestRequest::get()
|
||||||
.uri(&format!("/get/{}", user.id))
|
.uri(&format!("/get/{}", user.id))
|
||||||
.to_request();
|
.to_request();
|
||||||
@@ -112,4 +130,30 @@ mod tests {
|
|||||||
delete_feed(&mut connection, feed.id);
|
delete_feed(&mut connection, feed.id);
|
||||||
delete_user(&mut connection, user.id);
|
delete_user(&mut connection, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn get_rejects_requests_for_another_user() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user_a = insert_user(&mut connection, "secret");
|
||||||
|
let user_b = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_a.id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/get/{user_id}", web::get().to(get)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::get()
|
||||||
|
.uri(&format!("/get/{}", user_b.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::FORBIDDEN, resp.status());
|
||||||
|
|
||||||
|
delete_user(&mut connection, user_a.id);
|
||||||
|
delete_user(&mut connection, user_b.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
auth::extractor::AuthUser,
|
||||||
|
database::establish_connection,
|
||||||
|
error::AppError,
|
||||||
|
json_serialization::feed_info::{FeedInfo, FeedInfoList},
|
||||||
|
json_serialization::user::JsonUser,
|
||||||
|
schema::feed::{self, user_id},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list_feeds(
|
||||||
|
path: web::Path<JsonUser>,
|
||||||
|
req: HttpRequest,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let request = req.clone();
|
||||||
|
let req_user_id = path.user_id;
|
||||||
|
|
||||||
|
if auth_user.0 != req_user_id {
|
||||||
|
return Ok(HttpResponse::Forbidden().finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let feeds = feed::table
|
||||||
|
.filter(user_id.eq(req_user_id))
|
||||||
|
.select((feed::id, feed::title, feed::url))
|
||||||
|
.load::<(i32, String, String)>(&mut connection)?;
|
||||||
|
|
||||||
|
let feed_list: Vec<FeedInfo> = feeds
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, title, url)| FeedInfo { id, title, url })
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(FeedInfoList { feeds: feed_list }
|
||||||
|
.respond_to(&request)
|
||||||
|
.map_into_boxed_body())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
|
use actix_web::http::StatusCode;
|
||||||
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
|
||||||
|
use super::list_feeds;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::test_helpers::{delete_feed, delete_user, insert_feed, insert_user};
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn list_feeds_returns_feeds_for_user() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let feed = insert_feed(&mut connection, user.id);
|
||||||
|
|
||||||
|
let user_id = user.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feeds/{user_id}", web::get().to(list_feeds)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::get()
|
||||||
|
.uri(&format!("/feeds/{}", user.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::OK, resp.status());
|
||||||
|
let body = test::read_body(resp).await;
|
||||||
|
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(body_str.contains(&feed.title));
|
||||||
|
assert!(body_str.contains(&feed.url));
|
||||||
|
|
||||||
|
delete_feed(&mut connection, feed.id);
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn list_feeds_returns_empty_list_for_user_with_no_feeds() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
|
||||||
|
let user_id = user.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feeds/{user_id}", web::get().to(list_feeds)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::get()
|
||||||
|
.uri(&format!("/feeds/{}", user.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::OK, resp.status());
|
||||||
|
let body = test::read_body(resp).await;
|
||||||
|
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||||
|
assert!(body_str.contains("\"feeds\":[]"));
|
||||||
|
|
||||||
|
delete_user(&mut connection, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn list_feeds_rejects_requests_for_another_user() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user_a = insert_user(&mut connection, "secret");
|
||||||
|
let user_b = insert_user(&mut connection, "secret");
|
||||||
|
let feed_b = insert_feed(&mut connection, user_b.id);
|
||||||
|
|
||||||
|
let user_a_id = user_a.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_a_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/feeds/{user_id}", web::get().to(list_feeds)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::get()
|
||||||
|
.uri(&format!("/feeds/{}", user_b.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::FORBIDDEN, resp.status());
|
||||||
|
|
||||||
|
delete_feed(&mut connection, feed_b.id);
|
||||||
|
delete_user(&mut connection, user_a.id);
|
||||||
|
delete_user(&mut connection, user_b.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
-20
@@ -1,42 +1,56 @@
|
|||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::schema::feed_item::{id, read};
|
use crate::schema::feed_item::{id, read};
|
||||||
use crate::{
|
use crate::{
|
||||||
database::establish_connection, json_serialization::read_feed_item::ReadItem,
|
database::establish_connection,
|
||||||
models::feed_item::rss_feed_item::FeedItem, schema::feed_item,
|
json_serialization::read_feed_item::ReadItem,
|
||||||
|
models::feed_item::rss_feed_item::FeedItem,
|
||||||
|
schema::{feed, feed_item},
|
||||||
};
|
};
|
||||||
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
||||||
use diesel::RunQueryDsl;
|
use diesel::RunQueryDsl;
|
||||||
use diesel::{ExpressionMethods, QueryDsl};
|
use diesel::{ExpressionMethods, OptionalExtension, QueryDsl};
|
||||||
|
|
||||||
pub async fn mark_read(_req: HttpRequest, path: web::Path<ReadItem>) -> impl Responder {
|
pub async fn mark_read(
|
||||||
|
_req: HttpRequest,
|
||||||
|
path: web::Path<ReadItem>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
) -> Result<impl Responder, AppError> {
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
log::info!("Id: {}", path.id);
|
log::info!("Id: {}", path.id);
|
||||||
let feed_items: Vec<FeedItem> = feed_item::table
|
|
||||||
|
// Join through to `feed` so we can confirm the item belongs to the caller
|
||||||
|
// before mutating it. "Doesn't exist" and "not yours" both return 404.
|
||||||
|
let owned_item: Option<(FeedItem, i32)> = feed_item::table
|
||||||
|
.inner_join(feed::table)
|
||||||
.filter(id.eq(path.id))
|
.filter(id.eq(path.id))
|
||||||
.load::<FeedItem>(&mut connection)
|
.select((feed_item::all_columns, feed::user_id))
|
||||||
.unwrap();
|
.first(&mut connection)
|
||||||
|
.optional()?;
|
||||||
|
|
||||||
if feed_items.len() != 1 {
|
let feed_item = match owned_item {
|
||||||
return HttpResponse::NotFound();
|
Some((feed_item, owner_id)) if owner_id == auth_user.0 => feed_item,
|
||||||
}
|
_ => return Ok(HttpResponse::NotFound().finish()),
|
||||||
|
};
|
||||||
|
|
||||||
let feed_item: &FeedItem = feed_items.first().unwrap();
|
let result = diesel::update(&feed_item)
|
||||||
|
|
||||||
let result: Result<usize, diesel::result::Error> = diesel::update(feed_item)
|
|
||||||
.set(read.eq(true))
|
.set(read.eq(true))
|
||||||
.execute(&mut connection);
|
.execute(&mut connection)?;
|
||||||
|
|
||||||
log::info!("Mark as read: {:?}", result);
|
log::info!("Mark as read: {:?}", result);
|
||||||
|
|
||||||
HttpResponse::Ok()
|
Ok(HttpResponse::Ok().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
use actix_web::http::StatusCode;
|
use actix_web::http::StatusCode;
|
||||||
use actix_web::{test, web, App};
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use super::mark_read;
|
use super::mark_read;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
use crate::database::establish_connection;
|
use crate::database::establish_connection;
|
||||||
use crate::models::feed_item::rss_feed_item::FeedItem;
|
use crate::models::feed_item::rss_feed_item::FeedItem;
|
||||||
use crate::schema::feed_item;
|
use crate::schema::feed_item;
|
||||||
@@ -51,8 +65,16 @@ mod tests {
|
|||||||
let feed = insert_feed(&mut connection, user.id);
|
let feed = insert_feed(&mut connection, user.id);
|
||||||
let item = insert_feed_item(&mut connection, feed.id, false);
|
let item = insert_feed_item(&mut connection, feed.id, false);
|
||||||
|
|
||||||
let app =
|
let user_id = user.id;
|
||||||
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await;
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/read/{id}", web::put().to(mark_read)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let req = test::TestRequest::put()
|
let req = test::TestRequest::put()
|
||||||
.uri(&format!("/read/{}", item.id))
|
.uri(&format!("/read/{}", item.id))
|
||||||
.to_request();
|
.to_request();
|
||||||
@@ -73,11 +95,55 @@ mod tests {
|
|||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn mark_read_returns_not_found_for_unknown_id() {
|
async fn mark_read_returns_not_found_for_unknown_id() {
|
||||||
let app =
|
let app = test::init_service(
|
||||||
test::init_service(App::new().route("/read/{id}", web::put().to(mark_read))).await;
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(1));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/read/{id}", web::put().to(mark_read)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let req = test::TestRequest::put().uri("/read/999999999").to_request();
|
let req = test::TestRequest::put().uri("/read/999999999").to_request();
|
||||||
let resp = test::call_service(&app, req).await;
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn mark_read_rejects_other_users_item() {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
let user_a = insert_user(&mut connection, "secret");
|
||||||
|
let user_b = insert_user(&mut connection, "secret");
|
||||||
|
let feed_b = insert_feed(&mut connection, user_b.id);
|
||||||
|
let item_b = insert_feed_item(&mut connection, feed_b.id, false);
|
||||||
|
|
||||||
|
let user_a_id = user_a.id;
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_a_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/read/{id}", web::put().to(mark_read)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let req = test::TestRequest::put()
|
||||||
|
.uri(&format!("/read/{}", item_b.id))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::NOT_FOUND, resp.status());
|
||||||
|
|
||||||
|
let updated: FeedItem = feed_item::table
|
||||||
|
.find(item_b.id)
|
||||||
|
.first(&mut connection)
|
||||||
|
.unwrap();
|
||||||
|
assert!(!updated.read);
|
||||||
|
|
||||||
|
delete_feed_item(&mut connection, item_b.id);
|
||||||
|
delete_feed(&mut connection, feed_b.id);
|
||||||
|
delete_user(&mut connection, user_a.id);
|
||||||
|
delete_user(&mut connection, user_b.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod feeds;
|
|||||||
mod get;
|
mod get;
|
||||||
mod list_feeds;
|
mod list_feeds;
|
||||||
mod mark_read;
|
mod mark_read;
|
||||||
|
pub mod net;
|
||||||
mod read;
|
mod read;
|
||||||
mod scraper;
|
mod scraper;
|
||||||
pub mod structs;
|
pub mod structs;
|
||||||
|
|||||||
@@ -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(¤t).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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
use reqwest::Error;
|
use super::super::net::safe_fetch;
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
// Do a request for the given URL, with a minimum time between requests
|
// Do a request for the given URL, with a minimum time between requests
|
||||||
// to avoid overloading the server.
|
// to avoid overloading the server.
|
||||||
pub async fn do_throttled_request(url: &str) -> Result<String, Error> {
|
pub async fn do_throttled_request(url: &str) -> Result<String, AppError> {
|
||||||
let response = reqwest::get(url).await?;
|
let response = safe_fetch(url).await?;
|
||||||
response.text().await
|
Ok(response.text().await?)
|
||||||
}
|
}
|
||||||
|
|||||||
+223
-121
@@ -1,4 +1,6 @@
|
|||||||
use super::feeds;
|
use super::feeds;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::json_serialization::user::JsonUser;
|
use crate::json_serialization::user::JsonUser;
|
||||||
use crate::models::feed::rss_feed::Feed;
|
use crate::models::feed::rss_feed::Feed;
|
||||||
use crate::models::feed_item::new_feed_item::NewFeedItem;
|
use crate::models::feed_item::new_feed_item::NewFeedItem;
|
||||||
@@ -11,12 +13,13 @@ use crate::{
|
|||||||
feed_item,
|
feed_item,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use actix_web::{web, HttpRequest, HttpResponse, Responder};
|
use actix_web::{web, HttpRequest, HttpResponse};
|
||||||
use chrono::{DateTime, Duration, Local, NaiveDateTime};
|
use chrono::{DateTime, Local, NaiveDateTime};
|
||||||
use dateparser::parse;
|
use dateparser::parse;
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use rss::Item;
|
use rss::Item;
|
||||||
use scraper::{Html, Selector};
|
use scraper::{Html, Selector};
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
|
fn get_date(date_str: &str) -> Result<NaiveDateTime, chrono::ParseError> {
|
||||||
if let Ok(result) = parse(date_str) {
|
if let Ok(result) = parse(date_str) {
|
||||||
@@ -46,6 +49,20 @@ fn escape_html_attr(value: &str) -> String {
|
|||||||
.replace('>', ">")
|
.replace('>', ">")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Feed-supplied `<img>` markup is rendered as-is (via `v-html`) in the frontend,
|
||||||
|
// so strip everything except a harmless image tag before it's stored — in
|
||||||
|
// particular event handlers like `onerror`/`onload` that a malicious feed
|
||||||
|
// could use for XSS.
|
||||||
|
fn sanitize_img_html(html: &str) -> String {
|
||||||
|
let allowed_attributes = HashSet::from(["src", "alt", "title"]);
|
||||||
|
|
||||||
|
ammonia::Builder::default()
|
||||||
|
.tags(HashSet::from(["img"]))
|
||||||
|
.tag_attributes(HashMap::from([("img", allowed_attributes)]))
|
||||||
|
.clean(html)
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
// Some feeds (e.g. Deutsche Welle) don't embed an <img> in the item content at
|
// Some feeds (e.g. Deutsche Welle) don't embed an <img> in the item content at
|
||||||
// all — they carry the article image as an RSS <enclosure> instead. Build an
|
// all — they carry the article image as an RSS <enclosure> instead. Build an
|
||||||
// <img> tag from it so those feeds get a preview image too.
|
// <img> tag from it so those feeds get a preview image too.
|
||||||
@@ -60,14 +77,22 @@ fn enclosure_image_html(item: &Item) -> Option<String> {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) -> anyhow::Result<()> {
|
||||||
let item_title = item.title.clone().unwrap();
|
// Items without a title or link are malformed/unusable — skip them rather
|
||||||
|
// than failing the whole sync over one bad entry from an external feed.
|
||||||
|
let Some(item_title) = item.title.clone() else {
|
||||||
|
log::warn!("Skipping feed item without a title.");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if item.link.is_none() {
|
||||||
|
log::warn!("Skipping feed item without a link: {}", item_title);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
log::info!("Create feed item: {}", item_title);
|
log::info!("Create feed item: {}", item_title);
|
||||||
|
|
||||||
// Resolve the publication date before any HTML parsing or DB work so we can
|
// Items without a pub_date are treated as current (inserted unconditionally)
|
||||||
// bail out early for old articles. Items without a pub_date are treated as
|
// — feeds that don't publish dates are typically small/curated enough that
|
||||||
// current (inserted unconditionally) — feeds that don't publish dates are
|
// this is fine.
|
||||||
// typically small/curated enough that this is fine.
|
|
||||||
let mut time: NaiveDateTime = Local::now().naive_local();
|
let mut time: NaiveDateTime = Local::now().naive_local();
|
||||||
if let Some(pub_date) = item.pub_date() {
|
if let Some(pub_date) = item.pub_date() {
|
||||||
time = match get_date(pub_date) {
|
time = match get_date(pub_date) {
|
||||||
@@ -79,32 +104,40 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
let cutoff = Local::now().naive_local() - Duration::days(14);
|
|
||||||
if time < cutoff {
|
|
||||||
log::info!("Skipping item {} (older than 2 weeks).", item_title);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let base_content: &str = item.content().or(item.description()).unwrap_or_default();
|
let base_content: &str = item.content().or(item.description()).unwrap_or_default();
|
||||||
|
|
||||||
let frag = Html::parse_fragment(base_content);
|
let frag = Html::parse_fragment(base_content);
|
||||||
let mut content = "".to_string();
|
let mut content = "".to_string();
|
||||||
|
|
||||||
let selector_img = Selector::parse("img").unwrap();
|
// Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget
|
||||||
|
// (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the
|
||||||
|
// article content. It's not part of the article and isn't present in the
|
||||||
|
// scraped/readable edition either, so skip its text when flattening below.
|
||||||
|
let selector_social_bar =
|
||||||
|
Selector::parse("#article-social-bar").expect("\"#article-social-bar\" is a valid CSS selector");
|
||||||
|
let excluded_node_ids: std::collections::HashSet<_> = frag
|
||||||
|
.select(&selector_social_bar)
|
||||||
|
.flat_map(|el| el.descendants().map(|node| node.id()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let selector_img = Selector::parse("img").expect("\"img\" is a valid CSS selector");
|
||||||
match frag.select(&selector_img).find(image_src_is_resolvable) {
|
match frag.select(&selector_img).find(image_src_is_resolvable) {
|
||||||
Some(image) => {
|
Some(image) => {
|
||||||
content.push_str(&image.html());
|
content.push_str(&sanitize_img_html(&image.html()));
|
||||||
content.push_str("<br>");
|
content.push_str("<br>");
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
if let Some(image_html) = enclosure_image_html(&item) {
|
if let Some(image_html) = enclosure_image_html(&item) {
|
||||||
content.push_str(&image_html);
|
content.push_str(&sanitize_img_html(&image_html));
|
||||||
content.push_str("<br>");
|
content.push_str("<br>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for node in frag.tree.nodes() {
|
for node in frag.tree.nodes() {
|
||||||
|
if excluded_node_ids.contains(&node.id()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let scraper::node::Node::Text(text) = node.value() {
|
if let scraper::node::Node::Text(text) = node.value() {
|
||||||
content.push_str(&text.text);
|
content.push_str(&text.text);
|
||||||
}
|
}
|
||||||
@@ -113,15 +146,14 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
|||||||
let existing_item: Vec<FeedItem> = feed_item::table
|
let existing_item: Vec<FeedItem> = feed_item::table
|
||||||
.filter(feed_id.eq(feed.id))
|
.filter(feed_id.eq(feed.id))
|
||||||
.filter(title.eq(&item_title))
|
.filter(title.eq(&item_title))
|
||||||
.load(connection)
|
.load(connection)?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if existing_item.is_empty() {
|
if existing_item.is_empty() {
|
||||||
let new_feed_item = NewFeedItem::new(
|
let new_feed_item = NewFeedItem::new(
|
||||||
feed.id,
|
feed.id,
|
||||||
content.clone(),
|
content.clone(),
|
||||||
item_title.clone(),
|
item_title.clone(),
|
||||||
item.link.unwrap(),
|
item.link.expect("checked above"),
|
||||||
Some(time),
|
Some(time),
|
||||||
);
|
);
|
||||||
let insert_result = diesel::insert_into(feed_item::table)
|
let insert_result = diesel::insert_into(feed_item::table)
|
||||||
@@ -132,30 +164,26 @@ fn create_feed_item(item: Item, feed: &Feed, connection: &mut PgConnection) {
|
|||||||
} else {
|
} else {
|
||||||
log::info!("Item {} already exists.", item_title);
|
log::info!("Item {} already exists.", item_title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Items without a `created_ts` (e.g. ones inserted before this column existed,
|
pub async fn sync(
|
||||||
// or whose feed didn't provide a publish date) are left alone — `lt` never
|
_req: HttpRequest,
|
||||||
// matches NULL, so there's nothing to special-case here.
|
data: web::Json<JsonUser>,
|
||||||
fn delete_old_feed_items(connection: &mut PgConnection) {
|
auth_user: AuthUser,
|
||||||
let cutoff = Local::now().naive_local() - Duration::days(14);
|
) -> Result<HttpResponse, AppError> {
|
||||||
let result = diesel::delete(feed_item::table.filter(feed_item::created_ts.lt(cutoff)))
|
|
||||||
.execute(connection);
|
|
||||||
|
|
||||||
log::info!("Deleted old feed items (older than 2 weeks): {:?}", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responder {
|
|
||||||
let mut connection: diesel::PgConnection = establish_connection();
|
|
||||||
|
|
||||||
delete_old_feed_items(&mut connection);
|
|
||||||
|
|
||||||
let req_user_id: i32 = data.user_id;
|
let req_user_id: i32 = data.user_id;
|
||||||
|
|
||||||
|
if auth_user.0 != req_user_id {
|
||||||
|
return Ok(HttpResponse::Forbidden().finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut connection: diesel::PgConnection = establish_connection();
|
||||||
|
|
||||||
let feeds: Vec<Feed> = feed::table
|
let feeds: Vec<Feed> = feed::table
|
||||||
.filter(user_id.eq(req_user_id))
|
.filter(user_id.eq(req_user_id))
|
||||||
.load::<Feed>(&mut connection)
|
.load::<Feed>(&mut connection)?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
log::info!("Found {} feeds to sync.", feeds.len());
|
log::info!("Found {} feeds to sync.", feeds.len());
|
||||||
|
|
||||||
@@ -167,14 +195,16 @@ pub async fn sync(_req: HttpRequest, data: web::Json<JsonUser>) -> impl Responde
|
|||||||
Ok(channel) => {
|
Ok(channel) => {
|
||||||
for item in channel.into_items() {
|
for item in channel.into_items() {
|
||||||
log::info!("{:?}", item);
|
log::info!("{:?}", item);
|
||||||
create_feed_item(item, &feed, &mut connection);
|
if let Err(e) = create_feed_item(item, &feed, &mut connection) {
|
||||||
|
log::error!("Could not create feed item for {}: {:?}", feed.url, e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e),
|
Err(e) => log::error!("Could not get channel {}. Error: {}", feed.url, e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse::Ok()
|
Ok(HttpResponse::Ok().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -184,6 +214,7 @@ mod tests {
|
|||||||
use crate::models::user::rss_user::User;
|
use crate::models::user::rss_user::User;
|
||||||
use crate::schema::users;
|
use crate::schema::users;
|
||||||
use crate::test_helpers::unique_suffix;
|
use crate::test_helpers::unique_suffix;
|
||||||
|
use chrono::Duration;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -271,93 +302,46 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[test]
|
||||||
async fn delete_old_feed_items_removes_items_older_than_two_weeks_but_keeps_recent_ones() {
|
fn sanitize_img_html_strips_event_handlers() {
|
||||||
let mut connection = establish_connection();
|
let sanitized = sanitize_img_html(r#"<img src="x" onerror="alert(1)">"#);
|
||||||
let suffix = unique_suffix();
|
|
||||||
|
|
||||||
let new_user = NewUser::new(
|
assert!(!sanitized.contains("onerror"));
|
||||||
format!("cleanup_test_{suffix}"),
|
assert!(!sanitized.contains("alert"));
|
||||||
format!("cleanup_{suffix}@example.test"),
|
assert!(sanitized.contains(r#"src="x""#));
|
||||||
"secret".to_string(),
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sanitize_img_html_keeps_only_allowed_attributes() {
|
||||||
|
let sanitized = sanitize_img_html(
|
||||||
|
r#"<img src="https://example.test/img.jpg" alt="desc" title="t" style="display:none" class="evil">"#,
|
||||||
);
|
);
|
||||||
let user: User = diesel::insert_into(users::table)
|
|
||||||
.values(&new_user)
|
|
||||||
.get_result(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let new_feed = NewFeed::new(
|
assert!(sanitized.contains(r#"src="https://example.test/img.jpg""#));
|
||||||
format!("Cleanup test feed {suffix}"),
|
assert!(sanitized.contains(r#"alt="desc""#));
|
||||||
format!("https://example.test/feed/{suffix}"),
|
assert!(sanitized.contains(r#"title="t""#));
|
||||||
user.id,
|
assert!(!sanitized.contains("style"));
|
||||||
);
|
assert!(!sanitized.contains("class"));
|
||||||
let feed: Feed = diesel::insert_into(feed::table)
|
|
||||||
.values(&new_feed)
|
|
||||||
.get_result(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let now = Local::now().naive_local();
|
|
||||||
let old_item = NewFeedItem::new(
|
|
||||||
feed.id,
|
|
||||||
"old content".to_string(),
|
|
||||||
format!("Old article {suffix}"),
|
|
||||||
format!("https://example.test/article/old-{suffix}"),
|
|
||||||
Some(now - Duration::days(20)),
|
|
||||||
);
|
|
||||||
let recent_item = NewFeedItem::new(
|
|
||||||
feed.id,
|
|
||||||
"recent content".to_string(),
|
|
||||||
format!("Recent article {suffix}"),
|
|
||||||
format!("https://example.test/article/recent-{suffix}"),
|
|
||||||
Some(now - Duration::days(1)),
|
|
||||||
);
|
|
||||||
diesel::insert_into(feed_item::table)
|
|
||||||
.values(&old_item)
|
|
||||||
.execute(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
let recent: FeedItem = diesel::insert_into(feed_item::table)
|
|
||||||
.values(&recent_item)
|
|
||||||
.get_result(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
delete_old_feed_items(&mut connection);
|
|
||||||
|
|
||||||
let remaining: Vec<FeedItem> = feed_item::table
|
|
||||||
.filter(feed_id.eq(feed.id))
|
|
||||||
.load(&mut connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(1, remaining.len(), "only the recent item should survive cleanup");
|
|
||||||
assert_eq!(recent.id, remaining[0].id);
|
|
||||||
|
|
||||||
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
|
|
||||||
.execute(&mut connection)
|
|
||||||
.ok();
|
|
||||||
diesel::delete(feed::table.filter(feed::id.eq(feed.id)))
|
|
||||||
.execute(&mut connection)
|
|
||||||
.ok();
|
|
||||||
diesel::delete(users::table.filter(users::id.eq(user.id)))
|
|
||||||
.execute(&mut connection)
|
|
||||||
.ok();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn create_feed_item_skips_articles_older_than_two_weeks() {
|
async fn create_feed_item_inserts_articles_older_than_two_weeks() {
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
let suffix = unique_suffix();
|
let suffix = unique_suffix();
|
||||||
|
|
||||||
let new_user = NewUser::new(
|
let new_user = NewUser::new(
|
||||||
format!("age_skip_test_{suffix}"),
|
format!("age_test_{suffix}"),
|
||||||
format!("age_skip_{suffix}@example.test"),
|
format!("age_{suffix}@example.test"),
|
||||||
"secret".to_string(),
|
"secret".to_string(),
|
||||||
);
|
)
|
||||||
|
.unwrap();
|
||||||
let user: User = diesel::insert_into(users::table)
|
let user: User = diesel::insert_into(users::table)
|
||||||
.values(&new_user)
|
.values(&new_user)
|
||||||
.get_result(&mut connection)
|
.get_result(&mut connection)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let new_feed = NewFeed::new(
|
let new_feed = NewFeed::new(
|
||||||
format!("Age skip test feed {suffix}"),
|
format!("Age test feed {suffix}"),
|
||||||
format!("https://example.test/feed/{suffix}"),
|
format!("https://example.test/feed/{suffix}"),
|
||||||
user.id,
|
user.id,
|
||||||
);
|
);
|
||||||
@@ -366,7 +350,9 @@ mod tests {
|
|||||||
.get_result(&mut connection)
|
.get_result(&mut connection)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Item with a pub_date 20 days ago — should be ignored by create_feed_item.
|
// Item with a pub_date 20 days ago — should still be inserted, since
|
||||||
|
// infrequently-updated feeds (or infrequent syncs) must not lose
|
||||||
|
// articles the user hasn't seen yet.
|
||||||
let old_date = (Local::now() - Duration::days(20))
|
let old_date = (Local::now() - Duration::days(20))
|
||||||
.format("%a, %d %b %Y %H:%M:%S %z")
|
.format("%a, %d %b %Y %H:%M:%S %z")
|
||||||
.to_string();
|
.to_string();
|
||||||
@@ -382,19 +368,15 @@ mod tests {
|
|||||||
fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}")));
|
fresh_item.set_link(Some(format!("https://example.test/fresh/{suffix}")));
|
||||||
fresh_item.set_content(Some("<p>fresh</p>".to_string()));
|
fresh_item.set_content(Some("<p>fresh</p>".to_string()));
|
||||||
|
|
||||||
create_feed_item(old_item, &feed, &mut connection);
|
create_feed_item(old_item, &feed, &mut connection).unwrap();
|
||||||
create_feed_item(fresh_item, &feed, &mut connection);
|
create_feed_item(fresh_item, &feed, &mut connection).unwrap();
|
||||||
|
|
||||||
let items: Vec<FeedItem> = feed_item::table
|
let items: Vec<FeedItem> = feed_item::table
|
||||||
.filter(feed_id.eq(feed.id))
|
.filter(feed_id.eq(feed.id))
|
||||||
.load(&mut connection)
|
.load(&mut connection)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert_eq!(1, items.len(), "old item should have been skipped");
|
assert_eq!(2, items.len(), "both old and fresh items should be inserted");
|
||||||
assert!(
|
|
||||||
items[0].title.contains("Fresh article"),
|
|
||||||
"only the fresh item should be present"
|
|
||||||
);
|
|
||||||
|
|
||||||
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
|
diesel::delete(feed_item::table.filter(feed_id.eq(feed.id)))
|
||||||
.execute(&mut connection)
|
.execute(&mut connection)
|
||||||
@@ -416,7 +398,8 @@ mod tests {
|
|||||||
format!("sync_test_{suffix}"),
|
format!("sync_test_{suffix}"),
|
||||||
format!("sync_{suffix}@example.test"),
|
format!("sync_{suffix}@example.test"),
|
||||||
"secret".to_string(),
|
"secret".to_string(),
|
||||||
);
|
)
|
||||||
|
.unwrap();
|
||||||
let user: User = diesel::insert_into(users::table)
|
let user: User = diesel::insert_into(users::table)
|
||||||
.values(&new_user)
|
.values(&new_user)
|
||||||
.get_result(&mut connection)
|
.get_result(&mut connection)
|
||||||
@@ -437,8 +420,8 @@ mod tests {
|
|||||||
item.set_link(Some(format!("https://example.test/article/{suffix}")));
|
item.set_link(Some(format!("https://example.test/article/{suffix}")));
|
||||||
item.set_content(Some("<p>Hello world</p>".to_string()));
|
item.set_content(Some("<p>Hello world</p>".to_string()));
|
||||||
|
|
||||||
create_feed_item(item.clone(), &feed, &mut connection);
|
create_feed_item(item.clone(), &feed, &mut connection).unwrap();
|
||||||
create_feed_item(item, &feed, &mut connection);
|
create_feed_item(item, &feed, &mut connection).unwrap();
|
||||||
|
|
||||||
let items: Vec<FeedItem> = feed_item::table
|
let items: Vec<FeedItem> = feed_item::table
|
||||||
.filter(feed_id.eq(feed.id))
|
.filter(feed_id.eq(feed.id))
|
||||||
@@ -456,4 +439,123 @@ mod tests {
|
|||||||
.execute(&mut connection)
|
.execute(&mut connection)
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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"> </a></li>
|
||||||
|
<li><a id="link_copy" onclick="copyToClipboard()"> </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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ diesel::table! {
|
|||||||
email -> Varchar,
|
email -> Varchar,
|
||||||
password -> Varchar,
|
password -> Varchar,
|
||||||
unique_id -> Varchar,
|
unique_id -> Varchar,
|
||||||
|
token_version -> Int4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -28,7 +28,8 @@ pub fn insert_user(connection: &mut PgConnection, password: &str) -> User {
|
|||||||
format!("test_user_{suffix}"),
|
format!("test_user_{suffix}"),
|
||||||
format!("test_{suffix}@example.test"),
|
format!("test_{suffix}@example.test"),
|
||||||
password.to_string(),
|
password.to_string(),
|
||||||
);
|
)
|
||||||
|
.expect("failed to hash test user password");
|
||||||
diesel::insert_into(users::table)
|
diesel::insert_into(users::table)
|
||||||
.values(&new_user)
|
.values(&new_user)
|
||||||
.get_result(connection)
|
.get_result(connection)
|
||||||
|
|||||||
+10
-11
@@ -1,5 +1,6 @@
|
|||||||
use crate::database::establish_connection;
|
use crate::database::establish_connection;
|
||||||
use crate::diesel;
|
use crate::diesel;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::json_serialization::login::Login;
|
use crate::json_serialization::login::Login;
|
||||||
use crate::models::user::rss_user::User;
|
use crate::models::user::rss_user::User;
|
||||||
use crate::schema::users;
|
use crate::schema::users;
|
||||||
@@ -7,7 +8,7 @@ use crate::{auth::jwt::JwtToken, schema::users::username};
|
|||||||
use actix_web::{web, HttpResponse};
|
use actix_web::{web, HttpResponse};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
|
pub async fn login(credentials: web::Json<Login>) -> Result<HttpResponse, AppError> {
|
||||||
let username_cred: String = credentials.username.clone();
|
let username_cred: String = credentials.username.clone();
|
||||||
let password: String = credentials.password.clone();
|
let password: String = credentials.password.clone();
|
||||||
|
|
||||||
@@ -15,32 +16,30 @@ pub async fn login(credentials: web::Json<Login>) -> HttpResponse {
|
|||||||
|
|
||||||
let users: Vec<User> = users::table
|
let users: Vec<User> = users::table
|
||||||
.filter(username.eq(username_cred.as_str()))
|
.filter(username.eq(username_cred.as_str()))
|
||||||
.load::<User>(&mut connection)
|
.load::<User>(&mut connection)?;
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
if users.is_empty() {
|
if users.is_empty() {
|
||||||
return HttpResponse::NotFound().await.unwrap();
|
return Ok(HttpResponse::NotFound().finish());
|
||||||
} else if users.len() > 1 {
|
} else if users.len() > 1 {
|
||||||
log::error!(
|
log::error!(
|
||||||
"multiple user have the usernam: {}",
|
"multiple user have the usernam: {}",
|
||||||
credentials.username.clone()
|
credentials.username.clone()
|
||||||
);
|
);
|
||||||
return HttpResponse::Conflict().await.unwrap();
|
return Ok(HttpResponse::Conflict().finish());
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: &User = &users[0];
|
let user: &User = &users[0];
|
||||||
|
|
||||||
match user.clone().verify(password) {
|
match user.clone().verify(password)? {
|
||||||
true => {
|
true => {
|
||||||
log::info!("verified password successfully for user {}", user.id);
|
log::info!("verified password successfully for user {}", user.id);
|
||||||
let token: String = JwtToken::encode(user.clone().id);
|
let token: String = JwtToken::encode(user.id, user.token_version);
|
||||||
HttpResponse::Ok()
|
Ok(HttpResponse::Ok()
|
||||||
.insert_header(("token", token))
|
.insert_header(("token", token))
|
||||||
.insert_header(("user_id", user.id))
|
.insert_header(("user_id", user.id))
|
||||||
.await
|
.finish())
|
||||||
.unwrap()
|
|
||||||
}
|
}
|
||||||
false => HttpResponse::Unauthorized().await.unwrap(),
|
false => Ok(HttpResponse::Unauthorized().finish()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,62 @@
|
|||||||
use actix_web::HttpResponse;
|
use actix_web::HttpResponse;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
// JWT auth is stateless and there is no token blacklist, so logging out is
|
use crate::auth::extractor::AuthUser;
|
||||||
// purely a client-side action (discarding the stored token). This endpoint
|
use crate::database::establish_connection;
|
||||||
// exists so the frontend has something to call and gets a clean response.
|
use crate::error::AppError;
|
||||||
pub async fn logout() -> HttpResponse {
|
use crate::schema::users;
|
||||||
HttpResponse::Ok().finish()
|
|
||||||
|
/// Invalidates every token previously issued for this user by bumping
|
||||||
|
/// `token_version` — the auth middleware rejects tokens whose `tv` claim no
|
||||||
|
/// longer matches the stored value.
|
||||||
|
pub async fn logout(auth_user: AuthUser) -> Result<HttpResponse, AppError> {
|
||||||
|
let mut connection = establish_connection();
|
||||||
|
|
||||||
|
diesel::update(users::table.find(auth_user.0))
|
||||||
|
.set(users::token_version.eq(users::token_version + 1))
|
||||||
|
.execute(&mut connection)?;
|
||||||
|
|
||||||
|
Ok(HttpResponse::Ok().finish())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use actix_service::Service;
|
||||||
use actix_web::http::StatusCode;
|
use actix_web::http::StatusCode;
|
||||||
use actix_web::{test, web, App};
|
use actix_web::{test, web, App, HttpMessage};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
|
||||||
use super::logout;
|
use super::logout;
|
||||||
|
use crate::auth::extractor::AuthUser;
|
||||||
|
use crate::database::establish_connection;
|
||||||
|
use crate::models::user::rss_user::User;
|
||||||
|
use crate::schema::users;
|
||||||
|
use crate::test_helpers::{delete_user, insert_user};
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn logout_returns_ok() {
|
async fn logout_bumps_token_version() {
|
||||||
let app = test::init_service(App::new().route("/logout", web::post().to(logout))).await;
|
let mut connection = establish_connection();
|
||||||
|
let user = insert_user(&mut connection, "secret");
|
||||||
|
let user_id = user.id;
|
||||||
|
let initial_version = user.token_version;
|
||||||
|
|
||||||
|
let app = test::init_service(
|
||||||
|
App::new()
|
||||||
|
.wrap_fn(move |req, srv| {
|
||||||
|
req.extensions_mut().insert(AuthUser(user_id));
|
||||||
|
srv.call(req)
|
||||||
|
})
|
||||||
|
.route("/logout", web::post().to(logout)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
let req = test::TestRequest::post().uri("/logout").to_request();
|
let req = test::TestRequest::post().uri("/logout").to_request();
|
||||||
let resp = test::call_service(&app, req).await;
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
assert_eq!(StatusCode::OK, resp.status());
|
assert_eq!(StatusCode::OK, resp.status());
|
||||||
|
|
||||||
|
let updated: User = users::table.find(user_id).first(&mut connection).unwrap();
|
||||||
|
assert_eq!(initial_version + 1, updated.token_version);
|
||||||
|
|
||||||
|
delete_user(&mut connection, user_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -1,3 +1,4 @@
|
|||||||
|
use actix_governor::{Governor, GovernorConfigBuilder};
|
||||||
use actix_web::web;
|
use actix_web::web;
|
||||||
use actix_web::web::ServiceConfig;
|
use actix_web::web::ServiceConfig;
|
||||||
|
|
||||||
@@ -12,9 +13,19 @@ pub fn auth_factory(app: &mut ServiceConfig) {
|
|||||||
backend: true,
|
backend: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
app.route(
|
// Login is the only unauthenticated endpoint that checks a password, so
|
||||||
&base_path.define(String::from("/login")),
|
// it's the only one worth rate-limiting against brute-force/credential
|
||||||
web::post().to(login::login),
|
// stuffing. One request every 2s with a burst of 5 per IP.
|
||||||
|
let login_rate_limit = GovernorConfigBuilder::default()
|
||||||
|
.seconds_per_request(2)
|
||||||
|
.burst_size(5)
|
||||||
|
.finish()
|
||||||
|
.expect("valid governor rate-limit config");
|
||||||
|
|
||||||
|
app.service(
|
||||||
|
web::resource(base_path.define(String::from("/login")))
|
||||||
|
.wrap(Governor::new(&login_rate_limit))
|
||||||
|
.route(web::post().to(login::login)),
|
||||||
);
|
);
|
||||||
app.route(
|
app.route(
|
||||||
&base_path.define(String::from("/logout")),
|
&base_path.define(String::from("/logout")),
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
use crate::database::establish_connection;
|
use crate::database::establish_connection;
|
||||||
use crate::diesel;
|
use crate::diesel;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::json_serialization::new_user::NewUserSchema;
|
use crate::json_serialization::new_user::NewUserSchema;
|
||||||
use crate::models::user::new_user::NewUser;
|
use crate::models::user::new_user::{validate_password, NewUser};
|
||||||
use crate::schema::users;
|
use crate::schema::users;
|
||||||
use actix_web::{web, HttpResponse};
|
use actix_web::{web, HttpResponse};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
|
|
||||||
pub async fn create(new_user: web::Json<NewUserSchema>) -> HttpResponse {
|
pub async fn create(new_user: web::Json<NewUserSchema>) -> Result<HttpResponse, AppError> {
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
let name: String = new_user.name.clone();
|
let name: String = new_user.name.clone();
|
||||||
let email: String = new_user.email.clone();
|
let email: String = new_user.email.clone();
|
||||||
let new_password: String = new_user.password.clone();
|
let new_password: String = new_user.password.clone();
|
||||||
|
|
||||||
let new_user = NewUser::new(name, email, new_password);
|
if let Err(message) = validate_password(&new_password) {
|
||||||
|
return Ok(HttpResponse::BadRequest().body(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_user = NewUser::new(name, email, new_password)?;
|
||||||
|
|
||||||
let insert_result = diesel::insert_into(users::table)
|
let insert_result = diesel::insert_into(users::table)
|
||||||
.values(&new_user)
|
.values(&new_user)
|
||||||
.execute(&mut connection);
|
.execute(&mut connection);
|
||||||
|
|
||||||
match insert_result {
|
Ok(match insert_result {
|
||||||
Ok(_) => HttpResponse::Created().await.unwrap(),
|
Ok(_) => HttpResponse::Created().finish(),
|
||||||
Err(_) => HttpResponse::Conflict().await.unwrap(),
|
Err(_) => HttpResponse::Conflict().finish(),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -60,6 +65,24 @@ mod tests {
|
|||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_web::test]
|
||||||
|
async fn create_fails_for_short_password() {
|
||||||
|
let suffix = unique_suffix();
|
||||||
|
|
||||||
|
let app = test::init_service(App::new().route("/create", web::post().to(create))).await;
|
||||||
|
let req = test::TestRequest::post()
|
||||||
|
.uri("/create")
|
||||||
|
.set_json(serde_json::json!({
|
||||||
|
"name": format!("short_pw_{suffix}"),
|
||||||
|
"email": format!("short_{suffix}@example.test"),
|
||||||
|
"password": "abc"
|
||||||
|
}))
|
||||||
|
.to_request();
|
||||||
|
let resp = test::call_service(&app, req).await;
|
||||||
|
|
||||||
|
assert_eq!(StatusCode::BAD_REQUEST, resp.status());
|
||||||
|
}
|
||||||
|
|
||||||
#[actix_web::test]
|
#[actix_web::test]
|
||||||
async fn create_fails_for_duplicate_user() {
|
async fn create_fails_for_duplicate_user() {
|
||||||
let mut connection = establish_connection();
|
let mut connection = establish_connection();
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
<link rel="alternate icon" href="/favicon.ico">
|
<link rel="alternate icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>RSS-Reader</title>
|
<title>RSS-Reader</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Lora:ital,wght@0,400;0,700;1,400&family=Merriweather:ital,wght@0,400;0,700;1,400&family=Playfair+Display:wght@400;700&family=Raleway:wght@400;700&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,700;1,8..60,400&display=swap" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { RouterView, useRoute } from 'vue-router'
|
import { RouterView, useRoute } from 'vue-router'
|
||||||
import AppNav from './components/AppNav.vue'
|
import AppNav from './components/AppNav.vue'
|
||||||
|
import { useSettings } from './composables/useSettings.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const { applySettings } = useSettings()
|
||||||
|
onMounted(applySettings)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -23,6 +23,13 @@
|
|||||||
|
|
||||||
/* semantic color variables for this project */
|
/* semantic color variables for this project */
|
||||||
:root {
|
:root {
|
||||||
|
--headline-font-family: Glook, 'Courier New';
|
||||||
|
--content-font-family: Merriweather, Georgia, 'Times New Roman', Times, serif;
|
||||||
|
--headline-font-size-scale: 1;
|
||||||
|
--content-font-size-scale: 1;
|
||||||
|
--content-text-align: left;
|
||||||
|
--content-padding: 1rem;
|
||||||
|
|
||||||
--color-background: var(--vt-c-white);
|
--color-background: var(--vt-c-white);
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
@@ -70,6 +77,10 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
/* Full-bleed article images use a `100vw`-based breakout, which can be
|
||||||
|
wider than the visible content area (scrollbar) and would otherwise
|
||||||
|
introduce a horizontal scrollbar. */
|
||||||
|
overflow-x: hidden;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
transition: color 0.5s, background-color 0.5s;
|
transition: color 0.5s, background-color 0.5s;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
max-width: 1280px;
|
max-width: 1280px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
|
padding-top: var(--app-nav-height, 4.5rem);
|
||||||
|
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
@@ -68,8 +69,8 @@ a,
|
|||||||
|
|
||||||
.feed-title {
|
.feed-title {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: 'Courier New';
|
font-family: var(--headline-font-family);
|
||||||
font-size: clamp(1.25rem, 4.5vw, 1.6rem);
|
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--color-accent-2);
|
color: var(--color-accent-2);
|
||||||
border-bottom: 1px solid #ccc;
|
border-bottom: 1px solid #ccc;
|
||||||
@@ -83,9 +84,10 @@ a,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.feed-content {
|
.feed-content {
|
||||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
font-family: var(--content-font-family);
|
||||||
font-size: clamp(1rem, 3.5vw, 1.25rem);
|
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
|
||||||
padding: 0 1em 1em;
|
text-align: var(--content-text-align);
|
||||||
|
padding: 0 var(--content-padding) 1em;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +102,7 @@ a,
|
|||||||
|
|
||||||
.feed-content h3 {
|
.feed-content h3 {
|
||||||
padding: 0.5em 0;
|
padding: 0.5em 0;
|
||||||
font-size: clamp(1rem, 3vw, 1.3rem);
|
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,5 +113,6 @@ h3 {
|
|||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
#app {
|
#app {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
padding-top: var(--app-nav-height, 4.5rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const feeds = ref([])
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'user-token': localStorage.getItem('user-token'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFeeds() {
|
||||||
|
const userId = localStorage.getItem('user-id')
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/v1/article/feeds/${userId}`, authHeaders())
|
||||||
|
feeds.value = response.data.feeds
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Failed to load feeds.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFeed(feedId) {
|
||||||
|
if (!window.confirm('Delete this feed and all its articles?')) return
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api/v1/article/feed/${feedId}`, authHeaders())
|
||||||
|
feeds.value = feeds.value.filter(f => f.id !== feedId)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = 'Failed to delete feed.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadFeeds)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin">
|
||||||
|
<h1 class="admin__heading">Admin</h1>
|
||||||
|
<p v-if="error" class="admin__error">{{ error }}</p>
|
||||||
|
<p v-else-if="feeds.length === 0" class="admin__empty">No feeds added yet.</p>
|
||||||
|
<ul v-else class="admin__list">
|
||||||
|
<li v-for="feed in feeds" :key="feed.id" class="admin__item">
|
||||||
|
<div class="admin__item-info">
|
||||||
|
<span class="admin__item-title">{{ feed.title }}</span>
|
||||||
|
<a :href="feed.url" class="admin__item-url" target="_blank" rel="noopener">{{ feed.url }}</a>
|
||||||
|
</div>
|
||||||
|
<button class="admin__delete" type="button" @click="deleteFeed(feed.id)">Delete</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin {
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__heading {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__error,
|
||||||
|
.admin__empty {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__item-title {
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__item-url {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__delete {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0.3rem 0.9rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin__delete:hover {
|
||||||
|
border-color: var(--color-border-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
+113
-13
@@ -1,10 +1,89 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||||
import { useFeeds } from '@/composables/useFeeds'
|
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
||||||
|
import Modal from './modal/AddUrl.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds()
|
const route = useRoute()
|
||||||
|
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds, lastProgrammaticScroll } = useFeeds()
|
||||||
|
|
||||||
|
const headerRef = ref(null)
|
||||||
|
|
||||||
|
// Scroll-driven show/hide: the header slides out of view on scroll-down and
|
||||||
|
// back in on scroll-up. This is show/hide via `transform` (not the old
|
||||||
|
// resize behaviour) — the header is position:fixed, so translating it never
|
||||||
|
// reflows content, and the app's programmatic scrolls resolve to sensible
|
||||||
|
// states: scrollTo(0, 0) lands near the top → shown; the list-view
|
||||||
|
// read-correction scrollBy moves only a few px → stays under the threshold.
|
||||||
|
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)
|
||||||
|
const headerH = headerRef.value?.offsetHeight ?? 0
|
||||||
|
|
||||||
|
// Always reveal near the very top, and keep it visible while the menu is
|
||||||
|
// open (the dropdown is anchored to the header, so hiding it would slide the
|
||||||
|
// open menu off-screen).
|
||||||
|
if (y <= headerH || 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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('scroll', onScrollRaf)
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFeedsPage = computed(() => route.path === '/feeds')
|
||||||
|
|
||||||
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
||||||
|
|
||||||
@@ -18,9 +97,8 @@ function closeMenu() {
|
|||||||
menuOpen.value = false
|
menuOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
async function logout() {
|
||||||
localStorage.removeItem('user-token')
|
await logoutSession()
|
||||||
localStorage.removeItem('user-id')
|
|
||||||
closeMenu()
|
closeMenu()
|
||||||
router.push({ name: 'login' })
|
router.push({ name: 'login' })
|
||||||
}
|
}
|
||||||
@@ -52,7 +130,7 @@ function handleToggleLayout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="app-nav">
|
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
||||||
<div class="app-nav__wrapper">
|
<div class="app-nav__wrapper">
|
||||||
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
||||||
<button
|
<button
|
||||||
@@ -78,26 +156,48 @@ function handleToggleLayout() {
|
|||||||
>
|
>
|
||||||
<div class="app-nav__menu-panel">
|
<div class="app-nav__menu-panel">
|
||||||
<RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink>
|
<RouterLink to="/feeds" class="app-nav__menu-item" @click="closeMenu">Feeds</RouterLink>
|
||||||
|
<template v-if="onFeedsPage">
|
||||||
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
|
<button class="app-nav__menu-item" type="button" @click="handleToggleViewMode">
|
||||||
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
|
{{ viewMode === 'list' ? 'Article view' : 'List view' }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
|
<button v-if="viewMode === 'list'" class="app-nav__menu-item" type="button" @click="handleToggleLayout">
|
||||||
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
|
{{ layout === 'list' ? 'Card layout' : 'List layout' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
|
|
||||||
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
<button class="app-nav__menu-item" type="button" @click="handleMarkAllRead">Mark all as read</button>
|
||||||
|
</template>
|
||||||
|
<button class="app-nav__menu-item" type="button" @click="handleSync">Sync</button>
|
||||||
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
|
<button class="app-nav__menu-item" type="button" @click="openAddModal">Add RSS</button>
|
||||||
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
|
<RouterLink to="/admin" class="app-nav__menu-item" @click="closeMenu">Admin</RouterLink>
|
||||||
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
|
<button class="app-nav__menu-item app-nav__logout" type="button" @click="logout">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<Modal :show="showModal" @close="showModal = false">
|
||||||
|
<template #header>
|
||||||
|
<h3>Add RSS Feed</h3>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</Teleport>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-nav {
|
.app-nav {
|
||||||
position: relative;
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
background: var(--color-background);
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav--hidden {
|
||||||
|
transform: translateY(-100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__wrapper {
|
.app-nav__wrapper {
|
||||||
@@ -106,12 +206,12 @@ function handleToggleLayout() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.375rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__title {
|
.app-nav__title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__unread {
|
.app-nav__unread {
|
||||||
@@ -203,7 +303,7 @@ function handleToggleLayout() {
|
|||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.app-nav__wrapper {
|
.app-nav__wrapper {
|
||||||
padding: 1rem 2rem;
|
padding: 0.5rem 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+256
-34
@@ -1,27 +1,71 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue';
|
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
|
||||||
import Modal from './modal/AddUrl.vue';
|
|
||||||
import { useFeeds } from '@/composables/useFeeds';
|
import { useFeeds } from '@/composables/useFeeds';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
feeds,
|
feeds,
|
||||||
showMessage,
|
showMessage,
|
||||||
message,
|
message,
|
||||||
showModal,
|
|
||||||
viewMode,
|
viewMode,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
leaveArticleView,
|
|
||||||
layout,
|
layout,
|
||||||
nextArticle,
|
nextArticle,
|
||||||
prevArticle,
|
prevArticle,
|
||||||
fetchData,
|
fetchData,
|
||||||
|
sync,
|
||||||
getReadable,
|
getReadable,
|
||||||
|
disconnectObserver,
|
||||||
setInitialLoad,
|
setInitialLoad,
|
||||||
showMessageForXSeconds,
|
showMessageForXSeconds,
|
||||||
} = useFeeds()
|
} = useFeeds()
|
||||||
|
|
||||||
|
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
|
||||||
|
|
||||||
const shareLabel = navigator.share ? 'Share' : 'Copy link'
|
const shareLabel = navigator.share ? 'Share' : 'Copy link'
|
||||||
|
|
||||||
|
function scrollToNextArticle() {
|
||||||
|
const articles = document.querySelectorAll('#article .observe')
|
||||||
|
const threshold = window.scrollY + 1
|
||||||
|
for (const el of articles) {
|
||||||
|
if (el.offsetTop > threshold) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small images (icons, logos, ...) look bad stretched to the full-bleed
|
||||||
|
// width used for readable article images — leave them at their natural size
|
||||||
|
// instead. Intrinsic size is only known once the image has loaded, so check
|
||||||
|
// on load (or immediately if it's already cached/complete).
|
||||||
|
const SMALL_IMAGE_THRESHOLD = 200
|
||||||
|
|
||||||
|
function markSmallImages() {
|
||||||
|
document.querySelectorAll('.article-feature__content--readable img, .feed-content--readable img').forEach(img => {
|
||||||
|
const checkSize = () => {
|
||||||
|
if (img.naturalWidth && img.naturalWidth <= SMALL_IMAGE_THRESHOLD) {
|
||||||
|
img.classList.add('article-feature__image--small')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (img.complete) {
|
||||||
|
checkSize()
|
||||||
|
} else {
|
||||||
|
img.addEventListener('load', checkSize, { once: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => feeds.value[currentIndex.value]?.content, async () => {
|
||||||
|
await nextTick()
|
||||||
|
markSmallImages()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadReadable(feed, index) {
|
||||||
|
await getReadable(feed, index)
|
||||||
|
await nextTick()
|
||||||
|
markSmallImages()
|
||||||
|
}
|
||||||
|
|
||||||
async function shareUrl(url) {
|
async function shareUrl(url) {
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
await navigator.share({ url })
|
await navigator.share({ url })
|
||||||
@@ -31,9 +75,15 @@ async function shareUrl(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disconnectObserver()
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
setInitialLoad(false)
|
setInitialLoad(false)
|
||||||
await fetchData()
|
await fetchData()
|
||||||
|
sync(true)
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
setInitialLoad(true)
|
setInitialLoad(true)
|
||||||
console.log('set to true')
|
console.log('set to true')
|
||||||
@@ -42,13 +92,6 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
|
||||||
<modal :show="showModal" @close="showModal = false">
|
|
||||||
<template #header>
|
|
||||||
<h3>Add RSS Feed</h3>
|
|
||||||
</template>
|
|
||||||
</modal>
|
|
||||||
</Teleport>
|
|
||||||
<div>
|
<div>
|
||||||
<div v-if="showMessage" class="message">{{ message }}</div>
|
<div v-if="showMessage" class="message">{{ message }}</div>
|
||||||
|
|
||||||
@@ -60,24 +103,30 @@ onMounted(async () => {
|
|||||||
</svg>
|
</svg>
|
||||||
<p class="empty-state__label">All caught up</p>
|
<p class="empty-state__label">All caught up</p>
|
||||||
</div>
|
</div>
|
||||||
<template v-for="( feed, index ) in feeds ">
|
<template v-for="( feed, index ) in feeds " :key="feed.id">
|
||||||
<div v-bind:id="index" class="observe">
|
<div v-bind:id="index" class="observe">
|
||||||
<p class="feed-source">{{ feed.feedTitle }}</p>
|
<p class="feed-source">{{ feed.feedTitle }}</p>
|
||||||
<h2 @click="getReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
|
<h2 @click="loadReadable(feed, index)" class="feed-title">{{ feed.title }}</h2>
|
||||||
<h3>{{ feed.timestamp }}</h3>
|
<h3>{{ feed.timestamp }}</h3>
|
||||||
<p v-if="!feed.readable" class="feed-original-link">
|
<p class="feed-original-link">
|
||||||
<a :href="feed.url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
<a :href="feed.url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
||||||
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feed.url)" :aria-label="shareLabel">
|
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feed.url)" :aria-label="shareLabel">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="feed-content" v-html='feed.content'></p>
|
<p class="feed-content" :class="{ 'feed-content--readable': feed.readable }" v-html='feed.content'></p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<button
|
||||||
|
v-if="feeds.length"
|
||||||
|
type="button"
|
||||||
|
class="article-nav__btn list-skip-btn"
|
||||||
|
aria-label="Skip to next article"
|
||||||
|
@click="scrollToNextArticle"
|
||||||
|
>↓</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="article-single">
|
<div v-else class="article-single">
|
||||||
<button type="button" class="article-single__back" @click="leaveArticleView">← Back to list</button>
|
|
||||||
<div v-if="feeds.length == 0" class="empty-state">
|
<div v-if="feeds.length == 0" class="empty-state">
|
||||||
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="10"/>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
@@ -86,14 +135,16 @@ onMounted(async () => {
|
|||||||
<p class="empty-state__label">All caught up</p>
|
<p class="empty-state__label">All caught up</p>
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p class="feed-source">{{ feeds[currentIndex].feedTitle }}</p>
|
<article class="article-feature">
|
||||||
<h2 @click="getReadable(feeds[currentIndex], currentIndex)" class="feed-title">{{ feeds[currentIndex].title }}</h2>
|
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||||
<h3>{{ feeds[currentIndex].timestamp }}</h3>
|
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
||||||
<p v-if="!feeds[currentIndex].readable" class="feed-original-link">
|
<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 ↗</a>
|
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
||||||
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
|
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
|
||||||
</p>
|
</p>
|
||||||
<p class="feed-content" v-html="feeds[currentIndex].content"></p>
|
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
||||||
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="article-nav">
|
<div class="article-nav">
|
||||||
@@ -117,6 +168,17 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.list-skip-btn {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.observe {
|
||||||
|
scroll-margin-top: var(--app-nav-height, 4.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
/* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
|
/* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
|
||||||
with no truncation/max-height: normal block flow lets each card grow to fit
|
with no truncation/max-height: normal block flow lets each card grow to fit
|
||||||
its own full content (images included), with no cross-element interaction. */
|
its own full content (images included), with no cross-element interaction. */
|
||||||
@@ -170,6 +232,38 @@ onMounted(async () => {
|
|||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feed-content--readable :deep(img),
|
||||||
|
.feed-content--readable :deep(video) {
|
||||||
|
display: block;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
margin-left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-content--readable :deep(img.article-feature__image--small) {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 1.5em auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.feed-content--readable :deep(img),
|
||||||
|
.feed-content--readable :deep(video) {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1.5em auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.feed-original-link {
|
.feed-original-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -211,24 +305,152 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.article-single {
|
.article-single {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 1em;
|
||||||
padding-bottom: 5rem;
|
padding-bottom: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-single__back {
|
.article-feature {
|
||||||
display: inline-flex;
|
width: 100%;
|
||||||
align-items: center;
|
max-width: 720px;
|
||||||
min-height: 44px;
|
|
||||||
padding: 0.5rem 0.9rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-single__back:hover {
|
.article-feature__source {
|
||||||
border-color: var(--color-border-hover);
|
margin: 0 0 0.5em;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-size: clamp(0.75rem, 2vw, 0.85rem);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__title {
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-family: var(--headline-font-family);
|
||||||
|
font-size: calc(clamp(1.4rem, 5vw, 2rem) * var(--headline-font-size-scale));
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: var(--color-accent-2);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__title:hover {
|
||||||
|
color: var(--color-accent-2-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__meta {
|
||||||
|
margin: 0.75em 0 1.5em;
|
||||||
|
padding: 0 1rem 1.5em;
|
||||||
|
font-size: clamp(0.85rem, 2.5vw, 1rem);
|
||||||
|
font-weight: normal;
|
||||||
|
opacity: 0.55;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature .feed-original-link {
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content {
|
||||||
|
padding: 0 var(--content-padding);
|
||||||
|
text-align: var(--content-text-align);
|
||||||
|
font-family: var(--content-font-family);
|
||||||
|
font-size: calc(clamp(1rem, 3.5vw, 1.25rem) * var(--content-font-size-scale));
|
||||||
|
line-height: 1.75;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(p) {
|
||||||
|
padding: 0.5em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(h3) {
|
||||||
|
padding: 0.5em 0;
|
||||||
|
font-size: calc(clamp(1rem, 3vw, 1.3rem) * var(--headline-font-size-scale));
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(img),
|
||||||
|
.article-feature__content :deep(video) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content--readable :deep(img),
|
||||||
|
.article-feature__content--readable :deep(video) {
|
||||||
|
display: block;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
height: auto;
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
margin-left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Small images (icons, logos, ...) keep their natural size instead of being
|
||||||
|
stretched to the full-bleed width. */
|
||||||
|
.article-feature__content--readable :deep(img.article-feature__image--small) {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 1.5em auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On desktop the viewport is much wider than the article column, so the
|
||||||
|
full-bleed 100vw treatment above would blow images up far beyond their
|
||||||
|
natural resolution. Keep them at natural size, centered in the text. */
|
||||||
|
@media (min-width: 720px) {
|
||||||
|
.article-feature__content--readable :deep(img),
|
||||||
|
.article-feature__content--readable :deep(video) {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1.5em auto;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(a) {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration-color: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(blockquote) {
|
||||||
|
margin: 1.5em 0;
|
||||||
|
padding: 1em 0;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-family: var(--content-font-family);
|
||||||
|
font-size: 1.25em;
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(pre) {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1em;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(code) {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-feature__content :deep(figcaption) {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
font-size: 0.85em;
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-nav {
|
.article-nav {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
|
import { nextTick } from 'vue'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import AppNav from '../AppNav.vue'
|
import AppNav from '../AppNav.vue'
|
||||||
@@ -7,6 +8,15 @@ import { useFeeds } from '../../composables/useFeeds'
|
|||||||
|
|
||||||
vi.mock('axios')
|
vi.mock('axios')
|
||||||
|
|
||||||
|
// jsdom does not implement IntersectionObserver, but AppNav sets one up on mount
|
||||||
|
// to track whether the list view's title is scrolled into view.
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
describe('AppNav', () => {
|
describe('AppNav', () => {
|
||||||
let router
|
let router
|
||||||
|
|
||||||
@@ -35,15 +45,35 @@ describe('AppNav', () => {
|
|||||||
await router.isReady()
|
await router.isReady()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Unmount every AppNav mounted via mountNav() after each test so mounted
|
||||||
|
// instances (and their router/menu listeners) don't pile up across the file.
|
||||||
|
let mountedWrappers = []
|
||||||
|
function mountNav(options = { global: { plugins: [router] } }) {
|
||||||
|
const wrapper = mount(AppNav, options)
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const wrapper of mountedWrappers) {
|
||||||
|
try {
|
||||||
|
wrapper.unmount()
|
||||||
|
} catch {
|
||||||
|
// already unmounted by the test itself — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mountedWrappers = []
|
||||||
|
})
|
||||||
|
|
||||||
async function mountWithMenuOpen() {
|
async function mountWithMenuOpen() {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await wrapper.find('.app-nav__hamburger').trigger('click')
|
await wrapper.find('.app-nav__hamburger').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
return wrapper
|
return wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
it('toggles the menu open and closed via the hamburger button', async () => {
|
it('toggles the menu open and closed via the hamburger button', async () => {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||||
|
|
||||||
@@ -155,7 +185,7 @@ describe('AppNav', () => {
|
|||||||
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
@@ -168,19 +198,122 @@ describe('AppNav', () => {
|
|||||||
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides the unread count when there are no articles', async () => {
|
it('hides the unread count when there are no articles', async () => {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 })
|
||||||
|
// offsetHeight is 0 in jsdom; give the header a real height so the
|
||||||
|
// "near the top" guard (scrollY <= headerH) has something to compare to.
|
||||||
|
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
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('always shows the header near the top of the page', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
scrollTo(400)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Back within the header's own height of the top → always revealed.
|
||||||
|
scrollTo(10)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not let a programmatic upward jump reveal the header mid-read', async () => {
|
||||||
|
const { markProgrammaticScroll } = useFeeds()
|
||||||
|
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)
|
||||||
|
const wrapper = mountNav()
|
||||||
|
|
||||||
|
// Hide it first via a normal scroll-down (no programmatic flag active).
|
||||||
|
scrollTo(400)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// A read-correction flags a programmatic scroll, then the page jumps
|
||||||
|
// upward. Within the window that upward jump must NOT reveal the header.
|
||||||
|
markProgrammaticScroll() // records lastProgrammaticScroll = 1000
|
||||||
|
nowSpy.mockReturnValue(1100) // 100ms later — inside the 300ms window
|
||||||
|
scrollTo(200)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Still allows hiding on scroll-down even while the flag is active.
|
||||||
|
scrollTo(500)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
// Once the window elapses, a genuine scroll-up reveals it again.
|
||||||
|
nowSpy.mockReturnValue(1500) // 500ms after the flag — outside the window
|
||||||
|
scrollTo(450)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not toggle on sub-threshold jitter', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
// Start well below the top so the "near the top" guard doesn't apply.
|
||||||
|
scrollTo(300)
|
||||||
|
await nextTick()
|
||||||
|
// Reveal first so we're testing that small moves don't hide it.
|
||||||
|
scrollTo(260)
|
||||||
|
await nextTick()
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
|
||||||
|
scrollTo(268) // +8px, under the 12px threshold
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('does not mark articles as read when the confirmation is dismissed', async () => {
|
it('does not mark articles as read when the confirmation is dismissed', async () => {
|
||||||
const { feeds } = useFeeds()
|
const { feeds } = useFeeds()
|
||||||
feeds.value = [
|
feeds.value = [
|
||||||
@@ -199,4 +332,5 @@ describe('AppNav', () => {
|
|||||||
|
|
||||||
confirmSpy.mockRestore()
|
confirmSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -119,7 +119,14 @@ describe('RssFeeds', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
// axios.post is also hit by the sync triggered on mount, so branch on the
|
||||||
|
// URL rather than relying on call order via `mockResolvedValueOnce`.
|
||||||
|
axios.post.mockImplementation((url) => {
|
||||||
|
if (url === '/api/v1/article/sync') {
|
||||||
|
return Promise.resolve({ status: 200 })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
||||||
|
})
|
||||||
|
|
||||||
const { layout } = useFeeds()
|
const { layout } = useFeeds()
|
||||||
layout.value = 'cards'
|
layout.value = 'cards'
|
||||||
@@ -177,10 +184,7 @@ describe('RssFeeds', () => {
|
|||||||
expect(titles).toEqual(['Newer article', 'Older article'])
|
expect(titles).toEqual(['Newer article', 'Older article'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows a link to the original article until the readable version is loaded', async () => {
|
it('keeps a link to the original article visible after the readable version is loaded', async () => {
|
||||||
// The API returns each item with a short summary already in `content` —
|
|
||||||
// the link must key off the `readable` flag (set once Readability has
|
|
||||||
// parsed the full article), not off `content` truthiness.
|
|
||||||
axios.get.mockResolvedValueOnce({
|
axios.get.mockResolvedValueOnce({
|
||||||
data: {
|
data: {
|
||||||
feeds: [
|
feeds: [
|
||||||
@@ -199,7 +203,14 @@ describe('RssFeeds', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
axios.post.mockResolvedValueOnce({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
// axios.post is also hit by the sync triggered on mount, so branch on the
|
||||||
|
// URL rather than relying on call order via `mockResolvedValueOnce`.
|
||||||
|
axios.post.mockImplementation((url) => {
|
||||||
|
if (url === '/api/v1/article/sync') {
|
||||||
|
return Promise.resolve({ status: 200 })
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: { content: '<html><body><article><p>full text</p></article></body></html>' } })
|
||||||
|
})
|
||||||
|
|
||||||
const wrapper = mount(RssFeeds)
|
const wrapper = mount(RssFeeds)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
@@ -212,7 +223,9 @@ describe('RssFeeds', () => {
|
|||||||
await wrapper.find('.feed-title').trigger('click')
|
await wrapper.find('.feed-title').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.feed-original-link a').exists()).toBe(false)
|
const linkAfter = wrapper.find('.feed-original-link a')
|
||||||
|
expect(linkAfter.exists()).toBe(true)
|
||||||
|
expect(linkAfter.attributes('href')).toBe('https://example.test/1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches to article view and navigates between articles', async () => {
|
it('switches to article view and navigates between articles', async () => {
|
||||||
@@ -252,30 +265,31 @@ describe('RssFeeds', () => {
|
|||||||
useFeeds().toggleViewMode()
|
useFeeds().toggleViewMode()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
// Same as in list view: the readable content is loaded on demand by
|
// Same as in list view: the readable content is loaded on demand by
|
||||||
// clicking the headline, not fetched automatically on entering the view.
|
// clicking the headline, not fetched automatically on entering the view.
|
||||||
expect(axios.post).not.toHaveBeenCalled()
|
// (axios.post is also hit by the sync triggered on mount.)
|
||||||
|
expect(axios.post).not.toHaveBeenCalledWith('/api/v1/article/read', expect.anything(), expect.anything())
|
||||||
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
|
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
|
||||||
|
|
||||||
await wrapper.find('.article-single .feed-title').trigger('click')
|
await wrapper.find('.article-single .article-feature__title').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything())
|
expect(axios.post).toHaveBeenCalledWith('/api/v1/article/read', { url: 'https://example.test/1' }, expect.anything())
|
||||||
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(false)
|
expect(wrapper.find('.article-single .feed-original-link a').exists()).toBe(true)
|
||||||
|
|
||||||
expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
|
expect(wrapper.findAll('.article-nav__btn')[0].attributes('disabled')).toBeDefined()
|
||||||
|
|
||||||
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
await wrapper.findAll('.article-nav__btn')[1].trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article two')
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
|
||||||
expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
|
expect(wrapper.findAll('.article-nav__btn')[1].attributes('disabled')).toBeDefined()
|
||||||
|
|
||||||
await wrapper.findAll('.article-nav__btn')[0].trigger('click')
|
await wrapper.findAll('.article-nav__btn')[0].trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.article-single .feed-title').text()).toBe('Article one')
|
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('drops articles read while paging through article view once back in the list', async () => {
|
it('drops articles read while paging through article view once back in the list', async () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useFeeds } from '../useFeeds'
|
import { useFeeds } from '../useFeeds'
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ class FakeIntersectionObserver {
|
|||||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
describe('useFeeds', () => {
|
describe('useFeeds', () => {
|
||||||
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable } = useFeeds()
|
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection } = useFeeds()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
@@ -91,6 +92,81 @@ describe('useFeeds', () => {
|
|||||||
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
|
expect(axios.get).toHaveBeenCalledWith('/api/v1/article/get/7', expect.anything())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('marks the correct articles read when several scroll out of view in one batch', async () => {
|
||||||
|
feeds.value = [
|
||||||
|
{ id: 101, title: 'First' },
|
||||||
|
{ id: 102, title: 'Second' },
|
||||||
|
{ id: 103, title: 'Third' },
|
||||||
|
]
|
||||||
|
setInitialLoad(true)
|
||||||
|
axios.put.mockResolvedValue({ status: 200 })
|
||||||
|
|
||||||
|
// Both the first and second articles scrolled above the viewport in the
|
||||||
|
// same IntersectionObserver callback — their `target.id` reflects their
|
||||||
|
// original render-time indices (0 and 1).
|
||||||
|
await handleIntersection([
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
|
||||||
|
{ isIntersecting: false, boundingClientRect: { y: -5 }, target: { id: '1' } },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/101', null, expect.anything())
|
||||||
|
expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/102', null, expect.anything())
|
||||||
|
expect(axios.put).not.toHaveBeenCalledWith('/api/v1/article/read/103', null, expect.anything())
|
||||||
|
expect(feeds.value).toEqual([{ id: 103, title: 'Third' }])
|
||||||
|
|
||||||
|
setInitialLoad(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips leftover embedded-video placeholder headings', async () => {
|
||||||
|
feeds.value = [{
|
||||||
|
id: 1,
|
||||||
|
title: 'Article one',
|
||||||
|
url: 'https://www.dw.com/en/article-one/a-1',
|
||||||
|
content: '',
|
||||||
|
}]
|
||||||
|
axios.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
content: `<html><body><article>
|
||||||
|
<h2 aria-label="Eingebettetes Video — Iran-Krieg belastet Wirtschaft und Märkte in Deutschland">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><g fill-rule="evenodd"><path d="M14.114 7.599H13.5l.002 4.706h.601l4.582 3.25-.005-11.11zM11.084 4.444l-9.007.002-1.336.797.002 9.514 1.334.793 9.007.006 1.509-.799-.004-9.516z"></path></g></svg>
|
||||||
|
Iran-Krieg belastet Wirtschaft und Märkte in Deutschland
|
||||||
|
</h2>
|
||||||
|
<p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p>
|
||||||
|
</article></body></html>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await getReadable(feeds.value[0], 0)
|
||||||
|
|
||||||
|
expect(feeds.value[0].content).not.toContain('Eingebettetes Video')
|
||||||
|
expect(feeds.value[0].content).not.toContain('<svg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips leftover embedded-audio placeholder headings', async () => {
|
||||||
|
feeds.value = [{
|
||||||
|
id: 1,
|
||||||
|
title: 'Article one',
|
||||||
|
url: 'https://www.dw.com/en/article-one/a-1',
|
||||||
|
content: '',
|
||||||
|
}]
|
||||||
|
axios.post.mockResolvedValueOnce({
|
||||||
|
data: {
|
||||||
|
content: `<html><body><article>
|
||||||
|
<h2 aria-label="Eingebetteter Audio-Beitrag — Der Gender Pay Gap existiert noch immer">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><g fill-rule="evenodd"><path d="M14.114 7.599H13.5l.002 4.706h.601l4.582 3.25-.005-11.11zM11.084 4.444l-9.007.002-1.336.797.002 9.514 1.334.793 9.007.006 1.509-.799-.004-9.516z"></path></g></svg>
|
||||||
|
Der Gender Pay Gap existiert noch immer
|
||||||
|
</h2>
|
||||||
|
<p>some article text long enough for readability to keep the paragraph as the main content body, padded with extra words to pass the content-length heuristics used by Mozilla Readability when scoring candidate nodes.</p>
|
||||||
|
</article></body></html>`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await getReadable(feeds.value[0], 0)
|
||||||
|
|
||||||
|
expect(feeds.value[0].content).not.toContain('Eingebetteter Audio-Beitrag')
|
||||||
|
expect(feeds.value[0].content).not.toContain('<svg')
|
||||||
|
})
|
||||||
|
|
||||||
it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => {
|
it('resolves Deutsche-Welle-style templated image URLs from data-format/data-url', async () => {
|
||||||
feeds.value = [{
|
feeds.value = [{
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -112,7 +188,10 @@ describe('useFeeds', () => {
|
|||||||
|
|
||||||
await getReadable(feeds.value[0], 0)
|
await getReadable(feeds.value[0], 0)
|
||||||
|
|
||||||
expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_MASTER_LANDSCAPE.jpg"')
|
// "MASTER_LANDSCAPE" is a symbolic name from DW's CMS, not a valid value
|
||||||
|
// for the CDN's numeric `formatId` — it must be mapped to "6" or the
|
||||||
|
// resulting URL 400s and the image fails to load.
|
||||||
|
expect(feeds.value[0].content).toContain('src="https://static.dw.com/image/76212061_6.jpg"')
|
||||||
// The rendered `src` is what matters — `data-url` retaining the raw
|
// The rendered `src` is what matters — `data-url` retaining the raw
|
||||||
// template is harmless since browsers don't load images from data-* attrs.
|
// template is harmless since browsers don't load images from data-* attrs.
|
||||||
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
|
expect(feeds.value[0].content).not.toMatch(/src="[^"]*(\$\{|%7[bB])/)
|
||||||
|
|||||||
+163
-23
@@ -1,4 +1,4 @@
|
|||||||
import { ref, unref, nextTick } from 'vue';
|
import { ref, nextTick } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Readability } from '@mozilla/readability';
|
import { Readability } from '@mozilla/readability';
|
||||||
|
|
||||||
@@ -15,7 +15,17 @@ const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards'
|
|||||||
let observer; // Declare observer outside the setup function
|
let observer; // Declare observer outside the setup function
|
||||||
let initialLoad = false
|
let initialLoad = false
|
||||||
|
|
||||||
function authHeaders() {
|
// Timestamp (performance.now()) of the most recent programmatic scroll / list
|
||||||
|
// mutation that moves the page without user intent — currently the list-view
|
||||||
|
// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline
|
||||||
|
// (instead of treating the induced jump as a user scroll) for a short window
|
||||||
|
// after this, so removing read articles can't pop the header in/out mid-read.
|
||||||
|
const lastProgrammaticScroll = ref(0)
|
||||||
|
function markProgrammaticScroll() {
|
||||||
|
lastProgrammaticScroll.value = performance.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authHeaders() {
|
||||||
return {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -24,6 +34,20 @@ function authHeaders() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tells the server to revoke the current token (bumps token_version, so any
|
||||||
|
// other outstanding tokens for this account are invalidated too) before
|
||||||
|
// clearing the local session. Best-effort: if the request fails (e.g. the
|
||||||
|
// token already expired) the local session is cleared regardless.
|
||||||
|
export async function logout() {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/v1/auth/logout', null, authHeaders())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error logging out', error)
|
||||||
|
}
|
||||||
|
localStorage.removeItem('user-token')
|
||||||
|
localStorage.removeItem('user-id')
|
||||||
|
}
|
||||||
|
|
||||||
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
|
// Some feeds (e.g. Deutsche Welle) ship <img> tags whose `src` and various
|
||||||
// lazy-load attributes (`data-url`, `data-src`, `srcset`, ...) contain an
|
// lazy-load attributes (`data-url`, `data-src`, `srcset`, ...) contain an
|
||||||
// unresolved `${placeholderName}` template — or its URL-encoded `%7B...%7D`
|
// unresolved `${placeholderName}` template — or its URL-encoded `%7B...%7D`
|
||||||
@@ -35,8 +59,19 @@ function authHeaders() {
|
|||||||
const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/
|
const TEMPLATE_PATTERN = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/
|
||||||
const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g
|
const TEMPLATE_PATTERN_GLOBAL = /\$\{[^}]+\}|%7[bB][^%]*%7[dD]/g
|
||||||
|
|
||||||
|
// `data-format` holds a symbolic name from DW's CMS (e.g. "MASTER_LANDSCAPE"),
|
||||||
|
// but their image CDN only accepts numeric format ids in the URL — the
|
||||||
|
// template's `${formatId}` literally means a number. Substituting the
|
||||||
|
// symbolic name verbatim produces a 400 (image fails to load). DW generates
|
||||||
|
// the same fixed set of numeric variants for every image, so map the
|
||||||
|
// symbolic names we've seen to their numeric equivalent.
|
||||||
|
const DW_FORMAT_IDS = {
|
||||||
|
MASTER_LANDSCAPE: '6', // 940x529, 16:9 — matches DW's `16/9` aspect ratio
|
||||||
|
}
|
||||||
|
|
||||||
function resolveTemplatedImage(img) {
|
function resolveTemplatedImage(img) {
|
||||||
const format = img.getAttribute('data-format')
|
const rawFormat = img.getAttribute('data-format')
|
||||||
|
const format = rawFormat && (DW_FORMAT_IDS[rawFormat] ?? (/^\d+$/.test(rawFormat) ? rawFormat : null))
|
||||||
const dataUrl = img.getAttribute('data-url')
|
const dataUrl = img.getAttribute('data-url')
|
||||||
|
|
||||||
if (format) {
|
if (format) {
|
||||||
@@ -81,7 +116,49 @@ async function getReadable(feed, index) {
|
|||||||
doc.head.prepend(base);
|
doc.head.prepend(base);
|
||||||
doc.querySelectorAll('img').forEach(resolveTemplatedImage);
|
doc.querySelectorAll('img').forEach(resolveTemplatedImage);
|
||||||
doc.querySelectorAll('video, audio').forEach(el => el.remove());
|
doc.querySelectorAll('video, audio').forEach(el => el.remove());
|
||||||
|
// Some feeds (e.g. Stuttgarter Nachrichten) embed a social-sharing widget
|
||||||
|
// (WhatsApp/Email/Facebook/... links plus a "Link kopiert" tooltip) in the
|
||||||
|
// article body. It's not part of the article, so strip it before Readability
|
||||||
|
// pulls it into the parsed content.
|
||||||
|
doc.querySelectorAll('#article-social-bar').forEach(el => el.remove())
|
||||||
|
// Some feeds (e.g. Deutsche Welle) leave behind a heading + play-icon SVG
|
||||||
|
// for an embedded video/audio player whose actual <video>/<audio>/<iframe>
|
||||||
|
// we already stripped — without it, the heading is just a giant orphaned
|
||||||
|
// icon that takes up space and links nowhere.
|
||||||
|
doc.querySelectorAll('[aria-label]').forEach(el => {
|
||||||
|
if (/^(Eingebettete[rs]?|Embedded) (Video|Audio)/i.test(el.getAttribute('aria-label'))) {
|
||||||
|
el.remove()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Alpine.js widget overlays: x-cloak marks elements that should be hidden
|
||||||
|
// until Alpine.js initialises (prevents FOUC). These are always widget
|
||||||
|
// containers (e.g. taz's "taz schneller googeln" promo), never article
|
||||||
|
// content, so they're safe to remove unconditionally.
|
||||||
|
doc.querySelectorAll('[x-cloak]').forEach(el => el.remove())
|
||||||
|
// taz subscription promo blocks: a standalone <section> whose link(s) point
|
||||||
|
// to an /abo/ subscription page. Only climb to <section>, not <article>,
|
||||||
|
// to avoid accidentally removing the main article body.
|
||||||
|
doc.querySelectorAll('a[href*="/abo/"]').forEach(el => {
|
||||||
|
const container = el.closest('section')
|
||||||
|
if (container) container.remove()
|
||||||
|
})
|
||||||
|
// taz "Mehr zum Thema" related-articles teaser section.
|
||||||
|
doc.querySelectorAll('#articleTeaser').forEach(el => el.remove())
|
||||||
|
// taz subsidiary magazine promo blocks (e.g. taz FUTURZWEI): either the
|
||||||
|
// <article> itself or its direct <a> child carries an aria-label containing "Abo".
|
||||||
|
doc.querySelectorAll('article[aria-label*="Abo"]').forEach(el => {
|
||||||
|
const container = el.closest('section') ?? el
|
||||||
|
container.remove()
|
||||||
|
})
|
||||||
|
doc.querySelectorAll('article > a[aria-label*="Abo"]').forEach(el => {
|
||||||
|
const container = el.closest('section') ?? el.closest('article')
|
||||||
|
if (container) container.remove()
|
||||||
|
})
|
||||||
const article = new Readability(doc).parse();
|
const article = new Readability(doc).parse();
|
||||||
|
if (!article) {
|
||||||
|
showMessageForXSeconds('Could not extract readable content.', 5)
|
||||||
|
return
|
||||||
|
}
|
||||||
feeds.value[index].content = article.content;
|
feeds.value[index].content = article.content;
|
||||||
feeds.value[index].readable = true;
|
feeds.value[index].readable = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -119,30 +196,37 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function sync() {
|
async function sync(silent = false) {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/v1/article/sync', {
|
const response = await axios.post('/api/v1/article/sync', {
|
||||||
user_id: parseInt(localStorage.getItem("user-id"))
|
user_id: parseInt(localStorage.getItem("user-id"))
|
||||||
}, authHeaders())
|
}, authHeaders())
|
||||||
|
|
||||||
if (response.status == 200) {
|
if (response.status == 200 && !silent) {
|
||||||
showMessageForXSeconds('Sync successful.', 5)
|
showMessageForXSeconds('Sync successful.', 5)
|
||||||
}
|
}
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sync', error)
|
console.error('Error sync', error)
|
||||||
|
if (!silent) {
|
||||||
showMessageForXSeconds(error, 5)
|
showMessageForXSeconds(error, 5)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setupIntersectionObserver() {
|
function setupIntersectionObserver() {
|
||||||
if (observer) {
|
if (observer) {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
observer = new IntersectionObserver(handleIntersection, {
|
// The sticky topbar overlays the top of the viewport, so an article fully
|
||||||
|
// hidden behind it should already count as "scrolled past" — shrink the
|
||||||
|
// observer's root by that height so it stops intersecting at that point.
|
||||||
|
const topbarHeight = document.querySelector('.app-nav')?.getBoundingClientRect().height ?? 0;
|
||||||
|
|
||||||
|
observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), {
|
||||||
root: null, // Use the viewport as the root
|
root: null, // Use the viewport as the root
|
||||||
rootMargin: '0px',
|
rootMargin: `-${topbarHeight}px 0px 0px 0px`,
|
||||||
// threshold: 0.5, // Fire the callback when at least 50% of the element is visible
|
// threshold: 0.5, // Fire the callback when at least 50% of the element is visible
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,22 +238,59 @@ function setupIntersectionObserver() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleIntersection(entries) {
|
function handleIntersection(entries, topbarHeight = 0) {
|
||||||
// The callback function for when the target element enters or exits the viewport
|
// Resolve all affected feeds before touching feeds.value — the target.id
|
||||||
for (const entry of entries) {
|
// indices are render-time positions that shift once we splice the array.
|
||||||
// An article that has scrolled above the viewport (not intersecting,
|
const readFeeds = entries
|
||||||
// bounding box above the top edge) has been read — mark it and remove it.
|
.filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight)
|
||||||
if (initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < 0) {
|
.map(entry => feeds.value[entry.target.id])
|
||||||
await markRead(feeds.value[entry.target.id].id)
|
.filter(Boolean)
|
||||||
removeFeed(entry.target.id)
|
|
||||||
document.getElementById(0)?.scrollIntoView()
|
if (readFeeds.length === 0) return
|
||||||
}
|
|
||||||
}
|
// Disconnect before the DOM mutation. In card layout the cards are short
|
||||||
|
// enough that the shift caused by removing one can push the next card above
|
||||||
|
// the header, which the observer would immediately treat as another read —
|
||||||
|
// cascading until many articles disappear at once.
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFeed(index) {
|
// Both the array splice (via scroll anchoring) and the scrollBy correction
|
||||||
const array = unref(feeds);
|
// below move the page — flag it so AppNav's header auto-hide ignores the jump.
|
||||||
array.splice(index, 1);
|
markProgrammaticScroll()
|
||||||
|
const readIds = new Set(readFeeds.map(feed => feed.id))
|
||||||
|
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
|
||||||
|
|
||||||
|
for (const feed of readFeeds) {
|
||||||
|
markRead(feed.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick().then(() => {
|
||||||
|
// If scroll anchoring didn't compensate for the removed content (common
|
||||||
|
// with position:fixed headers and overflow-x:hidden on body), the first
|
||||||
|
// remaining article will have drifted above the header. Correct the scroll
|
||||||
|
// position so it sits exactly at the header bottom before reconnecting —
|
||||||
|
// otherwise the initial observation would immediately mark everything above
|
||||||
|
// the topbar as read and cascade until the list is empty.
|
||||||
|
const first = document.querySelector('.observe')
|
||||||
|
if (first) {
|
||||||
|
const top = first.getBoundingClientRect().top
|
||||||
|
if (top < topbarHeight) {
|
||||||
|
markProgrammaticScroll()
|
||||||
|
window.scrollBy(0, top - topbarHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setupIntersectionObserver()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectObserver() {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInitialLoad(value) {
|
function setInitialLoad(value) {
|
||||||
@@ -217,21 +338,36 @@ function toggleViewMode() {
|
|||||||
if (viewMode.value === 'article') {
|
if (viewMode.value === 'article') {
|
||||||
leaveArticleView()
|
leaveArticleView()
|
||||||
} else {
|
} else {
|
||||||
|
// Disconnect first: the v-if switch is about to unmount all .observe
|
||||||
|
// elements, which would otherwise fire intersection callbacks reporting
|
||||||
|
// them as no-longer-intersecting and mark every visible article read.
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
viewMode.value = 'article'
|
viewMode.value = 'article'
|
||||||
currentIndex.value = 0
|
currentIndex.value = 0
|
||||||
markCurrentArticleRead()
|
markCurrentArticleRead()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLayout() {
|
async function toggleLayout() {
|
||||||
|
if (observer) {
|
||||||
|
observer.disconnect()
|
||||||
|
observer = null
|
||||||
|
}
|
||||||
|
window.scrollTo(0, 0)
|
||||||
layout.value = layout.value === 'list' ? 'cards' : 'list'
|
layout.value = layout.value === 'list' ? 'cards' : 'list'
|
||||||
localStorage.setItem('layout', layout.value)
|
localStorage.setItem('layout', layout.value)
|
||||||
|
await nextTick()
|
||||||
|
setupIntersectionObserver()
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextArticle() {
|
function nextArticle() {
|
||||||
if (currentIndex.value < feeds.value.length - 1) {
|
if (currentIndex.value < feeds.value.length - 1) {
|
||||||
currentIndex.value += 1
|
currentIndex.value += 1
|
||||||
markCurrentArticleRead()
|
markCurrentArticleRead()
|
||||||
|
window.scrollTo(0, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,6 +375,7 @@ function prevArticle() {
|
|||||||
if (currentIndex.value > 0) {
|
if (currentIndex.value > 0) {
|
||||||
currentIndex.value -= 1
|
currentIndex.value -= 1
|
||||||
markCurrentArticleRead()
|
markCurrentArticleRead()
|
||||||
|
window.scrollTo(0, 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +400,10 @@ export function useFeeds() {
|
|||||||
markAllRead,
|
markAllRead,
|
||||||
showMessageForXSeconds,
|
showMessageForXSeconds,
|
||||||
setupIntersectionObserver,
|
setupIntersectionObserver,
|
||||||
removeFeed,
|
disconnectObserver,
|
||||||
setInitialLoad,
|
setInitialLoad,
|
||||||
|
handleIntersection,
|
||||||
|
lastProgrammaticScroll,
|
||||||
|
markProgrammaticScroll,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const HEADLINE_FONT_OPTIONS = [
|
||||||
|
{ key: 'default', label: 'Default (Glook)', value: "Glook, 'Courier New'" },
|
||||||
|
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
|
||||||
|
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
|
||||||
|
{ key: 'raleway', label: 'Raleway', value: "Raleway, -apple-system, sans-serif" },
|
||||||
|
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const CONTENT_FONT_OPTIONS = [
|
||||||
|
{ key: 'default', label: 'Default (Merriweather)', value: "Merriweather, Georgia, 'Times New Roman', Times, serif" },
|
||||||
|
{ key: 'lora', label: 'Lora', value: "Lora, Georgia, serif" },
|
||||||
|
{ key: 'source-serif', label: 'Source Serif 4', value: "'Source Serif 4', Georgia, serif" },
|
||||||
|
{ key: 'inter', label: 'Inter', value: "Inter, -apple-system, sans-serif" },
|
||||||
|
{ key: 'playfair', label: 'Playfair Display', value: "'Playfair Display', Georgia, serif" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SIZE_STEPS = [0.85, 1, 1.2, 1.45]
|
||||||
|
const SIZE_LABELS = ['S', 'M', 'L', 'XL']
|
||||||
|
|
||||||
|
const TEXT_ALIGN_OPTIONS = [
|
||||||
|
{ key: 'left', label: 'Left' },
|
||||||
|
{ key: 'justify', label: 'Justified' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const PADDING_STEPS = [1, 0.5, 0.15]
|
||||||
|
const PADDING_LABELS = ['Default', 'Compact', 'Minimal']
|
||||||
|
|
||||||
|
const headlineSizeScale = ref(parseFloat(localStorage.getItem('s-headline-size') ?? '1'))
|
||||||
|
const contentSizeScale = ref(parseFloat(localStorage.getItem('s-content-size') ?? '1'))
|
||||||
|
const headlineFontKey = ref(localStorage.getItem('s-headline-font') ?? 'default')
|
||||||
|
const contentFontKey = ref(localStorage.getItem('s-content-font') ?? 'default')
|
||||||
|
const textAlignKey = ref(localStorage.getItem('s-text-align') ?? 'left')
|
||||||
|
const contentPadding = ref(parseFloat(localStorage.getItem('s-content-padding') ?? '1'))
|
||||||
|
|
||||||
|
function fontValue(options, key) {
|
||||||
|
return (options.find(o => o.key === key) ?? options[0]).value
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySettings() {
|
||||||
|
const s = document.documentElement.style
|
||||||
|
s.setProperty('--headline-font-size-scale', headlineSizeScale.value)
|
||||||
|
s.setProperty('--content-font-size-scale', contentSizeScale.value)
|
||||||
|
s.setProperty('--headline-font-family', fontValue(HEADLINE_FONT_OPTIONS, headlineFontKey.value))
|
||||||
|
s.setProperty('--content-font-family', fontValue(CONTENT_FONT_OPTIONS, contentFontKey.value))
|
||||||
|
s.setProperty('--content-text-align', textAlignKey.value)
|
||||||
|
s.setProperty('--content-padding', contentPadding.value + 'rem')
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeadlineSize(scale) {
|
||||||
|
headlineSizeScale.value = scale
|
||||||
|
localStorage.setItem('s-headline-size', scale)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentSize(scale) {
|
||||||
|
contentSizeScale.value = scale
|
||||||
|
localStorage.setItem('s-content-size', scale)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHeadlineFont(key) {
|
||||||
|
headlineFontKey.value = key
|
||||||
|
localStorage.setItem('s-headline-font', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentFont(key) {
|
||||||
|
contentFontKey.value = key
|
||||||
|
localStorage.setItem('s-content-font', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTextAlign(key) {
|
||||||
|
textAlignKey.value = key
|
||||||
|
localStorage.setItem('s-text-align', key)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
function setContentPadding(step) {
|
||||||
|
contentPadding.value = step
|
||||||
|
localStorage.setItem('s-content-padding', step)
|
||||||
|
applySettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
return {
|
||||||
|
headlineSizeScale,
|
||||||
|
contentSizeScale,
|
||||||
|
headlineFontKey,
|
||||||
|
contentFontKey,
|
||||||
|
SIZE_STEPS,
|
||||||
|
SIZE_LABELS,
|
||||||
|
HEADLINE_FONT_OPTIONS,
|
||||||
|
CONTENT_FONT_OPTIONS,
|
||||||
|
TEXT_ALIGN_OPTIONS,
|
||||||
|
PADDING_STEPS,
|
||||||
|
PADDING_LABELS,
|
||||||
|
applySettings,
|
||||||
|
setHeadlineSize,
|
||||||
|
setContentSize,
|
||||||
|
setHeadlineFont,
|
||||||
|
setContentFont,
|
||||||
|
setTextAlign,
|
||||||
|
setContentPadding,
|
||||||
|
textAlignKey,
|
||||||
|
contentPadding,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,28 @@
|
|||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
|
// A 401 means the server has rejected the token (missing, expired, or
|
||||||
|
// revoked via logout/token_version bump elsewhere). Drop the stale session
|
||||||
|
// and send the user back to login rather than leaving them on a page where
|
||||||
|
// every request silently fails.
|
||||||
|
axios.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('user-token')
|
||||||
|
localStorage.removeItem('user-id')
|
||||||
|
if (router.currentRoute.value.name !== 'login') {
|
||||||
|
router.push({ name: 'login' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|||||||
+8
-17
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
scrollBehavior: () => ({ top: 0, behavior: 'instant' }),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -30,23 +31,13 @@ const router = createRouter({
|
|||||||
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to) => {
|
||||||
if (to.meta.requiresAuth) {
|
const isAuthenticated = localStorage.getItem("user-token") != null;
|
||||||
let isAuthenticated = false;
|
// Redirect unauthenticated users hitting a protected route to login;
|
||||||
if (localStorage.getItem("user-token") != null){
|
// returning a value (instead of the deprecated next() callback) is the
|
||||||
isAuthenticated = true;
|
// modern vue-router guard API. Returning nothing lets navigation proceed.
|
||||||
}
|
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||||
|
return '/login';
|
||||||
if (!isAuthenticated) {
|
|
||||||
// Redirect to the login page
|
|
||||||
next('/login');
|
|
||||||
} else {
|
|
||||||
// Proceed to the protected route
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For routes that don't require authentication, proceed without checking
|
|
||||||
next();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup>
|
||||||
|
import AdminFeeds from '../components/AdminFeeds.vue'
|
||||||
|
import AdminSettings from '../components/AdminSettings.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main>
|
||||||
|
<AdminSettings />
|
||||||
|
<AdminFeeds />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user