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:
parent
402b3149e1
commit
ebbdd7185f
1742
Cargo.lock
generated
1742
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
14
Cargo.toml
14
Cargo.toml
@ -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"
|
@ -1,4 +1,4 @@
|
||||
edition = "2018"
|
||||
tab_spaces = 2
|
||||
hard_tabs = true
|
||||
max_width = 175
|
||||
max_width = 150
|
245
src/main.rs
245
src/main.rs
@ -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(())
|
||||
}
|
||||
|
22
src/post.rs
22
src/post.rs
@ -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,
|
||||
|
35
src/proxy.rs
35
src/proxy.rs
@ -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("&", "&")).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")),
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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"),
|
||||
|
19
src/user.rs
19
src/user.rs
@ -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 {
|
||||
|
115
src/utils.rs
115
src/utils.rs
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -12,4 +12,4 @@
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
@ -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="
|
||||
|
@ -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>
|
||||
|
@ -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 %}
|
||||
|
Loading…
Reference in New Issue
Block a user