Restrict Proxy to Reddit Domains

This commit is contained in:
spikecodes 2021-01-02 20:50:23 -08:00
parent f49bff9853
commit 5ea504e6e8
14 changed files with 156 additions and 61 deletions

View File

@ -7,13 +7,9 @@ version = "0.2.5"
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"] authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
edition = "2018" edition = "2018"
[features]
default = ["proxy"]
proxy = ["actix-web/rustls", "base64"]
[dependencies] [dependencies]
base64 = { version = "0.13.0", optional = true } base64 = "0.13.0"
actix-web = "3.2.0" actix-web = { version = "3.2.0", features = ["rustls"] }
reqwest = { version = "0.10", default_features = false, features = ["rustls-tls"] } reqwest = { version = "0.10", default_features = false, features = ["rustls-tls"] }
askama = "0.8.0" askama = "0.8.0"
serde = "1.0.117" serde = "1.0.117"

View File

@ -5,6 +5,7 @@ use actix_web::{get, middleware::NormalizePath, web, App, HttpResponse, HttpServ
mod post; mod post;
mod proxy; mod proxy;
mod search; mod search;
// mod settings;
mod subreddit; mod subreddit;
mod user; mod user;
mod utils; mod utils;
@ -50,6 +51,9 @@ async fn main() -> std::io::Result<()> {
.route("/style.css/", web::get().to(style)) .route("/style.css/", web::get().to(style))
.route("/favicon.ico/", web::get().to(HttpResponse::Ok)) .route("/favicon.ico/", web::get().to(HttpResponse::Ok))
.route("/robots.txt/", web::get().to(robots)) .route("/robots.txt/", web::get().to(robots))
// SETTINGS SERVICE
// .route("/settings/", web::get().to(settings::get))
// .route("/settings/save/", web::post().to(settings::set))
// PROXY SERVICE // PROXY SERVICE
.route("/proxy/{url:.*}/", web::get().to(proxy::handler)) .route("/proxy/{url:.*}/", web::get().to(proxy::handler))
// SEARCH SERVICES // SEARCH SERVICES

View File

@ -16,7 +16,7 @@ struct PostTemplate {
sort: String, sort: String,
} }
pub async fn item(req: HttpRequest) -> Result<HttpResponse> { pub async fn item(req: HttpRequest) -> HttpResponse {
let path = format!("{}.json?{}&raw_json=1", req.path(), req.query_string()); let path = format!("{}.json?{}&raw_json=1", req.path(), req.query_string());
let sort = param(&path, "sort"); let sort = param(&path, "sort");
let id = req.match_info().get("id").unwrap_or("").to_string(); let id = req.match_info().get("id").unwrap_or("").to_string();
@ -35,7 +35,7 @@ pub async fn item(req: HttpRequest) -> Result<HttpResponse> {
// 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
let s = PostTemplate { comments, post, sort }.render().unwrap(); let s = PostTemplate { comments, post, sort }.render().unwrap();
Ok(HttpResponse::Ok().content_type("text/html").body(s)) HttpResponse::Ok().content_type("text/html").body(s)
} }
// 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(msg.to_string()).await, Err(msg) => error(msg.to_string()).await,

View File

@ -1,30 +1,40 @@
use actix_web::{client::Client, web, Error, HttpResponse, Result}; use actix_web::{client::Client, error, web, Error, HttpResponse, Result};
use url::Url;
#[cfg(feature = "proxy")]
use base64::decode; use base64::decode;
pub async fn handler(web::Path(url): web::Path<String>) -> Result<HttpResponse> { pub async fn handler(web::Path(b64): web::Path<String>) -> Result<HttpResponse> {
if cfg!(feature = "proxy") { let domains = vec![
#[cfg(feature = "proxy")] "a.thumbs.redditmedia.com",
let media: String; "b.thumbs.redditmedia.com",
"preview.redd.it",
"external-preview.redd.it",
"i.redd.it",
"v.redd.it",
];
#[cfg(not(feature = "proxy"))] match decode(b64) {
let media = url; Ok(bytes) => {
let media = String::from_utf8(bytes).unwrap();
#[cfg(feature = "proxy")] match Url::parse(media.as_str()) {
match decode(url) { Ok(url) => {
Ok(bytes) => media = String::from_utf8(bytes).unwrap(), let domain = url.domain().unwrap_or_default();
Err(_e) => return Ok(HttpResponse::Ok().body("")),
};
let client = Client::default(); if domains.contains(&domain) {
client Client::default()
.get(media.replace("&amp;", "&")) .get(media.replace("&amp;", "&"))
.send() .send()
.await .await
.map_err(Error::from) .map_err(Error::from)
.map(|res| HttpResponse::build(res.status()).streaming(res)) .map(|res| HttpResponse::build(res.status()).streaming(res))
} else { } else {
Ok(HttpResponse::Ok().body("")) Err(error::ErrorForbidden("Resource must be from Reddit"))
}
}
Err(_) => Err(error::ErrorBadRequest("Can't parse encoded base64 URL")),
}
}
Err(_) => Err(error::ErrorBadRequest("Can't decode base64 URL")),
} }
} }

