diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 5723180..262c5e9 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,3 @@ -liberapay: spike -custom: ['https://www.buymeacoffee.com/spikecodes'] +liberapay: sigaloid +buy_me_a_coffee: sigaloid +github: sigaloid \ No newline at end of file diff --git a/.github/workflows/main-docker.yml b/.github/workflows/main-docker.yml index 9a10ac7..5442775 100644 --- a/.github/workflows/main-docker.yml +++ b/.github/workflows/main-docker.yml @@ -15,15 +15,13 @@ jobs: fail-fast: false matrix: include: - - { platform: linux/amd64, target: x86_64-unknown-linux-musl} - - { platform: linux/arm64, target: aarch64-unknown-linux-musl} - - { platform: linux/arm/v7, target: armv7-unknown-linux-musleabihf} + - { platform: linux/amd64, target: x86_64-unknown-linux-musl } + - { platform: linux/arm64, target: aarch64-unknown-linux-musl } + - { platform: linux/arm/v7, target: armv7-unknown-linux-musleabihf } steps: - - - name: Checkout + - name: Checkout uses: actions/checkout@v4 - - - name: Docker meta + - name: Docker meta id: meta uses: docker/metadata-action@v5 with: @@ -31,21 +29,17 @@ jobs: tags: | type=sha type=raw,value=latest,enable={{is_default_branch}} - - - name: Set up QEMU + - name: Set up QEMU uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - - name: Login to Quay.io Container Registry + - name: Login to Quay.io Container Registry uses: docker/login-action@v3 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_ROBOT_TOKEN }} - - - name: Build and push + - name: Build and push id: build uses: docker/build-push-action@v5 with: @@ -55,17 +49,15 @@ jobs: outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true file: Dockerfile build-args: TARGET=${{ matrix.target }} - - - name: Export digest + - name: Export digest run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - - - name: Upload digest - uses: actions/upload-artifact@v3 + - name: Upload digest + uses: actions/upload-artifact@v4 with: - name: digests + name: digests-${{ matrix.target }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 @@ -74,17 +66,16 @@ jobs: needs: - build steps: - - - name: Download digests - uses: actions/download-artifact@v3 + - name: Download digests + uses: actions/download-artifact@v4.1.7 with: - name: digests path: /tmp/digests - - - name: Set up Docker Buildx + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - - name: Docker meta + - name: Docker meta id: meta uses: docker/metadata-action@v5 with: @@ -92,31 +83,27 @@ jobs: tags: | type=sha type=raw,value=latest,enable={{is_default_branch}} - - - name: Login to Quay.io Container Registry + - name: Login to Quay.io Container Registry uses: docker/login-action@v3 with: registry: quay.io username: ${{ secrets.QUAY_USERNAME }} password: ${{ secrets.QUAY_ROBOT_TOKEN }} - - - name: Create manifest list and push + - name: Create manifest list and push working-directory: /tmp/digests run: | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) - - name: Push README to Quay.io - uses: christian-korneck/update-container-description-action@v1 - env: - DOCKER_APIKEY: ${{ secrets.APIKEY__QUAY_IO }} - with: - destination_container_repo: quay.io/redlib/redlib - provider: quay - readme_file: 'README.md' + # - name: Push README to Quay.io + # uses: christian-korneck/update-container-description-action@v1 + # env: + # DOCKER_APIKEY: ${{ secrets.APIKEY__QUAY_IO }} + # with: + # destination_container_repo: quay.io/redlib/redlib + # provider: quay + # readme_file: 'README.md' - - - name: Inspect image + - name: Inspect image run: | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} - diff --git a/.github/workflows/main-rust.yml b/.github/workflows/main-rust.yml index ea44565..f38c01d 100644 --- a/.github/workflows/main-rust.yml +++ b/.github/workflows/main-rust.yml @@ -56,7 +56,7 @@ jobs: - name: Calculate SHA256 checksum run: sha256sum target/x86_64-unknown-linux-musl/release/redlib > redlib.sha256 - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 name: Upload a Build Artifact with: name: redlib diff --git a/Cargo.lock b/Cargo.lock index 7905732..5313874 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,6 +116,17 @@ dependencies = [ "nom", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.68", +] + [[package]] name = "async-trait" version = "0.1.80" @@ -161,6 +172,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -822,9 +839,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.25.0" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399c78f9338483cb7e630c8474b07268983c6bd5acee012e4211f9f7bb21b070" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ "futures-util", "http", @@ -832,7 +849,6 @@ dependencies = [ "log", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", ] @@ -1220,7 +1236,8 @@ version = "0.35.1" dependencies = [ "arc-swap", "askama", - "base64", + "async-recursion", + "base64 0.22.1", "brotli", "build_html", "cached", @@ -1386,55 +1403,44 @@ dependencies = [ [[package]] name = "rustls" -version = "0.22.4" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", "ring", - "rustls-pki-types", "rustls-webpki", - "subtle", - "zeroize", + "sct", ] [[package]] name = "rustls-native-certs" -version = "0.7.0" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f1fb85efa936c42c6d5fc28d2629bb51e4b2f4b8a5211e297d599cc5a093792" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ "openssl-probe", "rustls-pemfile", - "rustls-pki-types", "schannel", "security-framework", ] [[package]] name = "rustls-pemfile" -version = "2.1.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" dependencies = [ - "base64", - "rustls-pki-types", + "base64 0.21.7", ] -[[package]] -name = "rustls-pki-types" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" - [[package]] name = "rustls-webpki" -version = "0.102.4" +version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "rustls-pki-types", "untrusted", ] @@ -1480,6 +1486,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sealed_test" version = "1.1.0" @@ -1694,12 +1710,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "syn" version = "1.0.109" @@ -1843,12 +1853,11 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.25.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ "rustls", - "rustls-pki-types", "tokio", ] @@ -2214,9 +2223,3 @@ dependencies = [ "quote", "syn 2.0.68", ] - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index 80bb11b..c29ce1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,7 @@ serde = { version = "1.0.193", features = ["derive"] } cookie = "0.18.0" futures-lite = "2.2.0" hyper = { version = "0.14.28", features = ["full"] } -hyper-rustls = "0.25.0" +hyper-rustls = "0.24.2" percent-encoding = "2.3.1" route-recognizer = "0.3.1" serde_json = "1.0.108" @@ -45,6 +45,7 @@ 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] diff --git a/src/client.rs b/src/client.rs index 6e73a59..ba1ff8c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -22,15 +22,16 @@ 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>> = Lazy::new(|| { - let https = hyper_rustls::HttpsConnectorBuilder::new() - .with_native_roots() - .expect("No native root certificates found") - .https_only() - .enable_http1() - .build(); + let https = hyper_rustls::HttpsConnectorBuilder::new().with_native_roots().https_only().enable_http1().build(); client::Client::builder().build(https) }); @@ -44,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`. /// @@ -57,13 +63,32 @@ 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, String> { - let res = reddit_head(path.clone(), true).await?; +#[async_recursion::async_recursion] +pub async fn canonical_path(path: String, tries: i8) -> Result, 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(); match status { - 429 => Err("Too many requests.".to_string()), - // If Reddit responds with a 2xx, then the path is already canonical. 200..=299 => Ok(Some(path)), @@ -73,6 +98,7 @@ pub async fn canonical_path(path: String) -> Result, 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 @@ -85,7 +111,9 @@ pub async fn canonical_path(path: String) -> Result, 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), }, @@ -94,6 +122,12 @@ pub async fn canonical_path(path: String) -> Result, String> { // as above), return a None. 300..=399 => Ok(None), + // Rate limiting + 429 => Err("Too many requests.".to_string()), + + // Special condition rate limiting - https://github.com/redlib-org/redlib/issues/229 + 403 if policy_error => Err("Too many requests.".to_string()), + _ => Ok( res .headers() @@ -160,20 +194,26 @@ async fn stream(url: &str, req: &Request) -> Result, 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, 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, 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, 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, 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, String>> { +fn request(method: &'static Method, path: String, redirect: bool, quarantine: bool, base_path: &'static str, host: &'static str) -> Boxed, 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(); @@ -198,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") @@ -253,6 +293,8 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo .to_string(), true, quarantine, + base_path, + host, ) .await; }; @@ -374,6 +416,16 @@ pub async fn json(path: String, quarantine: bool) -> Result { 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 @@ -382,6 +434,7 @@ pub async fn json(path: String, quarantine: bool) -> Result { 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()); @@ -390,6 +443,15 @@ pub async fn json(path: String, quarantine: bool) -> Result { 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) @@ -425,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())); } diff --git a/src/main.rs b/src/main.rs index 7efe6e1..61f810e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -344,7 +344,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, @@ -363,7 +363,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, diff --git a/src/oauth.rs b/src/oauth.rs index efdf41e..8173fa5 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -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, 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 @@ -76,6 +98,8 @@ impl Oauth { // 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()); } diff --git a/src/post.rs b/src/post.rs index 0dc9182..2642d24 100644 --- a/src/post.rs +++ b/src/post.rs @@ -11,7 +11,7 @@ use hyper::{Body, Request, Response}; use askama::Template; use once_cell::sync::Lazy; use regex::Regex; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; // STRUCTS #[derive(Template)] @@ -72,11 +72,15 @@ pub async fn item(req: Request) -> Result, 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::>(); + 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), diff --git a/src/search.rs b/src/search.rs index 2b25983..6c8c173 100644 --- a/src/search.rs +++ b/src/search.rs @@ -60,7 +60,8 @@ pub async fn find(req: Request) -> Result, 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(); diff --git a/src/subreddit.rs b/src/subreddit.rs index 5d05f96..8a9270e 100644 --- a/src/subreddit.rs +++ b/src/subreddit.rs @@ -494,6 +494,11 @@ pub async fn rss(req: Request) -> Result, String> { link: Some(utils::get_post_url(&post)), author: Some(post.author.name), content: Some(rewrite_urls(&post.body)), + description: Some(format!( + "Comments", + config::get_setting("REDLIB_FULL_URL").unwrap_or_default(), + post.permalink + )), ..Default::default() }) .collect::>(), diff --git a/src/utils.rs b/src/utils.rs index 010e8db..987565f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1181,6 +1181,18 @@ pub fn enable_rss() -> bool { } } +/// Returns true if the config/env variable `REDLIB_ROBOTS_DISABLE_INDEXING` carries the +/// value `on`. +/// +/// If this variable is set as such, the instance will block all robots in robots.txt and +/// insert the noindex, nofollow meta tag on every page. +pub fn disable_indexing() -> bool { + match get_setting("REDLIB_ROBOTS_DISABLE_INDEXING") { + Some(val) => val == "on", + None => false, + } +} + // Determines if a request shoud redirect to a nsfw landing gate. pub fn should_be_nsfw_gated(req: &Request, req_url: &str) -> bool { let sfw_instance = sfw_only(); diff --git a/static/style.css b/static/style.css index 3ab98b4..888d2ff 100644 --- a/static/style.css +++ b/static/style.css @@ -137,6 +137,7 @@ input { margin: 0; color: var(--text); font-family: "Inter", sans-serif; + word-wrap: anywhere; } html.fixed_navbar { @@ -166,10 +167,8 @@ body.fixed_navbar { } nav { - display: grid; - grid-template-areas: "logo searchbox links"; + display: flex; - justify-content: space-between; align-items: center; color: var(--accent); @@ -202,15 +201,18 @@ nav #code > svg { } nav #logo { - grid-area: logo; white-space: nowrap; margin-right: 5px; + flex-grow: 1; + flex-basis: 0; } nav #links { - grid-area: links; margin-left: 10px; display: flex; + flex-grow: 1; + flex-basis: 0; + justify-content: flex-end; } nav #links svg { @@ -358,10 +360,13 @@ main { #column_one { width: 100%; - max-width: 750px; border-radius: 5px; + max-width: 750px; overflow: inherit; } +@media screen and (max-width: 800px) { + #column_one { max-width: unset; } +} /* Body footer. */ body > footer { @@ -374,14 +379,13 @@ body > footer { bottom: 0; } -.footer-button { +.footer-buttons { align-items: center; border-radius: 0.25rem; box-sizing: border-box; color: var(--text); cursor: pointer; display: inline-flex; - padding-left: 1em; opacity: 0.8; } @@ -458,9 +462,8 @@ aside { border-radius: 5px; overflow: hidden; } -#subreddit, -#sidebar { - min-width: 350px; +@media screen and (min-width: 800px) { + #subreddit, #sidebar { min-width: 350px; } } #user *, @@ -522,7 +525,9 @@ aside { #user_actions { display: grid; grid-template-columns: repeat(2, 1fr); - grid-column-gap: 20px; +} +@media screen and (max-width: 279px) { + #sub_actions { display: unset; } } #user_details > label, @@ -689,6 +694,7 @@ select, display: flex; box-shadow: var(--shadow); border-radius: 5px; + margin-bottom: 0; } #searchbox > *, @@ -698,9 +704,12 @@ select, #search { border-right: 2px var(--outside) solid; - min-width: 0; flex-grow: 1; } +@media screen and (max-width: 800px) { + #search { width: 0; } + #search.commentQuery { width: unset; } +} #inside { display: flex; @@ -779,7 +788,7 @@ button.submit:hover > svg { #sort, #search_sort { display: flex; - align-items: center; + align-items: stretch; margin-bottom: 20px; } @@ -806,10 +815,6 @@ button.submit:hover > svg { /* When screen size is smaller than 480px we switch to a design better suited for mobile devices */ @media screen and (max-width: 480px) { - #search_sort { - align-items: unset; - } - .search_widget_divider_box > #search { flex: 1; min-width: unset; @@ -834,7 +839,7 @@ button.submit:hover > svg { } #sort_submit { - height: unset; + height: auto; border-left: 2px var(--outside) solid; } } @@ -858,6 +863,7 @@ main > * > footer > a { text-align: center; cursor: pointer; transition: 0.2s background; + word-wrap: normal; } #sort_options > a.selected, @@ -1035,6 +1041,8 @@ a.search_subreddit:hover { border-radius: 5px; font-size: 12px; font-weight: bold; + vertical-align: text-top; + line-height: 1.6; } .awards { @@ -1103,6 +1111,8 @@ a.search_subreddit:hover { } .post_media_video { + min-width: 100px; + max-width: 100px; width: auto; height: auto; max-width: 100%; @@ -1121,9 +1131,7 @@ a.search_subreddit:hover { margin: auto; } -.post_blurred img, -.post_blurred svg, -.post_blurred video { +.post_blurred .post_media_content * { filter: blur(1.5rem); } @@ -1131,16 +1139,16 @@ a.search_subreddit:hover { filter: blur(0.25rem); } -.post_blurred .post_thumbnail svg { +.post_blurred .post_thumbnail * { filter: blur(0.3rem); } -.post_blurred img:hover, -.post_blurred svg:hover, -.post_blurred video:hover, +.post_blurred .post_media_content:hover *, +.post_blurred .post_media_content:hover ~ .post_body, +.post_blurred .post_media_content:has(~ .post_body:hover) *, .post_blurred .post_body:hover, -.post_blurred .post_thumbnail:hover svg { - filter: none; +.post_blurred .post_thumbnail:hover * { + filter: none; } .post_media_image svg { @@ -1234,6 +1242,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); @@ -1259,6 +1271,7 @@ a.search_subreddit:hover { #comment_count { font-weight: 500; opacity: 0.9; + align-self: center; } #comment_count > #sorted_by { @@ -1282,7 +1295,7 @@ a.search_subreddit:hover { display: auto; } -@media screen and (min-width: 481px) { +@media screen and (min-width: 508px) { .mobile_item { display: none; } @@ -1894,10 +1907,6 @@ th { /* Mobile */ @media screen and (max-width: 800px) { - body.fixed_navbar { - padding-top: 120px; - } - main { flex-direction: column-reverse; padding: 10px; @@ -1906,9 +1915,11 @@ th { } nav { + display: grid; grid-template-areas: "logo links" "searchbox searchbox"; - padding: 10px; + padding: 5px 10px 0; width: calc(100% - 20px); + margin: 0; } nav #links { @@ -1941,8 +1952,19 @@ th { margin: 0; max-width: 100%; } + #user { margin: 0 0 20px; } + + body.fixed_navbar { + min-height: calc(100vh - 75px); + padding-top: 45px; + #subreddit { margin: 49px 0 0; } + #user { margin: 49px 0 20px 0; } + #settings { margin-top: 48px; } + div.post.highlighted { margin-top: 49px; } + main:not(:has(div + aside)) { #sort { margin-top: 49px; } } + #column_one:has(div.post.highlighted + #commentQueryForms) { #sort { margin-top: 0 } } + } - #user, #sidebar { margin: 20px 0; } @@ -1951,14 +1973,19 @@ th { margin-bottom: 5px; } #searchbox { - width: calc(100vw - 35px); + width: calc(100vw - 20px); + margin-bottom: 10px; } } -@media screen and (max-width: 480px) { - body.fixed_navbar { - padding-top: 100px; +@media screen and (max-width: 580px) { + #commentQueryForms { + display: initial; + justify-content: initial; } +} + +@media screen and (max-width: 507px) { #version { display: none; } @@ -2052,11 +2079,12 @@ th { } .comment_score { - min-width: 32px; + min-width: 34px; height: 20px; - font-size: 15px; + font-size: 12px; padding: 7px 0px; margin-right: -5px; + align-content: center; } #post_links > li { @@ -2080,11 +2108,6 @@ th { .popup-inner { max-width: 80%; } - - #commentQueryForms { - display: initial; - justify-content: initial; - } } .quality-selector { diff --git a/templates/base.html b/templates/base.html index 139c76f..bb11560 100644 --- a/templates/base.html +++ b/templates/base.html @@ -8,6 +8,9 @@ + {% if crate::utils::disable_indexing() %} + + {% endif %} @@ -71,12 +74,8 @@ {% block footer %} {% endblock %} diff --git a/templates/comment.html b/templates/comment.html index ef1f4d8..36c9b60 100644 --- a/templates/comment.html +++ b/templates/comment.html @@ -24,7 +24,7 @@ {% if author.flair.flair_parts.len() > 0 %} {% call utils::render_flair(author.flair.flair_parts) %} {% endif %} - {{ rel_time }} + {{ rel_time }} {% if edited.0 != "".to_string() %}edited {{ edited.0 }}{% endif %} {% if !awards.is_empty() && prefs.hide_awards != "on" %} diff --git a/templates/settings.html b/templates/settings.html index 434f0d2..3940cc7 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -3,6 +3,10 @@ {% block title %}Redlib Settings{% endblock %} +{% block subscriptions %} + {% call utils::sub_list("") %} +{% endblock %} + {% block search %} {% call utils::search("".to_owned(), "") %} {% endblock %} diff --git a/templates/utils.html b/templates/utils.html index b88702b..e0b3589 100644 --- a/templates/utils.html +++ b/templates/utils.html @@ -87,12 +87,12 @@ {% endif %}

- {{ post.title }} {% if post.flair.flair_parts.len() > 0 %} {% call render_flair(post.flair.flair_parts) %} {% endif %} + {{ post.title }} {% if post.flags.nsfw %} NSFW{% endif %} {% if post.flags.spoiler %} Spoiler{% endif %}

@@ -153,7 +153,10 @@ {% endif %} -
{{ post.body|safe }}
+
+ {{ post.body|safe }} + {% call poll(post) %} +
{% if prefs.hide_score != "on" %} {{ post.score.0 }} @@ -175,6 +178,10 @@ {% endif %} + {% if post.post_type == "link" %} +
  • archive.is
  • +
  • archive
  • + {% endif %} {% call external_reddit_link(post.permalink) %} {% if post.media.download_name != "" %}