Merge branch 'main' into readme-updates

This commit is contained in:
Ayaka 2025-05-24 01:09:50 +12:00
commit 026e456855
33 changed files with 1648 additions and 559 deletions

View File

@ -1,6 +1,6 @@
{
"name": "Rust",
"image": "mcr.microsoft.com/devcontainers/rust:0-1-bullseye",
"image": "mcr.microsoft.com/devcontainers/rust:1.0.9-bookworm",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},

606
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,7 @@ name = "redsunlib"
description = " Alternative private front-end to Reddit"
license = "AGPL-3.0-only"
repository = "https://git.stardust.wtf/iridium/redsunlib"
version = "0.35.4"
version = "0.36.0"
authors = [
"Matthew Esposito <matt+cargo@matthew.science>",
"spikecodes <19519553+spikecodes@users.noreply.github.com>",
@ -27,7 +27,7 @@ hyper = { version = "0.14.31", features = ["full"] }
percent-encoding = "2.3.1"
route-recognizer = "0.3.1"
serde_json = "1.0.133"
tokio = { version = "1.35.1", features = ["full"] }
tokio = { version = "1.44.2", features = ["full"] }
time = { version = "0.3.31", features = ["local-offset"] }
url = "2.5.0"
rust-embed = { version = "8.1.0", features = ["include-exclude"] }
@ -47,8 +47,15 @@ rss = "2.0.7"
arc-swap = "1.7.1"
serde_json_path = "0.7.1"
async-recursion = "1.1.1"
common-words-all = { version = "0.0.2", default-features = false, features = ["english", "one"] }
pulldown-cmark = { version = "0.12.0", features = ["simd", "html"], default-features = false }
hyper-rustls = { version = "0.24.2", features = [ "http2" ] }
tegen = "0.1.4"
serde_urlencoded = "0.7.1"
chrono = { version = "0.4.39", default-features = false, features = [ "std" ] }
htmlescape = "0.3.1"
bincode = "1.3.3"
base2048 = "2.0.2"
revision = "0.10.0"
[dev-dependencies]

45
Dockerfile.alpine Normal file
View File

@ -0,0 +1,45 @@
# supported versions here: https://hub.docker.com/_/rust
ARG ALPINE_VERSION=3.20
########################
## builder image
########################
FROM rust:alpine${ALPINE_VERSION} AS builder
RUN apk add --no-cache musl-dev
WORKDIR /redlib
# download (most) dependencies in their own layer
COPY Cargo.lock Cargo.toml ./
RUN mkdir src && echo "fn main() { panic!(\"why am i running?\") }" > src/main.rs
RUN cargo build --release --locked --bin redlib
RUN rm ./src/main.rs && rmdir ./src
# copy the source and build the redlib binary
COPY . ./
RUN cargo build --release --locked --bin redlib
RUN echo "finished building redlib!"
########################
## release image
########################
FROM alpine:${ALPINE_VERSION} AS release
# Import redlib binary from builder
COPY --from=builder /redlib/target/release/redlib /usr/local/bin/redlib
# Add non-root user for running redlib
RUN adduser --home /nonexistent --no-create-home --disabled-password redlib
USER redlib
# Document that we intend to expose port 8080 to whoever runs the container
EXPOSE 8080
# Run a healthcheck every minute to make sure redlib is functional
HEALTHCHECK --interval=1m --timeout=3s CMD wget --spider --q http://localhost:8080/settings || exit 1
# Add container metadata
LABEL org.opencontainers.image.authors="sigaloid"
CMD ["redlib"]

51
Dockerfile.ubuntu Normal file
View File

@ -0,0 +1,51 @@
# supported versions here: https://hub.docker.com/_/rust
ARG RUST_BUILDER_VERSION=slim-bookworm
ARG UBUNTU_RELEASE_VERSION=noble
########################
## builder image
########################
FROM rust:${RUST_BUILDER_VERSION} AS builder
WORKDIR /redlib
# download (most) dependencies in their own layer
COPY Cargo.lock Cargo.toml ./
RUN mkdir src && echo "fn main() { panic!(\"why am i running?\") }" > src/main.rs
RUN cargo build --release --locked --bin redlib
RUN rm ./src/main.rs && rmdir ./src
# copy the source and build the redlib binary
COPY . ./
RUN cargo build --release --locked --bin redlib
RUN echo "finished building redlib!"
########################
## release image
########################
FROM ubuntu:${UBUNTU_RELEASE_VERSION} AS release
# Install ca-certificates
RUN apt-get update && apt-get install -y ca-certificates
# Import redlib binary from builder
COPY --from=builder /redlib/target/release/redlib /usr/local/bin/redlib
# Add non-root user for running redlib
RUN useradd \
--no-create-home \
--password "!" \
--comment "user for running redlib" \
redlib
USER redlib
# Document that we intend to expose port 8080 to whoever runs the container
EXPOSE 8080
# Run a healthcheck every minute to make sure redlib is functional
HEALTHCHECK --interval=1m --timeout=3s CMD wget --spider --q http://localhost:8080/settings || exit 1
# Add container metadata
LABEL org.opencontainers.image.authors="sigaloid"
CMD ["redlib"]

View File

@ -314,6 +314,17 @@ REDLIB_DEFAULT_USE_HLS = "on"
>
> If using Docker Compose, no changes are needed as the `.env` file is already referenced in `compose.yaml` via the `env_file: .env` line.
## Command Line Flags
Redlib supports the following command line flags:
- `-4`, `--ipv4-only`: Listen on IPv4 only.
- `-6`, `--ipv6-only`: Listen on IPv6 only.
- `-r`, `--redirect-https`: Redirect all HTTP requests to HTTPS (no longer functional).
- `-a`, `--address <ADDRESS>`: Sets address to listen on. Default is `[::]`.
- `-p`, `--port <PORT>`: Port to listen on. Default is `8080`.
- `-H`, `--hsts <EXPIRE_TIME>`: HSTS header to tell browsers that this site should only be accessed over HTTPS. Default is `604800`.
## Instance settings
Assign a default value for each instance-specific setting by passing environment variables to Redlib in the format `REDLIB_{X}`. Replace `{X}` with the setting name (see list below) in capital letters.
@ -340,7 +351,7 @@ Assign a default value for each user-modifiable setting by passing environment v
| `WIDE` | `["on", "off"]` | `off` |
| `POST_SORT` | `["hot", "new", "top", "rising", "controversial"]` | `hot` |
| `COMMENT_SORT` | `["confidence", "top", "new", "controversial", "old"]` | `confidence` |
| `BLUR_SPOILER` | `["on", "off"]` | `off` |
| `BLUR_SPOILER` | `["on", "off"]` | `off` |
| `SHOW_NSFW` | `["on", "off"]` | `off` |
| `BLUR_NSFW` | `["on", "off"]` | `off` |
| `USE_HLS` | `["on", "off"]` | `off` |
@ -352,4 +363,6 @@ Assign a default value for each user-modifiable setting by passing environment v
| `DISABLE_VISIT_REDDIT_CONFIRMATION` | `["on", "off"]` | `off` |
| `HIDE_SCORE` | `["on", "off"]` | `off` |
| `HIDE_SIDEBAR_AND_SUMMARY` | `["on", "off"]` | `off` |
| `HIDE_BANNER` | `["on", "off"]` | `off` |
| `FIXED_NAVBAR` | `["on", "off"]` | `on` |
| `REMOVE_DEFAULT_FEEDS` | `["on", "off"]` | `off` |

View File

@ -82,6 +82,9 @@
},
"REDLIB_FULL_URL": {
"required": false
},
"REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS": {
"required": false
}
}
}

32
flake.lock generated
View File