View File

@ -1,11 +1,10 @@
// CRATES // CRATES
use crate::utils::{error, fetch_posts, param, Post}; use crate::utils::{error, fetch_posts, param, Post};
use actix_web::{HttpRequest, HttpResponse, Result}; use actix_web::{HttpRequest, HttpResponse};
use askama::Template; use askama::Template;
// STRUCTS // STRUCTS
#[derive(Template)] #[derive(Template)]
#[allow(dead_code)]
#[template(path = "search.html", escape = "none")] #[template(path = "search.html", escape = "none")]
struct SearchTemplate { struct SearchTemplate {
posts: Vec<Post>, posts: Vec<Post>,
@ -16,7 +15,7 @@ struct SearchTemplate {
} }
// SERVICES // SERVICES
pub async fn find(req: HttpRequest) -> Result<HttpResponse> { pub async fn find(req: HttpRequest) -> HttpResponse {
let path = format!("{}.json?{}", req.path(), req.query_string()); let path = format!("{}.json?{}", req.path(), req.query_string());
let q = param(&path, "q"); let q = param(&path, "q");
let sort = if param(&path, "sort").is_empty() { let sort = if param(&path, "sort").is_empty() {
@ -27,8 +26,8 @@ pub async fn find(req: HttpRequest) -> Result<HttpResponse> {
let sub = req.match_info().get("sub").unwrap_or("").to_string(); let sub = req.match_info().get("sub").unwrap_or("").to_string();
match fetch_posts(&path, String::new()).await { match fetch_posts(&path, String::new()).await {
Ok(posts) => { Ok(posts) => HttpResponse::Ok().content_type("text/html").body(
let s = SearchTemplate { SearchTemplate {
posts: posts.0, posts: posts.0,
query: q, query: q,
sub, sub,
@ -36,9 +35,8 @@ pub async fn find(req: HttpRequest) -> Result<HttpResponse> {
ends: (param(&path, "after"), posts.1), ends: (param(&path, "after"), posts.1),
} }
.render() .render()
.unwrap(); .unwrap(),
Ok(HttpResponse::Ok().content_type("text/html").body(s)) ),
}
Err(msg) => error(msg.to_string()).await, Err(msg) => error(msg.to_string()).await,
} }
} }

48
src/settings.rs Normal file
View File

@ -0,0 +1,48 @@
// // CRATES
// use crate::utils::cookies;
// use actix_web::{cookie::Cookie, web::Form, HttpRequest, HttpResponse, Result}; // http::Method,
// use askama::Template;
// // STRUCTS
// #[derive(Template)]
// #[template(path = "settings.html", escape = "none")]
// struct SettingsTemplate {
// pref_nsfw: String,
// }
// #[derive(serde::Deserialize)]
// pub struct Preferences {
// pref_nsfw: Option<String>,
// }
// // FUNCTIONS
// // Retrieve cookies from request "Cookie" header
// pub async fn get(req: HttpRequest) -> Result<HttpResponse> {
// let cookies = cookies(req);
// let pref_nsfw: String = cookies.get("pref_nsfw").unwrap_or(&String::new()).to_owned();
// let s = SettingsTemplate { pref_nsfw }.render().unwrap();
// Ok(HttpResponse::Ok().content_type("text/html").body(s))
// }
// // Set cookies using response "Set-Cookie" header
// pub async fn set(form: Form<Preferences>) -> HttpResponse {
// let nsfw: Cookie = match &form.pref_nsfw {
// Some(value) => Cookie::build("pref_nsfw", value).path("/").secure(true).http_only(true).finish(),
// None => Cookie::build("pref_nsfw", "").finish(),
// };
// let body = SettingsTemplate {
// pref_nsfw: form.pref_nsfw.clone().unwrap_or_default(),
// }
// .render()
// .unwrap();
// HttpResponse::Found()
// .content_type("text/html")
// .set_header("Set-Cookie", nsfw.to_string())
// .set_header("Location", "/settings")
// .body(body)
// }

View File

@ -22,7 +22,7 @@ struct WikiTemplate {
} }
// SERVICES // SERVICES
pub async fn page(req: HttpRequest) -> Result<HttpResponse> { pub async fn page(req: HttpRequest) -> HttpResponse {
let path = format!("{}.json?{}", req.path(), req.query_string()); let path = format!("{}.json?{}", req.path(), req.query_string());
let sub = req.match_info().get("sub").unwrap_or("popular").to_string(); let sub = req.match_info().get("sub").unwrap_or("popular").to_string();
let sort = req.match_info().get("sort").unwrap_or("hot").to_string(); let sort = req.match_info().get("sort").unwrap_or("hot").to_string();
@ -43,13 +43,13 @@ pub async fn page(req: HttpRequest) -> Result<HttpResponse> {
} }
.render() .render()
.unwrap(); .unwrap();
Ok(HttpResponse::Ok().content_type("text/html").body(s)) HttpResponse::Ok().content_type("text/html").body(s)
} }
Err(msg) => error(msg.to_string()).await, Err(msg) => error(msg.to_string()).await,
} }
} }
pub async fn wiki(req: HttpRequest) -> Result<HttpResponse> { pub async fn wiki(req: HttpRequest) -> HttpResponse {
let sub = req.match_info().get("sub").unwrap_or("reddit.com"); let sub = req.match_info().get("sub").unwrap_or("reddit.com");
let page = req.match_info().get("page").unwrap_or("index"); let page = req.match_info().get("page").unwrap_or("index");
let path: String = format!("r/{}/wiki/{}.json?raw_json=1", sub, page); let path: String = format!("r/{}/wiki/{}.json?raw_json=1", sub, page);
@ -63,7 +63,7 @@ pub async fn wiki(req: HttpRequest) -> Result<HttpResponse> {
} }
.render() .render()
.unwrap(); .unwrap();
Ok(HttpResponse::Ok().content_type("text/html").body(s)) HttpResponse::Ok().content_type("text/html").body(s)
} }
Err(msg) => error(msg.to_string()).await, Err(msg) => error(msg.to_string()).await,
} }

