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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 1283 additions and 1189 deletions

1744
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()
.content_type("image/png")
.body(include_bytes!("../static/touch-icon-iphone.png").as_ref())
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()
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
.body("User-agent: *\nAllow: /")
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")
.header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
.body(include_bytes!("../static/favicon.ico").as_ref())
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()
// 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
}
})
})
// Append trailing slash and remove double slashes
.wrap(middleware::NormalizePath::default())
// 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(
"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())))
// 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))
// Proxy media through Libreddit
.route("/proxy/{url:.*}/", web::get().to(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)),
),
),
)
// Configure settings
.service(web::resource("/settings/").route(web::get().to(settings::get)).route(web::post().to(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))
// Handle subscribe/unsubscribe
.route("/{action:subscribe|unsubscribe}/", web::post().to(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)),
)
// Search inside subreddit
.route("/search/", web::get().to(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)),
),
)
// Front page
.route("/", web::get().to(subreddit::page))
.route("/{sort:best|hot|new|top|rising|controversial}/", web::get().to(subreddit::page))
// View Reddit wiki
.service(
web::scope("/wiki")
.route("/", web::get().to(subreddit::wiki))
.route("/{page}/", web::get().to(subreddit::wiki)),
)
// Search all of Reddit
.route("/search/", web::get().to(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
let mut app = tide::new();
// Redirect to HTTPS if "--redirect-https" enabled
app.with(HttpsRedirect(force_https));
// Append trailing slash and remove double slashes
app.with(NormalizePath);
// Apply default headers for security
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';",
);
Ok(res)
}));
// Read static files
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
app.at("/proxy/*url/").get(proxy::handler);
// Browse user profile
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
app.at("/settings/").get(settings::get).post(settings::set);
// Subreddit services
// See posts and info about subreddit
app.at("/r/:sub/").get(subreddit::page);
// Handle subscribe/unsubscribe
app.at("/r/:sub/subscribe/").post(subreddit::subscriptions);
app.at("/r/:sub/unsubscribe/").post(subreddit::subscriptions);
// View post on subreddit
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
app.at("/r/:sub/search/").get(search::find);
// View wiki of subreddit
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
app.at("/").get(subreddit::page);
// View Reddit 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
app.at("/search/").get(search::find);
// Short link for post
// .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,24 +50,20 @@ 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 {
posts,
subreddits,
sub,
params: SearchParams {
q: param(&path, "q"),
sort,
t: param(&path, "t"),
before: param(&path, "after"),
after,
restrict_sr: param(&path, "restrict_sr"),
},
prefs: prefs(req),
}
.render()
.unwrap(),
),
Ok((posts, after)) => template(SearchTemplate {
posts,
subreddits,
sub,
params: SearchParams {
q: param(&path, "q"),
sort,
t: param(&path, "t"),
before: param(&path, "after"),
after,
restrict_sr: param(&path, "restrict_sr"),
},
prefs: prefs(req),
}),
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 {
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)
}
Ok(res) => template(WikiTemplate {
sub,
wiki: rewrite_url(res["data"]["content_html"].as_str().unwrap_or_default()),
page,
prefs: prefs(req),
}),
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

@ -1,15 +1,15 @@
{
"name": "Libreddit",
"short_name": "Libreddit",
"display": "standalone",
"background_color": "#1F1F1F",
"description": "An alternative private front-end to Reddit",
"theme_color": "#1F1F1F",
"icons": [
{
"src": "./logo.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
"name": "Libreddit",
"short_name": "Libreddit",
"display": "standalone",
"background_color": "#1F1F1F",
"description": "An alternative private front-end to Reddit",
"theme_color": "#1F1F1F",
"icons": [
{
"src": "./logo.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

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 %}