@ -1,17 +1,12 @@
{
"nodes": {
"crane": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1717025063,
"narHash": "sha256-dIubLa56W9sNNz0e8jGxrX3CAkPXsq7snuFA/Ie6dn8=",
"lastModified": 1731974733,
"narHash": "sha256-enYSSZVVl15FI5p+0Y5/Ckf5DZAvXe6fBrHxyhA/njc=",
"owner": "ipetkov",
"repo": "crane",
"rev": "480dff0be03dac0e51a8dfc26e882b0d123a450e",
"rev": "3cb338ce81076ce5e461cf77f7824476addb0e1c",
"type": "github"
},
"original": {
@ -25,11 +20,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@ -40,11 +35,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1717112898,
"narHash": "sha256-7R2ZvOnvd9h8fDd65p0JnB7wXfUvreox3xFdYWd1BnY=",
"lastModified": 1731890469,
"narHash": "sha256-D1FNZ70NmQEwNxpSSdTXCSklBH1z2isPR84J6DQrJGs=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6132b0f6e344ce2fe34fc051b72fb46e34f668e0",
"rev": "5083ec887760adfe12af64830a66807423a859a7",
"type": "github"
},
"original": {
@ -64,19 +59,16 @@
},
"rust-overlay": {
"inputs": {
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1717121863,
"narHash": "sha256-/3sxIe7MZqF/jw1RTQCSmgTjwVod43mmrk84m50MJQ4=",
"lastModified": 1732069891,
"narHash": "sha256-moKx8AVJrViCSdA0e0nSsG8b1dAsObI4sRAtbqbvBY8=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "2a7b53172ed08f856b8382d7dcfd36a4e0cbd866",
"rev": "8509a51241c407d583b1963d5079585a992506e8",
"type": "github"
},
"original": {

View File

@ -4,19 +4,13 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
crane = {
url = "github:ipetkov/crane";
inputs.nixpkgs.follows = "nixpkgs";
};
crane.url = "github:ipetkov/crane";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs = {
nixpkgs.follows = "nixpkgs";
flake-utils.follows = "flake-utils";
};
inputs.nixpkgs.follows = "nixpkgs";
};
};

View File

@ -24,7 +24,7 @@ echo "// Please do not edit manually" >> "$filename"
echo "// Filled in with real app versions" >> "$filename"
# Open the array in the source file
echo "pub static _IOS_APP_VERSION_LIST: &[&str; $ios_app_count] = &[" >> "$filename"
echo "pub const _IOS_APP_VERSION_LIST: &[&str; $ios_app_count] = &[" >> "$filename"
num=0
@ -39,12 +39,12 @@ done
echo "];" >> "$filename"
# Fetch Android app versions
page_1=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions/" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq)
page_1=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions/" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq | sed 's/ //g')
# Append with pages
page_2=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=2" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq)
page_3=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=3" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq)
page_4=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=4" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq)
page_5=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=5" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq)
page_2=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=2" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq | sed 's/ //g')
page_3=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=3" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq | sed 's/ //g')
page_4=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=4" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq | sed 's/ //g')
page_5=$(curl -s "https://apkcombo.com/reddit/com.reddit.frontpage/old-versions?page=5" | rg "<a class=\"ver-item\" href=\"(/reddit/com\.reddit\.frontpage/download/phone-20\d{2}\.\d+\.\d+-apk)\" rel=\"nofollow\">" -r "https://apkcombo.com\$1" | sort | uniq | sed 's/ //g')
# Concatenate all pages
versions="${page_1}"
@ -63,7 +63,7 @@ android_count=$(echo "$versions" | wc -l)
echo -e "Fetching \e[32m$android_count Android app versions...\e[0m"
# Append to the source file
echo "pub static ANDROID_APP_VERSION_LIST: &[&str; $android_count] = &[" >> "$filename"
echo "pub const ANDROID_APP_VERSION_LIST: &[&str; $android_count] = &[" >> "$filename"
num=0
@ -89,7 +89,7 @@ ios_count=$(echo "$table" | wc -l)
echo -e "Fetching \e[34m$ios_count iOS versions...\e[0m"
# Append to the source file
echo "pub static _IOS_OS_VERSION_LIST: &[&str; $ios_count] = &[" >> "$filename"
echo "pub const _IOS_OS_VERSION_LIST: &[&str; $ios_count] = &[" >> "$filename"
num=0

View File

@ -7,7 +7,7 @@ use hyper::header::HeaderValue;
use hyper::{body, body::Buf, header, Body, Client, Method, Request, Response, Uri};
use hyper_rustls::HttpsConnector;
use libflate::gzip;
use log::{error, trace, warn};
use log::{debug, error, warn};
use once_cell::sync::Lazy;
use percent_encoding::{percent_encode, CONTROLS};
use serde_json::Value;
@ -19,7 +19,7 @@ use std::{io, result::Result};
use crate::dbg_msg;
use crate::oauth::{force_refresh_token, token_daemon, Oauth};
use crate::server::RequestExt;
use crate::utils::format_url;
use crate::utils::{format_url, Post};
const REDDIT_URL_BASE: &str = "https://oauth.reddit.com";
const REDDIT_URL_BASE_HOST: &str = "oauth.reddit.com";
@ -45,7 +45,7 @@ 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] = [
const URL_PAIRS: [(&str, &str); 2] = [
(ALTERNATIVE_REDDIT_URL_BASE, ALTERNATIVE_REDDIT_URL_BASE_HOST),
(REDDIT_SHORT_URL_BASE, REDDIT_SHORT_URL_BASE_HOST),
];
@ -218,40 +218,28 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo
// Construct the hyper client from the HTTPS connector.
let client: &Lazy<Client<_, Body>> = &CLIENT;
let (token, vendor_id, device_id, user_agent, loid) = {
let client = OAUTH_CLIENT.load_full();
(
client.token.clone(),
client.headers_map.get("Client-Vendor-Id").cloned().unwrap_or_default(),
client.headers_map.get("X-Reddit-Device-Id").cloned().unwrap_or_default(),
client.headers_map.get("User-Agent").cloned().unwrap_or_default(),
client.headers_map.get("x-reddit-loid").cloned().unwrap_or_default(),
)
};
// Build request to Reddit. When making a GET, request gzip compression.
// (Reddit doesn't do brotli yet.)
let mut headers = vec![
("User-Agent", user_agent),
("Client-Vendor-Id", vendor_id),
("X-Reddit-Device-Id", device_id),
("x-reddit-loid", loid),
("Host", host.to_string()),
("Authorization", format!("Bearer {token}")),
("Accept-Encoding", if method == Method::GET { "gzip".into() } else { "identity".into() }),
let mut headers: Vec<(String, String)> = vec![
("Host".into(), host.into()),
("Accept-Encoding".into(), if method == Method::GET { "gzip".into() } else { "identity".into() }),
(
"Cookie",
"Cookie".into(),
if quarantine {
"_options=%7B%22pref_quarantine_optin%22%3A%20true%2C%20%22pref_gated_sr_optin%22%3A%20true%7D".into()
} else {
"".into()
},
),
("X-Reddit-Width", fastrand::u32(300..500).to_string()),
("X-Reddit-DPR", "2".to_owned()),
("Device-Name", format!("Android {}", fastrand::u8(9..=14))),
];
{
let client = OAUTH_CLIENT.load_full();
for (key, value) in client.headers_map.clone() {
headers.push((key, value));
}
}
// shuffle headers: https://github.com/redlib-org/redlib/issues/324
fastrand::shuffle(&mut headers);
@ -274,7 +262,7 @@ fn request(method: &'static Method, path: String, redirect: bool, quarantine: bo
return Ok(response);
};
let location_header = response.headers().get(header::LOCATION);
if location_header == Some(&HeaderValue::from_static("https://www.reddit.com/")) {
if location_header == Some(&HeaderValue::from_static(ALTERNATIVE_REDDIT_URL_BASE)) {
return Err("Reddit response was invalid".to_string());
}
return request(
@ -396,10 +384,16 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
response.headers().get("x-ratelimit-reset").and_then(|val| val.to_str().ok().map(|s| s.to_string())),
response.headers().get("x-ratelimit-used").and_then(|val| val.to_str().ok().map(|s| s.to_string())),
) {
trace!(
debug!(
"Ratelimit remaining: Header says {remaining}, we have {current_rate_limit}. Resets in {reset}. Rollover: {}. Ratelimit used: {used}",
if is_rolling_over { "yes" } else { "no" },
);
// If can parse remaining as a float, round to a u16 and save
if let Ok(val) = remaining.parse::<f32>() {
OAUTH_RATELIMIT_REMAINING.store(val.round() as u16, Ordering::SeqCst);
}
Some(reset)
} else {
None
@ -484,8 +478,57 @@ pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
}
}
async fn self_check(sub: &str) -> Result<(), String> {
let query = format!("/r/{sub}/hot.json?&raw_json=1");
match Post::fetch(&query, true).await {
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
pub async fn rate_limit_check() -> Result<(), String> {
// First, check a subreddit.
self_check("reddit").await?;
// This will reduce the rate limit to 99. Assert this check.
if OAUTH_RATELIMIT_REMAINING.load(Ordering::SeqCst) != 99 {
return Err(format!("Rate limit check failed: expected 99, got {}", OAUTH_RATELIMIT_REMAINING.load(Ordering::SeqCst)));
}
// Now, we switch out the OAuth client.
// This checks for the IP rate limit association.
force_refresh_token().await;
// Now, check a new sub to break cache.
self_check("rust").await?;
// Again, assert the rate limit check.
if OAUTH_RATELIMIT_REMAINING.load(Ordering::SeqCst) != 99 {
return Err(format!("Rate limit check failed: expected 99, got {}", OAUTH_RATELIMIT_REMAINING.load(Ordering::SeqCst)));
}
Ok(())
}
#[cfg(test)]
static POPULAR_URL: &str = "/r/popular/hot.json?&raw_json=1&geo_filter=GLOBAL";
use {crate::config::get_setting, sealed_test::prelude::*};
#[tokio::test(flavor = "multi_thread")]
async fn test_rate_limit_check() {
rate_limit_check().await.unwrap();
}
#[test]
#[sealed_test(env = [("REDLIB_DEFAULT_SUBSCRIPTIONS", "rust")])]
fn test_default_subscriptions() {
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
let subscriptions = get_setting("REDLIB_DEFAULT_SUBSCRIPTIONS");
assert!(subscriptions.is_some());
// check rate limit
rate_limit_check().await.unwrap();
});
}
#[cfg(test)]
const POPULAR_URL: &str = "/r/popular/hot.json?&raw_json=1&geo_filter=GLOBAL";
#[tokio::test(flavor = "multi_thread")]
async fn test_localization_popular() {
@ -501,12 +544,6 @@ async fn test_obfuscated_share_link() {
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 = "/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;

View File

@ -84,6 +84,10 @@ pub struct Config {
#[serde(alias = "LIBREDDIT_DEFAULT_HIDE_SIDEBAR_AND_SUMMARY")]
pub(crate) default_hide_sidebar_and_summary: Option<String>,
#[serde(rename = "REDLIB_DEFAULT_HIDE_BANNER")]
#[serde(alias = "LIBREDDIT_DEFAULT_HIDE_BANNER")]
pub(crate) default_hide_banner: Option<String>,
#[serde(rename = "REDLIB_DEFAULT_HIDE_SCORE")]
#[serde(alias = "LIBREDDIT_DEFAULT_HIDE_SCORE")]
pub(crate) default_hide_score: Option<String>,
@ -121,6 +125,9 @@ pub struct Config {
#[serde(rename = "REDLIB_FULL_URL")]
pub(crate) full_url: Option<String>,
#[serde(rename = "REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS")]
pub(crate) default_remove_default_feeds: Option<String>,
}
impl Config {
@ -161,6 +168,7 @@ impl Config {
default_hide_hls_notification: parse("REDLIB_DEFAULT_HIDE_HLS_NOTIFICATION"),
default_hide_awards: parse("REDLIB_DEFAULT_HIDE_AWARDS"),
default_hide_sidebar_and_summary: parse("REDLIB_DEFAULT_HIDE_SIDEBAR_AND_SUMMARY"),
default_hide_banner: parse("REDLIB_DEFAULT_HIDE_BANNER"),
default_hide_score: parse("REDLIB_DEFAULT_HIDE_SCORE"),
default_subscriptions: parse("REDLIB_DEFAULT_SUBSCRIPTIONS"),
default_filters: parse("REDLIB_DEFAULT_FILTERS"),
@ -171,6 +179,7 @@ impl Config {
pushshift: parse("REDLIB_PUSHSHIFT_FRONTEND"),
enable_rss: parse("REDLIB_ENABLE_RSS"),
full_url: parse("REDLIB_FULL_URL"),
default_remove_default_feeds: parse("REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS"),
}
}
}
@ -193,6 +202,7 @@ fn get_setting_from_config(name: &str, config: &Config) -> Option<String> {
"REDLIB_DEFAULT_WIDE" => config.default_wide.clone(),
"REDLIB_DEFAULT_HIDE_AWARDS" => config.default_hide_awards.clone(),
"REDLIB_DEFAULT_HIDE_SIDEBAR_AND_SUMMARY" => config.default_hide_sidebar_and_summary.clone(),
"REDLIB_DEFAULT_HIDE_BANNER" => config.default_hide_banner.clone(),
"REDLIB_DEFAULT_HIDE_SCORE" => config.default_hide_score.clone(),
"REDLIB_DEFAULT_SUBSCRIPTIONS" => config.default_subscriptions.clone(),
"REDLIB_DEFAULT_FILTERS" => config.default_filters.clone(),
@ -203,6 +213,7 @@ fn get_setting_from_config(name: &str, config: &Config) -> Option<String> {
"REDLIB_PUSHSHIFT_FRONTEND" => config.pushshift.clone(),
"REDLIB_ENABLE_RSS" => config.enable_rss.clone(),
"REDLIB_FULL_URL" => config.full_url.clone(),
"REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS" => config.default_remove_default_feeds.clone(),
_ => None,
}
}

View File

@ -128,6 +128,7 @@ impl InstanceInfo {
["Pushshift frontend", &convert(&self.config.pushshift)],
["RSS enabled", &convert(&self.config.enable_rss)],
["Full URL", &convert(&self.config.full_url)],
["Remove default feeds", &convert(&self.config.default_remove_default_feeds)],
//TODO: fallback to crate::config::DEFAULT_PUSHSHIFT_FRONTEND
])
.with_header_row(["Settings"]),
@ -148,7 +149,7 @@ impl InstanceInfo {
["Show NSFW", &convert(&self.config.default_show_nsfw)],
["Blur NSFW", &convert(&self.config.default_blur_nsfw)],
["Use HLS", &convert(&self.config.default_use_hls)],
["Use FFmpeg", &convert(&self.config.default_ffmpeg_video_downloads)],
["Use FFmpeg", &convert(&self.config.default_ffmpeg_video_downloads)],
["Hide HLS notification", &convert(&self.config.default_hide_hls_notification)],
["Subscriptions", &convert(&self.config.default_subscriptions)],
["Filters", &convert(&self.config.default_filters)],
@ -172,6 +173,7 @@ impl InstanceInfo {
Pushshift frontend: {:?}\n
RSS enabled: {:?}\n
Full URL: {:?}\n
Remove default feeds: {:?}\n
Config:\n
Banner: {:?}\n
Hide awards: {:?}\n
@ -201,6 +203,7 @@ impl InstanceInfo {
self.config.sfw_only,
self.config.enable_rss,
self.config.full_url,
self.config.default_remove_default_feeds,
self.config.pushshift,
self.config.banner,
self.config.default_hide_awards,

View File

@ -9,11 +9,11 @@ use std::str::FromStr;
use futures_lite::FutureExt;
use hyper::Uri;
use hyper::{header::HeaderValue, Body, Request, Response};
use log::info;
use log::{info, warn};
use once_cell::sync::Lazy;
use redsunlib::client::{canonical_path, proxy, CLIENT};
use redsunlib::client::{canonical_path, proxy, rate_limit_check, CLIENT};
use redsunlib::server::{self, RequestExt};
use redsunlib::utils::{error, redirect, ThemeAssets, MascotAssets};
use redsunlib::utils::{error, redirect, MascotAssets, ThemeAssets};
use redsunlib::{config, duplicates, headers, instance_info, post, search, settings, subreddit, user};
use redsunlib::client::OAUTH_CLIENT;
@ -75,6 +75,17 @@ async fn ffmpeg() -> Result<Response<Body>, String> {
)
}
async fn opensearch() -> Result<Response<Body>, String> {
Ok(
Response::builder()
.status(200)
.header("content-type", "application/opensearchdescription+xml")
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
.body(include_bytes!("../static/opensearch.xml").as_ref().into())
.unwrap_or_default(),
)
}
async fn resource(body: &str, content_type: &str, cache: bool) -> Result<Response<Body>, String> {
let mut res = Response::builder()
.status(200)
@ -110,8 +121,7 @@ async fn style() -> Result<Response<Body>, String> {
/// Serve mascot
async fn mascot_image(req: Request<Body>) -> Result<Response<Body>, String> {
let res = MascotAssets::get(&req.param("name").unwrap())
.unwrap_or(MascotAssets::get("redsunlib.png").unwrap());
let res = MascotAssets::get(&req.param("name").unwrap()).unwrap_or(MascotAssets::get("redsunlib.png").unwrap());
Ok(
Response::builder()
.status(200)
@ -133,6 +143,8 @@ async fn main() {
let matches = Command::new("Redlib")
.version(env!("CARGO_PKG_VERSION"))
.about("Private front-end for Reddit written in Rust ")
.arg(Arg::new("ipv4-only").short('4').long("ipv4-only").help("Listen on IPv4 only").num_args(0))
.arg(Arg::new("ipv6-only").short('6').long("ipv6-only").help("Listen on IPv6 only").num_args(0))
.arg(
Arg::new("redirect-https")
.short('r')
@ -171,11 +183,34 @@ async fn main() {
)
.get_matches();
match rate_limit_check().await {
Ok(()) => {
info!("[✅] Rate limit check passed");
}
Err(e) => {
let mut message = format!("Rate limit check failed: {}", e);
message += "\nThis may cause issues with the rate limit.";
message += "\nPlease report this error with the above information.";
message += "\nhttps://github.com/redlib-org/redlib/issues/new?assignees=sigaloid&labels=bug&title=%F0%9F%90%9B+Bug+Report%3A+Rate+limit+mismatch";
warn!("{}", message);
eprintln!("{}", message);
}
}
let address = matches.get_one::<String>("address").unwrap();
let port = matches.get_one::<String>("port").unwrap();
let hsts = matches.get_one("hsts").map(|m: &String| m.as_str());
let listener = [address, ":", port].concat();
let ipv4_only = std::env::var("IPV4_ONLY").is_ok() || matches.get_flag("ipv4-only");
let ipv6_only = std::env::var("IPV6_ONLY").is_ok() || matches.get_flag("ipv6-only");
let listener = if ipv4_only {
format!("0.0.0.0:{}", port)
} else if ipv6_only {
format!("[::]:{}", port)
} else {
[address, ":", port].concat()
};
println!("Starting Redsunlib...");
@ -234,6 +269,7 @@ async fn main() {
app.at("/Inter.var.woff2").get(|_| font().boxed());
app.at("/touch-icon-iphone.png").get(|_| iphone_logo().boxed());
app.at("/apple-touch-icon.png").get(|_| iphone_logo().boxed());
app.at("/opensearch.xml").get(|_| opensearch().boxed());
app
.at("/videoUtils.js")
.get(|_| resource(include_str!("../static/videoUtils.js"), "text/javascript", false).boxed());
@ -246,10 +282,11 @@ async fn main() {
app
.at("/check_update.js")
.get(|_| resource(include_str!("../static/check_update.js"), "text/javascript", false).boxed());
app.at("/copy.js").get(|_| resource(include_str!("../static/copy.js"), "text/javascript", false).boxed());
app.at("/commits.json").get(|_| async move { proxy_commit_info().await }.boxed());
app.at("/instances.json").get(|_| async move { proxy_instances().await }.boxed());
// FFmpeg
app
.at("/ffmpeg/814.ffmpeg.js")
@ -305,6 +342,7 @@ async fn main() {
// Configure settings
app.at("/settings").get(|r| settings::get(r).boxed()).post(|r| settings::set(r).boxed());
app.at("/settings/restore").get(|r| settings::restore(r).boxed());
// app.at("/settings/encoded-restore").post(|r| settings::encoded_restore(r).boxed()); NOTE: TODO (Well, TOFIX)
app.at("/settings/update").get(|r| settings::update(r).boxed());
// Mascots
@ -406,7 +444,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}"), 3).await {
Some(id) if (5..8).contains(&id.len()) => match canonical_path(format!("/comments/{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,14 +6,17 @@ use crate::{
};
use base64::{engine::general_purpose, Engine as _};
use hyper::{client, Body, Method, Request};
use log::{error, info, trace};
use log::{debug, error, info, trace};
use serde_json::json;
use tegen::tegen::TextGenerator;
use tokio::time::{error::Elapsed, timeout};
static REDDIT_ANDROID_OAUTH_CLIENT_ID: &str = "ohXpoqrZYub1kg";
const REDDIT_ANDROID_OAUTH_CLIENT_ID: &str = "ohXpoqrZYub1kg";
static AUTH_ENDPOINT: &str = "https://www.reddit.com";
const AUTH_ENDPOINT: &str = "https://www.reddit.com";
const OAUTH_TIMEOUT: Duration = Duration::from_secs(5);
// Spoofed client for Android devices
#[derive(Debug, Clone, Default)]
@ -32,24 +35,30 @@ impl Oauth {
loop {
let attempt = Self::new_with_timeout().await;
match attempt {
Ok(Some(oauth)) => {
Ok(Ok(oauth)) => {
info!("[✅] Successfully created OAuth client");
return oauth;
}
Ok(None) => {
error!("Failed to create OAuth client. Retrying in 5 seconds...");
continue;
Ok(Err(e)) => {
error!("Failed to create OAuth client: {}. Retrying in 5 seconds...", {
match e {
AuthError::Hyper(error) => error.to_string(),
AuthError::SerdeDeserialize(error) => error.to_string(),
AuthError::Field((value, error)) => format!("{error}\n{value}"),
}
});
}
Err(duration) => {
error!("Failed to create OAuth client in {duration:?}. Retrying in 5 seconds...");
Err(_) => {
error!("Failed to create OAuth client before timeout. Retrying in 5 seconds...");
}
}
tokio::time::sleep(OAUTH_TIMEOUT).await;
}
}
async fn new_with_timeout() -> Result<Option<Self>, Elapsed> {
async fn new_with_timeout() -> Result<Result<Self, AuthError>, Elapsed> {
let mut oauth = Self::default();
timeout(Duration::from_secs(5), oauth.login()).await.map(|result| result.map(|_| oauth))
timeout(OAUTH_TIMEOUT, oauth.login()).await.map(|result: Result<(), AuthError>| result.map(|_| oauth))
}
pub(crate) fn default() -> Self {
@ -66,7 +75,7 @@ impl Oauth {
device,
}
}
async fn login(&mut self) -> Option<()> {
async fn login(&mut self) -> Result<(), AuthError> {
// Construct URL for OAuth token
let url = format!("{AUTH_ENDPOINT}/auth/v2/oauth/access-token/loid");
let mut builder = Request::builder().method(Method::POST).uri(&url);
@ -84,20 +93,21 @@ impl Oauth {
// Set JSON body. I couldn't tell you what this means. But that's what the client sends
let json = json!({
"scopes": ["*","email"]
"scopes": ["*","email", "pii"]
});
let body = Body::from(json.to_string());
// Build request
let request = builder.body(body).unwrap();
trace!("Sending token request...");
trace!("Sending token request...\n\n{request:?}");
// Send request
let client: &once_cell::sync::Lazy<client::Client<_, Body>> = &CLIENT;
let resp = client.request(request).await.ok()?;
let resp = client.request(request).await?;
trace!("Received response with status {} and length {:?}", resp.status(), resp.headers().get("content-length"));
trace!("OAuth headers: {:#?}", resp.headers());
// 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.
@ -105,30 +115,58 @@ impl Oauth {
// 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());
self.headers_map.insert("x-reddit-loid".to_owned(), header.to_str().unwrap().to_string());
}
// Same with x-reddit-session
if let Some(header) = resp.headers().get("x-reddit-session") {
self.headers_map.insert("x-reddit-session".to_owned(), header.to_str().ok()?.to_string());
self.headers_map.insert("x-reddit-session".to_owned(), header.to_str().unwrap().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()?;
let body_bytes = hyper::body::to_bytes(resp.into_body()).await?;
let json: serde_json::Value = serde_json::from_slice(&body_bytes)?;
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()?;
self.token = json
.get("access_token")
.ok_or_else(|| AuthError::Field((json.clone(), "access_token")))?
.as_str()
.ok_or_else(|| AuthError::Field((json.clone(), "access_token: as_str")))?
.to_string();
self.expires_in = json
.get("expires_in")
.ok_or_else(|| AuthError::Field((json.clone(), "expires_in")))?
.as_u64()
.ok_or_else(|| AuthError::Field((json.clone(), "expires_in: as_u64")))?;
self.headers_map.insert("Authorization".to_owned(), format!("Bearer {}", self.token));
info!("[✅] Success - Retrieved token \"{}...\", expires in {}", &self.token[..32], self.expires_in);
Some(())
Ok(())
}
}
#[derive(Debug)]
enum AuthError {
Hyper(hyper::Error),
SerdeDeserialize(serde_json::Error),
Field((serde_json::Value, &'static str)),
}
impl From<hyper::Error> for AuthError {
fn from(err: hyper::Error) -> Self {
AuthError::Hyper(err)
}
}
impl From<serde_json::Error> for AuthError {
fn from(err: serde_json::Error) -> Self {
AuthError::SerdeDeserialize(err)
}
}
@ -160,7 +198,7 @@ pub async fn force_refresh_token() {
return;
}
trace!("Rolling over refresh token. Current rate limit: {}", OAUTH_RATELIMIT_REMAINING.load(Ordering::SeqCst));
debug!("Rolling over refresh token. Current rate limit: {}", OAUTH_RATELIMIT_REMAINING.load(Ordering::SeqCst));
let new_client = Oauth::new().await;
OAUTH_CLIENT.swap(new_client.into());
OAUTH_RATELIMIT_REMAINING.store(99, Ordering::SeqCst);
@ -185,11 +223,22 @@ impl Device {
let android_user_agent = format!("Reddit/{android_app_version}/Android {android_version}");
let qos = fastrand::u32(1000..=100_000);
let qos: f32 = qos as f32 / 1000.0;
let qos = format!("{:.3}", qos);
let codecs = TextGenerator::new().generate("available-codecs=video/avc, video/hevc{, video/x-vnd.on2.vp9|}");
// Android device headers
let headers = HashMap::from([
("Client-Vendor-Id".into(), uuid.clone()),
("X-Reddit-Device-Id".into(), uuid.clone()),
let headers: HashMap<String, String> = HashMap::from([
("User-Agent".into(), android_user_agent),
("x-reddit-retry".into(), "algo=no-retries".into()),
("x-reddit-compression".into(), "1".into()),
("x-reddit-qos".into(), qos),
("x-reddit-media-codecs".into(), codecs),
("Content-Type".into(), "application/json; charset=UTF-8".into()),
("client-vendor-id".into(), uuid.clone()),
("X-Reddit-Device-Id".into(), uuid.clone()),
]);
info!("[🔄] Spoofing Android client with headers: {headers:?}, uuid: \"{uuid}\", and OAuth ID \"{REDDIT_ANDROID_OAUTH_CLIENT_ID}\"");

View File

@ -2,8 +2,38 @@
// Rerun scripts/update_oauth_resources.sh to update this file
// Please do not edit manually
// Filled in with real app versions
pub static _IOS_APP_VERSION_LIST: &[&str; 1] = &[""];
pub static ANDROID_APP_VERSION_LIST: &[&str; 150] = &[
pub const _IOS_APP_VERSION_LIST: &[&str; 1] = &[""];
pub const ANDROID_APP_VERSION_LIST: &[&str; 150] = &[
"Version 2024.22.1/Build 1652272",
"Version 2024.23.1/Build 1665606",
"Version 2024.24.1/Build 1682520",
"Version 2024.25.0/Build 1693595",
"Version 2024.25.2/Build 1700401",
"Version 2024.25.3/Build 1703490",
"Version 2024.26.0/Build 1710470",
"Version 2024.26.1/Build 1717435",
"Version 2024.28.0/Build 1737665",
"Version 2024.28.1/Build 1741165",
"Version 2024.30.0/Build 1770787",
"Version 2024.31.0/Build 1786202",
"Version 2024.32.0/Build 1809095",
"Version 2024.32.1/Build 1813258",
"Version 2024.33.0/Build 1819908",
"Version 2024.34.0/Build 1837909",
"Version 2024.35.0/Build 1861437",
"Version 2024.36.0/Build 1875012",
"Version 2024.37.0/Build 1888053",
"Version 2024.38.0/Build 1902791",
"Version 2024.39.0/Build 1916713",
"Version 2024.40.0/Build 1928580",
"Version 2024.41.0/Build 1941199",
"Version 2024.41.1/Build 1947805",
"Version 2024.42.0/Build 1952440",
"Version 2024.43.0/Build 1972250",
"Version 2024.44.0/Build 1988458",
"Version 2024.45.0/Build 2001943",
"Version 2024.46.0/Build 2012731",
"Version 2024.47.0/Build 2029755",
"Version 2023.48.0/Build 1319123",
"Version 2023.49.0/Build 1321715",
"Version 2023.49.1/Build 1322281",
@ -31,9 +61,9 @@ pub static ANDROID_APP_VERSION_LIST: &[&str; 150] = &[
"Version 2024.20.0/Build 1612800",
"Version 2024.20.1/Build 1615586",
"Version 2024.20.2/Build 1624969",
"Version 2024.20.3/Build 1624970",
"Version 2024.21.0/Build 1631686",
"Version 2024.22.0/Build 1645257",
"Version 2024.22.1/Build 1652272",
"Version 2023.21.0/Build 956283",
"Version 2023.22.0/Build 968223",
"Version 2023.23.0/Build 983896",
@ -124,35 +154,5 @@ pub static ANDROID_APP_VERSION_LIST: &[&str; 150] = &[
"Version 2022.40.0/Build 624782",
"Version 2022.41.0/Build 630468",
"Version 2022.41.1/Build 634168",
"Version 2021.39.1/Build 372418",
"Version 2021.41.0/Build 376052",
"Version 2021.42.0/Build 378193",
"Version 2021.43.0/Build 382019",
"Version 2021.44.0/Build 385129",
"Version 2021.45.0/Build 387663",
"Version 2021.46.0/Build 392043",
"Version 2021.47.0/Build 394342",
"Version 2022.10.0/Build 429896",
"Version 2022.1.0/Build 402829",
"Version 2022.11.0/Build 433004",
"Version 2022.12.0/Build 436848",
"Version 2022.13.0/Build 442084",
"Version 2022.13.1/Build 444621",
"Version 2022.14.1/Build 452742",
"Version 2022.15.0/Build 455453",
"Version 2022.16.0/Build 462377",
"Version 2022.17.0/Build 468480",
"Version 2022.18.0/Build 473740",
"Version 2022.19.1/Build 482464",
"Version 2022.2.0/Build 405543",
"Version 2022.3.0/Build 408637",
"Version 2022.4.0/Build 411368",
"Version 2022.5.0/Build 414731",
"Version 2022.6.0/Build 418391",
"Version 2022.6.1/Build 419585",
"Version 2022.6.2/Build 420562",
"Version 2022.7.0/Build 420849",
"Version 2022.8.0/Build 423906",
"Version 2022.9.0/Build 426592",
];
pub static _IOS_OS_VERSION_LIST: &[&str; 1] = &[""];
pub const _IOS_OS_VERSION_LIST: &[&str; 1] = &[""];

View File

@ -75,7 +75,11 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
return Ok(redirect(&format!("/{query}")));
}
if query.starts_with("u/") {
if query.starts_with("R/") {
return Ok(redirect(&format!("/r{}", &query[1..])));
}
if query.starts_with("u/") || query.starts_with("U/") {
return Ok(redirect(&format!("/user{}", &query[1..])));
}

View File

@ -25,7 +25,7 @@ use std::{
str::{from_utf8, Split},
string::ToString,
};
use time::Duration;
use time::OffsetDateTime;
use crate::dbg_msg;
@ -170,10 +170,8 @@ impl ResponseExt for Response<Body> {
}
fn remove_cookie(&mut self, name: String) {
let mut cookie = Cookie::from(name);
cookie.set_path("/");
cookie.set_max_age(Duration::seconds(1));
if let Ok(val) = header::HeaderValue::from_str(&cookie.to_string()) {
let removal_cookie = Cookie::build(name).path("/").http_only(true).expires(OffsetDateTime::now_utc());
if let Ok(val) = header::HeaderValue::from_str(&removal_cookie.to_string()) {
self.headers_mut().append("Set-Cookie", val);
}
}
@ -240,8 +238,14 @@ impl Server {
path.pop();
}
// Replace HEAD with GET for routing
let (method, is_head) = match req.method() {
&Method::HEAD => (&Method::GET, true),
method => (method, false),
};
// Match the visited path with an added route
match router.recognize(&format!("/{}{}", req.method().as_str(), path)) {
match router.recognize(&format!("/{}{}", method.as_str(), path)) {
// If a route was configured for this path
Ok(found) => {
let mut parammed = req;
@ -253,17 +257,21 @@ impl Server {
match func.await {
Ok(mut res) => {
res.headers_mut().extend(def_headers);
let _ = compress_response(&req_headers, &mut res).await;
if is_head {
*res.body_mut() = Body::empty();
} else {
let _ = compress_response(&req_headers, &mut res).await;
}
Ok(res)
}
Err(msg) => new_boilerplate(def_headers, req_headers, 500, Body::from(msg)).await,
Err(msg) => new_boilerplate(def_headers, req_headers, 500, if is_head { Body::empty() } else { Body::from(msg) }).await,
}
}
.boxed()
}
// If there was a routing error
Err(e) => new_boilerplate(def_headers, req_headers, 404, e.into()).boxed(),
Err(e) => new_boilerplate(def_headers, req_headers, 404, if is_head { Body::empty() } else { e.into() }).boxed(),
}
}))
}
@ -274,8 +282,19 @@ impl Server {
// Bind server to address specified above. Gracefully shut down if CTRL+C is pressed
let server = HyperServer::bind(address).serve(make_svc).with_graceful_shutdown(async {
#[cfg(windows)]
// Wait for the CTRL+C signal
tokio::signal::ctrl_c().await.expect("Failed to install CTRL+C signal handler");
#[cfg(unix)]
{
// Wait for CTRL+C or SIGTERM signals
let mut signal_terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("Failed to install SIGTERM signal handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => (),
_ = signal_terminate.recv() => ()
}
}
});
server.boxed()

View File

@ -4,12 +4,15 @@ use std::collections::HashMap;
// CRATES
use crate::server::ResponseExt;
use crate::utils::{redirect, template, Preferences};
use crate::subreddit::join_until_size_limit;
use crate::utils::{deflate_decompress, redirect, template, Preferences};
use cookie::Cookie;
use futures_lite::StreamExt;
use hyper::{Body, Request, Response};
use rinja::Template;
use time::{Duration, OffsetDateTime};
use tokio::time::timeout;
use url::form_urlencoded;
// STRUCTS
#[derive(Template)]
@ -21,7 +24,7 @@ struct SettingsTemplate {
// CONSTANTS
const PREFS: [&str; 21] = [
const PREFS: [&str; 23] = [
"theme",
"mascot",
"redsunlib_colorway",
@ -38,11 +41,13 @@ const PREFS: [&str; 21] = [
"hide_hls_notification",
"autoplay_videos",
"hide_sidebar_and_summary",
"hide_banner",
"fixed_navbar",
"hide_awards",
"hide_score",
"disable_visit_reddit_confirmation",
"video_quality",
"remove_default_feeds",
];
// FUNCTIONS
@ -139,6 +144,119 @@ fn set_cookies_method(req: Request<Body>, remove_cookies: bool) -> Response<Body
};
}
// Get subscriptions/filters to restore from query string
let subscriptions = form.get("subscriptions");
let filters = form.get("filters");
// We can't search through the cookies directly like in subreddit.rs, so instead we have to make a string out of the request's headers to search through
let cookies_string = parts
.headers
.get("cookie")
.map(|hv| hv.to_str().unwrap_or("").to_string()) // Return String
.unwrap_or_else(String::new); // Return an empty string if None
// If there are subscriptions to restore set them and delete any old subscriptions cookies, otherwise delete them all
if subscriptions.is_some() {
let sub_list: Vec<String> = subscriptions.expect("Subscriptions").split('+').map(str::to_string).collect();
// Start at 0 to keep track of what number we need to start deleting old subscription cookies from
let mut subscriptions_number_to_delete_from = 0;
// Starting at 0 so we handle the subscription cookie without a number first
for (subscriptions_number, list) in join_until_size_limit(&sub_list).into_iter().enumerate() {
let subscriptions_cookie = if subscriptions_number == 0 {
"subscriptions".to_string()
} else {
format!("subscriptions{}", subscriptions_number)
};
response.insert_cookie(
Cookie::build((subscriptions_cookie, list))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.into(),
);
subscriptions_number_to_delete_from += 1;
}
// While subscriptionsNUMBER= is in the string of cookies add a response removing that cookie
while cookies_string.contains(&format!("subscriptions{subscriptions_number_to_delete_from}=")) {
// Remove that subscriptions cookie
response.remove_cookie(format!("subscriptions{subscriptions_number_to_delete_from}"));
// Increment subscriptions cookie number
subscriptions_number_to_delete_from += 1;
}
} else {
// Remove unnumbered subscriptions cookie
response.remove_cookie("subscriptions".to_string());
// Starts at one to deal with the first numbered subscription cookie and onwards
let mut subscriptions_number_to_delete_from = 1;
// While subscriptionsNUMBER= is in the string of cookies add a response removing that cookie
while cookies_string.contains(&format!("subscriptions{subscriptions_number_to_delete_from}=")) {
// Remove that subscriptions cookie
response.remove_cookie(format!("subscriptions{subscriptions_number_to_delete_from}"));
// Increment subscriptions cookie number
subscriptions_number_to_delete_from += 1;
}
}
// If there are filters to restore set them and delete any old filters cookies, otherwise delete them all
if filters.is_some() {
let filters_list: Vec<String> = filters.expect("Filters").split('+').map(str::to_string).collect();
// Start at 0 to keep track of what number we need to start deleting old subscription cookies from
let mut filters_number_to_delete_from = 0;
// Starting at 0 so we handle the subscription cookie without a number first
for (filters_number, list) in join_until_size_limit(&filters_list).into_iter().enumerate() {
let filters_cookie = if filters_number == 0 {
"filters".to_string()
} else {
format!("filters{}", filters_number)
};
response.insert_cookie(
Cookie::build((filters_cookie, list))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.into(),
);
filters_number_to_delete_from += 1;
}
// While filtersNUMBER= is in the string of cookies add a response removing that cookie
while cookies_string.contains(&format!("filters{filters_number_to_delete_from}=")) {
// Remove that filters cookie
response.remove_cookie(format!("filters{filters_number_to_delete_from}"));
// Increment filters cookie number
filters_number_to_delete_from += 1;
}
} else {
// Remove unnumbered filters cookie
response.remove_cookie("filters".to_string());
// Starts at one to deal with the first numbered subscription cookie and onwards
let mut filters_number_to_delete_from = 1;
// While filtersNUMBER= is in the string of cookies add a response removing that cookie
while cookies_string.contains(&format!("filters{filters_number_to_delete_from}=")) {
// Remove that sfilters cookie
response.remove_cookie(format!("filters{filters_number_to_delete_from}"));
// Increment filters cookie number
filters_number_to_delete_from += 1;
}
}
response
}
@ -150,3 +268,35 @@ pub async fn restore(req: Request<Body>) -> Result<Response<Body>, String> {
pub async fn update(req: Request<Body>) -> Result<Response<Body>, String> {
Ok(set_cookies_method(req, false))
}
pub async fn encoded_restore(req: Request<Body>) -> Result<Response<Body>, String> {
let body = hyper::body::to_bytes(req.into_body())
.await
.map_err(|e| format!("Failed to get bytes from request body: {}", e))?;
if body.len() > 1024 * 1024 {
return Err("Request body too large".to_string());
}
let encoded_prefs = form_urlencoded::parse(&body)
.find(|(key, _)| key == "encoded_prefs")
.map(|(_, value)| value)
.ok_or_else(|| "encoded_prefs parameter not found in request body".to_string())?;
let bytes = base2048::decode(&encoded_prefs).ok_or_else(|| "Failed to decode base2048 encoded preferences".to_string())?;
let out = timeout(std::time::Duration::from_secs(1), async { deflate_decompress(bytes) })
.await
.map_err(|e| format!("Failed to decompress bytes: {}", e))??;
let mut prefs: Preferences = timeout(std::time::Duration::from_secs(1), async { bincode::deserialize(&out) })
.await
.map_err(|e| format!("Failed to deserialize preferences: {}", e))?
.map_err(|e| format!("Failed to deserialize bytes into Preferences struct: {}", e))?;
prefs.available_themes = vec![];
let url = format!("/settings/restore/?{}", prefs.to_urlencoded()?);
Ok(redirect(&url))
}

View File

@ -3,16 +3,21 @@
use crate::{config, utils};
// CRATES
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,
catch_random, error, filter_posts, format_num, format_url, get_filters, info, nsfw_landing, param, redirect, rewrite_urls, setting, template, val, Post, Preferences,
Subreddit,
};
use crate::{client::json, server::RequestExt, server::ResponseExt};
use cookie::Cookie;
use htmlescape::decode_html;
use hyper::{Body, Request, Response};
use rinja::Template;
use chrono::DateTime;
use once_cell::sync::Lazy;
use regex::Regex;
use time::{Duration, OffsetDateTime};
use time::{macros::format_description, Duration, OffsetDateTime};
use log::trace;
// STRUCTS
#[derive(Template)]
@ -64,6 +69,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
let query = req.uri().query().unwrap_or_default().to_string();
let subscribed = setting(&req, "subscriptions");
let front_page = setting(&req, "front_page");
let remove_default_feeds = setting(&req, "remove_default_feeds") == "on";
let post_sort = req.cookie("post_sort").map_or_else(|| "hot".to_string(), |c| c.value().to_string());
let sort = req.param("sort").unwrap_or_else(|| req.param("id").unwrap_or(post_sort));
@ -76,6 +82,21 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
} else {
front_page.clone()
});
if (sub_name == "popular" || sub_name == "all") && remove_default_feeds {
if subscribed.is_empty() {
return info(req, "Subscribe to some subreddits! (Default feeds disabled in settings)").await;
} else {
// If there are subscribed subs, but we get here, then the problem is that front_page pref is set to something besides default.
// Tell user to go to settings and change front page to default.
return info(
req,
"You have subscribed to some subreddits, but your front page is not set to default. Visit settings and change front page to default.",
)
.await;
}
}
let quarantined = can_access_quarantine(&req, &sub_name) || root;
// Handle random subreddits
@ -211,6 +232,41 @@ pub fn can_access_quarantine(req: &Request<Body>, sub: &str) -> bool {
setting(req, &format!("allow_quaran_{}", sub.to_lowercase())).parse().unwrap_or_default()
}
// Join items in chunks of 4000 bytes in length for cookies
pub fn join_until_size_limit<T: std::fmt::Display>(vec: &[T]) -> Vec<std::string::String> {
let mut result = Vec::new();
let mut list = String::new();
let mut current_size = 0;
for item in vec {
// Size in bytes
let item_size = item.to_string().len();
// Use 4000 bytes to leave us some headroom because the name and options of the cookie count towards the 4096 byte cap
if current_size + item_size > 4000 {
// If last item add a seperator on the end of the list so it's interpreted properly in tanden with the next cookie
list.push('+');
// Push current list to result vector
result.push(list);
// Reset the list variable so we can continue with only new items
list = String::new();
}
// Add separator if not the first item
if !list.is_empty() {
list.push('+');
}
// Add current item to list
list.push_str(&item.to_string());
current_size = list.len() + item_size;
}
// Make sure to push whatever the remaining subreddits are there into the result vector
result.push(list);
// Return resulting vector
result
}
// Sub, filter, unfilter, quicklist, unquicklist or unsub by setting subscription cookie using response "Set-Cookie" header
pub async fn subscriptions_filters_quicklists(req: Request<Body>) -> Result<Response<Body>, String> {
let sub = req.param("sub").unwrap_or_default();
@ -299,8 +355,7 @@ pub async fn subscriptions_filters_quicklists(req: Request<Body>) -> Result<Resp
} else if action.contains(&"unquicklist".to_string()) {
// Remove sub name from filtered list
quicklist.retain(|s| s.to_lowercase() != part.to_lowercase());
}
}
}
// Redirect back to subreddit
@ -313,39 +368,112 @@ pub async fn subscriptions_filters_quicklists(req: Request<Body>) -> Result<Resp
let mut response = redirect(&path);
// Delete cookie if empty, else set
// If sub_list is empty remove all subscriptions cookies, otherwise update them and remove old ones
if sub_list.is_empty() {
// Remove subscriptions cookie
response.remove_cookie("subscriptions".to_string());
// Start with first numbered subscriptions cookie
let mut subscriptions_number = 1;
// While whatever subscriptionsNUMBER cookie we're looking at has a value
while req.cookie(&format!("subscriptions{}", subscriptions_number)).is_some() {
// Remove that subscriptions cookie
response.remove_cookie(format!("subscriptions{}", subscriptions_number));
// Increment subscriptions cookie number
subscriptions_number += 1;
}
} else {
response.insert_cookie(
Cookie::build(("subscriptions", sub_list.join("+")))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.into(),
);
// Start at 0 to keep track of what number we need to start deleting old subscription cookies from
let mut subscriptions_number_to_delete_from = 0;
// Starting at 0 so we handle the subscription cookie without a number first
for (subscriptions_number, list) in join_until_size_limit(&sub_list).into_iter().enumerate() {
let subscriptions_cookie = if subscriptions_number == 0 {
"subscriptions".to_string()
} else {
format!("subscriptions{}", subscriptions_number)
};
response.insert_cookie(
Cookie::build((subscriptions_cookie, list))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.into(),
);
subscriptions_number_to_delete_from += 1;
}
// While whatever subscriptionsNUMBER cookie we're looking at has a value
while req.cookie(&format!("subscriptions{}", subscriptions_number_to_delete_from)).is_some() {
// Remove that subscriptions cookie
response.remove_cookie(format!("subscriptions{}", subscriptions_number_to_delete_from));
// Increment subscriptions cookie number
subscriptions_number_to_delete_from += 1;
}
}
// If filters is empty remove all filters cookies, otherwise update them and remove old ones
if filters.is_empty() {
// Remove filters cookie
response.remove_cookie("filters".to_string());
// Start with first numbered filters cookie
let mut filters_number = 1;
// While whatever filtersNUMBER cookie we're looking at has a value
while req.cookie(&format!("filters{}", filters_number)).is_some() {
// Remove that filters cookie
response.remove_cookie(format!("filters{}", filters_number));
// Increment filters cookie number
filters_number += 1;
}
} else {
response.insert_cookie(
Cookie::build(("filters", filters.join("+")))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.into(),
);
}
if quicklist.is_empty() {
response.remove_cookie("quicklist".to_string());
} else {
response.insert_cookie(
Cookie::build(("quicklist", quicklist.join("+")))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.into(),
);
// Start at 0 to keep track of what number we need to start deleting old filters cookies from
let mut filters_number_to_delete_from = 0;
for (filters_number, list) in join_until_size_limit(&filters).into_iter().enumerate() {
let filters_cookie = if filters_number == 0 {
"filters".to_string()
} else {
format!("filters{}", filters_number)
};
response.insert_cookie(
Cookie::build((filters_cookie, list))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.into(),
);
filters_number_to_delete_from += 1;
}
// While whatever filtersNUMBER cookie we're looking at has a value
while req.cookie(&format!("filters{}", filters_number_to_delete_from)).is_some() {
// Remove that filters cookie
response.remove_cookie(format!("filters{}", filters_number_to_delete_from));
// Increment filters cookie number
filters_number_to_delete_from += 1;
}
if quicklist.is_empty() {
response.remove_cookie("quicklist".to_string());
} else {
response.insert_cookie(
Cookie::build(("quicklist", quicklist.join("+")))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.into(),
);
}
}
Ok(response)
@ -461,14 +589,24 @@ async fn subreddit(sub: &str, quarantined: bool) -> Result<Subreddit, String> {
// Send a request to the url
let res = json(path, quarantined).await?;
trace!("Subreddit info from r/{} : {}", sub, res["data"]);
// Metadata regarding the subreddit
let members: i64 = res["data"]["subscribers"].as_u64().unwrap_or_default() as i64;
let active: i64 = res["data"]["accounts_active"].as_u64().unwrap_or_default() as i64;
// Grab creation date as unix timestamp
let created_unix = res["data"]["created"].as_f64().unwrap_or(0.0).round() as i64;
let created = OffsetDateTime::from_unix_timestamp(created_unix).unwrap_or(OffsetDateTime::UNIX_EPOCH);
// Fetch subreddit icon either from the community_icon or icon_img value
let community_icon: &str = res["data"]["community_icon"].as_str().unwrap_or_default();
let icon = if community_icon.is_empty() { val(&res, "icon_img") } else { community_icon.to_string() };
// Fetch subreddit banner either from the banner_background_image or banner_img value
let banner_background_image: &str = res["data"]["banner_background_image"].as_str().unwrap_or_default();
let banner = if banner_background_image.is_empty() { val(&res, "banner_img") } else { banner_background_image.to_string() };
Ok(Subreddit {
name: val(&res, "display_name"),
title: val(&res, "title"),
@ -476,8 +614,10 @@ async fn subreddit(sub: &str, quarantined: bool) -> Result<Subreddit, String> {
info: rewrite_urls(&val(&res, "description_html")),
// moderators: moderators_list(sub, quarantined).await.unwrap_or_default(),
icon: format_url(&icon),
banner: format_url(&banner),
members: format_num(members),
active: format_num(active),
created: created.format(format_description!("[month repr:short] [day] '[year repr:last_two]")).unwrap_or_default(),
wiki: res["data"]["wiki_enabled"].as_bool().unwrap_or_default(),
nsfw: res["data"]["over18"].as_bool().unwrap_or_default(),
})
@ -514,9 +654,10 @@ pub async fn rss(req: Request<Body>) -> Result<Response<Body>, String> {
.into_iter()
.map(|post| Item {
title: Some(post.title.to_string()),
link: Some(utils::get_post_url(&post)),
link: Some(format_url(&utils::get_post_url(&post))),
author: Some(post.author.name),
content: Some(rewrite_urls(&post.body)),
content: Some(rewrite_urls(&decode_html(&post.body).unwrap())),
pub_date: Some(DateTime::from_timestamp(post.created_ts as i64, 0).unwrap_or_default().to_rfc2822()),
description: Some(format!(
"<a href='{}{}'>Comments</a>",
config::get_setting("REDLIB_FULL_URL").unwrap_or_default(),

View File

@ -5,7 +5,10 @@ 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 chrono::DateTime;
use htmlescape::decode_html;
use hyper::{Body, Request, Response};
use log::trace;
use rinja::Template;
use time::{macros::format_description, OffsetDateTime};
@ -111,6 +114,7 @@ async fn user(name: &str) -> Result<User, String> {
// Send a request to the url
json(path, false).await.map(|res| {
trace!("User info from r/{} : {}", name, res["data"]);
// Grab creation date as unix timestamp
let created_unix = res["data"]["created"].as_f64().unwrap_or(0.0).round() as i64;
let created = OffsetDateTime::from_unix_timestamp(created_unix).unwrap_or(OffsetDateTime::UNIX_EPOCH);
@ -163,9 +167,10 @@ pub async fn rss(req: Request<Body>) -> Result<Response<Body>, String> {
.into_iter()
.map(|post| Item {
title: Some(post.title.to_string()),
link: Some(utils::get_post_url(&post)),
link: Some(format_url(&utils::get_post_url(&post))),
author: Some(post.author.name),
content: Some(rewrite_urls(&post.body)),
pub_date: Some(DateTime::from_timestamp(post.created_ts as i64, 0).unwrap_or_default().to_rfc2822()),
content: Some(rewrite_urls(&decode_html(&post.body).unwrap())),
..Default::default()
})
.collect::<Vec<_>>(),

View File

@ -8,16 +8,19 @@ use crate::config::{self, get_setting};
use crate::{client::json, server::RequestExt};
use cookie::Cookie;
use hyper::{Body, Request, Response};
use libflate::deflate::{Decoder, Encoder};
use log::error;
use once_cell::sync::Lazy;
use regex::Regex;
use revision::revisioned;
use rinja::Template;
use rust_embed::RustEmbed;
use serde::Serialize;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use serde_json_path::{JsonPath, JsonPathExt};
use std::collections::{HashMap, HashSet};
use std::env;
use std::io::{Read, Write};
use std::str::FromStr;
use std::string::ToString;
use time::{macros::format_description, Duration, OffsetDateTime};
@ -233,6 +236,14 @@ impl Media {
// If this post contains a gallery of images
gallery = GalleryMedia::parse(&data["gallery_data"]["items"], &data["media_metadata"]);
("gallery", &data["url"], None)
} else if data["crosspost_parent_list"][0]["is_gallery"].as_bool().unwrap_or_default() {
// If this post contains a gallery of images
gallery = GalleryMedia::parse(
&data["crosspost_parent_list"][0]["gallery_data"]["items"],
&data["crosspost_parent_list"][0]["media_metadata"],
);
("gallery", &data["url"], None)
} else if data["is_reddit_media_domain"].as_bool().unwrap_or_default() && data["domain"] == "i.redd.it" {
// If this post contains a reddit media (image) URL.
@ -542,6 +553,14 @@ pub struct ErrorTemplate {
pub url: String,
}
#[derive(Template)]
#[template(path = "info.html")]
pub struct InfoTemplate {
pub msg: String,
pub prefs: Preferences,
pub url: String,
}
/// Template for NSFW landing page. The landing page is displayed when a page's
/// content is wholly NSFW, but a user has not enabled the option to view NSFW
/// posts.
@ -585,8 +604,10 @@ pub struct Subreddit {
pub info: String,
// pub moderators: Vec<String>,
pub icon: String,
pub banner: String,
pub members: (String, String),
pub active: (String, String),
pub created: String,
pub wiki: bool,
pub nsfw: bool,
}
@ -601,34 +622,86 @@ pub struct Params {
pub before: Option<String>,
}
#[derive(Default)]
#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[revisioned(revision = 1)]
pub struct Preferences {
#[revision(start = 1)]
#[serde(skip_serializing, skip_deserializing)]
pub available_themes: Vec<String>,
#[revision(start = 1)]
pub available_mascots: Vec<String>,
#[revision(start = 1)]
pub theme: String,
#[revision(start = 1)]
pub mascot: String,
#[revision(start = 1)]
pub redsunlib_colorway: String,
#[revision(start = 1)]
pub front_page: String,
#[revision(start = 1)]
pub layout: String,
#[revision(start = 1)]
pub wide: String,
#[revision(start = 1)]
pub blur_spoiler: String,
#[revision(start = 1)]
pub show_nsfw: String,
#[revision(start = 1)]
pub blur_nsfw: String,
#[revision(start = 1)]
pub hide_hls_notification: String,
#[revision(start = 1)]
pub video_quality: String,
#[revision(start = 1)]
pub hide_sidebar_and_summary: String,
#[revision(start = 1)]
pub hide_banner: String,
#[revision(start = 1)]
pub use_hls: String,
#[revision(start = 1)]
pub ffmpeg_video_downloads: String,
#[revision(start = 1)]
pub autoplay_videos: String,
#[revision(start = 1)]
pub fixed_navbar: String,
#[revision(start = 1)]
pub disable_visit_reddit_confirmation: String,
#[revision(start = 1)]
pub comment_sort: String,
#[revision(start = 1)]
pub post_sort: String,
#[revision(start = 1)]
#[serde(serialize_with = "serialize_vec_with_plus", deserialize_with = "deserialize_vec_with_plus")]
pub subscriptions: Vec<String>,
#[revision(start = 1)]
pub quicklist: Vec<String>,
#[revision(start = 1)]
#[serde(serialize_with = "serialize_vec_with_plus", deserialize_with = "deserialize_vec_with_plus")]
pub filters: Vec<String>,
#[revision(start = 1)]
pub hide_awards: String,
#[revision(start = 1)]
pub hide_score: String,
#[revision(start = 1)]
pub remove_default_feeds: String,
}
fn serialize_vec_with_plus<S>(vec: &[String], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&vec.join("+"))
}
fn deserialize_vec_with_plus<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let string = String::deserialize(deserializer)?;
if string.is_empty() {
return Ok(Vec::new());
}
Ok(string.split('+').map(|s| s.to_string()).collect())
}
#[derive(RustEmbed)]
@ -670,6 +743,7 @@ impl Preferences {
blur_spoiler: setting(req, "blur_spoiler"),
show_nsfw: setting(req, "show_nsfw"),
hide_sidebar_and_summary: setting(req, "hide_sidebar_and_summary"),
hide_banner: setting(req, "hide_banner"),
blur_nsfw: setting(req, "blur_nsfw"),
use_hls: setting(req, "use_hls"),
ffmpeg_video_downloads: setting(req, "ffmpeg_video_downloads"),
@ -685,8 +759,36 @@ impl Preferences {
quicklist: setting(req, "quicklist").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
hide_awards: setting(req, "hide_awards"),
hide_score: setting(req, "hide_score"),
remove_default_feeds: setting(req, "remove_default_feeds"),
}
}
pub fn to_urlencoded(&self) -> Result<String, String> {
serde_urlencoded::to_string(self).map_err(|e| e.to_string())
}
pub fn to_bincode(&self) -> Result<Vec<u8>, String> {
bincode::serialize(self).map_err(|e| e.to_string())
}
pub fn to_compressed_bincode(&self) -> Result<Vec<u8>, String> {
deflate_compress(self.to_bincode()?)
}
pub fn to_bincode_str(&self) -> Result<String, String> {
Ok(base2048::encode(&self.to_compressed_bincode()?))
}
}
pub fn deflate_compress(i: Vec<u8>) -> Result<Vec<u8>, String> {
let mut e = Encoder::new(Vec::new());
e.write_all(&i).map_err(|e| e.to_string())?;
e.finish().into_result().map_err(|e| e.to_string())
}
pub fn deflate_decompress(i: Vec<u8>) -> Result<Vec<u8>, String> {
let mut decoder = Decoder::new(&i[..]);
let mut out = Vec::new();
decoder.read_to_end(&mut out).map_err(|e| format!("Failed to read from gzip decoder: {}", e))?;
Ok(out)
}
/// Gets a `HashSet` of filters from the cookie in the given `Request`.
@ -742,7 +844,15 @@ pub async fn parse_post(post: &Value) -> Post {
get_setting("REDLIB_PUSHSHIFT_FRONTEND").unwrap_or_else(|| String::from(crate::config::DEFAULT_PUSHSHIFT_FRONTEND)),
)
} else {
rewrite_urls(&val(post, "selftext_html"))
let selftext = val(post, "selftext");
if selftext.contains("```") {
let mut html_output = String::new();
let parser = pulldown_cmark::Parser::new(&selftext);
pulldown_cmark::html::push_html(&mut html_output, parser);
rewrite_urls(&html_output)
} else {
rewrite_urls(&val(post, "selftext_html"))
}
};
// Build a post using data parsed from Reddit post API
@ -833,18 +943,72 @@ pub fn param(path: &str, value: &str) -> Option<String> {
// Retrieve the value of a setting by name
pub fn setting(req: &Request<Body>, name: &str) -> String {
// Parse a cookie value from request
req
.cookie(name)
.unwrap_or_else(|| {
// If there is no cookie for this setting, try receiving a default from the config
if let Some(default) = get_setting(&format!("REDLIB_DEFAULT_{}", name.to_uppercase())) {
Cookie::new(name, default)
} else {
Cookie::from(name)
}
})
.value()
.to_string()
// If this was called with "subscriptions" and the "subscriptions" cookie has a value
if name == "subscriptions" && req.cookie("subscriptions").is_some() {
// Create subscriptions string
let mut subscriptions = String::new();
// Default subscriptions cookie
if req.cookie("subscriptions").is_some() {
subscriptions.push_str(req.cookie("subscriptions").unwrap().value());
}
// Start with first numbered subscription cookie
let mut subscriptions_number = 1;
// While whatever subscriptionsNUMBER cookie we're looking at has a value
while req.cookie(&format!("subscriptions{}", subscriptions_number)).is_some() {
// Push whatever subscriptionsNUMBER cookie we're looking at into the subscriptions string
subscriptions.push_str(req.cookie(&format!("subscriptions{}", subscriptions_number)).unwrap().value());
// Increment subscription cookie number
subscriptions_number += 1;
}
// Return the subscriptions cookies as one large string
subscriptions
}
// If this was called with "filters" and the "filters" cookie has a value
else if name == "filters" && req.cookie("filters").is_some() {
// Create filters string
let mut filters = String::new();
// Default filters cookie
if req.cookie("filters").is_some() {
filters.push_str(req.cookie("filters").unwrap().value());
}
// Start with first numbered filters cookie
let mut filters_number = 1;
// While whatever filtersNUMBER cookie we're looking at has a value
while req.cookie(&format!("filters{}", filters_number)).is_some() {
// Push whatever filtersNUMBER cookie we're looking at into the filters string
filters.push_str(req.cookie(&format!("filters{}", filters_number)).unwrap().value());
// Increment filters cookie number
filters_number += 1;
}
// Return the filters cookies as one large string
filters
}
// The above two still come to this if there was no existing value
else {
req
.cookie(name)
.unwrap_or_else(|| {
// If there is no cookie for this setting, try receiving a default from the config
if let Some(default) = get_setting(&format!("REDLIB_DEFAULT_{}", name.to_uppercase())) {
Cookie::new(name, default)
} else {
Cookie::from(name)
}
})
.value()
.to_string()
}
}
// Retrieve the value of a setting by name or the default value
@ -860,11 +1024,12 @@ pub fn setting_or_default(req: &Request<Body>, name: &str, default: String) -> S
// Detect and redirect in the event of a random subreddit
pub async fn catch_random(sub: &str, additional: &str) -> Result<Response<Body>, String> {
if sub == "random" || sub == "randnsfw" {
let new_sub = json(format!("/r/{sub}/about.json?raw_json=1"), false).await?["data"]["display_name"]
.as_str()
.unwrap_or_default()
.to_string();
Ok(redirect(&format!("/r/{new_sub}{additional}")))
Ok(redirect(&format!(
"/r/{}{additional}",
json(format!("/r/{sub}/about.json?raw_json=1"), false).await?["data"]["display_name"]
.as_str()
.unwrap_or_default()
)))
} else {
Err("No redirect needed".to_string())
}
@ -942,9 +1107,20 @@ pub fn format_url(url: &str) -> String {
}
}
static REGEX_BULLET: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?m)^- (.*)$").unwrap());
static REGEX_BULLET_CONSECUTIVE_LINES: Lazy<Regex> = Lazy::new(|| Regex::new(r"</ul>\n<ul>").unwrap());
pub fn render_bullet_lists(input_text: &str) -> String {
// ref: https://stackoverflow.com/a/4902622
// First enclose each bullet with <ul> <li> tags
let text1 = REGEX_BULLET.replace_all(input_text, "<ul><li>$1</li></ul>").to_string();
// Then remove any consecutive </ul> <ul> tags
REGEX_BULLET_CONSECUTIVE_LINES.replace_all(&text1, "").to_string()
}
// These are links we want to replace in-body
static REDDIT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"href="(https|http|)://(www\.|old\.|np\.|amp\.|new\.|)(reddit\.com|redd\.it)/"#).unwrap());
static REDDIT_PREVIEW_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"https?://(external-preview|preview|i)\.redd\.it(.*)[^?]").unwrap());
static REDDIT_PREVIEW_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"https?://(external-preview|preview|i)\.redd\.it(.*)").unwrap());
static REDDIT_EMOJI_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r"https?://(www|).redditstatic\.com/(.*)").unwrap());
static REDLIB_PREVIEW_LINK_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r#"/(img|preview/)(pre|external-pre)?/(.*?)>"#).unwrap());
static REDLIB_PREVIEW_TEXT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r">(.*?)</a>").unwrap());
@ -953,8 +1129,7 @@ static REDLIB_PREVIEW_TEXT_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new(r">(.*?)
pub fn rewrite_urls(input_text: &str) -> String {
let mut text1 =
// Rewrite Reddit links to Redlib
REDDIT_REGEX.replace_all(input_text, r#"href="/"#)
.to_string();
REDDIT_REGEX.replace_all(input_text, r#"href="/"#).to_string();
loop {
if REDDIT_EMOJI_REGEX.find(&text1).is_none() {
@ -976,49 +1151,44 @@ pub fn rewrite_urls(input_text: &str) -> String {
} else {
let formatted_url = format_url(REDDIT_PREVIEW_REGEX.find(&text1).map(|x| x.as_str()).unwrap_or_default());
let image_url = REDLIB_PREVIEW_LINK_REGEX.find(&formatted_url).map_or("", |m| m.as_str()).to_string();
let mut image_caption = REDLIB_PREVIEW_TEXT_REGEX.find(&formatted_url).map_or("", |m| m.as_str()).to_string();
let image_url = REDLIB_PREVIEW_LINK_REGEX.find(&formatted_url).map_or("", |m| m.as_str());
let mut image_caption = REDLIB_PREVIEW_TEXT_REGEX.find(&formatted_url).map_or("", |m| m.as_str());
/* As long as image_caption isn't empty remove first and last four characters of image_text to leave us with just the text in the caption without any HTML.
This makes it possible to enclose it in a <figcaption> later on without having stray HTML breaking it */
if !image_caption.is_empty() {
image_caption = image_caption[1..image_caption.len() - 4].to_string();
image_caption = &image_caption[1..image_caption.len() - 4];
}
// image_url contains > at the end of it, and right above this we remove image_text's front >, leaving us with just a single > between them
let image_to_replace = format!("<a href=\"{image_url}{image_caption}</a>");
// _image_replacement needs to be in scope for the replacement at the bottom of the loop
let mut _image_replacement = String::new();
let image_to_replace = format!("<p><a href=\"{image_url}{image_caption}</a></p>");
/* We don't want to show a caption that's just the image's link, so we check if we find a Reddit preview link within the image's caption.
If we don't find one we must have actual text, so we include a <figcaption> block that contains it.
Otherwise we don't include the <figcaption> block as we don't need it. */
if REDDIT_PREVIEW_REGEX.find(&image_caption).is_none() {
let _image_replacement = if REDDIT_PREVIEW_REGEX.find(image_caption).is_none() {
// Without this " would show as \" instead. "\&quot;" is how the quotes are formatted within image_text beforehand
image_caption = image_caption.replace("\\&quot;", "\"");
_image_replacement = format!("<figure><a href=\"{image_url}<img loading=\"lazy\" src=\"{image_url}</a><figcaption>{image_caption}</figcaption></figure>");
format!(
"<figure><a href=\"{image_url}<img loading=\"lazy\" src=\"{image_url}</a><figcaption>{}</figcaption></figure>",
image_caption.replace("\\&quot;", "\"")
)
} else {
_image_replacement = format!("<figure><a href=\"{image_url}<img loading=\"lazy\" src=\"{image_url}</a></figure>");
}
format!("<figure><a href=\"{image_url}<img loading=\"lazy\" src=\"{image_url}</a></figure>")
};
/* In order to know if we're dealing with a normal or external preview we need to take a look at the first capture group of REDDIT_PREVIEW_REGEX
if it's preview we're dealing with something that needs /preview/pre, external-preview is /preview/external-pre, and i is /img */
let reddit_preview_regex_capture = REDDIT_PREVIEW_REGEX.captures(&text1).unwrap().get(1).map_or("", |m| m.as_str()).to_string();
let mut _preview_type = String::new();
if reddit_preview_regex_capture == "preview" {
_preview_type = "/preview/pre".to_string();
} else if reddit_preview_regex_capture == "external-preview" {
_preview_type = "/preview/external-pre".to_string();
} else {
_preview_type = "/img".to_string();
}
let reddit_preview_regex_capture = REDDIT_PREVIEW_REGEX.captures(&text1).unwrap().get(1).map_or("", |m| m.as_str());
let _preview_type = match reddit_preview_regex_capture {
"preview" => "/preview/pre",
"external-preview" => "/preview/external-pre",
_ => "/img",
};
text1 = REDDIT_PREVIEW_REGEX
.replace(&text1, format!("{_preview_type}$2"))
.replace(&image_to_replace, &_image_replacement)
.to_string()
}
}
}
@ -1092,10 +1262,14 @@ pub fn rewrite_emotes(media_metadata: &Value, comment: String) -> String {
);
// 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();
comment = comment.replace(&id, &to_replace_with);
}
}
}
// render bullet (unordered) lists
comment = render_bullet_lists(&comment);
// Call rewrite_urls() to transform any other Reddit links
rewrite_urls(&comment)
}
@ -1192,6 +1366,20 @@ pub async fn error(req: Request<Body>, msg: &str) -> Result<Response<Body>, Stri
Ok(Response::builder().status(404).header("content-type", "text/html").body(body.into()).unwrap_or_default())
}
/// Renders a generic info landing page.
pub async fn info(req: Request<Body>, msg: &str) -> Result<Response<Body>, String> {
let url = req.uri().to_string();
let body = InfoTemplate {
msg: msg.to_string(),
prefs: Preferences::new(&req),
url,
}
.render()
.unwrap_or_default();
Ok(Response::builder().status(200).header("content-type", "text/html").body(body.into()).unwrap_or_default())
}
/// Returns true if the config/env variable `REDLIB_SFW_ONLY` carries the
/// value `on`.
///
@ -1279,7 +1467,7 @@ pub fn url_path_basename(path: &str) -> String {
let mut url = url_result.unwrap();
url.path_segments_mut().unwrap().pop_if_empty();
url.path_segments().unwrap().last().unwrap().to_string()
url.path_segments().unwrap().next_back().unwrap().to_string()
}
}
@ -1299,7 +1487,7 @@ pub fn get_post_url(post: &Post) -> String {
#[cfg(test)]
mod tests {
use super::{format_num, format_url, rewrite_urls};
use super::{format_num, format_url, rewrite_urls, Preferences};
#[test]
fn format_num_works() {
@ -1366,6 +1554,36 @@ mod tests {
assert_eq!(format_url("nsfw"), "");
assert_eq!(format_url("spoiler"), "");
}
#[test]
fn serialize_prefs() {
let prefs = Preferences {
available_themes: vec![],
theme: "laserwave".to_owned(),
front_page: "default".to_owned(),
layout: "compact".to_owned(),
wide: "on".to_owned(),
blur_spoiler: "on".to_owned(),
show_nsfw: "off".to_owned(),
blur_nsfw: "on".to_owned(),
hide_hls_notification: "off".to_owned(),
video_quality: "best".to_owned(),
hide_sidebar_and_summary: "off".to_owned(),
use_hls: "on".to_owned(),
autoplay_videos: "on".to_owned(),
fixed_navbar: "on".to_owned(),
disable_visit_reddit_confirmation: "on".to_owned(),
comment_sort: "confidence".to_owned(),
post_sort: "top".to_owned(),
subscriptions: vec!["memes".to_owned(), "mildlyinteresting".to_owned()],
filters: vec![],
hide_awards: "off".to_owned(),
hide_score: "off".to_owned(),
remove_default_feeds: "off".to_owned(),
};
let urlencoded = serde_urlencoded::to_string(prefs).expect("Failed to serialize Prefs");
assert_eq!(urlencoded, "theme=laserwave&front_page=default&layout=compact&wide=on&blur_spoiler=on&show_nsfw=off&blur_nsfw=on&hide_hls_notification=off&video_quality=best&hide_sidebar_and_summary=off&use_hls=on&autoplay_videos=on&fixed_navbar=on&disable_visit_reddit_confirmation=on&comment_sort=confidence&post_sort=top&subscriptions=memes%2Bmildlyinteresting&filters=&hide_awards=off&hide_score=off&remove_default_feeds=off");
}
}
#[test]
@ -1384,7 +1602,10 @@ async fn test_fetching_subreddit_quarantined() {
#[tokio::test(flavor = "multi_thread")]
async fn test_fetching_nsfw_subreddit() {
let subreddit = Post::fetch("/r/randnsfw", false).await;
// Gonwild is a place for closed, Euclidean Geometric shapes to exchange their nth terms for karma; showing off their edges in a comfortable environment without pressure.
// Find a good sub that is tagged NSFW but that actually isn't in case my future employers are watching (they probably are)
// switched from randnsfw as it is no longer functional.
let subreddit = Post::fetch("/r/gonwild", false).await;
assert!(subreddit.is_ok());
assert!(!subreddit.unwrap().0.is_empty());
}
@ -1402,7 +1623,7 @@ async fn test_fetching_ws() {
fn test_rewriting_image_links() {
let input =
r#"<p><a href="https://preview.redd.it/6awags382xo31.png?width=2560&amp;format=png&amp;auto=webp&amp;s=9c563aed4f07a91bdd249b5a3cea43a79710dcfc">caption 1</a></p>"#;
let output = r#"<p><figure><a href="/preview/pre/6awags382xo31.png?width=2560&amp;format=png&amp;auto=webp&amp;s=9c563aed4f07a91bdd249b5a3cea43a79710dcfc"><img loading="lazy" src="/preview/pre/6awags382xo31.png?width=2560&amp;format=png&amp;auto=webp&amp;s=9c563aed4f07a91bdd249b5a3cea43a79710dcfc"></a><figcaption>caption 1</figcaption></figure></p"#;
let output = r#"<figure><a href="/preview/pre/6awags382xo31.png?width=2560&amp;format=png&amp;auto=webp&amp;s=9c563aed4f07a91bdd249b5a3cea43a79710dcfc"><img loading="lazy" src="/preview/pre/6awags382xo31.png?width=2560&amp;format=png&amp;auto=webp&amp;s=9c563aed4f07a91bdd249b5a3cea43a79710dcfc"></a><figcaption>caption 1</figcaption></figure>"#;
assert_eq!(rewrite_urls(input), output);
}
@ -1429,3 +1650,77 @@ fn test_rewriting_emotes() {
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);
}
#[test]
fn test_rewriting_bullet_list() {
let input = r#"<div class="md"><p>Hi, I&#39;ve bought this very same monitor and found no calibration whatsoever. I have an ICC profile that has been set up since I&#39;ve installed its driver from the LG website and it works ok. I also used <a href="http://www.lagom.nl/lcd-test/">http://www.lagom.nl/lcd-test/</a> to calibrate it. After some good tinkering I&#39;ve found the following settings + the color profile from the driver gets me past all the tests perfectly:
- Brightness 50 (still have to settle on this one, it&#39;s personal preference, it controls the backlight, not the colors)
- Contrast 70 (which for me was the default one)
- Picture mode Custom
- Super resolution + Off (it looks horrible anyway)
- Sharpness 50 (default one I think)
- Black level High (low messes up gray colors)
- DFC Off
- Response Time Middle (personal preference, <a href="https://www.blurbusters.com/">https://www.blurbusters.com/</a> show horrible overdrive with it on high)
- Freesync doesn&#39;t matter
- Black stabilizer 50
- Gamma setting on 0
- Color Temp Medium
How`s your monitor by the way? Any IPS bleed whatsoever? I either got lucky or the panel is pretty good, 0 bleed for me, just the usual IPS glow. How about the pixels? I see the pixels even at one meter away, especially on Microsoft Edge&#39;s icon for example, the blue background is just blocky, don&#39;t know why.</p>
</div>"#;
let output = r#"<div class="md"><p>Hi, I&#39;ve bought this very same monitor and found no calibration whatsoever. I have an ICC profile that has been set up since I&#39;ve installed its driver from the LG website and it works ok. I also used <a href="http://www.lagom.nl/lcd-test/">http://www.lagom.nl/lcd-test/</a> to calibrate it. After some good tinkering I&#39;ve found the following settings + the color profile from the driver gets me past all the tests perfectly:
<ul><li>Brightness 50 (still have to settle on this one, it&#39;s personal preference, it controls the backlight, not the colors)</li><li>Contrast 70 (which for me was the default one)</li><li>Picture mode Custom</li><li>Super resolution + Off (it looks horrible anyway)</li><li>Sharpness 50 (default one I think)</li><li>Black level High (low messes up gray colors)</li><li>DFC Off </li><li>Response Time Middle (personal preference, <a href="https://www.blurbusters.com/">https://www.blurbusters.com/</a> show horrible overdrive with it on high)</li><li>Freesync doesn&#39;t matter</li><li>Black stabilizer 50</li><li>Gamma setting on 0 </li><li>Color Temp Medium</li></ul>
How`s your monitor by the way? Any IPS bleed whatsoever? I either got lucky or the panel is pretty good, 0 bleed for me, just the usual IPS glow. How about the pixels? I see the pixels even at one meter away, especially on Microsoft Edge&#39;s icon for example, the blue background is just blocky, don&#39;t know why.</p>
</div>"#;
assert_eq!(render_bullet_lists(input), output);
}
#[test]
fn test_default_prefs_serialization_loop_json() {
let prefs = Preferences::default();
let serialized = serde_json::to_string(&prefs).unwrap();
let deserialized: Preferences = serde_json::from_str(&serialized).unwrap();
assert_eq!(prefs, deserialized);
}
#[test]
fn test_default_prefs_serialization_loop_bincode() {
let prefs = Preferences::default();
test_round_trip(&prefs, false);
test_round_trip(&prefs, true);
}
static KNOWN_GOOD_CONFIGS: &[&str] = &[
"ఴӅβØØҞÉဏႢձĬ༧ȒʯऌԔӵ୮༏",
"ਧՊΥÀÃǎƱГ۸ඣമĖฤ႙ʟาúໜϾௐɥঀĜໃહཞઠѫҲɂఙ࿔DzઉƲӟӻĻฅΜδ໖ԜǗဖငƦơ৶Ą௩ԹʛใЛʃශаΏ",
"ਧԩΥÀÃΊ౭൩ඔႠϼҭöҪƸռઇԾॐნɔາǒՍҰच௨ಖມŃЉŐདƦ๙ϩএఠȝഽйʮჯඒϰळՋ௮ສ৵ऎΦѧਹಧଟƙŃ३î༦ŌပղयƟแҜ།",
];
#[test]
fn test_known_good_configs_deserialization() {
for config in KNOWN_GOOD_CONFIGS {
let bytes = base2048::decode(config).unwrap();
let decompressed = deflate_decompress(bytes).unwrap();
assert!(bincode::deserialize::<Preferences>(&decompressed).is_ok());
}
}
#[test]
fn test_known_good_configs_full_round_trip() {
for config in KNOWN_GOOD_CONFIGS {
let bytes = base2048::decode(config).unwrap();
let decompressed = deflate_decompress(bytes).unwrap();
let prefs: Preferences = bincode::deserialize(&decompressed).unwrap();
test_round_trip(&prefs, false);
test_round_trip(&prefs, true);
}
}
fn test_round_trip(input: &Preferences, compression: bool) {
let serialized = bincode::serialize(input).unwrap();
let compressed = if compression { deflate_compress(serialized).unwrap() } else { serialized };
let decompressed = if compression { deflate_decompress(compressed).unwrap() } else { compressed };
let deserialized: Preferences = bincode::deserialize(&decompressed).unwrap();
assert_eq!(*input, deserialized);
}

View File

@ -30,7 +30,7 @@ async function checkInstanceUpdateStatus() {
document.getElementById('update-status').innerText = statusMessage;
} catch (error) {
console.error('Error fetching commits:', error);
document.getElementById('update-status').innerText = '⚠️ Error checking update status.';
document.getElementById('update-status').innerText = '⚠️ Error checking update status: ' + error;
}
}
@ -45,7 +45,7 @@ async function checkOtherInstances() {
//document.getElementById('random-instance').innerText = "Visit Random Instance";
} catch (error) {
console.error('Error fetching instances:', error);
document.getElementById('update-status').innerText = '⚠️ Error checking update status.';
document.getElementById('update-status').innerText = '⚠️ Error checking other instances: ' + error;
}
}

9
static/copy.js Normal file
View File

@ -0,0 +1,9 @@
async function copy() {
await navigator.clipboard.writeText(document.getElementById('bincode_str').value);
}
async function set_listener() {
document.getElementById('copy').addEventListener('click', copy);
}
window.addEventListener('load', set_listener);

View File

@ -3,9 +3,9 @@
<ShortName>Search Redlib</ShortName>
<Description>Search for whatever you want on Redlib, awesome Reddit frontend</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="32" height="32" type="image/x-icon">/favicon.ico</Image>
<Url type="text/html" template="/search">
<Image width="32" height="32" type="image/x-icon">https://localhost:8080/favicon.ico</Image>
<Url type="text/html" template="https://localhost:8080/search">
<Param name="q" value="{searchTerms}"/>
</Url>
<moz:SearchForm>/search</moz:SearchForm>
<moz:SearchForm>https://localhost:8080/search</moz:SearchForm>
</OpenSearchDescription>

View File

@ -499,7 +499,7 @@ aside {
height: 100px;
border: 2px solid var(--accent);
border-radius: 100%;
padding: 10px;
padding: 0px;
margin: 10px;
}
@ -531,11 +531,14 @@ aside {
grid-template-columns: auto 4fr 1fr;
}
#user_details,
#sub_details {
#user_details {
display: grid;
grid-template-columns: repeat(2, 1fr);
}
#sub_details {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
@media screen and (max-width: 279px) {
#sub_actions { display: unset; }
}
@ -547,13 +550,9 @@ aside {
/* Subscriptions */
#sub_subscription,
#user_subscription,
#sub_filter,
#user_filter,
#sub_quicklist,
#user_quicklist,
#sub_rss,
#user_rss {
margin-top: 20px;
}
@ -572,14 +571,22 @@ aside {
.filter,
.unquick,
.quick,
.unfilter {
.unfilter,
.copy,
.import {
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
}
.copy,
.import {
margin: 5px;
}
.subscribe,
.filter {
.filter,
.copy,
.import {
color: var(--foreground);
background-color: var(--accent);
}
@ -1228,6 +1235,13 @@ a.search_subreddit:hover {
overflow-wrap: anywhere;
}
.post_body pre {
background: var(--background);
overflow-x: auto;
margin: 10px 0;
padding: 10px;
}
.post_body img {
max-width: 100%;
display: block;
@ -1446,6 +1460,7 @@ a.search_subreddit:hover {
border-radius: 5px;
font-size: 12px;
font-weight: bold;
unicode-bidi: isolate;
}
.comment_score {

View File

@ -0,0 +1,14 @@
/* midnightpurple theme setting */
.midnightPurple{
--accent: #be6ede;
--green: #268F02;
--text: white;
--foreground: #222;
--background: #000000;
--outside: #1f1f1f;
--post: #000000;
--panel-border: 1px solid #4E1764;
--highlighted: #333;
--visited: #aaa;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}

View File

@ -41,7 +41,7 @@
<div class="comment_body {% if highlighted %}highlighted{% endif %}">{{ body|safe }}</div>
{% endif %}
<blockquote class="replies">{% for c in replies -%}{{ c.render().unwrap()|safe }}{%- endfor %}
</bockquote>
</blockquote>
</details>
</div>
{% endif %}

20
templates/info.html Normal file
View File

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% import "utils.html" as utils %}
{% block title %}Info: {{ msg }}{% endblock %}
{% block sortstyle %}{% endblock %}
{% block subscriptions %}
{% call utils::sub_list("") %}
{% endblock %}
{% block search %}
{% call utils::search("".to_owned(), "") %}
{% endblock %}
{% block content %}
<div id="error">
<h2>{{ msg }}</h2>
<br />
</div>
{% endblock %}

View File

@ -1,7 +1,13 @@
{% extends "base.html" %}
{% import "utils.html" as utils %}
{% block title %}{{ post.title }} - r/{{ post.community }}{% endblock %}
{% block title %}
{% if single_thread %}
{{ comments[0].author.name }} comments on {{ post.title }} - r/{{ post.community }}
{% else %}
{{ post.title }} - r/{{ post.community }}
{% endif %}
{% endblock %}
{% block search %}
{% call utils::search(["/r/", post.community.as_str()].concat(), "") %}

View File

@ -4,22 +4,22 @@
{% block title %}Redlib Settings{% endblock %}
{% block subscriptions %}
{% call utils::sub_list("") %}
{% call utils::sub_list("") %}
{% endblock %}
{% block search %}
{% call utils::search("".to_owned(), "") %}
{% call utils::search("".to_owned(), "") %}
{% endblock %}
{% block content %}
<div id="settings">
<div id="settings">
<form action="/settings" method="POST">
<div class="prefs">
<fieldset>
<legend>Appearance</legend>
<div class="prefs-group">
<label for="theme">Theme:</label>
<select name="theme" id="theme">
<select name="theme" id="theme">
{% call utils::options(prefs.theme, prefs.available_themes, "system") %}
</select>
</div>
@ -39,7 +39,7 @@
<legend>Interface</legend>
<div class="prefs-group">
<label for="front_page">Front page:</label>
<select name="front_page" id="front_page">
<select name="front_page" id="front_page">
{% call utils::options(prefs.front_page, ["default", "popular", "all"], "default") %}
</select>
</div>
@ -49,6 +49,12 @@
{% call utils::options(prefs.layout, ["card", "clean", "compact", "old", "waterfall"], "card") %}
</select>
</div>
<div class="prefs-group">
<label for="remove_default_feeds">Remove default feeds</label>
<input type="hidden" value="off" name="remove_default_feeds">
<input type="checkbox" name="remove_default_feeds" id="remove_default_feeds" {% if
prefs.remove_default_feeds=="on" %}checked{% endif %}>
</div>
<div class="prefs-group">
<label for="wide">Wide UI:</label>
<input type="hidden" value="off" name="wide">
@ -65,6 +71,11 @@
<input type="hidden" value="off" name="hide_sidebar_and_summary">
<input type="checkbox" name="hide_sidebar_and_summary" {% if prefs.hide_sidebar_and_summary == "on" %}checked{% endif %}>
</div>
<div class="prefs-group">
<label for="hide_banner">Hide subreddit banners</label>
<input type="hidden" value="off" name="hide_banner">
<input type="checkbox" name="hide_banner" {% if prefs.hide_banner == "on" %}checked{% endif %}>
</div>
<div class="prefs-group">
<label for="disable_visit_reddit_confirmation">Do not confirm before visiting content on Reddit</label>
<input type="hidden" value="off" name="disable_visit_reddit_confirmation">
@ -73,35 +84,46 @@
</fieldset>
<fieldset>
<legend>Content</legend>
<div class="prefs-group">
<label for="video_quality">Video quality:</label>
<select name="video_quality" id="video_quality">
{% call utils::options(prefs.video_quality, ["best", "medium", "worst"], "best") %}
</select>
</div>
<div class="prefs-group">
<label for="post_sort" title="Applies only to subreddit feeds">Default subreddit post sort:</label>
<select name="post_sort">
{% call utils::options(prefs.post_sort, ["hot", "new", "top", "rising", "controversial"], "hot") %}
<select name="post_sort">
{% call utils::options(prefs.post_sort, ["hot", "new", "top", "rising", "controversial"], "hot")
%}
</select>
</div>
<div class="prefs-group">
<label for="comment_sort">Default comment sort:</label>
<select name="comment_sort" id="comment_sort">
{% call utils::options(prefs.comment_sort, ["confidence", "top", "new", "controversial", "old"], "confidence") %}
<select name="comment_sort" id="comment_sort">
{% call utils::options(prefs.comment_sort, ["confidence", "top", "new", "controversial", "old"],
"confidence") %}
</select>
</div>
<div class="prefs-group">
<label for="blur_spoiler">Blur spoiler previews:</label>
<input type="hidden" value="off" name="blur_spoiler">
<input type="checkbox" name="blur_spoiler" id="blur_spoiler" {% if prefs.blur_spoiler == "on" %}checked{% endif %}>
<input type="checkbox" name="blur_spoiler" id="blur_spoiler" {% if prefs.blur_spoiler=="on"
%}checked{% endif %}>
</div>
{% if !crate::utils::sfw_only() %}
{% if !crate::utils::sfw_only() %}
<div class="prefs-group">
<label for="show_nsfw">Show NSFW posts:</label>
<input type="hidden" value="off" name="show_nsfw">
<input type="checkbox" name="show_nsfw" id="show_nsfw" {% if prefs.show_nsfw == "on" %}checked{% endif %}>
<input type="checkbox" name="show_nsfw" id="show_nsfw" {% if prefs.show_nsfw=="on" %}checked{% endif
%}>
</div>
<div class="prefs-group">
<label for="blur_nsfw">Blur NSFW previews:</label>
<input type="hidden" value="off" name="blur_nsfw">
<input type="checkbox" name="blur_nsfw" id="blur_nsfw" {% if prefs.blur_nsfw == "on" %}checked{% endif %}>
<input type="checkbox" name="blur_nsfw" id="blur_nsfw" {% if prefs.blur_nsfw=="on" %}checked{% endif
%}>
</div>
{% endif %}
{% endif %}
<div class="prefs-group">
<label for="hide_awards">Hide awards</label>
<input type="hidden" value="off" name="hide_awards">
@ -124,14 +146,17 @@
<div class="prefs-group">
<label for="autoplay_videos">Autoplay videos</label>
<input type="hidden" value="off" name="autoplay_videos">
<input type="checkbox" name="autoplay_videos" id="autoplay_videos" {% if prefs.autoplay_videos == "on" %}checked{% endif %}>
<input type="checkbox" name="autoplay_videos" id="autoplay_videos" {% if prefs.autoplay_videos=="on"
%}checked{% endif %}>
</div>
<div class="prefs-group">
<label for="use_hls">Use HLS for videos</label>
{% if prefs.ffmpeg_video_downloads != "on" %}
<details id="feeds">
<summary>Why?</summary>
<div id="feed_list" class="helper">Reddit videos require JavaScript (via HLS.js) to be enabled to be played with audio. Therefore, this toggle lets you either use Redlib JS-free or utilize this feature.</div>
<div id="feed_list" class="helper">Reddit videos require JavaScript (via HLS.js) to be enabled
to be played with audio. Therefore, this toggle lets you either use Redlib JS-free or
utilize this feature.</div>
</details>
{% endif %}
{% if prefs.ffmpeg_video_downloads == "on" %}<u>ⓘ HLS is required for downloads</u>{% endif %}
@ -150,7 +175,8 @@
<div class="prefs-group">
<label for="hide_hls_notification">Hide notification about possible HLS usage</label>
<input type="hidden" value="off" name="hide_hls_notification">
<input type="checkbox" name="hide_hls_notification" id="hide_hls_notification" {% if prefs.hide_hls_notification == "on" %}checked{% endif %}>
<input type="checkbox" name="hide_hls_notification" id="hide_hls_notification" {% if
prefs.hide_hls_notification=="on" %}checked{% endif %}>
</div>
</fieldset>
<input id="save" type="submit" value="Save">
@ -158,38 +184,71 @@
</form>
<div id="settings_note">
<p><b>Note:</b> settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.</p><br>
<p>You can restore your current settings and subscriptions after clearing your cookies using <a href="/settings/restore/?theme={{ prefs.theme }}&mascot={{ prefs.mascot }}&redsunlib_colorway={{ prefs.redsunlib_colorway }}&front_page={{ prefs.front_page }}&layout={{ prefs.layout }}&wide={{ prefs.wide }}&post_sort={{ prefs.post_sort }}&comment_sort={{ prefs.comment_sort }}&show_nsfw={{ prefs.show_nsfw }}&use_hls={{ prefs.use_hls }}&ffmpeg_video_downloads={{ prefs.ffmpeg_video_downloads }}&hide_hls_notification={{ prefs.hide_hls_notification }}&hide_awards={{ prefs.hide_awards }}&fixed_navbar={{ prefs.fixed_navbar }}&hide_sidebar_and_summary={{ prefs.hide_sidebar_and_summary}}&subscriptions={{ prefs.subscriptions.join("%2B") }}&filters={{ prefs.filters.join("%2B") }}&quicklist={{ prefs.quicklist.join("%2B") }}">this link</a>.</p>
<p>You can restore your current settings and subscriptions after clearing your cookies using <a href="/settings/restore/?theme={{ prefs.theme }}&mascot={{ prefs.mascot }}&redsunlib_colorway={{ prefs.redsunlib_colorway }}&front_page={{ prefs.front_page }}&layout={{ prefs.layout }}&wide={{ prefs.wide }}&post_sort={{ prefs.post_sort }}&comment_sort={{ prefs.comment_sort }}&show_nsfw={{ prefs.show_nsfw }}&use_hls={{ prefs.use_hls }}&ffmpeg_video_downloads={{ prefs.ffmpeg_video_downloads }}&hide_hls_notification={{ prefs.hide_hls_notification }}&hide_awards={{ prefs.hide_awards }}&fixed_navbar={{ prefs.fixed_navbar }}&hide_sidebar_and_summary={{ prefs.hide_sidebar_and_summary}}&hide_banner={{ prefs.hide_banner}}&subscriptions={{ prefs.subscriptions.join("%2B") }}&filters={{ prefs.filters.join("%2B") }}&quicklist={{ prefs.quicklist.join("%2B") }}">this link</a>.</p>
</div>
{% if prefs.subscriptions.len() > 0 %}
<div class="prefs" id="settings_subs">
<legend>Subscribed Feeds</legend>
{% for sub in prefs.subscriptions %}
<div>
{% let feed -%}
{% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed = format!("r/{}", sub) -%}{% endif -%}
<a href="/{{ feed }}">{{ feed }}</a>
<form action="/r/{{ sub }}/unsubscribe/?redirect=settings" method="POST">
<button class="unsubscribe">Unsubscribe</button>
</form>
</div>
{% endfor %}
<div class="prefs" id="settings_subs">
<legend>Subscribed Feeds</legend>
{% for sub in prefs.subscriptions %}
<div>
{% let feed -%}
{% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed =
format!("r/{}", sub) -%}{% endif -%}
<a href="/{{ feed }}">{{ feed }}</a>
<form action="/r/{{ sub }}/unsubscribe/?redirect=settings" method="POST">
<button class="unsubscribe">Unsubscribe</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
{% if !prefs.filters.is_empty() %}
<div class="prefs" id="settings_filters">
<legend>Filtered Feeds</legend>
{% for sub in prefs.filters %}
<div>
{% let feed -%}
{% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed = format!("r/{}", sub) -%}{% endif -%}
<a href="/{{ feed }}">{{ feed }}</a>
<form action="/r/{{ sub }}/unfilter/?redirect=settings" method="POST">
<button class="unfilter">Unfilter</button>
</form>
</div>
{% endfor %}
<div class="prefs" id="settings_filters">
<legend>Filtered Feeds</legend>
{% for sub in prefs.filters %}
<div>
{% let feed -%}
{% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed =
format!("r/{}", sub) -%}{% endif -%}
<a href="/{{ feed }}">{{ feed }}</a>
<form action="/r/{{ sub }}/unfilter/?redirect=settings" method="POST">
<button class="unfilter">Unfilter</button>
</form>
</div>
{% endfor %}
</div>
{% endif %}
<!-- <div id="settings_note"> NOTE: TODO
<p><b>Note:</b> settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.
</p>
<br>
{% match prefs.to_urlencoded() %}
{% when Ok with (encoded_prefs) %}
<p>You can restore your current settings and subscriptions after clearing your cookies using <a
href="/settings/restore/?{{ encoded_prefs }}">this link</a>.</p>
{% when Err with (err) %}
<p>There was an error creating your restore link: {{ err }}</p>
<p>Please report this issue</p>
{% endmatch %}
<br />
<div>
<script src="/copy.js"></script>
<label for="bincode_str">Or, export/import here (be sure to save first):</label>
<br />
<input type="text" id="bincode_str" name="bincode_str"
value="{% match prefs.to_bincode_str() %}{% when Ok with (bincode_str) %}{{ bincode_str }}{% when Err with (err) %}Error: {{ err }}{% endmatch %}"
readonly>
<button id="copy" class="copy">Copy</button>
<br />
<form action="/settings/encoded-restore/" method="POST">
<input type="text" id="encoded_prefs" name="encoded_prefs" value=""
placeholder="Paste your encoded settings here">
<button class="import" type="submit">Import</button>
</form>
</div>
</div> -->
</div>
{% endblock %}
{% endblock %}

View File

@ -100,8 +100,16 @@
<a href="/r/{{ sub.name }}/wiki/index">Wiki</a>
</div>
{% endif %}
<div id="sub_meta">
{% if prefs.hide_banner != "on" %}
{% block head %}
{% call super() %}
<link rel="preload" as="image" href="{{ sub.banner }}">
{% endblock %}
{% endif %}
<div {% if prefs.hide_banner != "on" %}style="background: linear-gradient(to bottom, rgba(255,255,255,0) 10%, var(--outside)), url({{ sub.banner }});background-size: 100%;background-size: cover;background-position: center center;"{% endif %} id="iconbanner">
<img loading="lazy" id="sub_icon" src="{{ sub.icon }}" alt="Icon for r/{{ sub.name }}">
</div>
<div id="sub_meta">
<h1 id="sub_title">{{ sub.title }}</h1>
<p id="sub_name">r/{{ sub.name }}</p>
{% if crate::utils::enable_rss() %}
@ -120,9 +128,12 @@
<div id="sub_details">
<label>Members</label>
<label>Active</label>
<label>Created</label>
<div title="{{ sub.members.1 }}">{{ sub.members.0 }}</div>
<div title="{{ sub.active.1 }}">{{ sub.active.0 }}</div>
<div>{{ sub.created }}</div>
</div>
<hr>
<div id="sub_actions">
<div id="sub_subscription">
{% if prefs.subscriptions.contains(sub.name) %}

View File

@ -49,8 +49,10 @@
{% endif %}
<p>MAIN FEEDS</p>
<a href="/">Home</a>
<a href="/r/popular">Popular</a>
<a href="/r/all">All</a>
{% if prefs.remove_default_feeds != "on" %}
<a href="/r/popular">Popular</a>
<a href="/r/all">All</a>
{% endif %}
{% if prefs.subscriptions.len() > 0 %}
<p>REDDIT FEEDS</p>
{% for sub in prefs.subscriptions %}