Merge pull request #78 from mcrossman/subscriptions

Subscribing to subreddits (favorites)
This commit is contained in:
Spike 2021-01-30 21:28:51 -08:00 committed by GitHub
commit 79027c4c75
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 355 additions and 106 deletions

16
Cargo.lock generated
View File

@ -480,9 +480,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.5.0"
version = "3.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f07aa6688c702439a1be0307b6a94dffe1168569e45b9500c1372bc580740d59"
checksum = "099e596ef14349721d9016f6b80dd3419ea1bf289ab9b44df8e4dfd3a005d5d9"
[[package]]
name = "byteorder"
@ -531,9 +531,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chunked_transfer"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7477065d45a8fe57167bf3cf8bcd3729b54cfcb81cca49bda2d038ea89ae82ca"
checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e"
[[package]]
name = "const_fn"
@ -628,9 +628,9 @@ dependencies = [
[[package]]
name = "flate2"
version = "1.0.19"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7411863d55df97a419aa64cb4d2f167103ea9d767e2c54a1868b7ac3f6b47129"
checksum = "cd3aec53de10fe96d7d8c565eb17f2c687bb5518a2ec453b5b1252964526abe0"
dependencies = [
"cfg-if 1.0.0",
"crc32fast",
@ -988,9 +988,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.83"
version = "0.2.84"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb0c4e9c72ee9d69b767adebc5f4788462a3b45624acd919475c92597bcaf4f"
checksum = "1cca32fa0182e8c0989459524dc356b8f2b5c10f1b9eb521b7d182c03cf8c5ff"
[[package]]
name = "libreddit"

View File

@ -54,7 +54,7 @@ async fn main() -> std::io::Result<()> {
.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, _>|
srv.call(req).map(move |res: Result<ServiceResponse, _>| {
if force_https && !secure {
Ok(ServiceResponse::new(
res.unwrap().request().to_owned(),
@ -63,16 +63,21 @@ async fn main() -> std::io::Result<()> {
} 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'; media-src 'self'; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none';"))
.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'; 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
@ -99,6 +104,8 @@ async fn main() -> std::io::Result<()> {
// 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}")

View File

@ -24,26 +24,24 @@ pub async fn handler(web::Path(b64): web::Path<String>) -> Result<HttpResponse>
let decoded = decode(b64).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();
Ok(media) => match Url::parse(media.as_str()) {
Ok(url) => {
let domain = url.domain().unwrap_or_default();
if domains.contains(&domain) {
Client::default().get(media.replace("&amp;", "&")).send().await.map_err(Error::from).map(|res| {
HttpResponse::build(res.status())
.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)
})
} else {
Err(error::ErrorForbidden("Resource must be from Reddit"))
}
if domains.contains(&domain) {
Client::default().get(media.replace("&amp;", "&")).send().await.map_err(Error::from).map(|res| {
HttpResponse::build(res.status())
.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)
})
} else {
Err(error::ErrorForbidden("Resource must be from Reddit"))
}
_ => Err(error::ErrorBadRequest("Can't parse base64 into URL")),
}
}
_ => Err(error::ErrorBadRequest("Can't parse base64 into URL")),
},
_ => Err(error::ErrorBadRequest("Can't decode base64")),
}
}

View File

@ -1,7 +1,8 @@
// CRATES
use crate::utils::*;
use actix_web::{HttpRequest, HttpResponse, Result};
use actix_web::{cookie::Cookie, HttpRequest, HttpResponse, Result};
use askama::Template;
use time::{Duration, OffsetDateTime};
// STRUCTS
#[derive(Template)]
@ -25,23 +26,43 @@ struct WikiTemplate {
// SERVICES
pub async fn page(req: HttpRequest) -> HttpResponse {
let path = format!("{}.json?{}", req.path(), req.query_string());
let default = cookie(&req, "front_page");
let sub_name = req
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 sub = req
.match_info()
.get("sub")
.unwrap_or(if default.is_empty() { "popular" } else { default.as_str() })
.to_string();
let sort = req.match_info().get("sort").unwrap_or("hot").to_string();
.map(String::from)
.unwrap_or(if front_page == "default" || front_page.is_empty() {
if subscribed.is_empty() {
"popular".to_string()
} else {
subscribed.to_owned()
}
} else {
front_page.to_owned()
});
let path = format!("/r/{}.json?{}", sub, req.query_string());
match fetch_posts(&path, String::new()).await {
Ok((posts, after)) => {
// If you can get subreddit posts, also request subreddit metadata
let sub = if !sub_name.contains('+') && sub_name != "popular" && sub_name != "all" {
subreddit(&sub_name).await.unwrap_or_default()
} else if sub_name.contains('+') {
let sub = if !sub.contains('+') && sub != subscribed && sub != "popular" && sub != "all" {
// Regular subreddit
subreddit(&sub).await.unwrap_or_default()
} else if sub == subscribed {
// Subscription feed
if req.path().starts_with("/r/") {
subreddit(&sub).await.unwrap_or_default()
} else {
Subreddit::default()
}
} else if sub.contains('+') {
// Multireddit
Subreddit {
name: sub_name,
name: sub,
..Subreddit::default()
}
} else {
@ -63,6 +84,50 @@ pub async fn page(req: HttpRequest) -> HttpResponse {
}
}
// Sub or unsub by setting subscription cookie using response "Set-Cookie" header
pub async fn subscriptions(req: HttpRequest) -> HttpResponse {
let mut res = HttpResponse::Found();
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;
// Modify sub list based on action
if action == "subscribe" && !sub_list.contains(&sub) {
sub_list.push(sub.to_owned());
sub_list.sort();
} else if action == "unsubscribe" {
sub_list.retain(|s| s != &sub);
}
// Delete cookie if empty, else set
if sub_list.is_empty() {
res.del_cookie(&Cookie::build("subscriptions", "").path("/").finish());
} else {
res.cookie(
Cookie::build("subscriptions", sub_list.join("+"))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.finish(),
);
}
// 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))
}
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();

View File

@ -129,6 +129,7 @@ pub struct Preferences {
pub wide: String,
pub hide_nsfw: String,
pub comment_sort: String,
pub subs: Vec<String>,
}
//
@ -144,6 +145,7 @@ pub fn prefs(req: HttpRequest) -> Preferences {
wide: cookie(&req, "wide"),
hide_nsfw: cookie(&req, "hide_nsfw"),
comment_sort: cookie(&req, "comment_sort"),
subs: cookie(&req, "subscriptions").split('+').map(String::from).filter(|s| s != "").collect(),
}
}

View File

@ -68,11 +68,12 @@ pre, form, fieldset, table, th, td, select, input {
body {
background: var(--background);
font-size: 15px;
padding-top: 60px;
}
nav {
display: grid;
grid-template-areas: "logo searchbox code";
grid-template-areas: "logo searchbox links";
justify-content: space-between;
align-items: center;
@ -83,7 +84,7 @@ nav {
font-size: 20px;
z-index: 1;
z-index: 2;
top: 0;
padding: 5px 15px;
min-height: 40px;
@ -94,12 +95,23 @@ nav {
nav * { color: var(--text); }
nav #reddit, #code { color: var(--accent); }
nav #logo { grid-area: logo; }
nav #code { grid-area: code; }
nav #version { opacity: 50%; }
nav #links {
grid-area: links;
margin-left: 10px;
}
nav #version {
opacity: 50%;
vertical-align: -2px;
margin-right: 10px;
}
nav #libreddit {
vertical-align: -2px;
}
#settings_link {
font-size: 18px;
margin-left: 10px;
opacity: 0.8;
}
@ -108,7 +120,7 @@ main {
justify-content: center;
max-width: 1000px;
padding: 10px 20px;
margin: 60px auto 20px auto
margin: 0 auto;
}
.wide main {
@ -232,6 +244,71 @@ aside {
color: var(--accent);
}
/* Subscriptions */
#sub_subscription {
margin-top: 20px;
}
.subscribe, .unsubscribe {
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
}
.subscribe {
color: var(--foreground);
background-color: var(--accent);
}
.unsubscribe {
color: var(--text);
background-color: var(--highlighted);
}
/* Subscribed subreddit list */
#subscriptions {
position: relative;
border-radius: 5px;
border: var(--panel-border);
background-color: var(--outside);
align-items: center;
box-sizing: border-box;
font-size: 15px;
display: inline-block;
}
#subscriptions > summary {
padding: 8px 15px;
}
#sub_list {
position: absolute;
display: flex;
min-width: 100%;
border-radius: 5px;
box-shadow: var(--shadow);
background: var(--outside);
flex-direction: column;
overflow: auto;
z-index: 1;
}
#sub_list > a {
padding: 10px 20px;
transition: 0.2s background;
}
#sub_list > .selected {
background-color: var(--accent);
color: var(--foreground);
}
#sub_list > a:not(.selected):hover {
background-color: var(--foreground);
}
/* Wiki Pages */
#wiki {
@ -452,10 +529,6 @@ a.search_subreddit:hover {
.post:not(:last-child) { margin-bottom: 10px; }
.post.highlighted {
margin: 20px 0;
}
.post:hover {
background: var(--foreground);
}
@ -789,7 +862,7 @@ a.search_subreddit:hover {
/* Settings */
#settings {
#settings, #settings > form {
display: flex;
flex-direction: column;
align-items: center;
@ -802,7 +875,7 @@ a.search_subreddit:hover {
opacity: 0.75;
}
#prefs {
.prefs {
display: flex;
flex-direction: column;
justify-content: space-between;
@ -812,7 +885,7 @@ a.search_subreddit:hover {
border-radius: 5px;
}
#prefs > div {
.prefs > div {
display: flex;
justify-content: space-between;
width: 100%;
@ -820,17 +893,21 @@ a.search_subreddit:hover {
align-items: center;
}
#prefs > div:not(:last-of-type) {
.prefs > div:not(:last-of-type) {
margin-bottom: 10px;
}
#prefs select {
.prefs select {
border-radius: 5px;
box-shadow: var(--shadow);
margin-left: 20px;
background: var(--foreground);
}
aside.prefs {
margin-top: 20px;
}
#save {
background: var(--highlighted);
padding: 10px 15px;
@ -843,6 +920,27 @@ input[type="submit"] {
-webkit-appearance: none;
-moz-appearance: none;
}
#settings_subs {
list-style: none;
padding: 0;
}
#settings_subs > li {
display: flex;
margin: 10px 0;
}
#settings_subs > li:last-of-type { margin-bottom: 0; }
#settings_subs > li > span {
padding: 10px 0;
margin-right: auto;
}
#settings_subs .unsubscribe {
margin-left: 30px;
}
/* Markdown */
.md > *:not(:first-child) {
@ -916,6 +1014,8 @@ td, th {
/* Mobile */
@media screen and (max-width: 480px) {
#version { display: none; }
.post {
grid-template: "post_header post_header post_thumbnail" auto
"post_title post_title post_thumbnail" 1fr
@ -954,25 +1054,37 @@ td, th {
}
@media screen and (max-width: 800px) {
body { padding-top: 120px }
main {
flex-direction: column-reverse;
padding: 10px;
margin: 100px 0 10px 0;
margin: 0 0 10px 0;
max-width: 100%;
}
nav {
grid-template-areas: 'logo code' 'searchbox searchbox';
grid-template-areas: 'logo links' 'searchbox searchbox';
padding: 10px;
width: calc(100% - 20px);
}
nav #links { margin-left: auto; }
#subscriptions { position: unset; }
#sub_list {
left: 10px;
right: 10px;
min-width: auto;
}
aside, #subreddit, #user {
margin: 0;
max-width: 100%;
}
#user, #sidebar { margin: 20px 0; }
#logo { margin: 5px auto; }
#logo, #links { margin-bottom: 5px; }
#searchbox { width: calc(100vw - 35px); }
}

View File

@ -16,15 +16,18 @@
{% if prefs.theme != "system" %} {{ prefs.theme }}{% endif %}">
<!-- NAVIGATION BAR -->
<nav>
<p id="logo">
<div id="logo">
<a id="libreddit" href="/">
<span id="lib">lib</span><span id="reddit">reddit.</span>
</a>
<span id="version">v{{ env!("CARGO_PKG_VERSION") }}</span>
<a id="settings_link" href="/settings">settings</a>
</p>
{% block subscriptions %}{% endblock %}
</div>
{% block search %}{% endblock %}
<a id="code" href="https://github.com/spikecodes/libreddit">code</a>
<div id="links">
<a id="settings_link" href="/settings">settings</a>
<a id="code" href="https://github.com/spikecodes/libreddit">code</a>
</div>
</nav>
<!-- MAIN CONTENT -->

View File

@ -13,6 +13,10 @@
<meta name="author" content="u/{{ post.author.name }}">
{% endblock %}
{% block subscriptions %}
{% call utils::sub_list(post.community.as_str()) %}
{% endblock %}
<!-- OPEN COMMENT MACRO -->
{% macro comment(item) -%}
<div id="{{ item.id }}" class="comment">

View File

@ -3,6 +3,10 @@
{% block title %}Libreddit: search results - {{ params.q }}{% endblock %}
{% block subscriptions %}
{% call utils::sub_list("") %}
{% endblock %}
{% block content %}
<div id="column_one">
<form id="search_sort">

View File

@ -8,45 +8,63 @@
{% endblock %}
{% block content %}
<form id="settings" action="/settings" method="POST">
<div id="prefs">
<p>Appearance</p>
<div id="theme">
<label for="theme">Theme:</label>
<select name="theme">
{% call utils::options(prefs.theme, ["system", "light", "dark"], "system") %}
</select>
<div id="settings">
<form action="/settings" method="POST">
<div class="prefs">
<p>Appearance</p>
<div id="theme">
<label for="theme">Theme:</label>
<select name="theme">
{% call utils::options(prefs.theme, ["system", "light", "dark"], "system") %}
</select>
</div>
<p>Interface</p>
<div id="front_page">
<label for="front_page">Front page:</label>
<select name="front_page">
{% call utils::options(prefs.front_page, ["default", "popular", "all"], "default") %}
</select>
</div>
<div id="layout">
<label for="layout">Layout:</label>
<select name="layout">
{% call utils::options(prefs.layout, ["card", "clean", "compact"], "card") %}
</select>
</div>
<div id="wide">
<label for="wide">Wide UI:</label>
<input type="checkbox" name="wide" {% if prefs.wide == "on" %}checked{% endif %}>
</div>
<p>Content</p>
<div id="comment_sort">
<label for="comment_sort">Default comment sort:</label>
<select name="comment_sort">
{% call utils::options(prefs.comment_sort, ["confidence", "top", "new", "controversial", "old"], "confidence") %}
</select>
</div>
<div id="hide_nsfw">
<label for="hide_nsfw">Hide NSFW posts:</label>
<input type="checkbox" name="hide_nsfw" {% if prefs.hide_nsfw == "on" %}checked{% endif %}>
</div>
</div>
<p>Interface</p>
<div id="front_page">
<label for="front_page">Front page:</label>
<select name="front_page">
{% call utils::options(prefs.front_page, ["popular", "all"], "popular") %}
</select>
</div>
<div id="layout">
<label for="layout">Layout:</label>
<select name="layout">
{% call utils::options(prefs.layout, ["card", "clean", "compact"], "card") %}
</select>
</div>
<div id="wide">
<label for="wide">Wide UI:</label>
<input type="checkbox" name="wide" {% if prefs.wide == "on" %}checked{% endif %}>
</div>
<p>Content</p>
<div id="comment_sort">
<label for="comment_sort">Default comment sort:</label>
<select name="comment_sort">
{% call utils::options(prefs.comment_sort, ["confidence", "top", "new", "controversial", "old"], "confidence") %}
</select>
</div>
<div id="hide_nsfw">
<label for="hide_nsfw">Hide NSFW posts:</label>
<input type="checkbox" name="hide_nsfw" {% if prefs.hide_nsfw == "on" %}checked{% endif %}>
</div>
</div>
<p id="settings_note"><b>Note:</b> settings are saved in browser cookies. Clearing your cookie data will reset them.</p>
<input id="save" type="submit" value="Save">
</form>
<p id="settings_note"><b>Note:</b> settings are saved in browser cookies. Clearing your cookie data will reset them.</p>
<input id="save" type="submit" value="Save">
</form>
{% if prefs.subs.len() > 0 %}
<aside class="prefs">
<p>Subscribed Subreddits</p>
<ul id="settings_subs">
{% for sub in prefs.subs %}
<li>
<span>{{ sub }}</span>
<form action="/r/{{ sub }}/unsubscribe/?redirect=/settings" method="POST">
<button class="unsubscribe">Unsubscribe</button>
</form>
</li>
{% endfor %}
</ul>
</aside>
{% endif %}
</div>
{% endblock %}

View File

@ -11,6 +11,10 @@
{% call utils::search(["/r/", sub.name.as_str()].concat(), "") %}
{% endblock %}
{% block subscriptions %}
{% call utils::sub_list(sub.name.as_str(), "wide") %}
{% endblock %}
{% block body %}
<main>
<div id="column_one">
@ -121,6 +125,17 @@
<div>{{ sub.members }}</div>
<div>{{ sub.active }}</div>
</div>
<div id="sub_subscription">
{% if prefs.subs.contains(sub.name) %}
<form action="/r/{{ sub.name }}/unsubscribe" method="POST">
<button class="unsubscribe">Unsubscribe</button>
</form>
{% else %}
<form action="/r/{{ sub.name }}/subscribe" method="POST">
<button class="subscribe">Subscribe</button>
</form>
{% endif %}
</div>
</div>
</div>
<details class="panel" id="sidebar">

View File

@ -7,6 +7,10 @@
{% block title %}{{ user.name.replace("u/", "") }} (u/{{ user.name }}) - Libreddit{% endblock %}
{% block subscriptions %}
{% call utils::sub_list("") %}
{% endblock %}
{% block body %}
<main>
<div id="column_one">

View File

@ -39,3 +39,16 @@
{% else if flair_part.flair_part_type == "text" %}<span>{{ flair_part.value }}</span>{% endif %}
{% endfor %}
{%- endmacro %}
{% macro sub_list(current) -%}
{% if prefs.subs.len() > 0 %}
<details id="subscriptions">
<summary>Subscriptions</summary>
<div id="sub_list">
{% for sub in prefs.subs %}
<a href="/r/{{ sub }}" {% if sub == current %}class="selected"{% endif %}>{{ sub }}</a>
{% endfor %}
</div>
</details>
{% endif %}
{%- endmacro %}

View File

@ -10,6 +10,10 @@
{% call utils::search(["/r/", sub.as_str()].concat(), "") %}
{% endblock %}
{% block subscriptions %}
{% call utils::sub_list(sub.as_str()) %}
{% endblock %}
{% block body %}
<main>
<div class="panel" id="column_one">