View File

@ -14,7 +14,8 @@ struct UserTemplate {
ends: (String, String), ends: (String, String),
} }
pub async fn profile(req: HttpRequest) -> Result<HttpResponse> { // FUNCTIONS
pub async fn profile(req: HttpRequest) -> HttpResponse {
// Build the Reddit JSON API path // 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.path(), req.query_string());
@ -36,7 +37,7 @@ pub async fn profile(req: HttpRequest) -> Result<HttpResponse> {
} }
.render() .render()
.unwrap(); .unwrap();
Ok(HttpResponse::Ok().content_type("text/html").body(s)) HttpResponse::Ok().content_type("text/html").body(s)
} }
// If there is an error show error page // If there is an error show error page
Err(msg) => error(msg.to_string()).await, Err(msg) => error(msg.to_string()).await,

View File

@ -1,17 +1,17 @@
// use std::collections::HashMap;
// //
// CRATES // CRATES
// //
use actix_web::{http::StatusCode, HttpResponse, Result}; use actix_web::{HttpResponse, Result};
use askama::Template; use askama::Template;
use base64::encode;
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use regex::Regex; use regex::Regex;
use serde_json::from_str; use serde_json::from_str;
use url::Url; use url::Url;
// use surf::{client, get, middleware::Redirect}; // use surf::{client, get, middleware::Redirect};
#[cfg(feature = "proxy")]
use base64::encode;
// //
// STRUCTS // STRUCTS
// //
@ -102,17 +102,31 @@ pub fn param(path: &str, value: &str) -> String {
pairs.get(value).unwrap_or(&String::new()).to_owned() pairs.get(value).unwrap_or(&String::new()).to_owned()
} }
// Cookies from request
// pub fn cookies(req: HttpRequest) -> HashMap<String, String> {
// let mut result: HashMap<String, String> = HashMap::new();
// let cookies: Vec<Cookie> = req
// .headers()
// .get_all("Cookie")
// .map(|value| value.to_str().unwrap())
// .map(|unparsed| Cookie::parse(unparsed).unwrap())
// .collect();
// for cookie in cookies {
// result.insert(cookie.name().to_string(), cookie.value().to_string());
// }
// result
// }
// Direct urls to proxy if proxy is enabled // Direct urls to proxy if proxy is enabled
pub fn format_url(url: String) -> String { pub fn format_url(url: String) -> String {
if url.is_empty() { if url.is_empty() {
return String::new(); return String::new();
}; };
#[cfg(feature = "proxy")] format!("/proxy/{}", encode(url).as_str())
return "/proxy/".to_string() + encode(url).as_str();
#[cfg(not(feature = "proxy"))]
return url.to_string();
} }
// Rewrite Reddit links to Libreddit in body of text // Rewrite Reddit links to Libreddit in body of text
@ -217,10 +231,10 @@ pub async fn fetch_posts(path: &str, fallback_title: String) -> Result<(Vec<Post
// NETWORKING // NETWORKING
// //
pub async fn error(message: String) -> Result<HttpResponse> { pub async fn error(message: String) -> HttpResponse {
let msg = if message.is_empty() { "Page not found".to_string() } else { message }; let msg = if message.is_empty() { "Page not found".to_string() } else { message };
let body = ErrorTemplate { message: msg }.render().unwrap_or_default(); let body = ErrorTemplate { message: msg }.render().unwrap_or_default();
Ok(HttpResponse::Ok().status(StatusCode::NOT_FOUND).content_type("text/html").body(body)) HttpResponse::NotFound().content_type("text/html").body(body)
} }
// Make a request to a Reddit API and parse the JSON response // Make a request to a Reddit API and parse the JSON response

View File

@ -599,8 +599,15 @@ td, th {
} }
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
main { flex-direction: column-reverse; } main {
nav { flex-direction: column; } flex-direction: column-reverse;
padding: 10px;
margin: 10px 0;
}
nav {
flex-direction: column;
margin: 10px;
}
aside, #subreddit, #user { aside, #subreddit, #user {
margin: 0; margin: 0;

