Move from Actix Web to Tide (#99)

* Initial commit

* Port posts

* Pinpoint Tide Bug

* Revert testing

* Add basic sub support

* Unwrap nested routes

* Front page & sync templates

* Port remaining functions

* Log request errors

* Clean main and settings

* Handle /w/ requests

* Create template() util

* Reduce caching time to 30s

* Fix subscription redirects

* Handle frontpage sorting
This commit is contained in:
Spike
2021-02-09 09:38:52 -08:00
committed by GitHub
parent 402b3149e1
commit ebbdd7185f
15 changed files with 1283 additions and 1189 deletions

1742
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,15 +8,15 @@ authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
edition = "2018"
[dependencies]
tide = "0.16"
async-std = { version = "1", features = ["attributes"] }
surf = "2"
base64 = "0.13"
actix-web = { version = "3.3", features = ["rustls"] }
cached = "0.23"
futures = "0.3"
askama = "0.10"
ureq = "2"
serde = { version = "1.0", default_features = false, features = ["derive"] }
serde_json = "1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-recursion = "0.3"
url = "2.2"
regex = "1.4"
regex = "1"
time = "0.2"
cached = "0.23"

View File

@ -1,4 +1,4 @@
edition = "2018"
tab_spaces = 2
hard_tabs = true
max_width = 175
max_width = 150

View File

@ -1,9 +1,7 @@
// Import Crates
use actix_web::{
dev::{Service, ServiceResponse},
middleware, web, App, HttpResponse, HttpServer,
};
use futures::future::FutureExt;
// use askama::filters::format;
use surf::utils::async_trait;
use tide::{utils::After, Middleware, Next, Request, Response};
// Reference local files
mod post;
@ -14,43 +12,103 @@ mod subreddit;
mod user;
mod utils;
// Build middleware
struct HttpsRedirect<HttpsOnly>(HttpsOnly);
struct NormalizePath;
#[async_trait]
impl<State, HttpsOnly> Middleware<State> for HttpsRedirect<HttpsOnly>
where
State: Clone + Send + Sync + 'static,
HttpsOnly: Into<bool> + Copy + Send + Sync + 'static,
{
async fn handle(&self, request: Request<State>, next: Next<'_, State>) -> tide::Result {
let secure = request.url().scheme() == "https";
if self.0.into() && !secure {
let mut secured = request.url().to_owned();
secured.set_scheme("https").unwrap_or_default();
Ok(Response::builder(302).header("Location", secured.to_string()).build())
} else {
Ok(next.run(request).await)
}
}
}
#[async_trait]
impl<State: Clone + Send + Sync + 'static> Middleware<State> for NormalizePath {
async fn handle(&self, request: Request<State>, next: Next<'_, State>) -> tide::Result {
if !request.url().path().ends_with('/') {
Ok(Response::builder(301).header("Location", format!("{}/", request.url().path())).build())
} else {
Ok(next.run(request).await)
}
}
}
// Create Services
async fn style() -> HttpResponse {
HttpResponse::Ok().content_type("text/css").body(include_str!("../static/style.css"))
async fn style(_req: Request<()>) -> tide::Result {
Ok(
Response::builder(200)
.content_type("text/css")
.body(include_str!("../static/style.css"))
.build(),
)
}
// Required for creating a PWA
async fn manifest() -> HttpResponse {
HttpResponse::Ok().content_type("application/json").body(include_str!("../static/manifest.json"))
async fn manifest(_req: Request<()>) -> tide::Result {
Ok(
Response::builder(200)
.content_type("application/json")
.body(include_str!("../static/manifest.json"))
.build(),
)
}
// Required for the manifest to be valid
async fn pwa_logo() -> HttpResponse {
HttpResponse::Ok().content_type("image/png").body(include_bytes!("../static/logo.png").as_ref())
async fn pwa_logo(_req: Request<()>) -> tide::Result {
Ok(
Response::builder(200)
.content_type("image/png")
.body(include_bytes!("../static/logo.png").as_ref())
.build(),
)
}
// Required for iOS App Icons
async fn iphone_logo() -> HttpResponse {
HttpResponse::Ok()
async fn iphone_logo(_req: Request<()>) -> tide::Result {
Ok(
Response::builder(200)
.content_type("image/png")
.body(include_bytes!("../static/touch-icon-iphone.png").as_ref())
.build(),
)
}
async fn robots() -> HttpResponse {
HttpResponse::Ok()
async fn robots(_req: Request<()>) -> tide::Result {
Ok(
Response::builder(200)
.content_type("text/plain")
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
.body("User-agent: *\nAllow: /")
.build(),
)
}
async fn favicon() -> HttpResponse {
HttpResponse::Ok()
.content_type("image/x-icon")
async fn favicon(_req: Request<()>) -> tide::Result {
Ok(
Response::builder(200)
.content_type("image/vnd.microsoft.icon")
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
.body(include_bytes!("../static/favicon.ico").as_ref())
.build(),
)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
#[async_std::main]
async fn main() -> tide::Result<()> {
let mut address = "0.0.0.0:8080".to_string();
let mut force_https = false;
@ -62,101 +120,96 @@ async fn main() -> std::io::Result<()> {
}
}
// start http server
// Start HTTP server
println!("Running Libreddit v{} on {}!", env!("CARGO_PKG_VERSION"), &address);
HttpServer::new(move || {
App::new()
let mut app = tide::new();
// Redirect to HTTPS if "--redirect-https" enabled
.wrap_fn(move |req, srv| {
let secure = req.connection_info().scheme() == "https";
let https_url = format!("https://{}{}", req.connection_info().host(), req.uri().to_string());
srv.call(req).map(move |res: Result<ServiceResponse, _>| {
if force_https && !secure {
Ok(ServiceResponse::new(
res.unwrap().request().to_owned(),
HttpResponse::Found().header("Location", https_url).finish(),
))
} else {
res
}
})
})
app.with(HttpsRedirect(force_https));
// Append trailing slash and remove double slashes
.wrap(middleware::NormalizePath::default())
app.with(NormalizePath);
// Apply default headers for security
.wrap(
middleware::DefaultHeaders::new()
.header("Referrer-Policy", "no-referrer")
.header("X-Content-Type-Options", "nosniff")
.header("X-Frame-Options", "DENY")
.header(
app.with(After(|mut res: Response| async move {
res.insert_header("Referrer-Policy", "no-referrer");
res.insert_header("X-Content-Type-Options", "nosniff");
res.insert_header("X-Frame-Options", "DENY");
res.insert_header(
"Content-Security-Policy",
"default-src 'none'; manifest-src 'self'; media-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none';",
),
)
// Default service in case no routes match
.default_service(web::get().to(|| utils::error("Nothing here".to_string())))
);
Ok(res)
}));
// Read static files
.route("/style.css/", web::get().to(style))
.route("/favicon.ico/", web::get().to(favicon))
.route("/robots.txt/", web::get().to(robots))
.route("/manifest.json/", web::get().to(manifest))
.route("/logo.png/", web::get().to(pwa_logo))
.route("/touch-icon-iphone.png/", web::get().to(iphone_logo))
app.at("/style.css/").get(style);
app.at("/favicon.ico/").get(favicon);
app.at("/robots.txt/").get(robots);
app.at("/manifest.json/").get(manifest);
app.at("/logo.png/").get(pwa_logo);
app.at("/touch-icon-iphone.png/").get(iphone_logo);
// Proxy media through Libreddit
.route("/proxy/{url:.*}/", web::get().to(proxy::handler))
app.at("/proxy/*url/").get(proxy::handler);
// Browse user profile
.service(
web::scope("/{scope:user|u}").service(
web::scope("/{username}").route("/", web::get().to(user::profile)).service(
web::scope("/comments/{id}/{title}")
.route("/", web::get().to(post::item))
.route("/{comment_id}/", web::get().to(post::item)),
),
),
)
app.at("/u/:name/").get(user::profile);
app.at("/u/:name/comments/:id/:title/").get(post::item);
app.at("/u/:name/comments/:id/:title/:comment/").get(post::item);
app.at("/user/:name/").get(user::profile);
app.at("/user/:name/comments/:id/:title/").get(post::item);
app.at("/user/:name/comments/:id/:title/:comment/").get(post::item);
// Configure settings
.service(web::resource("/settings/").route(web::get().to(settings::get)).route(web::post().to(settings::set)))
app.at("/settings/").get(settings::get).post(settings::set);
// Subreddit services
.service(
web::scope("/r/{sub}")
// See posts and info about subreddit
.route("/", web::get().to(subreddit::page))
.route("/{sort:hot|new|top|rising|controversial}/", web::get().to(subreddit::page))
app.at("/r/:sub/").get(subreddit::page);
// Handle subscribe/unsubscribe
.route("/{action:subscribe|unsubscribe}/", web::post().to(subreddit::subscriptions))
app.at("/r/:sub/subscribe/").post(subreddit::subscriptions);
app.at("/r/:sub/unsubscribe/").post(subreddit::subscriptions);
// View post on subreddit
.service(
web::scope("/comments/{id}/{title}")
.route("/", web::get().to(post::item))
.route("/{comment_id}/", web::get().to(post::item)),
)
app.at("/r/:sub/comments/:id/:title/").get(post::item);
app.at("/r/:sub/comments/:id/:title/:comment_id/").get(post::item);
// Search inside subreddit
.route("/search/", web::get().to(search::find))
app.at("/r/:sub/search/").get(search::find);
// View wiki of subreddit
.service(
web::scope("/{scope:wiki|w}")
.route("/", web::get().to(subreddit::wiki))
.route("/{page}/", web::get().to(subreddit::wiki)),
),
)
app.at("/r/:sub/w/").get(subreddit::wiki);
app.at("/r/:sub/w/:page/").get(subreddit::wiki);
app.at("/r/:sub/wiki/").get(subreddit::wiki);
app.at("/r/:sub/wiki/:page/").get(subreddit::wiki);
// Sort subreddit posts
app.at("/r/:sub/:sort/").get(subreddit::page);
// Front page
.route("/", web::get().to(subreddit::page))
.route("/{sort:best|hot|new|top|rising|controversial}/", web::get().to(subreddit::page))
app.at("/").get(subreddit::page);
// View Reddit wiki
.service(
web::scope("/wiki")
.route("/", web::get().to(subreddit::wiki))
.route("/{page}/", web::get().to(subreddit::wiki)),
)
app.at("/w/").get(subreddit::wiki);
app.at("/w/:page/").get(subreddit::wiki);
app.at("/wiki/").get(subreddit::wiki);
app.at("/wiki/:page/").get(subreddit::wiki);
// Search all of Reddit
.route("/search/", web::get().to(search::find))
app.at("/search/").get(search::find);
// Short link for post
.route("/{id:.{5,6}}/", web::get().to(post::item))
})
.bind(&address)
.unwrap_or_else(|e| panic!("Cannot bind to the address {}: {}", address, e))
.run()
.await
// .route("/{sort:best|hot|new|top|rising|controversial}/", web::get().to(subreddit::page))
// .route("/{id:.{5,6}}/", web::get().to(post::item))
app.at("/:id/").get(|req: Request<()>| async {
match req.param("id").unwrap_or_default() {
"best" | "hot" | "new" | "top" | "rising" | "controversial" => subreddit::page(req).await,
_ => post::item(req).await,
}
});
// Default service in case no routes match
app.at("*").get(|_| utils::error("Nothing here".to_string()));
app.listen("127.0.0.1:8080").await?;
Ok(())
}

View File

@ -1,6 +1,6 @@
// CRATES
use crate::utils::*;
use actix_web::{HttpRequest, HttpResponse};
use tide::Request;
use async_recursion::async_recursion;
@ -16,9 +16,9 @@ struct PostTemplate {
prefs: Preferences,
}
pub async fn item(req: HttpRequest) -> HttpResponse {
pub async fn item(req: Request<()>) -> tide::Result {
// Build Reddit API path
let mut path: String = format!("{}.json?{}&raw_json=1", req.path(), req.query_string());
let mut path: String = format!("{}.json?{}&raw_json=1", req.url().path(), req.url().query().unwrap_or_default());
// Set sort to sort query parameter
let mut sort: String = param(&path, "sort");
@ -29,12 +29,17 @@ pub async fn item(req: HttpRequest) -> HttpResponse {
// If there's no sort query but there's a default sort, set sort to default_sort
if sort.is_empty() && !default_sort.is_empty() {
sort = default_sort;
path = format!("{}.json?{}&sort={}&raw_json=1", req.path(), req.query_string(), sort);
path = format!(
"{}.json?{}&sort={}&raw_json=1",
req.url().path(),
req.url().query().unwrap_or_default(),
sort
);
}
// Log the post ID being fetched in debug mode
#[cfg(debug_assertions)]
dbg!(req.match_info().get("id").unwrap_or(""));
dbg!(req.param("id").unwrap_or(""));
// Send a request to the url, receive JSON in response
match request(path).await {
@ -45,15 +50,12 @@ pub async fn item(req: HttpRequest) -> HttpResponse {
let comments = parse_comments(&res[1]).await;
// Use the Post and Comment structs to generate a website to show users
let s = PostTemplate {
template(PostTemplate {
comments,
post,
sort,
prefs: prefs(req),
}
.render()
.unwrap();
HttpResponse::Ok().content_type("text/html").body(s)
})
}
// If the Reddit API returns an error, exit and send error page to user
Err(msg) => error(msg).await,

View File

@ -1,9 +1,8 @@
use actix_web::{client::Client, error, web, Error, HttpResponse, Result};
use url::Url;
use base64::decode;
use surf::{Body, Url};
use tide::{Request, Response};
pub async fn handler(web::Path(b64): web::Path<String>) -> Result<HttpResponse> {
pub async fn handler(req: Request<()>) -> tide::Result {
let domains = vec![
// THUMBNAILS
"a.thumbs.redditmedia.com",
@ -21,27 +20,31 @@ pub async fn handler(web::Path(b64): web::Path<String>) -> Result<HttpResponse>
"v.redd.it",
];
let decoded = decode(b64).map(|bytes| String::from_utf8(bytes).unwrap_or_default());
let decoded = decode(req.param("url").unwrap_or_default()).map(|bytes| String::from_utf8(bytes).unwrap_or_default());
match decoded {
Ok(media) => match Url::parse(media.as_str()) {
Ok(url) => {
let domain = url.domain().unwrap_or_default();
if domains.contains(&url.domain().unwrap_or_default()) {
let http = surf::get(url).await.unwrap();
if domains.contains(&domain) {
Client::default().get(media.replace("&amp;", "&")).send().await.map_err(Error::from).map(|res| {
HttpResponse::build(res.status())
let content_length = http.header("Content-Length").map(|v| v.to_string()).unwrap_or_default();
let content_type = http.content_type().map(|m| m.to_string()).unwrap_or_default();
Ok(
Response::builder(http.status())
.body(Body::from_reader(http, None))
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
.header("Content-Length", res.headers().get("Content-Length").unwrap().to_owned())
.header("Content-Type", res.headers().get("Content-Type").unwrap().to_owned())
.streaming(res)
})
.header("Content-Length", content_length)
.header("Content-Type", content_type)
.build(),
)
} else {
Err(error::ErrorForbidden("Resource must be from Reddit"))
Err(tide::Error::from_str(403, "Resource must be from Reddit"))
}
}
_ => Err(error::ErrorBadRequest("Can't parse base64 into URL")),
Err(_) => Err(tide::Error::from_str(400, "Can't parse base64 into URL")),
},
_ => Err(error::ErrorBadRequest("Can't decode base64")),
Err(_) => Err(tide::Error::from_str(400, "Can't decode base64")),
}
}

View File

@ -1,7 +1,7 @@
// CRATES
use crate::utils::{cookie, error, fetch_posts, param, prefs, request, val, Post, Preferences};
use actix_web::{HttpRequest, HttpResponse};
use crate::utils::{cookie, error, fetch_posts, param, prefs, request, template, val, Post, Preferences};
use askama::Template;
use tide::Request;
// STRUCTS
struct SearchParams {
@ -32,10 +32,10 @@ struct SearchTemplate {
}
// SERVICES
pub async fn find(req: HttpRequest) -> HttpResponse {
pub async fn find(req: Request<()>) -> tide::Result {
let nsfw_results = if cookie(&req, "show_nsfw") == "on" { "&include_over_18=on" } else { "" };
let path = format!("{}.json?{}{}", req.path(), req.query_string(), nsfw_results);
let sub = req.match_info().get("sub").unwrap_or("").to_string();
let path = format!("{}.json?{}{}", req.url().path(), req.url().query().unwrap_or_default(), nsfw_results);
let sub = req.param("sub").unwrap_or("").to_string();
let sort = if param(&path, "sort").is_empty() {
"relevance".to_string()
@ -50,8 +50,7 @@ pub async fn find(req: HttpRequest) -> HttpResponse {
};
match fetch_posts(&path, String::new()).await {
Ok((posts, after)) => HttpResponse::Ok().content_type("text/html").body(
SearchTemplate {
Ok((posts, after)) => template(SearchTemplate {
posts,
subreddits,
sub,
@ -64,10 +63,7 @@ pub async fn find(req: HttpRequest) -> HttpResponse {
restrict_sr: param(&path, "restrict_sr"),
},
prefs: prefs(req),
}
.render()
.unwrap(),
),
}),
Err(msg) => error(msg).await,
}
}

View File

@ -1,7 +1,7 @@
// CRATES
use crate::utils::{prefs, Preferences};
use actix_web::{cookie::Cookie, web::Form, HttpRequest, HttpResponse};
use crate::utils::{prefs, template, Preferences};
use askama::Template;
use tide::{http::Cookie, Request, Response};
use time::{Duration, OffsetDateTime};
// STRUCTS
@ -11,7 +11,7 @@ struct SettingsTemplate {
prefs: Preferences,
}
#[derive(serde::Deserialize)]
#[derive(serde::Deserialize, Default)]
pub struct SettingsForm {
theme: Option<String>,
front_page: Option<String>,
@ -24,33 +24,35 @@ pub struct SettingsForm {
// FUNCTIONS
// Retrieve cookies from request "Cookie" header
pub async fn get(req: HttpRequest) -> HttpResponse {
let s = SettingsTemplate { prefs: prefs(req) }.render().unwrap();
HttpResponse::Ok().content_type("text/html").body(s)
pub async fn get(req: Request<()>) -> tide::Result {
template(SettingsTemplate { prefs: prefs(req) })
}
// Set cookies using response "Set-Cookie" header
pub async fn set(_req: HttpRequest, form: Form<SettingsForm>) -> HttpResponse {
let mut res = HttpResponse::Found();
pub async fn set(mut req: Request<()>) -> tide::Result {
let form: SettingsForm = req.body_form().await.unwrap_or_default();
let mut res = Response::builder(302)
.content_type("text/html")
.header("Location", "/settings")
.body(r#"Redirecting to <a href="/settings">settings</a>..."#)
.build();
let names = vec!["theme", "front_page", "layout", "wide", "comment_sort", "show_nsfw"];
let values = vec![&form.theme, &form.front_page, &form.layout, &form.wide, &form.comment_sort, &form.show_nsfw];
let values = vec![form.theme, form.front_page, form.layout, form.wide, form.comment_sort, form.show_nsfw];
for (i, name) in names.iter().enumerate() {
match values[i] {
Some(value) => res.cookie(
Cookie::build(name.to_owned(), value)
match values.get(i) {
Some(value) => res.insert_cookie(
Cookie::build(name.to_owned(), value.to_owned().unwrap_or_default())
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.finish(),
),
None => res.del_cookie(&Cookie::named(name.to_owned())),
None => res.remove_cookie(Cookie::named(name.to_owned())),
};
}
res
.content_type("text/html")
.set_header("Location", "/settings")
.body(r#"Redirecting to <a href="/settings">settings</a>..."#)
Ok(res)
}

View File

@ -1,7 +1,7 @@
// CRATES
use crate::utils::*;
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse, Result};
use askama::Template;
use tide::{http::Cookie, Request, Response};
use time::{Duration, OffsetDateTime};
// STRUCTS
@ -25,14 +25,14 @@ struct WikiTemplate {
}
// SERVICES
pub async fn page(req: HttpRequest) -> HttpResponse {
pub async fn page(req: Request<()>) -> tide::Result {
// Build Reddit API path
let subscribed = cookie(&req, "subscriptions");
let front_page = cookie(&req, "front_page");
let sort = req.match_info().get("sort").unwrap_or("hot").to_string();
let sort = req.param("sort").unwrap_or_else(|_| req.param("id").unwrap_or("hot")).to_string();
let sub = req
.match_info()
.get("sub")
.param("sub")
.map(String::from)
.unwrap_or(if front_page == "default" || front_page.is_empty() {
if subscribed.is_empty() {
@ -44,7 +44,7 @@ pub async fn page(req: HttpRequest) -> HttpResponse {
front_page.to_owned()
});
let path = format!("/r/{}/{}.json?{}", sub, sort, req.query_string());
let path = format!("/r/{}/{}.json?{}&raw_json=1", sub, sort, req.url().query().unwrap_or_default());
match fetch_posts(&path, String::new()).await {
Ok((posts, after)) => {
@ -54,7 +54,7 @@ pub async fn page(req: HttpRequest) -> HttpResponse {
subreddit(&sub).await.unwrap_or_default()
} else if sub == subscribed {
// Subscription feed
if req.path().starts_with("/r/") {
if req.url().path().starts_with("/r/") {
subreddit(&sub).await.unwrap_or_default()
} else {
Subreddit::default()
@ -69,42 +69,55 @@ pub async fn page(req: HttpRequest) -> HttpResponse {
Subreddit::default()
};
let s = SubredditTemplate {
template(SubredditTemplate {
sub,
posts,
sort: (sort, param(&path, "t")),
ends: (param(&path, "after"), after),
prefs: prefs(req),
}
.render()
.unwrap();
HttpResponse::Ok().content_type("text/html").body(s)
})
}
Err(msg) => error(msg).await,
}
}
// Sub or unsub by setting subscription cookie using response "Set-Cookie" header
pub async fn subscriptions(req: HttpRequest) -> HttpResponse {
let mut res = HttpResponse::Found();
pub async fn subscriptions(req: Request<()>) -> tide::Result {
let sub = req.param("sub").unwrap_or_default().to_string();
let query = req.url().query().unwrap_or_default().to_string();
let action: Vec<String> = req.url().path().split('/').map(String::from).collect();
let sub = req.match_info().get("sub").unwrap_or_default().to_string();
let action = req.match_info().get("action").unwrap_or_default().to_string();
let mut sub_list = prefs(req.to_owned()).subs;
let mut sub_list = prefs(req).subs;
// Modify sub list based on action
if action == "subscribe" && !sub_list.contains(&sub) {
if action.contains(&"subscribe".to_string()) && !sub_list.contains(&sub) {
sub_list.push(sub.to_owned());
sub_list.sort_by_key(|a| a.to_lowercase());
} else if action == "unsubscribe" {
sub_list.sort_by_key(|a| a.to_lowercase())
} else if action.contains(&"unsubscribe".to_string()) {
sub_list.retain(|s| s != &sub);
}
// Redirect back to subreddit
// check for redirect parameter if unsubscribing from outside sidebar
let redirect_path = param(format!("/?{}", query).as_str(), "redirect");
let path = if !redirect_path.is_empty() {
format!("/{}/", redirect_path)
} else {
format!("/r/{}", sub)
};
let mut res = Response::builder(302)
.content_type("text/html")
.header("Location", path.to_owned())
.body(format!("Redirecting to <a href=\"{0}\">{0}</a>...", path))
.build();
// Delete cookie if empty, else set
if sub_list.is_empty() {
res.del_cookie(&Cookie::build("subscriptions", "").path("/").finish());
// res.del_cookie(&Cookie::build("subscriptions", "").path("/").finish());
res.remove_cookie(Cookie::build("subscriptions", "").path("/").finish());
} else {
res.cookie(
res.insert_cookie(
Cookie::build("subscriptions", sub_list.join("+"))
.path("/")
.http_only(true)
@ -113,38 +126,21 @@ pub async fn subscriptions(req: HttpRequest) -> HttpResponse {
);
}
// Redirect back to subreddit
// check for redirect parameter if unsubscribing from outside sidebar
let redirect_path = param(&req.uri().to_string(), "redirect");
let path = if !redirect_path.is_empty() && redirect_path.starts_with('/') {
redirect_path
} else {
format!("/r/{}", sub)
};
res
.content_type("text/html")
.set_header("Location", path.to_owned())
.body(format!("Redirecting to <a href=\"{0}\">{0}</a>...", path))
Ok(res)
}
pub async fn wiki(req: HttpRequest) -> HttpResponse {
let sub = req.match_info().get("sub").unwrap_or("reddit.com").to_string();
let page = req.match_info().get("page").unwrap_or("index").to_string();
pub async fn wiki(req: Request<()>) -> tide::Result {
let sub = req.param("sub").unwrap_or("reddit.com").to_string();
let page = req.param("page").unwrap_or("index").to_string();
let path: String = format!("/r/{}/wiki/{}.json?raw_json=1", sub, page);
match request(path).await {
Ok(res) => {
let s = WikiTemplate {
Ok(res) => template(WikiTemplate {
sub,
wiki: rewrite_url(res["data"]["content_html"].as_str().unwrap_or_default()),
page,
prefs: prefs(req),
}
.render()
.unwrap();
HttpResponse::Ok().content_type("text/html").body(s)
}
}),
Err(msg) => error(msg).await,
}
}
@ -163,8 +159,14 @@ async fn subreddit(sub: &str) -> Result<Subreddit, String> {
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
let community_icon: &str = res["data"]["community_icon"].as_str().map_or("", |s| s.split('?').collect::<Vec<&str>>()[0]);
let icon = if community_icon.is_empty() { val(&res, "icon_img") } else { community_icon.to_string() };
let community_icon: &str = res["data"]["community_icon"]
.as_str()
.map_or("", |s| s.split('?').collect::<Vec<&str>>()[0]);
let icon = if community_icon.is_empty() {
val(&res, "icon_img")
} else {
community_icon.to_string()
};
let sub = Subreddit {
name: val(&res, "display_name"),

View File

@ -1,7 +1,7 @@
// CRATES
use crate::utils::{error, fetch_posts, format_url, param, prefs, request, Post, Preferences, User};
use actix_web::{HttpRequest, HttpResponse, Result};
use crate::utils::*;
use askama::Template;
use tide::Request;
use time::OffsetDateTime;
// STRUCTS
@ -16,13 +16,13 @@ struct UserTemplate {
}
// FUNCTIONS
pub async fn profile(req: HttpRequest) -> HttpResponse {
pub async fn profile(req: Request<()>) -> tide::Result {
// Build the Reddit JSON API path
let path = format!("{}.json?{}&raw_json=1", req.path(), req.query_string());
let path = format!("{}.json?{}&raw_json=1", req.url().path(), req.url().query().unwrap_or_default());
// Retrieve other variables from Libreddit request
let sort = param(&path, "sort");
let username = req.match_info().get("username").unwrap_or("").to_string();
let username = req.param("name").unwrap_or("").to_string();
// Request user posts/comments from Reddit
let posts = fetch_posts(&path, "Comment".to_string()).await;
@ -32,16 +32,13 @@ pub async fn profile(req: HttpRequest) -> HttpResponse {
// If you can get user posts, also request user data
let user = user(&username).await.unwrap_or_default();
let s = UserTemplate {
template(UserTemplate {
user,
posts,
sort: (sort, param(&path, "t")),
ends: (param(&path, "after"), after),
prefs: prefs(req),
}
.render()
.unwrap();
HttpResponse::Ok().content_type("text/html").body(s)
})
}
// If there is an error show error page
Err(msg) => error(msg).await,
@ -51,7 +48,7 @@ pub async fn profile(req: HttpRequest) -> HttpResponse {
// USER
async fn user(name: &str) -> Result<User, String> {
// Build the Reddit JSON API path
let path: String = format!("/user/{}/about.json", name);
let path: String = format!("/user/{}/about.json?raw_json=1", name);
// Send a request to the url
match request(path).await {

View File

@ -1,15 +1,14 @@
//
// CRATES
//
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse, Result};
use askama::Template;
use base64::encode;
use cached::proc_macro::cached;
use regex::Regex;
use serde_json::{from_str, Value};
use std::collections::HashMap;
use tide::{http::url::Url, http::Cookie, Request, Response};
use time::{Duration, OffsetDateTime};
use url::Url;
//
// STRUCTS
@ -147,7 +146,7 @@ pub struct Preferences {
//
// Build preferences from cookies
pub fn prefs(req: HttpRequest) -> Preferences {
pub fn prefs(req: Request<()>) -> Preferences {
Preferences {
theme: cookie(&req, "theme"),
front_page: cookie(&req, "front_page"),
@ -155,21 +154,32 @@ pub fn prefs(req: HttpRequest) -> Preferences {
wide: cookie(&req, "wide"),
show_nsfw: cookie(&req, "show_nsfw"),
comment_sort: cookie(&req, "comment_sort"),
subs: cookie(&req, "subscriptions").split('+').map(String::from).filter(|s| !s.is_empty()).collect(),
subs: cookie(&req, "subscriptions")
.split('+')
.map(String::from)
.filter(|s| !s.is_empty())
.collect(),
}
}
// Grab a query param from a url
pub fn param(path: &str, value: &str) -> String {
match Url::parse(format!("https://libredd.it/{}", path).as_str()) {
Ok(url) => url.query_pairs().into_owned().collect::<HashMap<_, _>>().get(value).unwrap_or(&String::new()).to_owned(),
Ok(url) => url
.query_pairs()
.into_owned()
.collect::<HashMap<_, _>>()
.get(value)
.unwrap_or(&String::new())
.to_owned(),
_ => String::new(),
}
}
// Parse Cookie value from request
pub fn cookie(req: &HttpRequest, name: &str) -> String {
actix_web::HttpMessage::cookie(req, name).unwrap_or_else(|| Cookie::new(name, "")).value().to_string()
pub fn cookie(req: &Request<()>, name: &str) -> String {
let cookie = req.cookie(name).unwrap_or_else(|| Cookie::named(name));
cookie.value().to_string()
}
// Direct urls to proxy if proxy is enabled
@ -177,7 +187,7 @@ pub fn format_url(url: &str) -> String {
if url.is_empty() || url == "self" || url == "default" || url == "nsfw" || url == "spoiler" {
String::new()
} else {
format!("/proxy/{}", encode(url).as_str())
format!("/proxy/{}/", encode(url).as_str())
}
}
@ -420,102 +430,57 @@ pub async fn fetch_posts(path: &str, fallback_title: String) -> Result<(Vec<Post
// NETWORKING
//
pub async fn error(msg: String) -> HttpResponse {
pub fn template(f: impl Template) -> tide::Result {
Ok(
Response::builder(200)
.content_type("text/html")
.body(f.render().unwrap_or_default())
.build(),
)
}
pub async fn error(msg: String) -> tide::Result {
let body = ErrorTemplate {
msg,
prefs: Preferences::default(),
}
.render()
.unwrap_or_default();
HttpResponse::NotFound().content_type("text/html").body(body)
Ok(Response::builder(404).content_type("text/html").body(body).build())
}
// Make a request to a Reddit API and parse the JSON response
#[cached(size = 100, time = 30, result = true)]
pub async fn request(path: String) -> Result<Value, String> {
let url = format!("https://www.reddit.com{}", path);
// Build reddit-compliant user agent for Libreddit
let user_agent = format!("web:libreddit:{}", env!("CARGO_PKG_VERSION"));
// Send request using awc
// async fn send(url: &str) -> Result<String, (bool, String)> {
// let client = actix_web::client::Client::default();
// let response = client.get(url).header("User-Agent", format!("web:libreddit:{}", env!("CARGO_PKG_VERSION"))).send().await;
// Send request using surf
let req = surf::get(&url).header("User-Agent", user_agent.as_str());
let client = surf::client().with(surf::middleware::Redirect::new(5));
// match response {
// Ok(mut payload) => {
// // Get first number of response HTTP status code
// match payload.status().to_string().chars().next() {
// // If success
// Some('2') => Ok(String::from_utf8(payload.body().limit(20_000_000).await.unwrap_or_default().to_vec()).unwrap_or_default()),
// // If redirection
// Some('3') => match payload.headers().get("location") {
// Some(location) => Err((true, location.to_str().unwrap_or_default().to_string())),
// None => Err((false, "Page not found".to_string())),
// },
// // Otherwise
// _ => Err((false, "Page not found".to_string())),
// }
// }
// Err(e) => { dbg!(e); Err((false, "Couldn't send request to Reddit, this instance may be being rate-limited. Try another.".to_string())) },
// }
// }
let res = client.send(req).await;
// // Print error if debugging then return error based on error message
// fn err(url: String, msg: String) -> Result<Value, String> {
// // #[cfg(debug_assertions)]
// dbg!(format!("{} - {}", url, msg));
// Err(msg)
// };
let body = res.unwrap().take_body().into_string().await;
// // Parse JSON from body. If parsing fails, return error
// fn json(url: String, body: String) -> Result<Value, String> {
// match from_str(body.as_str()) {
// Ok(json) => Ok(json),
// Err(_) => err(url, "Failed to parse page JSON data".to_string()),
// }
// }
// // Make request to Reddit using send function
// match send(&url).await {
// // If success, parse and return body
// Ok(body) => json(url, body),
// // Follow any redirects
// Err((true, location)) => match send(location.as_str()).await {
// // If success, parse and return body
// Ok(body) => json(url, body),
// // Follow any redirects again
// Err((true, location)) => err(url, location),
// // Return errors if request fails
// Err((_, msg)) => err(url, msg),
// },
// // Return errors if request fails
// Err((_, msg)) => err(url, msg),
// }
// Send request using ureq
match ureq::get(&url).set("User-Agent", user_agent.as_str()).call() {
match body {
// If response is success
Ok(response) => {
// Parse the response from Reddit as JSON
let json_string = &response.into_string().unwrap_or_default();
match from_str(json_string) {
match from_str(&response) {
Ok(json) => Ok(json),
Err(e) => {
println!("{} - Failed to parse page JSON data: {} - {}", url, e, json_string);
println!("{} - Failed to parse page JSON data: {}", url, e);
Err("Failed to parse page JSON data".to_string())
}
}
}
// If response is error
Err(ureq::Error::Status(_, _)) => {
#[cfg(debug_assertions)]
dbg!(format!("{} - Page not found", url));
Err("Page not found".to_string())
}
// If failed to send request
Err(e) => {
println!("{} - Couldn't send request to Reddit: {}", url, e);
Err("Couldn't send request to Reddit, this instance may be being rate-limited. Try another.".to_string())
Err("Couldn't send request to Reddit".to_string())
}
}
}

View File

@ -15,11 +15,11 @@
<!-- Android -->
<meta name="mobile-web-app-capable" content="yes">
<!-- iOS Logo -->
<link href="/touch-icon-iphone.png" rel="apple-touch-icon">
<link href="/touch-icon-iphone.png/" rel="apple-touch-icon">
<!-- PWA Manifest -->
<link rel="manifest" type="application/json" href="/manifest.json">
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico">
<link rel="stylesheet" type="text/css" href="/style.css">
<link rel="manifest" type="application/json" href="/manifest.json/">
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico/">
<link rel="stylesheet" type="text/css" href="/style.css/">
{% endblock %}
</head>
<body class="

View File

@ -9,7 +9,7 @@
{% block content %}
<div id="settings">
<form action="/settings" method="POST">
<form action="/settings/" method="POST">
<div class="prefs">
<p>Appearance</p>
<div id="theme">
@ -57,7 +57,7 @@
{% for sub in prefs.subs %}
<li>
<span>{{ sub }}</span>
<form action="/r/{{ sub }}/unsubscribe/?redirect=/settings" method="POST">
<form action="/r/{{ sub }}/unsubscribe/?redirect=settings" method="POST">
<button class="unsubscribe">Unsubscribe</button>
</form>
</li>

View File

@ -80,11 +80,11 @@
</div>
<div id="sub_subscription">
{% if prefs.subs.contains(sub.name) %}
<form action="/r/{{ sub.name }}/unsubscribe" method="POST">
<form action="/r/{{ sub.name }}/unsubscribe/" method="POST">
<button class="unsubscribe">Unsubscribe</button>
</form>
{% else %}
<form action="/r/{{ sub.name }}/subscribe" method="POST">
<form action="/r/{{ sub.name }}/subscribe/" method="POST">
<button class="subscribe">Subscribe</button>
</form>
{% endif %}