From c83a4e0cc87ef82e2fc4ea9031393533744a3d54 Mon Sep 17 00:00:00 2001 From: Daniel Valentine Date: Tue, 3 Jan 2023 02:39:45 -0700 Subject: [PATCH] Landing page for NSFW content, SFW-only mode (#656) Co-authored-by: Matt Co-authored-by: Spike <19519553+spikecodes@users.noreply.github.com> --- README.md | 12 +++++- app.json | 3 ++ src/duplicates.rs | 12 +++++- src/post.rs | 10 ++++- src/search.rs | 9 ++++- src/subreddit.rs | 9 ++++- src/user.rs | 12 +++++- src/utils.rs | 82 ++++++++++++++++++++++++++++++++++++++ static/style.css | 52 ++++++++++++++++++++++-- templates/base.html | 5 +++ templates/nsfwlanding.html | 28 +++++++++++++ templates/settings.html | 6 +++ 12 files changed, 227 insertions(+), 13 deletions(-) create mode 100644 templates/nsfwlanding.html diff --git a/README.md b/README.md index 891e6c9..5f3c647 100644 --- a/README.md +++ b/README.md @@ -182,9 +182,17 @@ Once installed, deploy Libreddit to `0.0.0.0:8080` by running: libreddit ``` -## Change Default Settings +## Instance settings -Assign a default value for each setting by passing environment variables to Libreddit in the format `LIBREDDIT_DEFAULT_{X}`. Replace `{X}` with the setting name (see list below) in capital letters. +Assign a default value for each instance-specific setting by passing environment variables to Libreddit in the format `LIBREDDIT_{X}`. Replace `{X}` with the setting name (see list below) in capital letters. + +|Name|Possible values|Default value|Description| +|-|-|-|-| +| `SFW_ONLY` | `["on", "off"]` | `off` | Enables SFW-only mode for the instance, i.e. all NSFW content is filtered. | + +## Default User Settings + +Assign a default value for each user-modifiable setting by passing environment variables to Libreddit in the format `LIBREDDIT_DEFAULT_{Y}`. Replace `{Y}` with the setting name (see list below) in capital letters. | Name | Possible values | Default value | |-------------------------|-----------------------------------------------------------------------------------------------------|---------------| diff --git a/app.json b/app.json index fd41fc8..48b6f1d 100644 --- a/app.json +++ b/app.json @@ -40,6 +40,9 @@ }, "LIBREDDIT_HIDE_HLS_NOTIFICATION": { "required": false + }, + "LIBREDDIT_SFW_ONLY": { + "required": false } } } diff --git a/src/duplicates.rs b/src/duplicates.rs index 6a64fc8..d68d1b3 100644 --- a/src/duplicates.rs +++ b/src/duplicates.rs @@ -3,7 +3,7 @@ use crate::client::json; use crate::server::RequestExt; use crate::subreddit::{can_access_quarantine, quarantine}; -use crate::utils::{error, filter_posts, get_filters, parse_post, template, Post, Preferences}; +use crate::utils::{error, filter_posts, get_filters, nsfw_landing, parse_post, setting, template, Post, Preferences}; use askama::Template; use hyper::{Body, Request, Response}; @@ -65,8 +65,16 @@ pub async fn item(req: Request) -> Result, String> { match json(path, quarantined).await { // Process response JSON. Ok(response) => { - let filters = get_filters(&req); let post = parse_post(&response[0]["data"]["children"][0]).await; + + // Return landing page if this post if this Reddit deems this post + // NSFW, but we have also disabled the display of NSFW content + // or if the instance is SFW-only. + if post.nsfw && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) { + return Ok(nsfw_landing(req).await.unwrap_or_default()); + } + + let filters = get_filters(&req); let (duplicates, num_posts_filtered, all_posts_filtered) = parse_duplicates(&response[1], &filters).await; // These are the values for the "before=", "after=", and "sort=" diff --git a/src/post.rs b/src/post.rs index e467fe7..1423e60 100644 --- a/src/post.rs +++ b/src/post.rs @@ -3,7 +3,7 @@ use crate::client::json; use crate::server::RequestExt; use crate::subreddit::{can_access_quarantine, quarantine}; use crate::utils::{ - error, format_num, get_filters, param, parse_post, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences, + error, format_num, get_filters, nsfw_landing, param, parse_post, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences, }; use hyper::{Body, Request, Response}; @@ -55,6 +55,14 @@ pub async fn item(req: Request) -> Result, String> { Ok(response) => { // Parse the JSON into Post and Comment structs let post = parse_post(&response[0]["data"]["children"][0]).await; + + // Return landing page if this post if this Reddit deems this post + // NSFW, but we have also disabled the display of NSFW content + // or if the instance is SFW-only. + if post.nsfw && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) { + return Ok(nsfw_landing(req).await.unwrap_or_default()); + } + let comments = parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req)); let url = req.uri().to_string(); diff --git a/src/search.rs b/src/search.rs index 87965c3..7158fdc 100644 --- a/src/search.rs +++ b/src/search.rs @@ -1,5 +1,5 @@ // CRATES -use crate::utils::{catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, setting, template, val, Post, Preferences}; +use crate::utils::{self, catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, setting, template, val, Post, Preferences}; use crate::{ client::json, subreddit::{can_access_quarantine, quarantine}, @@ -54,7 +54,12 @@ static REDDIT_URL_MATCH: Lazy = Lazy::new(|| Regex::new(r"^https?://([^\. // SERVICES pub async fn find(req: Request) -> Result, String> { - let nsfw_results = if setting(&req, "show_nsfw") == "on" { "&include_over_18=on" } else { "" }; + // This ensures that during a search, no NSFW posts are fetched at all + let nsfw_results = if setting(&req, "show_nsfw") == "on" && !utils::sfw_only() { + "&include_over_18=on" + } else { + "" + }; let path = format!("{}.json?{}{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default(), nsfw_results); let mut query = param(&path, "q").unwrap_or_default(); query = REDDIT_URL_MATCH.replace(&query, "").to_string(); diff --git a/src/subreddit.rs b/src/subreddit.rs index ef511c2..af87d93 100644 --- a/src/subreddit.rs +++ b/src/subreddit.rs @@ -1,6 +1,6 @@ // CRATES use crate::utils::{ - catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit, + catch_random, error, filter_posts, format_num, format_url, get_filters, nsfw_landing, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit, }; use crate::{client::json, server::ResponseExt, RequestExt}; use askama::Template; @@ -97,6 +97,12 @@ pub async fn community(req: Request) -> Result, String> { } }; + // Return landing page if this post if this is NSFW community but the user + // has disabled the display of NSFW content or if the instance is SFW-only. + if sub.nsfw && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) { + return Ok(nsfw_landing(req).await.unwrap_or_default()); + } + let path = format!("/r/{}/{}.json?{}&raw_json=1", sub_name.clone(), sort, req.uri().query().unwrap_or_default()); let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str())); let redirect_url = url[1..].replace('?', "%3F").replace('&', "%26").replace('+', "%2B"); @@ -424,5 +430,6 @@ async fn subreddit(sub: &str, quarantined: bool) -> Result { members: format_num(members), active: format_num(active), wiki: res["data"]["wiki_enabled"].as_bool().unwrap_or_default(), + nsfw: res["data"]["over18"].as_bool().unwrap_or_default(), }) } diff --git a/src/user.rs b/src/user.rs index 6c991ef..3620fce 100644 --- a/src/user.rs +++ b/src/user.rs @@ -1,7 +1,7 @@ // CRATES use crate::client::json; use crate::server::RequestExt; -use crate::utils::{error, filter_posts, format_url, get_filters, param, setting, template, Post, Preferences, User}; +use crate::utils::{error, filter_posts, format_url, get_filters, nsfw_landing, param, setting, template, Post, Preferences, User}; use askama::Template; use hyper::{Body, Request, Response}; use time::{macros::format_description, OffsetDateTime}; @@ -46,8 +46,17 @@ pub async fn profile(req: Request) -> Result, String> { // Retrieve other variables from Libreddit request let sort = param(&path, "sort").unwrap_or_default(); let username = req.param("name").unwrap_or_default(); + + // Retrieve info from user about page. let user = user(&username).await.unwrap_or_default(); + // Return landing page if this post if this Reddit deems this user NSFW, + // but we have also disabled the display of NSFW content or if the instance + // is SFW-only. + if user.nsfw && (setting(&req, "show_nsfw") != "on" || crate::utils::sfw_only()) { + return Ok(nsfw_landing(req).await.unwrap_or_default()); + } + let filters = get_filters(&req); if filters.contains(&["u_", &username].concat()) { template(UserTemplate { @@ -115,6 +124,7 @@ async fn user(name: &str) -> Result { created: created.format(format_description!("[month repr:short] [day] '[year repr:last_two]")).unwrap_or_default(), banner: about("banner_img"), description: about("public_description"), + nsfw: res["data"]["subreddit"]["over_18"].as_bool().unwrap_or_default(), } }) } diff --git a/src/utils.rs b/src/utils.rs index 06237e9..fee97e9 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -9,6 +9,7 @@ use regex::Regex; use rust_embed::RustEmbed; use serde_json::Value; use std::collections::{HashMap, HashSet}; +use std::env; use std::str::FromStr; use time::{macros::format_description, Duration, OffsetDateTime}; use url::Url; @@ -28,6 +29,16 @@ macro_rules! dbg_msg { }; } +/// Identifies whether or not the page is a subreddit, a user page, or a post. +/// This is used by the NSFW landing template to determine the mesage to convey +/// to the user. +#[derive(PartialEq, Eq)] +pub enum ResourceType { + Subreddit, + User, + Post, +} + // Post flair with content, background color and foreground color pub struct Flair { pub flair_parts: Vec, @@ -229,6 +240,7 @@ pub struct Post { pub comments: (String, String), pub gallery: Vec, pub awards: Awards, + pub nsfw: bool, } impl Post { @@ -329,6 +341,7 @@ impl Post { comments: format_num(data["num_comments"].as_i64().unwrap_or_default()), gallery, awards, + nsfw: post["data"]["over_18"].as_bool().unwrap_or_default(), }); } @@ -420,6 +433,27 @@ pub struct ErrorTemplate { pub url: String, } +/// Template for NSFW landing page. The landing page is displayed when a page's +/// content is wholly NSFW, but a user has not enabled the option to view NSFW +/// posts. +#[derive(Template)] +#[template(path = "nsfwlanding.html")] +pub struct NSFWLandingTemplate { + /// Identifier for the resource. This is either a subreddit name or a + /// username. (In the case of the latter, set is_user to true.) + pub res: String, + + /// Identifies whether or not the resource is a subreddit, a user page, + /// or a post. + pub res_type: ResourceType, + + /// User preferences. + pub prefs: Preferences, + + /// Request URL. + pub url: String, +} + #[derive(Default)] // User struct containing metadata about user pub struct User { @@ -430,6 +464,7 @@ pub struct User { pub created: String, pub banner: String, pub description: String, + pub nsfw: bool, } #[derive(Default)] @@ -444,6 +479,7 @@ pub struct Subreddit { pub members: (String, String), pub active: (String, String), pub wiki: bool, + pub nsfw: bool, } // Parser for query params, used in sorting (eg. /r/rust/?sort=hot) @@ -617,6 +653,7 @@ pub async fn parse_post(post: &serde_json::Value) -> Post { comments: format_num(post["data"]["num_comments"].as_i64().unwrap_or_default()), gallery, awards, + nsfw: post["data"]["over_18"].as_bool().unwrap_or_default(), } } @@ -829,6 +866,51 @@ pub async fn error(req: Request, msg: impl ToString) -> Result bool { + match env::var("LIBREDDIT_SFW_ONLY") { + Ok(val) => val == "on", + Err(_) => false, + } +} + +/// Renders the landing page for NSFW content when the user has not enabled +/// "show NSFW posts" in settings. +pub async fn nsfw_landing(req: Request) -> Result, String> { + let res_type: ResourceType; + let url = req.uri().to_string(); + + // Determine from the request URL if the resource is a subreddit, a user + // page, or a post. + let res: String = if !req.param("name").unwrap_or_default().is_empty() { + res_type = ResourceType::User; + req.param("name").unwrap_or_default() + } else if !req.param("id").unwrap_or_default().is_empty() { + res_type = ResourceType::Post; + req.param("id").unwrap_or_default() + } else { + res_type = ResourceType::Subreddit; + req.param("sub").unwrap_or_default() + }; + + let body = NSFWLandingTemplate { + res, + res_type, + prefs: Preferences::new(req), + url, + } + .render() + .unwrap_or_default(); + + Ok(Response::builder().status(403).header("content-type", "text/html").body(body.into()).unwrap_or_default()) +} + #[cfg(test)] mod tests { use super::{format_num, format_url, rewrite_urls}; diff --git a/static/style.css b/static/style.css index a0d4b69..05c493a 100644 --- a/static/style.css +++ b/static/style.css @@ -160,16 +160,35 @@ main { overflow: inherit; } -footer { +/* Body footer. */ +body > footer { + display: flex; + justify-content: center; + margin: 20px; +} + +body > footer > div#sfw-only { + color: var(--green); + border: 1px solid var(--green); + padding: 5px; + box-sizing: border-box; + border-radius: 5px; +} +/* / Body footer. */ + +/* Footer in content block. */ +main > * > footer { display: flex; justify-content: center; margin-top: 20px; } -footer > a { +main > * > footer > a { margin-right: 5px; } +/* / Footer in content block. */ + button { background: none; border: none; @@ -485,7 +504,7 @@ button.submit:hover > svg { stroke: var(--accent); } overflow-x: auto; } -#sort_options, #listing_options, footer > a { +#sort_options, #listing_options, main > * > footer > a { border-radius: 5px; align-items: center; box-shadow: var(--shadow); @@ -494,7 +513,7 @@ button.submit:hover > svg { stroke: var(--accent); } overflow: hidden; } -#sort_options > a, #listing_options > a, footer > a { +#sort_options > a, #listing_options > a, main > * > footer > a { color: var(--text); padding: 10px 20px; text-align: center; @@ -1315,6 +1334,31 @@ td, th { color: var(--accent); } +/* NSFW Landing Page */ + +#nsfw_landing { + display: inline-block; + text-align: center; + width: 100%; +} + +#nsfw_landing h1 { + display: inline-block; + margin-bottom: 20px; + text-align: center; + width: 100%; +} + +#nsfw_landing p { + display: inline-block; + text-align: center; + width: 100%; +} + +#nsfw_landing a { + color: var(--accent); +} + /* Mobile */ @media screen and (max-width: 800px) { diff --git a/templates/base.html b/templates/base.html index bbea3bf..dd882d8 100644 --- a/templates/base.html +++ b/templates/base.html @@ -65,5 +65,10 @@ {% endblock %} {% endblock %} + {% block footer %} + {% if crate::utils::sfw_only() %} +
This instance of Libreddit is SFW-only.
+ {% endif %} + {% endblock %} diff --git a/templates/nsfwlanding.html b/templates/nsfwlanding.html new file mode 100644 index 0000000..f6287a3 --- /dev/null +++ b/templates/nsfwlanding.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}NSFW content gated{% endblock %} +{% block sortstyle %}{% endblock %} +{% block content %} +
+