View File

@ -4,8 +4,7 @@
{% block head %} {% block head %}
<title>{% block title %}Libreddit{% endblock %}</title> <title>{% block title %}Libreddit{% endblock %}</title>
<meta http-equiv="Referrer-Policy" content="no-referrer"> <meta http-equiv="Referrer-Policy" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; form-action 'self'; <meta http-equiv="Content-Security-Policy" content="default-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; form-action 'self';">
{% if cfg!(not(feature = "proxy")) %}img-src https://*; media-src https://*{% endif %}">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="description" content="View on Libreddit, an alternative private front-end to Reddit."> <meta name="description" content="View on Libreddit, an alternative private front-end to Reddit.">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">

View File

@ -9,7 +9,7 @@
<input id="search" type="text" name="q" placeholder="Search" value="{{ query }}"> <input id="search" type="text" name="q" placeholder="Search" value="{{ query }}">
{% if sub != "" %} {% if sub != "" %}
<div id="inside"> <div id="inside">
<input type="checkbox" name="restrict_sr" id="restrict_sr" checked="checked" data-com.bitwarden.browser.user-edited="yes"> <input type="checkbox" name="restrict_sr" id="restrict_sr">
<label for="restrict_sr">in r/{{ sub }}</label> <label for="restrict_sr">in r/{{ sub }}</label>
</div> </div>
{% endif %} {% endif %}

18
templates/settings.html Normal file
View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% import "utils.html" as utils %}
{% block title %}Libreddit Settings{% endblock %}
{% block search %}
{% call utils::search("".to_owned(), "", "") %}
{% endblock %}
{% block body %}
<main>
<form action="/settings/save" method="POST">
<label for="pref_nsfw">NSFW</label>
<input type="checkbox" name="pref_nsfw" id="pref_nsfw" {% if pref_nsfw == "on" %}checked{% endif %}>
<input id="sort_submit" type="submit" value="&rarr;">
</form>
</main>
{% endblock %}

View File

@ -19,7 +19,7 @@
<input id="search" type="text" name="q" placeholder="Search" value="{{ search }}"> <input id="search" type="text" name="q" placeholder="Search" value="{{ search }}">
{% if root != "/r/" && !root.is_empty() %} {% if root != "/r/" && !root.is_empty() %}
<div id="inside"> <div id="inside">
<input type="checkbox" name="restrict_sr" id="restrict_sr" checked="checked" data-com.bitwarden.browser.user-edited="yes"> <input type="checkbox" name="restrict_sr" id="restrict_sr">
<label for="restrict_sr">in {{ root }}</label> <label for="restrict_sr">in {{ root }}</label>
</div> </div>
{% endif %} {% endif %}