Merge remote-tracking branch 'upstream/main'

This commit is contained in:
ayaka 2024-10-20 17:32:13 +13:00
commit 64eec64ebe
28 changed files with 491 additions and 104 deletions

5
.github/FUNDING.yml vendored
View File

@ -1,2 +1,3 @@
liberapay: spike
custom: ['https://www.buymeacoffee.com/spikecodes']
liberapay: sigaloid
buy_me_a_coffee: sigaloid
github: sigaloid

View File

@ -7,6 +7,10 @@ assignees: ''
---
<!--
BEFORE FILING A BUG REPORT: Ensure that you are running the latest git commit. Visit /info on your instance, and ensure the git commit listed is the same commit listed on the home page.
-->
## Describe the bug
<!--
A clear and concise description of what the bug is.
@ -31,3 +35,7 @@ Steps to reproduce the behavior:
<!--
Add any other context about the problem here.
-->
<!-- Mandatory -->
- [ ] I checked that the instance that this was reported on is running the latest git commit, or I can reproduce it locally on the latest git commit

175
Cargo.lock generated
View File

@ -30,6 +30,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
dependencies = [
"cfg-if",
"getrandom",
"once_cell",
"version_check",
"zerocopy",
@ -78,44 +79,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "askama"
version = "0.12.1"
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"askama_derive",
"askama_escape",
]
[[package]]
name = "askama_derive"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
dependencies = [
"askama_parser",
"mime",
"mime_guess",
"proc-macro2",
"quote",
"syn 2.0.68",
]
[[package]]
name = "askama_escape"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
[[package]]
name = "askama_parser"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [
"nom",
]
[[package]]
name = "async-trait"
version = "0.1.80"
@ -190,9 +163,9 @@ dependencies = [
[[package]]
name = "brotli"
version = "6.0.0"
version = "7.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b"
checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd"
dependencies = [
"alloc-no-stdlib",
"alloc-stdlib",
@ -242,7 +215,7 @@ dependencies = [
"cached_proc_macro",
"cached_proc_macro_types",
"futures",
"hashbrown",
"hashbrown 0.14.5",
"instant",
"once_cell",
"thiserror",
@ -756,6 +729,12 @@ dependencies = [
"allocator-api2",
]
[[package]]
name = "hashbrown"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb"
[[package]]
name = "hermit-abi"
version = "0.3.9"
@ -865,7 +844,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.14.5",
]
[[package]]
@ -877,6 +856,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "inventory"
version = "0.3.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f958d3d68f4167080a18141e10381e7634563984a537f2a49a30fd8e53ac5767"
[[package]]
name = "is-terminal"
version = "0.4.12"
@ -920,7 +905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e0d73b369f386f1c44abd9c570d5318f55ccde816ff4b562fa452e5182863d"
dependencies = [
"core2",
"hashbrown",
"hashbrown 0.14.5",
"rle-decode-fast",
]
@ -1069,6 +1054,18 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "once_map"
version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed29bb6f7d6ac14023acb332a356f3891265d780e254057c866dbe7a909d2d2d"
dependencies = [
"ahash",
"hashbrown 0.15.0",
"parking_lot",
"stable_deref_trait",
]
[[package]]
name = "openssl-probe"
version = "0.1.5"
@ -1227,7 +1224,7 @@ name = "redsunlib"
version = "0.35.2"
dependencies = [
"arc-swap",
"askama",
"async-recursion",
"base64 0.22.1",
"brotli",
"build_html",
@ -1246,12 +1243,14 @@ dependencies = [
"percent-encoding",
"pretty_env_logger",
"regex",
"rinja",
"route-recognizer",
"rss",
"rust-embed",
"sealed_test",
"serde",
"serde_json",
"serde_json_path",
"serde_yaml",
"time",
"tokio",
@ -1304,6 +1303,43 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rinja"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28580fecce391f3c0e65a692e5f2b5db258ba2346ee04f355ae56473ab973dc"
dependencies = [
"itoa",
"rinja_derive",
]
[[package]]
name = "rinja_derive"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f1ae91455a4c82892d9513fcfa1ac8faff6c523602d0041536341882714aede"
dependencies = [
"memchr",
"mime",
"mime_guess",
"once_map",
"proc-macro2",
"quote",
"rinja_parser",
"rustc-hash",
"syn 2.0.68",
]
[[package]]
name = "rinja_parser"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06ea17639e1f35032e1c67539856e498c04cd65fe2a45f55ec437ec55e4be941"
dependencies = [
"memchr",
"nom",
]
[[package]]
name = "rle-decode-fast"
version = "1.0.3"
@ -1369,6 +1405,12 @@ version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "rustc-hash"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "583034fd73374156e66797ed8e5b0d5690409c9226b22d87cb7f19821c05d152"
[[package]]
name = "rustix"
version = "0.38.34"
@ -1553,6 +1595,59 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_json_path"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bc0207b6351893eafa1e39aa9aea452abb6425ca7b02dd64faf29109e7a33ba"
dependencies = [
"inventory",
"nom",
"once_cell",
"regex",
"serde",
"serde_json",
"serde_json_path_core",
"serde_json_path_macros",
"thiserror",
]
[[package]]
name = "serde_json_path_core"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3d64fe53ce1aaa31bea2b2b46d3b6ab6a37e61854bedcbd9f174e188f3f7d79"
dependencies = [
"inventory",
"once_cell",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "serde_json_path_macros"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a31e8177a443fd3e94917f12946ae7891dfb656e6d4c5e79b8c5d202fbcb723"
dependencies = [
"inventory",
"once_cell",
"serde_json_path_core",
"serde_json_path_macros_internal",
]
[[package]]
name = "serde_json_path_macros_internal"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75dde5a1d2ed78dfc411fc45592f72d3694436524d3353683ecb3d22009731dc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.68",
]
[[package]]
name = "serde_spanned"
version = "0.6.6"
@ -1626,6 +1721,12 @@ version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
[[package]]
name = "strsim"
version = "0.10.0"

View File

@ -11,7 +11,7 @@ authors = [
edition = "2021"
[dependencies]
askama = { version = "0.12.1", default-features = false }
rinja = { version = "0.3.4", default-features = false }
cached = { version = "0.51.3", features = ["async"] }
clap = { version = "4.4.11", default-features = false, features = [
"std",
@ -31,7 +31,7 @@ time = { version = "0.3.31", features = ["local-offset"] }
url = "2.5.0"
rust-embed = { version = "8.1.0", features = ["include-exclude"] }
libflate = "2.0.0"
brotli = { version = "6.0.0", features = ["std"] }
brotli = { version = "7.0.0", features = ["std"] }
toml = "0.8.8"
once_cell = "1.19.0"
serde_yaml = "0.9.29"
@ -44,6 +44,8 @@ pretty_env_logger = "0.5.0"
dotenvy = "0.15.7"
rss = "2.0.7"
arc-swap = "1.7.1"
serde_json_path = "0.6.7"
async-recursion = "1.1.1"
[dev-dependencies]

View File

@ -71,7 +71,7 @@ And at the time, I was reading an excerpt from Mao Zedong, so the name seemed ap
- [Rust](https://www.rust-lang.org/) - Programming language
- [Hyper](https://github.com/hyperium/hyper) - HTTP server and client
- [Askama](https://github.com/djc/askama) - Templating engine
- [Rinja](https://github.com/rinja-rs/rinja) - Templating engine
- [Rustls](https://github.com/rustls/rustls) - TLS library
## How is it different from other Reddit front ends?
@ -320,7 +320,7 @@ Assign a default value for each user-modifiable setting by passing environment v
| Name | Possible values | Default value |
| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `THEME` | `["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox", "gruvboxdark", "gruvboxlight", "tokyoNight", "icebergDark"]` | `system` |
| `THEME` | `["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold", "rosebox", "gruvboxdark", "gruvboxlight", "tokyoNight", "catppuccin", "icebergDark", "doomone", "libredditBlack", "libredditDark", "libredditLight"]` | `system` |
| `MASCOT` | `["BoymoderBlahaj", "redsunlib" ... Add more at ./static/mascots] ` | _(none)_ |
| `FRONT_PAGE` | `["default", "popular", "all"]` | `default` |
| `LAYOUT` | `["card", "clean", "compact", "old", "waterfall"]` | `card` |

View File

@ -14,6 +14,6 @@ PORT=12345
#REDLIB_DEFAULT_FFMPEG_VIDEO_DOWNLOADS=off
#REDLIB_DEFAULT_HIDE_HLS_NOTIFICATION=off
#REDLIB_DEFAULT_AUTOPLAY_VIDEOS=off
#REDLIB_DEFAULT_SUBSCRIPTIONS=off (sub1+sub2+sub3)
#REDLIB_DEFAULT_SUBSCRIPTIONS=(sub1+sub2+sub3)
#REDLIB_DEFAULT_HIDE_AWARDS=off
#REDLIB_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION=off

View File

@ -30,7 +30,8 @@ RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service ~@privileged ~@resources
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
UMask=0077
[Install]

View File

@ -4,7 +4,6 @@ use futures_lite::future::block_on;
use futures_lite::{future::Boxed, FutureExt};
use hyper::client::HttpConnector;
use hyper::header::HeaderValue;
use hyper::StatusCode;
use hyper::{body, body::Buf, client, header, Body, Client, Method, Request, Response, Uri};
use hyper_rustls::HttpsConnector;
use libflate::gzip;
@ -23,7 +22,13 @@ use crate::server::RequestExt;
use crate::utils::format_url;
const REDDIT_URL_BASE: &str = "https://oauth.reddit.com";
const REDDIT_URL_BASE_HOST: &str = "oauth.reddit.com";
const REDDIT_SHORT_URL_BASE: &str = "https://redd.it";
const REDDIT_SHORT_URL_BASE_HOST: &str = "redd.it";
const ALTERNATIVE_REDDIT_URL_BASE: &str = "https://www.reddit.com";
const ALTERNATIVE_REDDIT_URL_BASE_HOST: &str = "www.reddit.com";
pub static CLIENT: Lazy<Client<HttpsConnector<HttpConnector>>> = Lazy::new(|| {
let https = hyper_rustls::HttpsConnectorBuilder::new().with_native_roots().https_only().enable_http1().build();
@ -40,6 +45,11 @@ pub static OAUTH_RATELIMIT_REMAINING: AtomicU16 = AtomicU16::new(99);
pub static OAUTH_IS_ROLLING_OVER: AtomicBool = AtomicBool::new(false);
static URL_PAIRS: [(&str, &str); 2] = [
(ALTERNATIVE_REDDIT_URL_BASE, ALTERNATIVE_REDDIT_URL_BASE_HOST),
(REDDIT_SHORT_URL_BASE, REDDIT_SHORT_URL_BASE_HOST),
];
/// Gets the canonical path for a resource on Reddit. This is accomplished by
/// making a `HEAD` request to Reddit at the path given in `path`.
///
@ -53,8 +63,28 @@ pub static OAUTH_IS_ROLLING_OVER: AtomicBool = AtomicBool::new(false);
/// `Location` header. An `Err(String)` is returned if Reddit responds with a
/// 429, or if we were unable to decode the value in the `Location` header.
#[cached(size = 1024, time = 600, result = true)]
pub async fn canonical_path(path: String) -> Result<Option<String>, String> {
let res = reddit_head(path.clone(), true).await?;
#[async_recursion::async_recursion]
pub async fn canonical_path(path: String, tries: i8) -> Result<Option<String>, String> {
if tries == 0 {
return Ok(None);
}
// for each URL pair, try the HEAD request
let res = {
// for url base and host in URL_PAIRS, try reddit_short_head(path.clone(), true, url_base, url_base_host) and if it succeeds, set res. else, res = None
let mut res = None;
for (url_base, url_base_host) in URL_PAIRS {
res = reddit_short_head(path.clone(), true, url_base, url_base_host).await.ok();
if let Some(res) = &res {
if !res.status().is_client_error() {
break;
}
}
}
res
};
let res = res.ok_or_else(|| "Unable to make HEAD request to Reddit.".to_string())?;
let status = res.status().as_u16();
let policy_error = res.headers().get(header::RETRY_AFTER).is_some();
@ -68,6 +98,7 @@ pub async fn canonical_path(path: String) -> Result<Option<String>, String> {
let Ok(original) = val.to_str() else {
return Err("Unable to decode Location header.".to_string());
};
// We need to strip the .json suffix from the original path.
// In addition, we want to remove share parameters.
// Cut it off here instead of letting it propagate all the way
@ -80,7 +111,9 @@ pub async fn canonical_path(path: String) -> Result<Option<String>, String> {
// also remove all Reddit domain parts with format_url.
// Otherwise, it will literally redirect to Reddit.com.
let uri = format_url(stripped_uri);
Ok(Some(uri))
// Decrement tries and try again
canonical_path(uri, tries - 1).await
}
None => Ok(None),
},
@ -161,20 +194,26 @@ async fn stream(url: &str, req: &Request<Body>) -> Result<Response<Body>, String
/// Makes a GET request to Reddit at `path`. By default, this will honor HTTP
/// 3xx codes Reddit returns and will automatically redirect.
fn reddit_get(path: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
request(&Method::GET, path, true, quarantine)
request(&Method::GET, path, true, quarantine, REDDIT_URL_BASE, REDDIT_URL_BASE_HOST)
}
/// Makes a HEAD request to Reddit at `path`. This will not follow redirects.
fn reddit_head(path: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
request(&Method::HEAD, path, false, quarantine)
/// Makes a HEAD request to Reddit at `path, using the short URL base. This will not follow redirects.
fn reddit_short_head(path: String, quarantine: bool, base_path: &'static str, host: &'static str) -> Boxed<Result<Response<Body>, String>> {
request(&Method::HEAD, path, false, quarantine, base_path, host)
}
// /// Makes a HEAD request to Reddit at `path`. This will not follow redirects.
// fn reddit_head(path: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
// request(&Method::HEAD, path, false, quarantine, false)
// }
// Unused - reddit_head is only ever called in the context of a short URL
/// Makes a request to Reddit. If `redirect` is `true`, `request_with_redirect`
/// will recurse on the URL that Reddit provides in the Location HTTP header
/// in its response.
fn request(method: &'static Method, path: String, redirect: bool, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
fn request(method: &'static Method, path: String, redirect: bool, quarantine: bool, base_path: &'static str, host: &'static str) -> Boxed<Result<Response<Body>, String>> {
// Build Reddit URL from path.
let url = format!("{REDDIT_URL_BASE}{path}");
let url = format!("{base_path}{path}");
// Construct the hyper client from the HTTPS connector.
let client: Client<_, Body> = CLIENT.clone();
@ -199,7 +238,7 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo
.header("Client-Vendor-Id", vendor_id)
.header("X-Reddit-Device-Id", device_id)
.header("x-reddit-loid", loid)
.header("Host", "oauth.reddit.com")
.header("Host", host)
.header("Authorization", &format!("Bearer {token}"))
.header("Accept-Encoding", if method == Method::GET { "gzip" } else { "identity" })
.header("Accept-Language", "en-US,en;q=0.5")
@ -254,16 +293,12 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo
.to_string(),
true,
quarantine,
base_path,
host,
)
.await;
};
// Special condition rate limiting - https://github.com/redlib-org/redlib/issues/229
if response.status() == StatusCode::FORBIDDEN && response.headers().get("retry-after").unwrap_or(&HeaderValue::from_static("0")).to_str().unwrap_or("0") == "0" {
force_refresh_token().await;
return Err("Rate limit - try refreshing soon".to_string());
}
match response.headers().get(header::CONTENT_ENCODING) {
// Content not compressed.
None => Ok(response),
@ -381,6 +416,16 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
match serde_json::from_reader(body.reader()) {
Ok(value) => {
let json: Value = value;
// If user is suspended
if let Some(data) = json.get("data") {
if let Some(is_suspended) = data.get("is_suspended").and_then(Value::as_bool) {
if is_suspended {
return Err("suspended".into());
}
}
}
// If Reddit returned an error
if json["error"].is_i64() {
// OAuth token has expired; http status 401
@ -389,6 +434,7 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
let () = force_refresh_token().await;
return Err("OAuth token has expired. Please refresh the page!".to_string());
}
// Handle quarantined
if json["reason"] == "quarantined" {
return Err("quarantined".into());
@ -397,6 +443,15 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
if json["reason"] == "gated" {
return Err("gated".into());
}
// Handle private subs
if json["reason"] == "private" {
return Err("private".into());
}
// Handle banned subs
if json["reason"] == "banned" {
return Err("banned".into());
}
Err(format!("Reddit error {} \"{}\": {} | {path}", json["error"], json["reason"], json["message"]))
} else {
Ok(json)
@ -432,13 +487,34 @@ async fn test_localization_popular() {
async fn test_obfuscated_share_link() {
let share_link = "/r/rust/s/kPgq8WNHRK".into();
// Correct link without share parameters
let canonical_link = "/r/rust/comments/18t5968/why_use_tuple_struct_over_standard_struct/kfbqlbc".into();
assert_eq!(canonical_path(share_link).await, Ok(Some(canonical_link)));
let canonical_link = "/r/rust/comments/18t5968/why_use_tuple_struct_over_standard_struct/kfbqlbc/".into();
assert_eq!(canonical_path(share_link, 3).await, Ok(Some(canonical_link)));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_share_link_strip_json() {
let link = "/17krzvz".into();
let canonical_link = "/r/nfl/comments/17krzvz/rapoport_sources_former_no_2_overall_pick/".into();
assert_eq!(canonical_path(link).await, Ok(Some(canonical_link)));
let canonical_link = "/comments/17krzvz".into();
assert_eq!(canonical_path(link, 3).await, Ok(Some(canonical_link)));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_private_sub() {
let link = json("/r/suicide/about.json?raw_json=1".into(), true).await;
assert!(link.is_err());
assert_eq!(link, Err("private".into()));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_banned_sub() {
let link = json("/r/aaa/about.json?raw_json=1".into(), true).await;
assert!(link.is_err());
assert_eq!(link, Err("banned".into()));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_gated_sub() {
// quarantine to false to specifically catch when we _don't_ catch it
let link = json("/r/drugs/about.json?raw_json=1".into(), false).await;
assert!(link.is_err());
assert_eq!(link, Err("gated".into()));
}

View File

@ -5,8 +5,8 @@ use crate::server::RequestExt;
use crate::subreddit::{can_access_quarantine, quarantine};
use crate::utils::{error, filter_posts, get_filters, nsfw_landing, parse_post, template, Post, Preferences};
use askama::Template;
use hyper::{Body, Request, Response};
use rinja::Template;
use serde_json::Value;
use std::borrow::ToOwned;
use std::collections::HashSet;

View File

@ -3,10 +3,10 @@ use crate::{
server::RequestExt,
utils::{ErrorTemplate, Preferences},
};
use askama::Template;
use build_html::{Container, Html, HtmlContainer, Table};
use hyper::{http::Error, Body, Request, Response};
use once_cell::sync::Lazy;
use rinja::Template;
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;

View File

@ -284,6 +284,9 @@ async fn main() {
app.at("/img/*path").get(|r| proxy(r, "https://i.redd.it/{path}").boxed());
app.at("/thumb/:point/:id").get(|r| proxy(r, "https://{point}.thumbs.redditmedia.com/{id}").boxed());
app.at("/emoji/:id/:name").get(|r| proxy(r, "https://emoji.redditmedia.com/{id}/{name}").boxed());
app
.at("/emote/:subreddit_id/:filename")
.get(|r| proxy(r, "https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/{subreddit_id}/{filename}").boxed());
app
.at("/preview/:loc/award_images/:fullname/:id")
.get(|r| proxy(r, "https://{loc}view.redd.it/award_images/{fullname}/{id}").boxed());
@ -389,7 +392,7 @@ async fn main() {
let sub = req.param("sub").unwrap_or_default();
match req.param("id").as_deref() {
// Share link
Some(id) if (8..12).contains(&id.len()) => match canonical_path(format!("/r/{sub}/s/{id}")).await {
Some(id) if (8..12).contains(&id.len()) => match canonical_path(format!("/r/{sub}/s/{id}"), 3).await {
Ok(Some(path)) => Ok(redirect(&path)),
Ok(None) => error(req, "Post ID is invalid. It may point to a post on a community that has been banned.").await,
Err(e) => error(req, &e).await,
@ -408,7 +411,7 @@ async fn main() {
Some("best" | "hot" | "new" | "top" | "rising" | "controversial") => subreddit::community(req).await,
// Short link for post
Some(id) if (5..8).contains(&id.len()) => match canonical_path(format!("/{id}")).await {
Some(id) if (5..8).contains(&id.len()) => match canonical_path(format!("/{id}"), 3).await {
Ok(path_opt) => match path_opt {
Some(path) => Ok(redirect(&path)),
None => error(req, "Post ID is invalid. It may point to a post on a community that has been banned.").await,

View File

@ -6,13 +6,14 @@ use crate::{
};
use base64::{engine::general_purpose, Engine as _};
use hyper::{client, Body, Method, Request};
use log::{info, trace};
use log::{error, info, trace};
use serde_json::json;
use tokio::time::{error::Elapsed, timeout};
static REDDIT_ANDROID_OAUTH_CLIENT_ID: &str = "ohXpoqrZYub1kg";
static AUTH_ENDPOINT: &str = "https://accounts.reddit.com";
static AUTH_ENDPOINT: &str = "https://www.reddit.com";
// Spoofed client for Android devices
#[derive(Debug, Clone, Default)]
@ -25,11 +26,32 @@ pub struct Oauth {
}
impl Oauth {
/// Create a new OAuth client
pub(crate) async fn new() -> Self {
let mut oauth = Self::default();
oauth.login().await;
oauth
// Call new_internal until it succeeds
loop {
let attempt = Self::new_with_timeout().await;
match attempt {
Ok(Some(oauth)) => {
info!("[✅] Successfully created OAuth client");
return oauth;
}
Ok(None) => {
error!("Failed to create OAuth client. Retrying in 5 seconds...");
continue;
}
Err(duration) => {
error!("Failed to create OAuth client in {duration:?}. Retrying in 5 seconds...");
}
}
}
}
async fn new_with_timeout() -> Result<Option<Self>, Elapsed> {
let mut oauth = Self::default();
timeout(Duration::from_secs(5), oauth.login()).await.map(|result| result.map(|_| oauth))
}
pub(crate) fn default() -> Self {
// Generate a device to spoof
let device = Device::new();
@ -46,7 +68,7 @@ impl Oauth {
}
async fn login(&mut self) -> Option<()> {
// Construct URL for OAuth token
let url = format!("{AUTH_ENDPOINT}/api/access_token");
let url = format!("{AUTH_ENDPOINT}/auth/v2/oauth/access-token/loid");
let mut builder = Request::builder().method(Method::POST).uri(&url);
// Add headers from spoofed client
@ -69,13 +91,19 @@ impl Oauth {
// Build request
let request = builder.body(body).unwrap();
trace!("Sending token request...");
// Send request
let client: client::Client<_, Body> = CLIENT.clone();
let resp = client.request(request).await.ok()?;
trace!("Received response with status {} and length {:?}", resp.status(), resp.headers().get("content-length"));
// Parse headers - loid header _should_ be saved sent on subsequent token refreshes.
// Technically it's not needed, but it's easy for Reddit API to check for this.
// It's some kind of header that uniquely identifies the device.
// Not worried about the privacy implications, since this is randomly changed
// and really only as privacy-concerning as the OAuth token itself.
if let Some(header) = resp.headers().get("x-reddit-loid") {
self.headers_map.insert("x-reddit-loid".to_owned(), header.to_str().ok()?.to_string());
}
@ -85,10 +113,14 @@ impl Oauth {
self.headers_map.insert("x-reddit-session".to_owned(), header.to_str().ok()?.to_string());
}
trace!("Serializing response...");
// Serialize response
let body_bytes = hyper::body::to_bytes(resp.into_body()).await.ok()?;
let json: serde_json::Value = serde_json::from_slice(&body_bytes).ok()?;
trace!("Accessing relevant fields...");
// Save token and expiry
self.token = json.get("access_token")?.as_str()?.to_string();
self.expires_in = json.get("expires_in")?.as_u64()?;

View File

@ -4,14 +4,14 @@ use crate::config::get_setting;
use crate::server::RequestExt;
use crate::subreddit::{can_access_quarantine, quarantine};
use crate::utils::{
error, format_num, get_filters, nsfw_landing, param, parse_post, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences,
error, format_num, get_filters, nsfw_landing, param, parse_post, rewrite_emotes, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences,
};
use hyper::{Body, Request, Response};
use askama::Template;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet;
use rinja::Template;
use std::collections::{HashMap, HashSet};
// STRUCTS
#[derive(Template)]
@ -72,11 +72,15 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
}
let query = match COMMENT_SEARCH_CAPTURE.captures(&url) {
let query_body = match COMMENT_SEARCH_CAPTURE.captures(&url) {
Some(captures) => captures.get(1).unwrap().as_str().replace("%20", " ").replace('+', " "),
None => String::new(),
};
let query_string = format!("q={query_body}&type=comment");
let form = url::form_urlencoded::parse(query_string.as_bytes()).collect::<HashMap<_, _>>();
let query = form.get("q").unwrap().clone().to_string();
let comments = match query.as_str() {
"" => parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &req),
_ => query_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &query, &req),
@ -174,7 +178,7 @@ fn build_comment(
get_setting("REDLIB_PUSHSHIFT_FRONTEND").unwrap_or_else(|| String::from(crate::config::DEFAULT_PUSHSHIFT_FRONTEND)),
)
} else {
rewrite_urls(&val(comment, "body_html"))
rewrite_emotes(&data["media_metadata"], val(comment, "body_html"))
};
let kind = comment["kind"].as_str().unwrap_or_default().to_string();

View File

@ -5,10 +5,10 @@ use crate::{
subreddit::{can_access_quarantine, quarantine},
RequestExt,
};
use askama::Template;
use hyper::{Body, Request, Response};
use once_cell::sync::Lazy;
use regex::Regex;
use rinja::Template;
// STRUCTS
struct SearchParams {
@ -60,7 +60,8 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
} else {
""
};
let path = format!("{}.json?{}{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default(), nsfw_results);
let uri_path = req.uri().path().replace("+", "%2B");
let path = format!("{}.json?{}{}&raw_json=1", uri_path, req.uri().query().unwrap_or_default(), nsfw_results);
let mut query = param(&path, "q").unwrap_or_default();
query = REDDIT_URL_MATCH.replace(&query, "").to_string();
@ -68,10 +69,14 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
return Ok(redirect("/"));
}
if query.starts_with("r/") {
if query.starts_with("r/") || query.starts_with("user/") {
return Ok(redirect(&format!("/{query}")));
}
if query.starts_with("u/") {
return Ok(redirect(&format!("/user{}", &query[1..])));
}
let sub = req.param("sub").unwrap_or_default();
let quarantined = can_access_quarantine(&req, &sub);
// Handle random subreddits

View File

@ -3,10 +3,10 @@ use std::collections::HashMap;
// CRATES
use crate::server::ResponseExt;
use crate::utils::{redirect, template, Preferences};
use askama::Template;
use cookie::Cookie;
use futures_lite::StreamExt;
use hyper::{Body, Request, Response};
use rinja::Template;
use time::{Duration, OffsetDateTime};
// STRUCTS

View File

@ -4,9 +4,9 @@ use crate::utils::{
catch_random, error, filter_posts, format_num, format_url, get_filters, nsfw_landing, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit,
};
use crate::{client::json, server::ResponseExt, RequestExt};
use askama::Template;
use cookie::Cookie;
use hyper::{Body, Request, Response};
use rinja::Template;
use once_cell::sync::Lazy;
use regex::Regex;
@ -494,6 +494,11 @@ pub async fn rss(req: Request<Body>) -> Result<Response<Body>, String> {
link: Some(utils::get_post_url(&post)),
author: Some(post.author.name),
content: Some(rewrite_urls(&post.body)),
description: Some(format!(
"<a href='{}{}'>Comments</a>",
config::get_setting("REDLIB_FULL_URL").unwrap_or_default(),
post.permalink
)),
..Default::default()
})
.collect::<Vec<_>>(),

View File

@ -3,8 +3,8 @@ use crate::client::json;
use crate::server::RequestExt;
use crate::utils::{error, filter_posts, format_url, get_filters, nsfw_landing, param, setting, template, Post, Preferences, User};
use crate::{config, utils};
use askama::Template;
use hyper::{Body, Request, Response};
use rinja::Template;
use time::{macros::format_description, OffsetDateTime};
// STRUCTS

View File

@ -4,14 +4,15 @@ use crate::config::{self, get_setting};
// CRATES
//
use crate::{client::json, server::RequestExt};
use askama::Template;
use cookie::Cookie;
use hyper::{Body, Request, Response};
use log::error;
use once_cell::sync::Lazy;
use regex::Regex;
use rinja::Template;
use rust_embed::RustEmbed;
use serde_json::Value;
use serde_json_path::{JsonPath, JsonPathExt};
use std::collections::{HashMap, HashSet};
use std::env;
use std::str::FromStr;
@ -937,12 +938,19 @@ pub fn rewrite_urls(input_text: &str) -> String {
// Rewrite Reddit links to Redlib
REDDIT_REGEX.replace_all(input_text, r#"href="/"#)
.to_string();
text1 = REDDIT_EMOJI_REGEX
.replace_all(&text1, format_url(REDDIT_EMOJI_REGEX.find(&text1).map(|x| x.as_str()).unwrap_or_default()))
.to_string()
// Remove (html-encoded) "\" from URLs.
.replace("%5C", "")
.replace("\\_", "_");
loop {
if REDDIT_EMOJI_REGEX.find(&text1).is_none() {
break;
} else {
text1 = REDDIT_EMOJI_REGEX
.replace_all(&text1, format_url(REDDIT_EMOJI_REGEX.find(&text1).map(|x| x.as_str()).unwrap_or_default()))
.to_string()
}
}
// Remove (html-encoded) "\" from URLs.
text1 = text1.replace("%5C", "").replace("\\_", "_");
// Rewrite external media previews to Redlib
loop {
@ -998,6 +1006,83 @@ pub fn rewrite_urls(input_text: &str) -> String {
}
}
// These links all follow a pattern of "https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/SUBREDDIT_ID/RANDOM_FILENAME.png"
static REDDIT_EMOTE_LINK_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/(.*)"#).unwrap());
// These all follow a pattern of '"emote|SUBREDDIT_IT|NUMBER"', we want the number
static REDDIT_EMOTE_ID_NUMBER_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#""emote\|.*\|(.*)""#).unwrap());
pub fn rewrite_emotes(media_metadata: &Value, comment: String) -> String {
/* Create the paths we'll use to look for our data inside the json.
Because we don't know the name of any given emote we use a wildcard to parse them. */
let link_path = JsonPath::parse("$[*].s.u").expect("valid JSON Path");
let id_path = JsonPath::parse("$[*].id").expect("valid JSON Path");
let size_path = JsonPath::parse("$[*].s.y").expect("valid JSON Path");
// Extract all of the results from those json paths
let link_nodes = media_metadata.json_path(&link_path);
let id_nodes = media_metadata.json_path(&id_path);
// Initialize our vectors
let mut id_vec = Vec::new();
let mut link_vec = Vec::new();
// Add the relevant data to each of our vectors so we can access it by number later
for current_id in id_nodes {
id_vec.push(current_id)
}
for current_link in link_nodes {
link_vec.push(current_link)
}
/* Set index to the length of link_vec.
This is one larger than we'll actually be looking at, but we correct that later */
let mut index = link_vec.len();
// Comment needs to be in scope for when we call rewrite_urls()
let mut comment = comment;
/* Loop until index hits zero.
This also prevents us from trying to do anything on an empty vector */
while index != 0 {
/* Subtract 1 from index to get the real index we should be looking at.
Then continue on each subsequent loop to continue until we hit the last entry in the vector.
This is how we get this to deal with multiple emotes in a single message and properly replace each ID with it's link */
index -= 1;
// Convert our current index in id_vec into a string so we can search through it with regex
let current_id = id_vec[index].to_string();
/* The ID number can be multiple lengths, so we capture it with regex.
We also want to only attempt anything when we get matches to avoid panicking */
if let Some(id_capture) = REDDIT_EMOTE_ID_NUMBER_REGEX.captures(&current_id) {
// Format the ID to include the colons it has in the comment text
let id = format!(":{}:", &id_capture[1]);
// Convert current link to string to search through it with the regex
let link = link_vec[index].to_string();
// Make sure we only do operations when we get matches, otherwise we panic when trying to access the first match
if let Some(link_capture) = REDDIT_EMOTE_LINK_REGEX.captures(&link) {
/* Reddit sends a size for the image based on whether it's alone or accompanied by text.
It's a good idea and makes everything look nicer, so we'll do the same. */
let size = media_metadata.json_path(&size_path).first().unwrap().to_string();
// Replace the ID we found earlier in the comment with the respective image and it's link from the regex capture
let to_replace_with = format!(
"<img loading=\"lazy\" src=\"/emote/{} width=\"{size}\" height=\"{size}\" style=\"vertical-align:text-bottom\">",
&link_capture[1]
);
// Inside the comment replace the ID we found with the string that will embed the image
comment = comment.replace(&id, &to_replace_with).to_string();
}
}
}
// Call rewrite_urls() to transform any other Reddit links
rewrite_urls(&comment)
}
// Format vote count to a string that will be displayed.
// Append `m` and `k` for millions and thousands respectively, and
// round to the nearest tenth.
@ -1319,3 +1404,11 @@ fn test_url_path_basename() {
// empty path
assert_eq!(url_path_basename("/"), "");
}
#[test]
fn test_rewriting_emotes() {
let json_input = serde_json::from_str(r#"{"emote|t5_31hpy|2028":{"e":"Image","id":"emote|t5_31hpy|2028","m":"image/png","s":{"u":"https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/t5_31hpy/PW6WsOaLcd.png","x":60,"y":60},"status":"valid","t":"sticker"}}"#).expect("Valid JSON");
let comment_input = r#"<div class="comment_body "><div class="md"><p>:2028:</p></div></div>"#;
let output = r#"<div class="comment_body "><div class="md"><p><img loading="lazy" src="/emote/t5_31hpy/PW6WsOaLcd.png" width="60" height="60" style="vertical-align:text-bottom"></p></div></div>"#;
assert_eq!(rewrite_emotes(&json_input, comment_input.to_string()), output);
}

View File

@ -41,7 +41,7 @@
:root,
.dark {
/* Default & fallback theme (dark) */
--accent: aqua;
--accent: #d54455;
--green: #5cff85;
--text: white;
--foreground: #222;
@ -62,7 +62,7 @@
/* Browser-defined light theme */
@media (prefers-color-scheme: light) {
:root {
--accent: #009a9a;
--accent: #bb2b3b;
--green: #00a229;
--text: black;
--foreground: #f5f5f5;
@ -192,7 +192,7 @@ nav.fixed_navbar {
nav * {
color: var(--text);
}
nav #reddit,
nav #red,
#code > span {
color: var(--accent);
}
@ -616,6 +616,7 @@ aside {
#feed_list > .selected {
background-color: var(--accent);
color: var(--foreground);
font-weight: 500;
}
#feed_list > a:not(.selected):hover {
@ -870,6 +871,7 @@ main > * > footer > a {
#listing_options > a.selected {
background: var(--accent);
color: var(--foreground);
font-weight: 500;
}
#sort_options > a:not(.selected):hover,
@ -1242,6 +1244,10 @@ a.search_subreddit:hover {
width: 100%;
}
.highlighted .post_poll {
padding: 15px 0 5px;
}
/* Used only for text post preview */
.post_preview {
-webkit-mask-image: linear-gradient(180deg, #000 60%, transparent);
@ -1818,7 +1824,7 @@ input[type="submit"] {
width: 100%;
}
.md > p:not(:first-child):not(:last-child) {
.md > p:not(:first-child) {
margin-top: 20px;
}

View File

@ -1,6 +1,6 @@
/* Black theme setting */
.black {
--accent: #009a9a;
--accent: #bb2b3b;
--green: #00a229;
--text: white;
--foreground: #0f0f0f;

View File

@ -1,6 +1,6 @@
/* Dark theme setting */
.dark{
--accent: aqua;
--accent: #d54455;
--green: #5cff85;
--text: white;
--foreground: #222;

View File

@ -0,0 +1,14 @@
/* Libreddit black theme setting */
.libredditBlack {
--accent: #009a9a;
--green: #00a229;
--text: white;
--foreground: #0f0f0f;
--background: black;
--outside: black;
--post: black;
--panel-border: 2px solid #0f0f0f;
--highlighted: #0f0f0f;
--visited: #aaa;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

View File

@ -0,0 +1,14 @@
/* Libreddit dark theme setting */
.libredditDark{
--accent: aqua;
--green: #5cff85;
--text: white;
--foreground: #222;
--background: #0f0f0f;
--outside: #1f1f1f;
--post: #161616;
--panel-border: 1px solid #333;
--highlighted: #333;
--visited: #aaa;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}

View File

@ -0,0 +1,19 @@
/* Libreddit light theme setting */
.libredditLight {
--accent: #009a9a;
--green: #00a229;
--text: black;
--foreground: #f5f5f5;
--background: #ddd;
--outside: #ececec;
--post: #eee;
--panel-border: 1px solid #ccc;
--highlighted: white;
--visited: #555;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
html:has(> .libredditLight) {
/* Hint color theme to browser for scrollbar */
color-scheme: light;
}

View File

@ -1,6 +1,6 @@
/* Light theme setting */
.light {
--accent: #009a9a;
--accent: #bb2b3b;
--green: #00a229;
--text: black;
--foreground: #f5f5f5;

View File

@ -38,7 +38,7 @@
<nav class="
{% if prefs.fixed_navbar == "on" %} fixed_navbar{% endif %}">
<div id="logo">
<a id="redlib" href="/"><span id="lib">red</span><span id="reddit">sun</span><span id="lib">lib</span></a>
<a id="redlib" href="/"><span id="lib">red</span><span id="reddit">sun</span><span id="lib">lib.</span></a>
{% block subscriptions %}{% endblock %}
</div>
{% block search %}{% endblock %}

View File

@ -61,7 +61,7 @@ body %}
<summary class="comment_data">
<a
class="comment_link"
href="{{ post.permalink }}"
href="{{ post.permalink }}#{{ post.id }}"
title="{{ post.link_title }}"
>{{ post.link_title }}</a
>

View File

@ -157,7 +157,10 @@
{% endif %}
<!-- POST BODY -->
<div class="post_body">{{ post.body|safe }}</div>
<div class="post_body">
{{ post.body|safe }}
{% call poll(post) %}
</div>
<div class="post_score" title="{{ post.score.1 }}">
{% if prefs.hide_score != "on" %}
{{ post.score.0 }}