Compare commits

...

10 Commits

Author SHA1 Message Date
4defb58f2a Optimizations and commenting 2021-05-20 12:24:06 -07:00
ba42fc066f Fix two subscription bugs 2021-05-19 20:30:10 -07:00
2cd35fb3b6 Upgrade to v0.14.2 2021-05-19 16:12:21 -07:00
b9af6f47f3 Use Inter font 2021-05-19 16:09:08 -07:00
73732a2a44 Fix subscription clearing when saving settings 2021-05-19 15:59:32 -07:00
43ed9756dc Upgrade to v0.14 2021-05-16 09:11:38 -07:00
8bb247af3b Added support for quarantined subreddits (#219)
* Added support for quarantined subreddits

* Added confirmation wall for quarantined subreddits

* Added quarantine walls to other routes and fixed case issue

* Correct obsolete use of cookie()

* Refactor param() and quarantine()

Co-authored-by: Spike <19519553+spikecodes@users.noreply.github.com>
2021-05-16 15:53:39 +00:00
ed05f5a092 misc: fix HLS typo (#220) 2021-05-16 15:41:47 +00:00
4f09333cd7 Handle three unwraps 2021-05-15 14:51:57 -07:00
31bf8c802e Document server configuration 2021-05-15 14:32:38 -07:00
16 changed files with 465 additions and 365 deletions

10
Cargo.lock generated
View File

@ -196,9 +196,9 @@ dependencies = [
[[package]] [[package]]
name = "const_fn" name = "const_fn"
version = "0.4.7" version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "402da840495de3f976eaefc3485b7f5eb5b0bf9761f9a47be27fe975b3b8c2ec" checksum = "f92cfa0fd5690b3cf8c1ef2cabbd9b7ef22fa53cf5e1f92b05103f6d5d1cf6e7"
[[package]] [[package]]
name = "cookie" name = "cookie"
@ -609,7 +609,7 @@ checksum = "18794a8ad5b29321f790b55d93dfba91e125cb1a9edbd4f8e3150acc771c1a5e"
[[package]] [[package]]
name = "libreddit" name = "libreddit"
version = "0.13.0" version = "0.14.4"
dependencies = [ dependencies = [
"askama", "askama",
"async-recursion", "async-recursion",
@ -807,9 +807,9 @@ checksum = "bc881b2c22681370c6a780e47af9840ef841837bc98118431d4e1868bd0c1086"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.26" version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a152013215dca273577e18d2bf00fa862b89b24169fb78c4c95aeb07992c9cec" checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038"
dependencies = [ dependencies = [
"unicode-xid", "unicode-xid",
] ]

View File

@ -3,7 +3,7 @@ name = "libreddit"
description = " Alternative private front-end to Reddit" description = " Alternative private front-end to Reddit"
license = "AGPL-3.0" license = "AGPL-3.0"
repository = "https://github.com/spikecodes/libreddit" repository = "https://github.com/spikecodes/libreddit"
version = "0.13.0" version = "0.14.4"
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"] authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
edition = "2018" edition = "2018"

View File

@ -194,6 +194,32 @@ Once installed, deploy Libreddit to `0.0.0.0:8080` by running:
libreddit libreddit
``` ```
## Change Default Settings
Assign a default value for each setting by passing environment variables to Libreddit in the format `LIBREDDIT_DEFAULT_{X}`. Replace `{X}` with the setting name (see list below) in capital letters.
| Name | Possible values | Default value |
|-----------------------|----------------------------------------------------------------------------------------|---------------|
| theme | ["system", "light", "dark", "black", "dracula", "nord", "laserwave", "violet", "gold"] | system |
| front_page | ["default", "popular", "all"] | default |
| layout | ["card", "clean", "compact"] | card |
| wide | ["on", "off"] | off |
| comment_sort | ["hot", "new", "top", "rising", "controversial"] | hot |
| post_sort | ["confidence", "top", "new", "controversial", "old"] | confidence |
| show_nsfw | ["on", "off"] | off |
| use_hls | ["on", "off"] | off |
| hide_hls_notification | ["on", "off"] | off |
### Examples
```bash
LIBREDDIT_DEFAULT_SHOW_NSFW=on libreddit
```
```bash
LIBREDDIT_DEFAULT_WIDE=on LIBREDDIT_DEFAULT_THEME=dark libreddit -r
```
## Proxying using NGINX ## Proxying using NGINX
**NOTE** If you're [proxying Libreddit through a NGINX Reverse Proxy](https://github.com/spikecodes/libreddit/issues/122#issuecomment-782226853), add **NOTE** If you're [proxying Libreddit through a NGINX Reverse Proxy](https://github.com/spikecodes/libreddit/issues/122#issuecomment-782226853), add

View File

@ -9,7 +9,9 @@ use crate::server::RequestExt;
pub async fn proxy(req: Request<Body>, format: &str) -> Result<Response<Body>, String> { pub async fn proxy(req: Request<Body>, format: &str) -> Result<Response<Body>, String> {
let mut url = format!("{}?{}", format, req.uri().query().unwrap_or_default()); let mut url = format!("{}?{}", format, req.uri().query().unwrap_or_default());
// For each parameter in request
for (name, value) in req.params().iter() { for (name, value) in req.params().iter() {
// Fill the parameter value in the url
url = url.replace(&format!("{{{}}}", name), value); url = url.replace(&format!("{{{}}}", name), value);
} }
@ -29,14 +31,13 @@ async fn stream(url: &str, req: &Request<Body>) -> Result<Response<Body>, String
let mut builder = Request::get(url); let mut builder = Request::get(url);
// Copy useful headers from original request // Copy useful headers from original request
let headers = req.headers();
for &key in &["Range", "If-Modified-Since", "Cache-Control"] { for &key in &["Range", "If-Modified-Since", "Cache-Control"] {
if let Some(value) = headers.get(key) { if let Some(value) = req.headers().get(key) {
builder = builder.header(key, value); builder = builder.header(key, value);
} }
} }
let stream_request = builder.body(Body::default()).expect("stream"); let stream_request = builder.body(Body::empty()).map_err(|_| "Couldn't build empty body in stream".to_string())?;
client client
.request(stream_request) .request(stream_request)
@ -60,13 +61,14 @@ async fn stream(url: &str, req: &Request<Body>) -> Result<Response<Body>, String
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
fn request(url: String) -> Boxed<Result<Response<Body>, String>> { fn request(url: String, quarantine: bool) -> Boxed<Result<Response<Body>, String>> {
// Prepare the HTTPS connector. // Prepare the HTTPS connector.
let https = hyper_rustls::HttpsConnector::with_native_roots(); let https = hyper_rustls::HttpsConnector::with_native_roots();
// Build the hyper client from the HTTPS connector. // Construct the hyper client from the HTTPS connector.
let client: client::Client<_, hyper::Body> = client::Client::builder().build(https); let client: client::Client<_, hyper::Body> = client::Client::builder().build(https);
// Build request
let builder = Request::builder() let builder = Request::builder()
.method("GET") .method("GET")
.uri(&url) .uri(&url)
@ -75,6 +77,7 @@ fn request(url: String) -> Boxed<Result<Response<Body>, String>> {
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8") .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.5") .header("Accept-Language", "en-US,en;q=0.5")
.header("Connection", "keep-alive") .header("Connection", "keep-alive")
.header("Cookie", if quarantine { "_options=%7B%22pref_quarantine_optin%22%3A%20true%7D" } else { "" })
.body(Body::empty()); .body(Body::empty());
async move { async move {
@ -89,6 +92,7 @@ fn request(url: String) -> Boxed<Result<Response<Body>, String>> {
.map(|val| val.to_str().unwrap_or_default()) .map(|val| val.to_str().unwrap_or_default())
.unwrap_or_default() .unwrap_or_default()
.to_string(), .to_string(),
quarantine,
) )
.await .await
} else { } else {
@ -105,7 +109,7 @@ fn request(url: String) -> Boxed<Result<Response<Body>, String>> {
// Make a request to a Reddit API and parse the JSON response // Make a request to a Reddit API and parse the JSON response
#[cached(size = 100, time = 30, result = true)] #[cached(size = 100, time = 30, result = true)]
pub async fn json(path: String) -> Result<Value, String> { pub async fn json(path: String, quarantine: bool) -> Result<Value, String> {
// Build Reddit url from path // Build Reddit url from path
let url = format!("https://www.reddit.com{}", path); let url = format!("https://www.reddit.com{}", path);
@ -116,7 +120,7 @@ pub async fn json(path: String) -> Result<Value, String> {
}; };
// Fetch the url... // Fetch the url...
match request(url.clone()).await { match request(url.clone(), quarantine).await {
Ok(response) => { Ok(response) => {
// asynchronously aggregate the chunks of the body // asynchronously aggregate the chunks of the body
match hyper::body::aggregate(response).await { match hyper::body::aggregate(response).await {

View File

@ -1,14 +1,7 @@
// Global specifiers // Global specifiers
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#![warn(clippy::pedantic, clippy::all)] #![warn(clippy::pedantic, clippy::all)]
#![allow( #![allow(clippy::needless_pass_by_value, clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::find_map)]
clippy::needless_pass_by_value,
clippy::match_wildcard_for_single_variants,
clippy::cast_possible_truncation,
clippy::similar_names,
clippy::cast_possible_wrap,
clippy::find_map
)]
// Reference local files // Reference local files
mod post; mod post;
@ -66,6 +59,16 @@ async fn favicon() -> Result<Response<Body>, String> {
) )
} }
async fn font() -> Result<Response<Body>, String> {
Ok(
Response::builder()
.status(200)
.header("content-type", "font/woff2")
.body(include_bytes!("../static/Inter.var.woff2").as_ref().into())
.unwrap_or_default(),
)
}
async fn resource(body: &str, content_type: &str, cache: bool) -> Result<Response<Body>, String> { async fn resource(body: &str, content_type: &str, cache: bool) -> Result<Response<Body>, String> {
let mut res = Response::builder() let mut res = Response::builder()
.status(200) .status(200)
@ -127,7 +130,7 @@ async fn main() {
let port = matches.value_of("port").unwrap_or("8080"); let port = matches.value_of("port").unwrap_or("8080");
let hsts = matches.value_of("hsts"); let hsts = matches.value_of("hsts");
let listener = format!("{}:{}", address, port); let listener = [address, ":", port].concat();
println!("Starting Libreddit..."); println!("Starting Libreddit...");
@ -139,7 +142,7 @@ async fn main() {
"Referrer-Policy" => "no-referrer", "Referrer-Policy" => "no-referrer",
"X-Content-Type-Options" => "nosniff", "X-Content-Type-Options" => "nosniff",
"X-Frame-Options" => "DENY", "X-Frame-Options" => "DENY",
"Content-Security-Policy" => "default-src 'none'; script-src 'self' blob:; manifest-src 'self'; media-src 'self' data: blob: about:; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; connect-src 'self'; worker-src blob:;" "Content-Security-Policy" => "default-src 'none'; font-src 'self'; script-src 'self' blob:; manifest-src 'self'; media-src 'self' data: blob: about:; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; connect-src 'self'; worker-src blob:;"
}; };
if let Some(expire_time) = hsts { if let Some(expire_time) = hsts {
@ -156,6 +159,7 @@ async fn main() {
app.at("/robots.txt").get(|_| resource("User-agent: *\nAllow: /", "text/plain", true).boxed()); app.at("/robots.txt").get(|_| resource("User-agent: *\nAllow: /", "text/plain", true).boxed());
app.at("/favicon.ico").get(|_| favicon().boxed()); app.at("/favicon.ico").get(|_| favicon().boxed());
app.at("/logo.png").get(|_| pwa_logo().boxed()); app.at("/logo.png").get(|_| pwa_logo().boxed());
app.at("/Inter.var.woff2").get(|_| font().boxed());
app.at("/touch-icon-iphone.png").get(|_| iphone_logo().boxed()); app.at("/touch-icon-iphone.png").get(|_| iphone_logo().boxed());
app.at("/apple-touch-icon.png").get(|_| iphone_logo().boxed()); app.at("/apple-touch-icon.png").get(|_| iphone_logo().boxed());
app app
@ -194,7 +198,10 @@ async fn main() {
app.at("/settings/update").get(|r| settings::update(r).boxed()); app.at("/settings/update").get(|r| settings::update(r).boxed());
// Subreddit services // Subreddit services
app.at("/r/:sub").get(|r| subreddit::community(r).boxed()); app
.at("/r/:sub")
.get(|r| subreddit::community(r).boxed())
.post(|r| subreddit::add_quarantine_exception(r).boxed());
app app
.at("/r/u_:name") .at("/r/u_:name")

View File

@ -2,10 +2,10 @@
use crate::client::json; use crate::client::json;
use crate::esc; use crate::esc;
use crate::server::RequestExt; use crate::server::RequestExt;
use crate::subreddit::{can_access_quarantine, quarantine};
use crate::utils::{error, format_num, format_url, param, rewrite_urls, setting, template, time, val, Author, Comment, Flags, Flair, FlairPart, Media, Post, Preferences}; use crate::utils::{error, format_num, format_url, param, rewrite_urls, setting, template, time, val, Author, Comment, Flags, Flair, FlairPart, Media, Post, Preferences};
use hyper::{Body, Request, Response};
use async_recursion::async_recursion; use hyper::{Body, Request, Response};
use askama::Template; use askama::Template;
@ -23,18 +23,22 @@ struct PostTemplate {
pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> { pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
// Build Reddit API path // Build Reddit API path
let mut path: String = format!("{}.json?{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default()); let mut path: String = format!("{}.json?{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default());
let sub = req.param("sub").unwrap_or_default();
let quarantined = can_access_quarantine(&req, &sub);
// Set sort to sort query parameter // Set sort to sort query parameter
let mut sort: String = param(&path, "sort"); let sort = param(&path, "sort").unwrap_or_else(|| {
// Grab default comment sort method from Cookies
let default_sort = setting(&req, "comment_sort");
// Grab default comment sort method from Cookies // If there's no sort query but there's a default sort, set sort to default_sort
let default_sort = setting(&req, "comment_sort"); if default_sort.is_empty() {
String::new()
// If there's no sort query but there's a default sort, set sort to default_sort } else {
if sort.is_empty() && !default_sort.is_empty() { path = format!("{}.json?{}&sort={}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default(), default_sort);
sort = default_sort; default_sort
path = format!("{}.json?{}&sort={}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default(), sort); }
} });
// Log the post ID being fetched in debug mode // Log the post ID being fetched in debug mode
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@ -44,12 +48,12 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
let highlighted_comment = &req.param("comment_id").unwrap_or_default(); let highlighted_comment = &req.param("comment_id").unwrap_or_default();
// Send a request to the url, receive JSON in response // Send a request to the url, receive JSON in response
match json(path).await { match json(path, quarantined).await {
// Otherwise, grab the JSON output from the request // Otherwise, grab the JSON output from the request
Ok(res) => { Ok(response) => {
// Parse the JSON into Post and Comment structs // Parse the JSON into Post and Comment structs
let post = parse_post(&res[0]).await; let post = parse_post(&response[0]).await;
let comments = parse_comments(&res[1], &post.permalink, &post.author.name, highlighted_comment).await; let comments = parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment);
// Use the Post and Comment structs to generate a website to show users // Use the Post and Comment structs to generate a website to show users
template(PostTemplate { template(PostTemplate {
@ -61,7 +65,14 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
}) })
} }
// If the Reddit API returns an error, exit and send error page to user // If the Reddit API returns an error, exit and send error page to user
Err(msg) => error(req, msg).await, Err(msg) => {
if msg == "quarantined" {
let sub = req.param("sub").unwrap_or_default();
quarantine(req, sub)
} else {
error(req, msg).await
}
}
} }
} }
@ -138,79 +149,71 @@ async fn parse_post(json: &serde_json::Value) -> Post {
} }
// COMMENTS // COMMENTS
#[async_recursion] fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, highlighted_comment: &str) -> Vec<Comment> {
async fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, highlighted_comment: &str) -> Vec<Comment> { // Parse the comment JSON into a Vector of Comments
// Separate the comment JSON into a Vector of comments let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned);
let comment_data = match json["data"]["children"].as_array() {
Some(f) => f.to_owned(),
None => Vec::new(),
};
let mut comments: Vec<Comment> = Vec::new();
// For each comment, retrieve the values to build a Comment object // For each comment, retrieve the values to build a Comment object
for comment in comment_data {
let kind = comment["kind"].as_str().unwrap_or_default().to_string();
let data = &comment["data"];
let unix_time = data["created_utc"].as_f64().unwrap_or_default();
let (rel_time, created) = time(unix_time);
let edited = match data["edited"].as_f64() {
Some(stamp) => time(stamp),
None => (String::new(), String::new()),
};
let score = data["score"].as_i64().unwrap_or(0);
let body = rewrite_urls(&val(&comment, "body_html"));
// If this comment contains replies, handle those too
let replies: Vec<Comment> = if data["replies"].is_object() {
parse_comments(&data["replies"], post_link, post_author, highlighted_comment).await
} else {
Vec::new()
};
let parent_kind_and_id = val(&comment, "parent_id");
let parent_info = parent_kind_and_id.split('_').collect::<Vec<&str>>();
let id = val(&comment, "id");
let highlighted = id == highlighted_comment;
comments.push(Comment {
id,
kind,
parent_id: parent_info[1].to_string(),
parent_kind: parent_info[0].to_string(),
post_link: post_link.to_string(),
post_author: post_author.to_string(),
body,
author: Author {
name: val(&comment, "author"),
flair: Flair {
flair_parts: FlairPart::parse(
data["author_flair_type"].as_str().unwrap_or_default(),
data["author_flair_richtext"].as_array(),
data["author_flair_text"].as_str(),
),
text: esc!(&comment, "link_flair_text"),
background_color: val(&comment, "author_flair_background_color"),
foreground_color: val(&comment, "author_flair_text_color"),
},
distinguished: val(&comment, "distinguished"),
},
score: if data["score_hidden"].as_bool().unwrap_or_default() {
("\u{2022}".to_string(), "Hidden".to_string())
} else {
format_num(score)
},
rel_time,
created,
edited,
replies,
highlighted,
});
}
comments comments
.into_iter()
.map(|comment| {
let kind = comment["kind"].as_str().unwrap_or_default().to_string();
let data = &comment["data"];
let unix_time = data["created_utc"].as_f64().unwrap_or_default();
let (rel_time, created) = time(unix_time);
let edited = data["edited"].as_f64().map_or((String::new(), String::new()), time);
let score = data["score"].as_i64().unwrap_or(0);
let body = rewrite_urls(&val(&comment, "body_html"));
// If this comment contains replies, handle those too
let replies: Vec<Comment> = if data["replies"].is_object() {
parse_comments(&data["replies"], post_link, post_author, highlighted_comment)
} else {
Vec::new()
};
let parent_kind_and_id = val(&comment, "parent_id");
let parent_info = parent_kind_and_id.split('_').collect::<Vec<&str>>();
let id = val(&comment, "id");
let highlighted = id == highlighted_comment;
Comment {
id,
kind,
parent_id: parent_info[1].to_string(),
parent_kind: parent_info[0].to_string(),
post_link: post_link.to_string(),
post_author: post_author.to_string(),
body,
author: Author {
name: val(&comment, "author"),
flair: Flair {
flair_parts: FlairPart::parse(
data["author_flair_type"].as_str().unwrap_or_default(),
data["author_flair_richtext"].as_array(),
data["author_flair_text"].as_str(),
),
text: esc!(&comment, "link_flair_text"),
background_color: val(&comment, "author_flair_background_color"),
foreground_color: val(&comment, "author_flair_text_color"),
},
distinguished: val(&comment, "distinguished"),
},
score: if data["score_hidden"].as_bool().unwrap_or_default() {
("\u{2022}".to_string(), "Hidden".to_string())
} else {
format_num(score)
},
rel_time,
created,
edited,
replies,
highlighted,
}
})
.collect()
} }

View File

@ -1,6 +1,10 @@
// CRATES // CRATES
use crate::utils::{catch_random, error, format_num, format_url, param, setting, template, val, Post, Preferences}; use crate::utils::{catch_random, error, format_num, format_url, param, setting, template, val, Post, Preferences};
use crate::{client::json, RequestExt}; use crate::{
client::json,
subreddit::{can_access_quarantine, quarantine},
RequestExt,
};
use askama::Template; use askama::Template;
use hyper::{Body, Request, Response}; use hyper::{Body, Request, Response};
@ -39,27 +43,21 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
let nsfw_results = if setting(&req, "show_nsfw") == "on" { "&include_over_18=on" } else { "" }; let nsfw_results = if setting(&req, "show_nsfw") == "on" { "&include_over_18=on" } else { "" };
let path = format!("{}.json?{}{}", req.uri().path(), req.uri().query().unwrap_or_default(), nsfw_results); let path = format!("{}.json?{}{}", req.uri().path(), req.uri().query().unwrap_or_default(), nsfw_results);
let sub = req.param("sub").unwrap_or_default(); let sub = req.param("sub").unwrap_or_default();
let quarantined = can_access_quarantine(&req, &sub);
// Handle random subreddits // Handle random subreddits
if let Ok(random) = catch_random(&sub, "/find").await { if let Ok(random) = catch_random(&sub, "/find").await {
return Ok(random); return Ok(random);
} }
let query = param(&path, "q"); let query = param(&path, "q").unwrap_or_default();
let sort = if param(&path, "sort").is_empty() { let sort = param(&path, "sort").unwrap_or_else(|| "relevance".to_string());
"relevance".to_string()
} else {
param(&path, "sort")
};
let subreddits = if param(&path, "restrict_sr").is_empty() { // If search is not restricted to this subreddit, show other subreddits in search results
search_subreddits(&query).await let subreddits = param(&path, "restrict_sr").map_or(search_subreddits(&query).await, |_| Vec::new());
} else {
Vec::new()
};
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str())); let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
match Post::fetch(&path, String::new()).await { match Post::fetch(&path, String::new(), quarantined).await {
Ok((posts, after)) => template(SearchTemplate { Ok((posts, after)) => template(SearchTemplate {
posts, posts,
subreddits, subreddits,
@ -67,15 +65,22 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
params: SearchParams { params: SearchParams {
q: query.replace('"', "&quot;"), q: query.replace('"', "&quot;"),
sort, sort,
t: param(&path, "t"), t: param(&path, "t").unwrap_or_default(),
before: param(&path, "after"), before: param(&path, "after").unwrap_or_default(),
after, after,
restrict_sr: param(&path, "restrict_sr"), restrict_sr: param(&path, "restrict_sr").unwrap_or_default(),
}, },
prefs: Preferences::new(req), prefs: Preferences::new(req),
url, url,
}), }),
Err(msg) => error(req, msg).await, Err(msg) => {
if msg == "quarantined" {
let sub = req.param("sub").unwrap_or_default();
quarantine(req, sub)
} else {
error(req, msg).await
}
}
} }
} }
@ -83,35 +88,25 @@ async fn search_subreddits(q: &str) -> Vec<Subreddit> {
let subreddit_search_path = format!("/subreddits/search.json?q={}&limit=3", q.replace(' ', "+")); let subreddit_search_path = format!("/subreddits/search.json?q={}&limit=3", q.replace(' ', "+"));
// Send a request to the url // Send a request to the url
match json(subreddit_search_path).await { json(subreddit_search_path, false).await.unwrap_or_default()["data"]["children"]
// If success, receive JSON in response .as_array()
Ok(response) => { .map(ToOwned::to_owned)
match response["data"]["children"].as_array() { .unwrap_or_default()
// For each subreddit from subreddit list .iter()
Some(list) => list .map(|subreddit| {
.iter() // For each subreddit from subreddit list
.map(|subreddit| { // Fetch subreddit icon either from the community_icon or icon_img value
// Fetch subreddit icon either from the community_icon or icon_img value let icon = subreddit["data"]["community_icon"]
let community_icon: &str = subreddit["data"]["community_icon"].as_str().map_or("", |s| s.split('?').collect::<Vec<&str>>()[0]); .as_str()
let icon = if community_icon.is_empty() { .map_or_else(|| val(&subreddit, "icon_img"), ToString::to_string);
val(&subreddit, "icon_img")
} else {
community_icon.to_string()
};
Subreddit { Subreddit {
name: val(subreddit, "display_name_prefixed"), name: val(subreddit, "display_name_prefixed"),
url: val(subreddit, "url"), url: val(subreddit, "url"),
icon: format_url(&icon), icon: format_url(&icon),
description: val(subreddit, "public_description"), description: val(subreddit, "public_description"),
subscribers: format_num(subreddit["data"]["subscribers"].as_f64().unwrap_or_default() as i64), subscribers: format_num(subreddit["data"]["subscribers"].as_f64().unwrap_or_default() as i64),
}
})
.collect::<Vec<Subreddit>>(),
_ => Vec::new(),
} }
} })
// If the Reddit API returns an error, exit this function .collect::<Vec<Subreddit>>()
_ => Vec::new(),
}
} }

View File

@ -69,29 +69,31 @@ impl RequestExt for Request<Body> {
} }
fn cookies(&self) -> Vec<Cookie> { fn cookies(&self) -> Vec<Cookie> {
let mut cookies = Vec::new(); self.headers().get("Cookie").map_or(Vec::new(), |header| {
if let Some(header) = self.headers().get("Cookie") { header
for cookie in header.to_str().unwrap_or_default().split("; ") { .to_str()
cookies.push(Cookie::parse(cookie).unwrap_or_else(|_| Cookie::named(""))); .unwrap_or_default()
} .split("; ")
} .map(|cookie| Cookie::parse(cookie).unwrap_or_else(|_| Cookie::named("")))
cookies .collect()
})
} }
fn cookie(&self, name: &str) -> Option<Cookie> { fn cookie(&self, name: &str) -> Option<Cookie> {
self.cookies().iter().find(|c| c.name() == name).map(std::borrow::ToOwned::to_owned) self.cookies().into_iter().find(|c| c.name() == name)
} }
} }
impl ResponseExt for Response<Body> { impl ResponseExt for Response<Body> {
fn cookies(&self) -> Vec<Cookie> { fn cookies(&self) -> Vec<Cookie> {
let mut cookies = Vec::new(); self.headers().get("Cookie").map_or(Vec::new(), |header| {
for header in self.headers().get_all("Cookie") { header
if let Ok(cookie) = Cookie::parse(header.to_str().unwrap_or_default()) { .to_str()
cookies.push(cookie); .unwrap_or_default()
} .split("; ")
} .map(|cookie| Cookie::parse(cookie).unwrap_or_else(|_| Cookie::named("")))
cookies .collect()
})
} }
fn insert_cookie(&mut self, cookie: Cookie) { fn insert_cookie(&mut self, cookie: Cookie) {
@ -144,6 +146,7 @@ impl Server {
pub fn listen(self, addr: String) -> Boxed<Result<(), hyper::Error>> { pub fn listen(self, addr: String) -> Boxed<Result<(), hyper::Error>> {
let make_svc = make_service_fn(move |_conn| { let make_svc = make_service_fn(move |_conn| {
// For correct borrowing, these values need to be borrowed
let router = self.router.clone(); let router = self.router.clone();
let default_headers = self.default_headers.clone(); let default_headers = self.default_headers.clone();
@ -159,7 +162,7 @@ impl Server {
let mut path = req.uri().path().replace("//", "/"); let mut path = req.uri().path().replace("//", "/");
// Remove trailing slashes // Remove trailing slashes
if path.ends_with('/') && path != "/" { if path != "/" && path.ends_with('/') {
path.pop(); path.pop();
} }
@ -198,17 +201,15 @@ impl Server {
} }
}); });
// Build SocketAddr from provided address
let address = &addr.parse().unwrap_or_else(|_| panic!("Cannot parse {} as address (example format: 0.0.0.0:8080)", addr)); let address = &addr.parse().unwrap_or_else(|_| panic!("Cannot parse {} as address (example format: 0.0.0.0:8080)", addr));
let server = HyperServer::bind(address).serve(make_svc); // 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 {
// Wait for the CTRL+C signal
tokio::signal::ctrl_c().await.expect("Failed to install CTRL+C signal handler")
});
let graceful = server.with_graceful_shutdown(shutdown_signal()); server.boxed()
graceful.boxed()
} }
} }
async fn shutdown_signal() {
// Wait for the CTRL+C signal
tokio::signal::ctrl_c().await.expect("Failed to install CTRL+C signal handler");
}

View File

@ -18,7 +18,7 @@ struct SettingsTemplate {
// CONSTANTS // CONSTANTS
const PREFS: [&str; 10] = [ const PREFS: [&str; 9] = [
"theme", "theme",
"front_page", "front_page",
"layout", "layout",
@ -28,7 +28,6 @@ const PREFS: [&str; 10] = [
"show_nsfw", "show_nsfw",
"use_hls", "use_hls",
"hide_hls_notification", "hide_hls_notification",
"subscriptions",
]; ];
// FUNCTIONS // FUNCTIONS
@ -44,12 +43,12 @@ pub async fn set(req: Request<Body>) -> Result<Response<Body>, String> {
let (parts, mut body) = req.into_parts(); let (parts, mut body) = req.into_parts();
// Grab existing cookies // Grab existing cookies
let mut cookies = Vec::new(); let _cookies: Vec<Cookie> = parts
for header in parts.headers.get_all("Cookie") { .headers
if let Ok(cookie) = Cookie::parse(header.to_str().unwrap_or_default()) { .get_all("Cookie")
cookies.push(cookie); .iter()
} .filter_map(|header| Cookie::parse(header.to_str().unwrap_or_default()).ok())
} .collect();
// Aggregate the body... // Aggregate the body...
// let whole_body = hyper::body::aggregate(req).await.map_err(|e| e.to_string())?; // let whole_body = hyper::body::aggregate(req).await.map_err(|e| e.to_string())?;
@ -63,22 +62,22 @@ pub async fn set(req: Request<Body>) -> Result<Response<Body>, String> {
let form = url::form_urlencoded::parse(&body_bytes).collect::<HashMap<_, _>>(); let form = url::form_urlencoded::parse(&body_bytes).collect::<HashMap<_, _>>();
let mut res = redirect("/settings".to_string()); let mut response = redirect("/settings".to_string());
for &name in &PREFS { for &name in &PREFS {
match form.get(name) { match form.get(name) {
Some(value) => res.insert_cookie( Some(value) => response.insert_cookie(
Cookie::build(name.to_owned(), value.to_owned()) Cookie::build(name.to_owned(), value.to_owned())
.path("/") .path("/")
.http_only(true) .http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52)) .expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.finish(), .finish(),
), ),
None => res.remove_cookie(name.to_string()), None => response.remove_cookie(name.to_string()),
}; };
} }
Ok(res) Ok(response)
} }
fn set_cookies_method(req: Request<Body>, remove_cookies: bool) -> Response<Body> { fn set_cookies_method(req: Request<Body>, remove_cookies: bool) -> Response<Body> {
@ -86,12 +85,12 @@ fn set_cookies_method(req: Request<Body>, remove_cookies: bool) -> Response<Body
let (parts, _) = req.into_parts(); let (parts, _) = req.into_parts();
// Grab existing cookies // Grab existing cookies
let mut cookies = Vec::new(); let _cookies: Vec<Cookie> = parts
for header in parts.headers.get_all("Cookie") { .headers
if let Ok(cookie) = Cookie::parse(header.to_str().unwrap_or_default()) { .get_all("Cookie")
cookies.push(cookie); .iter()
} .filter_map(|header| Cookie::parse(header.to_str().unwrap_or_default()).ok())
} .collect();
let query = parts.uri.query().unwrap_or_default().as_bytes(); let query = parts.uri.query().unwrap_or_default().as_bytes();
@ -102,11 +101,11 @@ fn set_cookies_method(req: Request<Body>, remove_cookies: bool) -> Response<Body
None => "/".to_string(), None => "/".to_string(),
}; };
let mut res = redirect(path); let mut response = redirect(path);
for &name in &PREFS { for name in [PREFS.to_vec(), vec!["subscriptions"]].concat() {
match form.get(name) { match form.get(name) {
Some(value) => res.insert_cookie( Some(value) => response.insert_cookie(
Cookie::build(name.to_owned(), value.to_owned()) Cookie::build(name.to_owned(), value.to_owned())
.path("/") .path("/")
.http_only(true) .http_only(true)
@ -115,13 +114,13 @@ fn set_cookies_method(req: Request<Body>, remove_cookies: bool) -> Response<Body
), ),
None => { None => {
if remove_cookies { if remove_cookies {
res.remove_cookie(name.to_string()) response.remove_cookie(name.to_string())
} }
} }
}; };
} }
res response
} }
// Set cookies using response "Set-Cookie" header // Set cookies using response "Set-Cookie" header

View File

@ -28,9 +28,20 @@ struct WikiTemplate {
prefs: Preferences, prefs: Preferences,
} }
#[derive(Template)]
#[template(path = "wall.html", escape = "none")]
struct WallTemplate {
title: String,
sub: String,
msg: String,
prefs: Preferences,
url: String,
}
// SERVICES // SERVICES
pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> { pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
// Build Reddit API path // Build Reddit API path
let root = req.uri().path() == "/";
let subscribed = setting(&req, "subscriptions"); let subscribed = setting(&req, "subscriptions");
let front_page = setting(&req, "front_page"); let front_page = setting(&req, "front_page");
let post_sort = req.cookie("post_sort").map_or_else(|| "hot".to_string(), |c| c.value().to_string()); let post_sort = req.cookie("post_sort").map_or_else(|| "hot".to_string(), |c| c.value().to_string());
@ -45,6 +56,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
} else { } else {
front_page.to_owned() front_page.to_owned()
}); });
let quarantined = can_access_quarantine(&req, &sub) || root;
// Handle random subreddits // Handle random subreddits
if let Ok(random) = catch_random(&sub, "").await { if let Ok(random) = catch_random(&sub, "").await {
@ -57,16 +69,16 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub, sort, req.uri().query().unwrap_or_default()); let path = format!("/r/{}/{}.json?{}&raw_json=1", sub, sort, req.uri().query().unwrap_or_default());
match Post::fetch(&path, String::new()).await { match Post::fetch(&path, String::new(), quarantined).await {
Ok((posts, after)) => { Ok((posts, after)) => {
// If you can get subreddit posts, also request subreddit metadata // If you can get subreddit posts, also request subreddit metadata
let sub = if !sub.contains('+') && sub != subscribed && sub != "popular" && sub != "all" { let sub = if !sub.contains('+') && sub != subscribed && sub != "popular" && sub != "all" {
// Regular subreddit // Regular subreddit
subreddit(&sub).await.unwrap_or_default() subreddit(&sub, quarantined).await.unwrap_or_default()
} else if sub == subscribed { } else if sub == subscribed {
// Subscription feed // Subscription feed
if req.uri().path().starts_with("/r/") { if req.uri().path().starts_with("/r/") {
subreddit(&sub).await.unwrap_or_default() subreddit(&sub, quarantined).await.unwrap_or_default()
} else { } else {
Subreddit::default() Subreddit::default()
} }
@ -85,14 +97,14 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
template(SubredditTemplate { template(SubredditTemplate {
sub, sub,
posts, posts,
sort: (sort, param(&path, "t")), sort: (sort, param(&path, "t").unwrap_or_default()),
ends: (param(&path, "after"), after), ends: (param(&path, "after").unwrap_or_default(), after),
prefs: Preferences::new(req), prefs: Preferences::new(req),
url, url,
}) })
} }
Err(msg) => match msg.as_str() { Err(msg) => match msg.as_str() {
"quarantined" => error(req, format!("r/{} has been quarantined by Reddit", sub)).await, "quarantined" => quarantine(req, sub),
"private" => error(req, format!("r/{} is a private community", sub)).await, "private" => error(req, format!("r/{} is a private community", sub)).await,
"banned" => error(req, format!("r/{} has been banned from Reddit", sub)).await, "banned" => error(req, format!("r/{} has been banned from Reddit", sub)).await,
_ => error(req, msg).await, _ => error(req, msg).await,
@ -100,6 +112,43 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
} }
} }
pub fn quarantine(req: Request<Body>, sub: String) -> Result<Response<Body>, String> {
let wall = WallTemplate {
title: format!("r/{} is quarantined", sub),
msg: "Please click the button below to continue to this subreddit.".to_string(),
url: req.uri().to_string(),
sub,
prefs: Preferences::new(req),
};
Ok(
Response::builder()
.status(403)
.header("content-type", "text/html")
.body(wall.render().unwrap_or_default().into())
.unwrap_or_default(),
)
}
pub async fn add_quarantine_exception(req: Request<Body>) -> Result<Response<Body>, String> {
let subreddit = req.param("sub").ok_or("Invalid URL")?;
let redir = param(&format!("?{}", req.uri().query().unwrap_or_default()), "redir").ok_or("Invalid URL")?;
let mut response = redirect(redir);
response.insert_cookie(
Cookie::build(&format!("allow_quaran_{}", subreddit.to_lowercase()), "true")
.path("/")
.http_only(true)
.expires(cookie::Expiration::Session)
.finish(),
);
Ok(response)
}
pub fn can_access_quarantine(req: &Request<Body>, sub: &str) -> bool {
// Determine if the subreddit can be accessed
setting(&req, &format!("allow_quaran_{}", sub.to_lowercase())).parse().unwrap_or_default()
}
// Sub or unsub by setting subscription cookie using response "Set-Cookie" header // Sub or unsub by setting subscription cookie using response "Set-Cookie" header
pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String> { pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String> {
let sub = req.param("sub").unwrap_or_default(); let sub = req.param("sub").unwrap_or_default();
@ -114,16 +163,19 @@ pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String>
let mut sub_list = Preferences::new(req).subscriptions; let mut sub_list = Preferences::new(req).subscriptions;
// Retrieve list of posts for these subreddits to extract display names // Retrieve list of posts for these subreddits to extract display names
let display = json(format!("/r/{}/hot.json?raw_json=1", sub)).await?; let posts = json(format!("/r/{}/hot.json?raw_json=1", sub), true).await?;
let display_lookup: Vec<(String, &str)> = display["data"]["children"] let display_lookup: Vec<(String, &str)> = posts["data"]["children"]
.as_array() .as_array()
.unwrap() .map(|list| {
.iter() list
.map(|post| { .iter()
let display_name = post["data"]["subreddit"].as_str().unwrap(); .map(|post| {
(display_name.to_lowercase(), display_name) let display_name = post["data"]["subreddit"].as_str().unwrap_or_default();
(display_name.to_lowercase(), display_name)
})
.collect::<Vec<_>>()
}) })
.collect(); .unwrap_or_default();
// Find each subreddit name (separated by '+') in sub parameter // Find each subreddit name (separated by '+') in sub parameter
for part in sub.split('+') { for part in sub.split('+') {
@ -135,7 +187,7 @@ pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String>
} else { } else {
// This subreddit display name isn't known, retrieve it // This subreddit display name isn't known, retrieve it
let path: String = format!("/r/{}/about.json?raw_json=1", part); let path: String = format!("/r/{}/about.json?raw_json=1", part);
display = json(path).await?; display = json(path, true).await?;
display["data"]["display_name"].as_str().ok_or_else(|| "Failed to query subreddit name".to_string())? display["data"]["display_name"].as_str().ok_or_else(|| "Failed to query subreddit name".to_string())?
}; };
@ -147,26 +199,25 @@ pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String>
sub_list.sort_by_key(|a| a.to_lowercase()) sub_list.sort_by_key(|a| a.to_lowercase())
} else if action.contains(&"unsubscribe".to_string()) { } else if action.contains(&"unsubscribe".to_string()) {
// Remove sub name from subscribed list // Remove sub name from subscribed list
sub_list.retain(|s| s != part); sub_list.retain(|s| s.to_lowercase() != part.to_lowercase());
} }
} }
// Redirect back to subreddit // Redirect back to subreddit
// check for redirect parameter if unsubscribing from outside sidebar // check for redirect parameter if unsubscribing from outside sidebar
let redirect_path = param(&format!("/?{}", query), "redirect"); let path = if let Some(redirect_path) = param(&format!("?{}", query), "redirect") {
let path = if redirect_path.is_empty() {
format!("/r/{}", sub)
} else {
format!("/{}/", redirect_path) format!("/{}/", redirect_path)
} else {
format!("/r/{}", sub)
}; };
let mut res = redirect(path); let mut response = redirect(path);
// Delete cookie if empty, else set // Delete cookie if empty, else set
if sub_list.is_empty() { if sub_list.is_empty() {
res.remove_cookie("subscriptions".to_string()); response.remove_cookie("subscriptions".to_string());
} else { } else {
res.insert_cookie( response.insert_cookie(
Cookie::build("subscriptions", sub_list.join("+")) Cookie::build("subscriptions", sub_list.join("+"))
.path("/") .path("/")
.http_only(true) .http_only(true)
@ -175,11 +226,12 @@ pub async fn subscriptions(req: Request<Body>) -> Result<Response<Body>, String>
); );
} }
Ok(res) Ok(response)
} }
pub async fn wiki(req: Request<Body>) -> Result<Response<Body>, String> { pub async fn wiki(req: Request<Body>) -> Result<Response<Body>, String> {
let sub = req.param("sub").unwrap_or_else(|| "reddit.com".to_string()); let sub = req.param("sub").unwrap_or_else(|| "reddit.com".to_string());
let quarantined = can_access_quarantine(&req, &sub);
// Handle random subreddits // Handle random subreddits
if let Ok(random) = catch_random(&sub, "/wiki").await { if let Ok(random) = catch_random(&sub, "/wiki").await {
return Ok(random); return Ok(random);
@ -188,19 +240,27 @@ pub async fn wiki(req: Request<Body>) -> Result<Response<Body>, String> {
let page = req.param("page").unwrap_or_else(|| "index".to_string()); let page = req.param("page").unwrap_or_else(|| "index".to_string());
let path: String = format!("/r/{}/wiki/{}.json?raw_json=1", sub, page); let path: String = format!("/r/{}/wiki/{}.json?raw_json=1", sub, page);
match json(path).await { match json(path, quarantined).await {
Ok(response) => template(WikiTemplate { Ok(response) => template(WikiTemplate {
sub, sub,
wiki: rewrite_urls(response["data"]["content_html"].as_str().unwrap_or("<h3>Wiki not found</h3>")), wiki: rewrite_urls(response["data"]["content_html"].as_str().unwrap_or("<h3>Wiki not found</h3>")),
page, page,
prefs: Preferences::new(req), prefs: Preferences::new(req),
}), }),
Err(msg) => error(req, msg).await, Err(msg) => {
if msg == "quarantined" {
quarantine(req, sub)
} else {
error(req, msg).await
}
}
} }
} }
pub async fn sidebar(req: Request<Body>) -> Result<Response<Body>, String> { pub async fn sidebar(req: Request<Body>) -> Result<Response<Body>, String> {
let sub = req.param("sub").unwrap_or_else(|| "reddit.com".to_string()); let sub = req.param("sub").unwrap_or_else(|| "reddit.com".to_string());
let quarantined = can_access_quarantine(&req, &sub);
// Handle random subreddits // Handle random subreddits
if let Ok(random) = catch_random(&sub, "/about/sidebar").await { if let Ok(random) = catch_random(&sub, "/about/sidebar").await {
return Ok(random); return Ok(random);
@ -210,26 +270,32 @@ pub async fn sidebar(req: Request<Body>) -> Result<Response<Body>, String> {
let path: String = format!("/r/{}/about.json?raw_json=1", sub); let path: String = format!("/r/{}/about.json?raw_json=1", sub);
// Send a request to the url // Send a request to the url
match json(path).await { match json(path, quarantined).await {
// If success, receive JSON in response // If success, receive JSON in response
Ok(response) => template(WikiTemplate { Ok(response) => template(WikiTemplate {
wiki: format!( wiki: format!(
"{}<hr><h1>Moderators</h1><br><ul>{}</ul>", "{}<hr><h1>Moderators</h1><br><ul>{}</ul>",
rewrite_urls(&val(&response, "description_html").replace("\\", "")), rewrite_urls(&val(&response, "description_html").replace("\\", "")),
moderators(&sub).await?.join(""), moderators(&sub, quarantined).await?.join(""),
), ),
sub, sub,
page: "Sidebar".to_string(), page: "Sidebar".to_string(),
prefs: Preferences::new(req), prefs: Preferences::new(req),
}), }),
Err(msg) => error(req, msg).await, Err(msg) => {
if msg == "quarantined" {
quarantine(req, sub)
} else {
error(req, msg).await
}
}
} }
} }
pub async fn moderators(sub: &str) -> Result<Vec<String>, String> { pub async fn moderators(sub: &str, quarantined: bool) -> Result<Vec<String>, String> {
// Retrieve and format the html for the moderators list // Retrieve and format the html for the moderators list
Ok( Ok(
moderators_list(sub) moderators_list(sub, quarantined)
.await? .await?
.iter() .iter()
.map(|m| format!("<li><a style=\"color: var(--accent)\" href=\"/u/{name}\">{name}</a></li>", name = m)) .map(|m| format!("<li><a style=\"color: var(--accent)\" href=\"/u/{name}\">{name}</a></li>", name = m))
@ -237,15 +303,14 @@ pub async fn moderators(sub: &str) -> Result<Vec<String>, String> {
) )
} }
async fn moderators_list(sub: &str) -> Result<Vec<String>, String> { async fn moderators_list(sub: &str, quarantined: bool) -> Result<Vec<String>, String> {
// Build the moderator list URL // Build the moderator list URL
let path: String = format!("/r/{}/about/moderators.json?raw_json=1", sub); let path: String = format!("/r/{}/about/moderators.json?raw_json=1", sub);
// Retrieve response // Retrieve response
let response = json(path).await?["data"]["children"].clone(); json(path, quarantined).await.map(|response| {
Ok(
// Traverse json tree and format into list of strings // Traverse json tree and format into list of strings
response response["data"]["children"]
.as_array() .as_array()
.unwrap_or(&Vec::new()) .unwrap_or(&Vec::new())
.iter() .iter()
@ -257,42 +322,35 @@ async fn moderators_list(sub: &str) -> Result<Vec<String>, String> {
Some(name.to_string()) Some(name.to_string())
} }
}) })
.collect::<Vec<_>>(), .collect::<Vec<_>>()
) })
} }
// SUBREDDIT // SUBREDDIT
async fn subreddit(sub: &str) -> Result<Subreddit, String> { async fn subreddit(sub: &str, quarantined: bool) -> Result<Subreddit, String> {
// Build the Reddit JSON API url // Build the Reddit JSON API url
let path: String = format!("/r/{}/about.json?raw_json=1", sub); let path: String = format!("/r/{}/about.json?raw_json=1", sub);
// Send a request to the url // Send a request to the url
match json(path).await { let res = json(path, quarantined).await?;
// If success, receive JSON in response
Ok(res) => {
// 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;
// Fetch subreddit icon either from the community_icon or icon_img value // Metadata regarding the subreddit
let community_icon: &str = res["data"]["community_icon"].as_str().unwrap(); let members: i64 = res["data"]["subscribers"].as_u64().unwrap_or_default() as i64;
let icon = if community_icon.is_empty() { val(&res, "icon_img") } else { community_icon.to_string() }; let active: i64 = res["data"]["accounts_active"].as_u64().unwrap_or_default() as i64;
let sub = Subreddit { // Fetch subreddit icon either from the community_icon or icon_img value
name: esc!(&res, "display_name"), let community_icon: &str = res["data"]["community_icon"].as_str().unwrap_or_default();
title: esc!(&res, "title"), let icon = if community_icon.is_empty() { val(&res, "icon_img") } else { community_icon.to_string() };
description: esc!(&res, "public_description"),
info: rewrite_urls(&val(&res, "description_html").replace("\\", "")),
moderators: moderators_list(sub).await?,
icon: format_url(&icon),
members: format_num(members),
active: format_num(active),
wiki: res["data"]["wiki_enabled"].as_bool().unwrap_or_default(),
};
Ok(sub) Ok(Subreddit {
} name: esc!(&res, "display_name"),
// If the Reddit API returns an error, exit this function title: esc!(&res, "title"),
Err(msg) => return Err(msg), description: esc!(&res, "public_description"),
} info: rewrite_urls(&val(&res, "description_html").replace("\\", "")),
moderators: moderators_list(sub, quarantined).await?,
icon: format_url(&icon),
members: format_num(members),
active: format_num(active),
wiki: res["data"]["wiki_enabled"].as_bool().unwrap_or_default(),
})
} }

View File

@ -29,11 +29,11 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
); );
// Retrieve other variables from Libreddit request // Retrieve other variables from Libreddit request
let sort = param(&path, "sort"); let sort = param(&path, "sort").unwrap_or_default();
let username = req.param("name").unwrap_or_default(); let username = req.param("name").unwrap_or_default();
// Request user posts/comments from Reddit // Request user posts/comments from Reddit
let posts = Post::fetch(&path, "Comment".to_string()).await; let posts = Post::fetch(&path, "Comment".to_string(), false).await;
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str())); let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
match posts { match posts {
@ -44,8 +44,8 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
template(UserTemplate { template(UserTemplate {
user, user,
posts, posts,
sort: (sort, param(&path, "t")), sort: (sort, param(&path, "t").unwrap_or_default()),
ends: (param(&path, "after"), after), ends: (param(&path, "after").unwrap_or_default(), after),
prefs: Preferences::new(req), prefs: Preferences::new(req),
url, url,
}) })
@ -61,27 +61,22 @@ async fn user(name: &str) -> Result<User, String> {
let path: String = format!("/user/{}/about.json?raw_json=1", name); let path: String = format!("/user/{}/about.json?raw_json=1", name);
// Send a request to the url // Send a request to the url
match json(path).await { json(path, false).await.map(|res| {
// If success, receive JSON in response // Grab creation date as unix timestamp
Ok(res) => { let created: i64 = res["data"]["created"].as_f64().unwrap_or(0.0).round() as i64;
// Grab creation date as unix timestamp
let created: i64 = res["data"]["created"].as_f64().unwrap_or(0.0).round() as i64;
// Closure used to parse JSON from Reddit APIs // Closure used to parse JSON from Reddit APIs
let about = |item| res["data"]["subreddit"][item].as_str().unwrap_or_default().to_string(); let about = |item| res["data"]["subreddit"][item].as_str().unwrap_or_default().to_string();
// Parse the JSON output into a User struct // Parse the JSON output into a User struct
Ok(User { User {
name: name.to_string(), name: name.to_string(),
title: esc!(about("title")), title: esc!(about("title")),
icon: format_url(&about("icon_img")), icon: format_url(&about("icon_img")),
karma: res["data"]["total_karma"].as_i64().unwrap_or(0), karma: res["data"]["total_karma"].as_i64().unwrap_or(0),
created: OffsetDateTime::from_unix_timestamp(created).format("%b %d '%y"), created: OffsetDateTime::from_unix_timestamp(created).format("%b %d '%y"),
banner: esc!(about("banner_img")), banner: esc!(about("banner_img")),
description: about("public_description"), description: about("public_description"),
})
} }
// If the Reddit API returns an error, exit this function })
Err(msg) => return Err(msg),
}
} }

View File

@ -217,12 +217,12 @@ pub struct Post {
impl Post { impl Post {
// Fetch posts of a user or subreddit and return a vector of posts and the "after" value // Fetch posts of a user or subreddit and return a vector of posts and the "after" value
pub async fn fetch(path: &str, fallback_title: String) -> Result<(Vec<Self>, String), String> { pub async fn fetch(path: &str, fallback_title: String, quarantine: bool) -> Result<(Vec<Self>, String), String> {
let res; let res;
let post_list; let post_list;
// Send a request to the url // Send a request to the url
match json(path.to_string()).await { match json(path.to_string(), quarantine).await {
// If success, receive JSON in response // If success, receive JSON in response
Ok(response) => { Ok(response) => {
res = response; res = response;
@ -416,11 +416,16 @@ impl Preferences {
// //
// Grab a query parameter from a url // Grab a query parameter from a url
pub fn param(path: &str, value: &str) -> String { pub fn param(path: &str, value: &str) -> Option<String> {
match Url::parse(format!("https://libredd.it/{}", path).as_str()) { Some(
Ok(url) => url.query_pairs().into_owned().collect::<HashMap<_, _>>().get(value).unwrap_or(&String::new()).to_owned(), Url::parse(format!("https://libredd.it/{}", path).as_str())
_ => String::new(), .ok()?
} .query_pairs()
.into_owned()
.collect::<HashMap<_, _>>()
.get(value)?
.to_owned(),
)
} }
// Retrieve the value of a setting by name // Retrieve the value of a setting by name
@ -443,7 +448,7 @@ pub fn setting(req: &Request<Body>, name: &str) -> String {
// Detect and redirect in the event of a random subreddit // Detect and redirect in the event of a random subreddit
pub async fn catch_random(sub: &str, additional: &str) -> Result<Response<Body>, String> { pub async fn catch_random(sub: &str, additional: &str) -> Result<Response<Body>, String> {
if (sub == "random" || sub == "randnsfw") && !sub.contains('+') { if (sub == "random" || sub == "randnsfw") && !sub.contains('+') {
let new_sub = json(format!("/r/{}/about.json?raw_json=1", sub)).await?["data"]["display_name"] let new_sub = json(format!("/r/{}/about.json?raw_json=1", sub), false).await?["data"]["display_name"]
.as_str() .as_str()
.unwrap_or_default() .unwrap_or_default()
.to_string(); .to_string();
@ -458,83 +463,71 @@ pub fn format_url(url: &str) -> String {
if url.is_empty() || url == "self" || url == "default" || url == "nsfw" || url == "spoiler" { if url.is_empty() || url == "self" || url == "default" || url == "nsfw" || url == "spoiler" {
String::new() String::new()
} else { } else {
match Url::parse(url) { Url::parse(url).map_or(String::new(), |parsed| {
Ok(parsed) => { let domain = parsed.domain().unwrap_or_default();
let domain = parsed.domain().unwrap_or_default();
let capture = |regex: &str, format: &str, segments: i16| { let capture = |regex: &str, format: &str, segments: i16| {
Regex::new(regex) Regex::new(regex).map_or(String::new(), |re| {
.map(|re| match re.captures(url) { re.captures(url).map_or(String::new(), |caps| match segments {
Some(caps) => match segments { 1 => [format, &caps[1]].join(""),
1 => [format, &caps[1]].join(""), 2 => [format, &caps[1], "/", &caps[2]].join(""),
2 => [format, &caps[1], "/", &caps[2]].join(""), _ => String::new(),
_ => String::new(), })
}, })
None => String::new(), };
})
.unwrap_or_default() macro_rules! chain {
() => {
{
String::new()
}
}; };
macro_rules! chain { ( $first_fn:expr, $($other_fns:expr), *) => {
() => { {
{ let result = $first_fn;
String::new() if result.is_empty() {
chain!($($other_fns,)*)
} }
}; else
( $first_fn:expr, $($other_fns:expr), *) => {
{ {
let result = $first_fn; result
if result.is_empty() {
chain!($($other_fns,)*)
}
else
{
result
}
} }
}; }
} };
match domain {
"v.redd.it" => chain!(
capture(r"https://v\.redd\.it/(.*)/DASH_([0-9]{2,4}(\.mp4|$))", "/vid/", 2),
capture(r"https://v\.redd\.it/(.+)/(HLSPlaylist\.m3u8.*)$", "/hls/", 2)
),
"i.redd.it" => capture(r"https://i\.redd\.it/(.*)", "/img/", 1),
"a.thumbs.redditmedia.com" => capture(r"https://a\.thumbs\.redditmedia\.com/(.*)", "/thumb/a/", 1),
"b.thumbs.redditmedia.com" => capture(r"https://b\.thumbs\.redditmedia\.com/(.*)", "/thumb/b/", 1),
"emoji.redditmedia.com" => capture(r"https://emoji\.redditmedia\.com/(.*)/(.*)", "/emoji/", 2),
"preview.redd.it" => capture(r"https://preview\.redd\.it/(.*)", "/preview/pre/", 1),
"external-preview.redd.it" => capture(r"https://external\-preview\.redd\.it/(.*)", "/preview/external-pre/", 1),
"styles.redditmedia.com" => capture(r"https://styles\.redditmedia\.com/(.*)", "/style/", 1),
"www.redditstatic.com" => capture(r"https://www\.redditstatic\.com/(.*)", "/static/", 1),
_ => String::new(),
}
} }
Err(_) => String::new(),
} match domain {
"v.redd.it" => chain!(
capture(r"https://v\.redd\.it/(.*)/DASH_([0-9]{2,4}(\.mp4|$))", "/vid/", 2),
capture(r"https://v\.redd\.it/(.+)/(HLSPlaylist\.m3u8.*)$", "/hls/", 2)
),
"i.redd.it" => capture(r"https://i\.redd\.it/(.*)", "/img/", 1),
"a.thumbs.redditmedia.com" => capture(r"https://a\.thumbs\.redditmedia\.com/(.*)", "/thumb/a/", 1),
"b.thumbs.redditmedia.com" => capture(r"https://b\.thumbs\.redditmedia\.com/(.*)", "/thumb/b/", 1),
"emoji.redditmedia.com" => capture(r"https://emoji\.redditmedia\.com/(.*)/(.*)", "/emoji/", 2),
"preview.redd.it" => capture(r"https://preview\.redd\.it/(.*)", "/preview/pre/", 1),
"external-preview.redd.it" => capture(r"https://external\-preview\.redd\.it/(.*)", "/preview/external-pre/", 1),
"styles.redditmedia.com" => capture(r"https://styles\.redditmedia\.com/(.*)", "/style/", 1),
"www.redditstatic.com" => capture(r"https://www\.redditstatic\.com/(.*)", "/static/", 1),
_ => String::new(),
}
})
} }
} }
// Rewrite Reddit links to Libreddit in body of text // Rewrite Reddit links to Libreddit in body of text
pub fn rewrite_urls(input_text: &str) -> String { pub fn rewrite_urls(input_text: &str) -> String {
let text1 = match Regex::new(r#"href="(https|http|)://(www.|old.|np.|amp.|)(reddit).(com)/"#) { let text1 = Regex::new(r#"href="(https|http|)://(www.|old.|np.|amp.|)(reddit).(com)/"#).map_or(String::new(), |re| re.replace_all(input_text, r#"href="/"#).to_string());
Ok(re) => re.replace_all(input_text, r#"href="/"#).to_string(),
Err(_) => String::new(),
};
// Rewrite external media previews to Libreddit // Rewrite external media previews to Libreddit
match Regex::new(r"https://external-preview\.redd\.it(.*)[^?]") { Regex::new(r"https://external-preview\.redd\.it(.*)[^?]").map_or(String::new(), |re| {
Ok(re) => { if re.is_match(&text1) {
if re.is_match(&text1) { re.replace_all(&text1, format_url(re.find(&text1).map(|x| x.as_str()).unwrap_or_default())).to_string()
re.replace_all(&text1, format_url(re.find(&text1).unwrap().as_str())).to_string() } else {
} else { text1
text1
}
} }
Err(_) => String::new(), })
}
} }
// Append `m` and `k` for millions and thousands respectively // Append `m` and `k` for millions and thousands respectively

BIN
static/Inter.var.woff2 Normal file

Binary file not shown.

View File

@ -6,6 +6,12 @@
--admin: #ea0027; --admin: #ea0027;
} }
@font-face {
font-family: 'Inter';
src: url('/Inter.var.woff2') format('woff2-variations');
font-style: normal;
}
/* Automatic theme selection */ /* Automatic theme selection */
:root, .dark{ :root, .dark{
/* Default & fallback theme (dark) */ /* Default & fallback theme (dark) */
@ -150,7 +156,7 @@ html, body, div, h1, h2, h3, h4, h5, h6, ul, ol, dl, li, dt, dd, p, blockquote,
pre, form, fieldset, table, th, td, select, input { pre, form, fieldset, table, th, td, select, input {
margin: 0; margin: 0;
color: var(--text); color: var(--text);
font-family: sans-serif; font-family: "Inter";
} }
body { body {

View File

@ -57,7 +57,7 @@
{% macro render_hls_notification(redirect_url) -%} {% macro render_hls_notification(redirect_url) -%}
{% if post.post_type == "video" && !post.media.alt_url.is_empty() && prefs.hide_hls_notification != "on" %} {% if post.post_type == "video" && !post.media.alt_url.is_empty() && prefs.hide_hls_notification != "on" %}
<div class="post_notification"><p><a href="/settings/update/?use_hls=on&redirect={{ redirect_url }}">Enable HSL</a> to view with audio, or <a href="/settings/update/?hide_hls_notification=on&redirect={{ redirect_url }}">disable this notification</a></p></div> <div class="post_notification"><p><a href="/settings/update/?use_hls=on&redirect={{ redirect_url }}">Enable HLS</a> to view with audio, or <a href="/settings/update/?hide_hls_notification=on&redirect={{ redirect_url }}">disable this notification</a></p></div>
{% endif %} {% endif %}
{%- endmacro %} {%- endmacro %}

13
templates/wall.html Normal file
View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}{{ msg }}{% endblock %}
{% block sortstyle %}{% endblock %}
{% block content %}
<div id="wall">
<h1>{{ title }}</h1>
<br>
<p>{{ msg }}</p>
<form action="/r/{{ sub }}?redir={{ url }}" method="POST">
<input id="save" type="submit" value="Continue">
</form>
</div>
{% endblock %}