+ 😱 + {% if res_type == crate::utils::ResourceType::Subreddit %} + r/{{ res }} is a NSFW community! + {% else if res_type == crate::utils::ResourceType::User %} + u/{{ res }}'s content is NSFW! + {% else if res_type == crate::utils::ResourceType::Post %} + This post is NSFW! + {% endif %} +

+
+ +

+ {% if crate::utils::sfw_only() %} + This instance of Libreddit is SFW-only.

+ {% else %} + Enable "Show NSFW posts" in settings to view this {% if res_type == crate::utils::ResourceType::Subreddit %}subreddit{% else if res_type == crate::utils::ResourceType::User %}user's posts or comments{% else if res_type == crate::utils::ResourceType::Post %}post{% endif %}. + {% endif %} +

+
+{% endblock %} +{% block footer %} +{% endblock %} diff --git a/templates/settings.html b/templates/settings.html index b4bab8c..530176e 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -54,6 +54,7 @@ {% call utils::options(prefs.comment_sort, ["confidence", "top", "new", "controversial", "old"], "confidence") %} + {% if !crate::utils::sfw_only() %}
@@ -64,6 +65,7 @@
+ {% endif %}
@@ -121,6 +123,10 @@

Note: settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.


You can restore your current settings and subscriptions after clearing your cookies using this link.

+
+ {% if crate::utils::sfw_only() %} +

This instance is SFW-only. It will block all NSFW content.

+ {% endif %}