100 |
101 |
{{ sub.title }}
102 |
r/{{ sub.name }}
103 |
{{ sub.description }}
104 |
105 |
Members
106 |
Active
107 |
{{ sub.members.0 }}
108 |
{{ sub.active.0 }}
109 |
110 |
111 |
112 | {% if prefs.subscriptions.contains(sub.name) %}
113 |
116 | {% else %}
117 |
120 | {% endif %}
121 |
122 |
123 | {% if prefs.filters.contains(sub.name) %}
124 |
127 | {% else %}
128 |
131 | {% endif %}
132 |
133 | {% if crate::utils::enable_rss() %}
134 |
139 | {% endif %}
140 |
141 |
142 |
156 | {% endif %}
157 |
158 | {% endif %}
159 |
160 | {% endblock %}
161 |
--------------------------------------------------------------------------------
/src/oauth_resources.rs:
--------------------------------------------------------------------------------
1 | // This file was generated by scripts/update_oauth_resources.sh
2 | // Rerun scripts/update_oauth_resources.sh to update this file
3 | // Please do not edit manually
4 | // Filled in with real app versions
5 | pub const _IOS_APP_VERSION_LIST: &[&str; 1] = &[""];
6 | pub const ANDROID_APP_VERSION_LIST: &[&str; 150] = &[
7 | "Version 2024.22.1/Build 1652272",
8 | "Version 2024.23.1/Build 1665606",
9 | "Version 2024.24.1/Build 1682520",
10 | "Version 2024.25.0/Build 1693595",
11 | "Version 2024.25.2/Build 1700401",
12 | "Version 2024.25.3/Build 1703490",
13 | "Version 2024.26.0/Build 1710470",
14 | "Version 2024.26.1/Build 1717435",
15 | "Version 2024.28.0/Build 1737665",
16 | "Version 2024.28.1/Build 1741165",
17 | "Version 2024.30.0/Build 1770787",
18 | "Version 2024.31.0/Build 1786202",
19 | "Version 2024.32.0/Build 1809095",
20 | "Version 2024.32.1/Build 1813258",
21 | "Version 2024.33.0/Build 1819908",
22 | "Version 2024.34.0/Build 1837909",
23 | "Version 2024.35.0/Build 1861437",
24 | "Version 2024.36.0/Build 1875012",
25 | "Version 2024.37.0/Build 1888053",
26 | "Version 2024.38.0/Build 1902791",
27 | "Version 2024.39.0/Build 1916713",
28 | "Version 2024.40.0/Build 1928580",
29 | "Version 2024.41.0/Build 1941199",
30 | "Version 2024.41.1/Build 1947805",
31 | "Version 2024.42.0/Build 1952440",
32 | "Version 2024.43.0/Build 1972250",
33 | "Version 2024.44.0/Build 1988458",
34 | "Version 2024.45.0/Build 2001943",
35 | "Version 2024.46.0/Build 2012731",
36 | "Version 2024.47.0/Build 2029755",
37 | "Version 2023.48.0/Build 1319123",
38 | "Version 2023.49.0/Build 1321715",
39 | "Version 2023.49.1/Build 1322281",
40 | "Version 2023.50.0/Build 1332338",
41 | "Version 2023.50.1/Build 1345844",
42 | "Version 2024.02.0/Build 1368985",
43 | "Version 2024.03.0/Build 1379408",
44 | "Version 2024.04.0/Build 1391236",
45 | "Version 2024.05.0/Build 1403584",
46 | "Version 2024.06.0/Build 1418489",
47 | "Version 2024.07.0/Build 1429651",
48 | "Version 2024.08.0/Build 1439531",
49 | "Version 2024.10.0/Build 1470045",
50 | "Version 2024.10.1/Build 1478645",
51 | "Version 2024.11.0/Build 1480707",
52 | "Version 2024.12.0/Build 1494694",
53 | "Version 2024.13.0/Build 1505187",
54 | "Version 2024.14.0/Build 1520556",
55 | "Version 2024.15.0/Build 1536823",
56 | "Version 2024.16.0/Build 1551366",
57 | "Version 2024.17.0/Build 1568106",
58 | "Version 2024.18.0/Build 1577901",
59 | "Version 2024.18.1/Build 1585304",
60 | "Version 2024.19.0/Build 1593346",
61 | "Version 2024.20.0/Build 1612800",
62 | "Version 2024.20.1/Build 1615586",
63 | "Version 2024.20.2/Build 1624969",
64 | "Version 2024.20.3/Build 1624970",
65 | "Version 2024.21.0/Build 1631686",
66 | "Version 2024.22.0/Build 1645257",
67 | "Version 2023.21.0/Build 956283",
68 | "Version 2023.22.0/Build 968223",
69 | "Version 2023.23.0/Build 983896",
70 | "Version 2023.24.0/Build 998541",
71 | "Version 2023.25.0/Build 1014750",
72 | "Version 2023.25.1/Build 1018737",
73 | "Version 2023.26.0/Build 1019073",
74 | "Version 2023.27.0/Build 1031923",
75 | "Version 2023.28.0/Build 1046887",
76 | "Version 2023.29.0/Build 1059855",
77 | "Version 2023.30.0/Build 1078734",
78 | "Version 2023.31.0/Build 1091027",
79 | "Version 2023.32.0/Build 1109919",
80 | "Version 2023.32.1/Build 1114141",
81 | "Version 2023.33.1/Build 1129741",
82 | "Version 2023.34.0/Build 1144243",
83 | "Version 2023.35.0/Build 1157967",
84 | "Version 2023.36.0/Build 1168982",
85 | "Version 2023.37.0/Build 1182743",
86 | "Version 2023.38.0/Build 1198522",
87 | "Version 2023.39.0/Build 1211607",
88 | "Version 2023.39.1/Build 1221505",
89 | "Version 2023.40.0/Build 1221521",
90 | "Version 2023.41.0/Build 1233125",
91 | "Version 2023.41.1/Build 1239615",
92 | "Version 2023.42.0/Build 1245088",
93 | "Version 2023.43.0/Build 1257426",
94 | "Version 2023.44.0/Build 1268622",
95 | "Version 2023.45.0/Build 1281371",
96 | "Version 2023.47.0/Build 1303604",
97 | "Version 2022.42.0/Build 638508",
98 | "Version 2022.43.0/Build 648277",
99 | "Version 2022.44.0/Build 664348",
100 | "Version 2022.45.0/Build 677985",
101 | "Version 2023.01.0/Build 709875",
102 | "Version 2023.02.0/Build 717912",
103 | "Version 2023.03.0/Build 729220",
104 | "Version 2023.04.0/Build 744681",
105 | "Version 2023.05.0/Build 755453",
106 | "Version 2023.06.0/Build 775017",
107 | "Version 2023.07.0/Build 788827",
108 | "Version 2023.07.1/Build 790267",
109 | "Version 2023.08.0/Build 798718",
110 | "Version 2023.09.0/Build 812015",
111 | "Version 2023.09.1/Build 816833",
112 | "Version 2023.10.0/Build 821148",
113 | "Version 2023.11.0/Build 830610",
114 | "Version 2023.12.0/Build 841150",
115 | "Version 2023.13.0/Build 852246",
116 | "Version 2023.14.0/Build 861593",
117 | "Version 2023.14.1/Build 864826",
118 | "Version 2023.15.0/Build 870628",
119 | "Version 2023.16.0/Build 883294",
120 | "Version 2023.16.1/Build 886269",
121 | "Version 2023.17.0/Build 896030",
122 | "Version 2023.17.1/Build 900542",
123 | "Version 2023.18.0/Build 911877",
124 | "Version 2023.19.0/Build 927681",
125 | "Version 2023.20.0/Build 943980",
126 | "Version 2023.20.1/Build 946732",
127 | "Version 2022.20.0/Build 487703",
128 | "Version 2022.21.0/Build 492436",
129 | "Version 2022.22.0/Build 498700",
130 | "Version 2022.23.0/Build 502374",
131 | "Version 2022.23.1/Build 506606",
132 | "Version 2022.24.0/Build 510950",
133 | "Version 2022.24.1/Build 513462",
134 | "Version 2022.25.0/Build 515072",
135 | "Version 2022.25.1/Build 516394",
136 | "Version 2022.25.2/Build 519915",
137 | "Version 2022.26.0/Build 521193",
138 | "Version 2022.27.0/Build 527406",
139 | "Version 2022.27.1/Build 529687",
140 | "Version 2022.28.0/Build 533235",
141 | "Version 2022.30.0/Build 548620",
142 | "Version 2022.31.0/Build 556666",
143 | "Version 2022.31.1/Build 562612",
144 | "Version 2022.32.0/Build 567875",
145 | "Version 2022.33.0/Build 572600",
146 | "Version 2022.34.0/Build 579352",
147 | "Version 2022.35.0/Build 588016",
148 | "Version 2022.35.1/Build 589034",
149 | "Version 2022.36.0/Build 593102",
150 | "Version 2022.37.0/Build 601691",
151 | "Version 2022.38.0/Build 607460",
152 | "Version 2022.39.0/Build 615385",
153 | "Version 2022.39.1/Build 619019",
154 | "Version 2022.40.0/Build 624782",
155 | "Version 2022.41.0/Build 630468",
156 | "Version 2022.41.1/Build 634168",
157 | ];
158 | pub const _IOS_OS_VERSION_LIST: &[&str; 1] = &[""];
159 |
--------------------------------------------------------------------------------
/src/search.rs:
--------------------------------------------------------------------------------
1 | #![allow(clippy::cmp_owned)]
2 |
3 | // CRATES
4 | use crate::utils::{self, catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, setting, template, val, Post, Preferences};
5 | use crate::{
6 | client::json,
7 | server::RequestExt,
8 | subreddit::{can_access_quarantine, quarantine},
9 | };
10 | use askama::Template;
11 | use hyper::{Body, Request, Response};
12 | use regex::Regex;
13 | use std::sync::LazyLock;
14 |
15 | // STRUCTS
16 | struct SearchParams {
17 | q: String,
18 | sort: String,
19 | t: String,
20 | before: String,
21 | after: String,
22 | restrict_sr: String,
23 | typed: String,
24 | }
25 |
26 | // STRUCTS
27 | struct Subreddit {
28 | name: String,
29 | url: String,
30 | icon: String,
31 | description: String,
32 | subscribers: (String, String),
33 | }
34 |
35 | #[derive(Template)]
36 | #[template(path = "search.html")]
37 | struct SearchTemplate {
38 | posts: Vec
,
39 | subreddits: Vec,
40 | sub: String,
41 | params: SearchParams,
42 | prefs: Preferences,
43 | url: String,
44 | /// Whether the subreddit itself is filtered.
45 | is_filtered: bool,
46 | /// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
47 | /// and all fetched posts being filtered).
48 | all_posts_filtered: bool,
49 | /// Whether all posts were hidden because they are NSFW (and user has disabled show NSFW)
50 | all_posts_hidden_nsfw: bool,
51 | no_posts: bool,
52 | }
53 |
54 | /// Regex matched against search queries to determine if they are reddit urls.
55 | static REDDIT_URL_MATCH: LazyLock = LazyLock::new(|| Regex::new(r"^https?://([^\./]+\.)*reddit.com/").unwrap());
56 |
57 | // SERVICES
58 | pub async fn find(req: Request) -> Result, String> {
59 | // This ensures that during a search, no NSFW posts are fetched at all
60 | let nsfw_results = if setting(&req, "show_nsfw") == "on" && !utils::sfw_only() {
61 | "&include_over_18=on"
62 | } else {
63 | ""
64 | };
65 | let uri_path = req.uri().path().replace("+", "%2B");
66 | let path = format!("{}.json?{}{}&raw_json=1", uri_path, req.uri().query().unwrap_or_default(), nsfw_results);
67 | let mut query = param(&path, "q").unwrap_or_default();
68 | query = REDDIT_URL_MATCH.replace(&query, "").to_string();
69 |
70 | if query.is_empty() {
71 | return Ok(redirect("/"));
72 | }
73 |
74 | if query.starts_with("r/") || query.starts_with("user/") {
75 | return Ok(redirect(&format!("/{query}")));
76 | }
77 |
78 | if query.starts_with("R/") {
79 | return Ok(redirect(&format!("/r{}", &query[1..])));
80 | }
81 |
82 | if query.starts_with("u/") || query.starts_with("U/") {
83 | return Ok(redirect(&format!("/user{}", &query[1..])));
84 | }
85 |
86 | let sub = req.param("sub").unwrap_or_default();
87 | let quarantined = can_access_quarantine(&req, &sub);
88 | // Handle random subreddits
89 | if let Ok(random) = catch_random(&sub, "/find").await {
90 | return Ok(random);
91 | }
92 |
93 | let typed = param(&path, "type").unwrap_or_default();
94 |
95 | let sort = param(&path, "sort").unwrap_or_else(|| "relevance".to_string());
96 | let filters = get_filters(&req);
97 |
98 | // If search is not restricted to this subreddit, show other subreddits in search results
99 | let subreddits = if param(&path, "restrict_sr").is_none() {
100 | let mut subreddits = search_subreddits(&query, &typed).await;
101 | subreddits.retain(|s| !filters.contains(s.name.as_str()));
102 | subreddits
103 | } else {
104 | Vec::new()
105 | };
106 |
107 | let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
108 |
109 | // If all requested subs are filtered, we don't need to fetch posts.
110 | if sub.split('+').all(|s| filters.contains(s)) {
111 | Ok(template(&SearchTemplate {
112 | posts: Vec::new(),
113 | subreddits,
114 | sub,
115 | params: SearchParams {
116 | q: query.replace('"', """),
117 | sort,
118 | t: param(&path, "t").unwrap_or_default(),
119 | before: param(&path, "after").unwrap_or_default(),
120 | after: String::new(),
121 | restrict_sr: param(&path, "restrict_sr").unwrap_or_default(),
122 | typed,
123 | },
124 | prefs: Preferences::new(&req),
125 | url,
126 | is_filtered: true,
127 | all_posts_filtered: false,
128 | all_posts_hidden_nsfw: false,
129 | no_posts: false,
130 | }))
131 | } else {
132 | match Post::fetch(&path, quarantined).await {
133 | Ok((mut posts, after)) => {
134 | let (_, all_posts_filtered) = filter_posts(&mut posts, &filters);
135 | let no_posts = posts.is_empty();
136 | let all_posts_hidden_nsfw = !no_posts && (posts.iter().all(|p| p.flags.nsfw) && setting(&req, "show_nsfw") != "on");
137 | Ok(template(&SearchTemplate {
138 | posts,
139 | subreddits,
140 | sub,
141 | params: SearchParams {
142 | q: query.replace('"', """),
143 | sort,
144 | t: param(&path, "t").unwrap_or_default(),
145 | before: param(&path, "after").unwrap_or_default(),
146 | after,
147 | restrict_sr: param(&path, "restrict_sr").unwrap_or_default(),
148 | typed,
149 | },
150 | prefs: Preferences::new(&req),
151 | url,
152 | is_filtered: false,
153 | all_posts_filtered,
154 | all_posts_hidden_nsfw,
155 | no_posts,
156 | }))
157 | }
158 | Err(msg) => {
159 | if msg == "quarantined" || msg == "gated" {
160 | let sub = req.param("sub").unwrap_or_default();
161 | Ok(quarantine(&req, sub, &msg))
162 | } else {
163 | error(req, &msg).await
164 | }
165 | }
166 | }
167 | }
168 | }
169 |
170 | async fn search_subreddits(q: &str, typed: &str) -> Vec {
171 | let limit = if typed == "sr_user" { "50" } else { "3" };
172 | let subreddit_search_path = format!("/subreddits/search.json?q={}&limit={limit}", q.replace(' ', "+"));
173 |
174 | // Send a request to the url
175 | json(subreddit_search_path, false).await.unwrap_or_default()["data"]["children"]
176 | .as_array()
177 | .map(ToOwned::to_owned)
178 | .unwrap_or_default()
179 | .iter()
180 | .map(|subreddit| {
181 | // For each subreddit from subreddit list
182 | // Fetch subreddit icon either from the community_icon or icon_img value
183 | let icon = subreddit["data"]["community_icon"].as_str().map_or_else(|| val(subreddit, "icon_img"), ToString::to_string);
184 |
185 | Subreddit {
186 | name: val(subreddit, "display_name"),
187 | url: val(subreddit, "url"),
188 | icon: format_url(&icon),
189 | description: val(subreddit, "public_description"),
190 | subscribers: format_num(subreddit["data"]["subscribers"].as_f64().unwrap_or_default() as i64),
191 | }
192 | })
193 | .collect::>()
194 | }
195 |
--------------------------------------------------------------------------------
/src/user.rs:
--------------------------------------------------------------------------------
1 | #![allow(clippy::cmp_owned)]
2 |
3 | // CRATES
4 | use crate::client::json;
5 | use crate::server::RequestExt;
6 | use crate::utils::{error, filter_posts, format_url, get_filters, nsfw_landing, param, setting, template, Post, Preferences, User};
7 | use crate::{config, utils};
8 | use askama::Template;
9 | use chrono::DateTime;
10 | use htmlescape::decode_html;
11 | use hyper::{Body, Request, Response};
12 | use time::{macros::format_description, OffsetDateTime};
13 |
14 | // STRUCTS
15 | #[derive(Template)]
16 | #[template(path = "user.html")]
17 | struct UserTemplate {
18 | user: User,
19 | posts: Vec,
20 | sort: (String, String),
21 | ends: (String, String),
22 | /// "overview", "comments", or "submitted"
23 | listing: String,
24 | prefs: Preferences,
25 | url: String,
26 | redirect_url: String,
27 | /// Whether the user themself is filtered.
28 | is_filtered: bool,
29 | /// Whether all fetched posts are filtered (to differentiate between no posts fetched in the first place,
30 | /// and all fetched posts being filtered).
31 | all_posts_filtered: bool,
32 | /// Whether all posts were hidden because they are NSFW (and user has disabled show NSFW)
33 | all_posts_hidden_nsfw: bool,
34 | no_posts: bool,
35 | }
36 |
37 | // FUNCTIONS
38 | pub async fn profile(req: Request) -> Result, String> {
39 | let listing = req.param("listing").unwrap_or_else(|| "overview".to_string());
40 |
41 | // Build the Reddit JSON API path
42 | let path = format!(
43 | "/user/{}/{listing}.json?{}&raw_json=1",
44 | req.param("name").unwrap_or_else(|| "reddit".to_string()),
45 | req.uri().query().unwrap_or_default(),
46 | );
47 | let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
48 | let redirect_url = url[1..].replace('?', "%3F").replace('&', "%26");
49 |
50 | // Retrieve other variables from Redlib request
51 | let sort = param(&path, "sort").unwrap_or_default();
52 | let username = req.param("name").unwrap_or_default();
53 |
54 | // Retrieve info from user about page.
55 | let user = user(&username).await.unwrap_or_default();
56 |
57 | let req_url = req.uri().to_string();
58 | // Return landing page if this post if this Reddit deems this user NSFW,
59 | // but we have also disabled the display of NSFW content or if the instance
60 | // is SFW-only.
61 | if user.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
62 | return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
63 | }
64 |
65 | let filters = get_filters(&req);
66 | if filters.contains(&["u_", &username].concat()) {
67 | Ok(template(&UserTemplate {
68 | user,
69 | posts: Vec::new(),
70 | sort: (sort, param(&path, "t").unwrap_or_default()),
71 | ends: (param(&path, "after").unwrap_or_default(), String::new()),
72 | listing,
73 | prefs: Preferences::new(&req),
74 | url,
75 | redirect_url,
76 | is_filtered: true,
77 | all_posts_filtered: false,
78 | all_posts_hidden_nsfw: false,
79 | no_posts: false,
80 | }))
81 | } else {
82 | // Request user posts/comments from Reddit
83 | match Post::fetch(&path, false).await {
84 | Ok((mut posts, after)) => {
85 | let (_, all_posts_filtered) = filter_posts(&mut posts, &filters);
86 | let no_posts = posts.is_empty();
87 | let all_posts_hidden_nsfw = !no_posts && (posts.iter().all(|p| p.flags.nsfw) && setting(&req, "show_nsfw") != "on");
88 | Ok(template(&UserTemplate {
89 | user,
90 | posts,
91 | sort: (sort, param(&path, "t").unwrap_or_default()),
92 | ends: (param(&path, "after").unwrap_or_default(), after),
93 | listing,
94 | prefs: Preferences::new(&req),
95 | url,
96 | redirect_url,
97 | is_filtered: false,
98 | all_posts_filtered,
99 | all_posts_hidden_nsfw,
100 | no_posts,
101 | }))
102 | }
103 | // If there is an error show error page
104 | Err(msg) => error(req, &msg).await,
105 | }
106 | }
107 | }
108 |
109 | // USER
110 | async fn user(name: &str) -> Result {
111 | // Build the Reddit JSON API path
112 | let path: String = format!("/user/{name}/about.json?raw_json=1");
113 |
114 | // Send a request to the url
115 | json(path, false).await.map(|res| {
116 | // Grab creation date as unix timestamp
117 | let created_unix = res["data"]["created"].as_f64().unwrap_or(0.0).round() as i64;
118 | let created = OffsetDateTime::from_unix_timestamp(created_unix).unwrap_or(OffsetDateTime::UNIX_EPOCH);
119 |
120 | // Closure used to parse JSON from Reddit APIs
121 | let about = |item| res["data"]["subreddit"][item].as_str().unwrap_or_default().to_string();
122 |
123 | // Parse the JSON output into a User struct
124 | User {
125 | name: res["data"]["name"].as_str().unwrap_or(name).to_owned(),
126 | title: about("title"),
127 | icon: format_url(&about("icon_img")),
128 | karma: res["data"]["total_karma"].as_i64().unwrap_or(0),
129 | created: created.format(format_description!("[month repr:short] [day] '[year repr:last_two]")).unwrap_or_default(),
130 | banner: about("banner_img"),
131 | description: about("public_description"),
132 | nsfw: res["data"]["subreddit"]["over_18"].as_bool().unwrap_or_default(),
133 | }
134 | })
135 | }
136 |
137 | pub async fn rss(req: Request) -> Result, String> {
138 | if config::get_setting("REDLIB_ENABLE_RSS").is_none() {
139 | return Ok(error(req, "RSS is disabled on this instance.").await.unwrap_or_default());
140 | }
141 | use crate::utils::rewrite_urls;
142 | use hyper::header::CONTENT_TYPE;
143 | use rss::{ChannelBuilder, Item};
144 |
145 | // Get user
146 | let user_str = req.param("name").unwrap_or_default();
147 |
148 | let listing = req.param("listing").unwrap_or_else(|| "overview".to_string());
149 |
150 | // Get path
151 | let path = format!("/user/{user_str}/{listing}.json?{}&raw_json=1", req.uri().query().unwrap_or_default(),);
152 |
153 | // Get user
154 | let user_obj = user(&user_str).await.unwrap_or_default();
155 |
156 | // Get posts
157 | let (posts, _) = Post::fetch(&path, false).await?;
158 |
159 | // Build the RSS feed
160 | let channel = ChannelBuilder::default()
161 | .title(user_str)
162 | .description(user_obj.description)
163 | .items(
164 | posts
165 | .into_iter()
166 | .map(|post| Item {
167 | title: Some(post.title.to_string()),
168 | link: Some(format_url(&utils::get_post_url(&post))),
169 | author: Some(post.author.name),
170 | pub_date: Some(DateTime::from_timestamp(post.created_ts as i64, 0).unwrap_or_default().to_rfc2822()),
171 | content: Some(rewrite_urls(&decode_html(&post.body).unwrap_or_else(|_| post.body.clone()))),
172 | ..Default::default()
173 | })
174 | .collect::>(),
175 | )
176 | .build();
177 |
178 | // Serialize the feed to RSS
179 | let body = channel.to_string().into_bytes();
180 |
181 | // Create the HTTP response
182 | let mut res = Response::new(Body::from(body));
183 | res.headers_mut().insert(CONTENT_TYPE, hyper::header::HeaderValue::from_static("application/rss+xml"));
184 |
185 | Ok(res)
186 | }
187 |
188 | #[tokio::test(flavor = "multi_thread")]
189 | async fn test_fetching_user() {
190 | let user = user("spez").await;
191 | assert!(user.is_ok());
192 | assert!(user.unwrap().karma > 100);
193 | }
194 |
--------------------------------------------------------------------------------
/templates/user.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %} {% import "utils.html" as utils %} {% block search %}
2 | {% call utils::search("".to_owned(), "") %} {% endblock %} {% block title %}{{
3 | user.name.replace("u/", "") }} (u/{{ user.name }}) - Redlib{% endblock %} {%
4 | block subscriptions %} {% call utils::sub_list("") %} {% endblock %} {% block
5 | body %}
6 |
7 | {% if !is_filtered %}
8 |
9 |
37 |
38 | {% if all_posts_hidden_nsfw %}
39 |
40 | All posts are hidden because they are NSFW. Enable "Show NSFW posts"
41 | in settings to view.
42 |
43 | {% endif %} {% if no_posts %}
44 |
No posts were found.
45 | {% endif %} {% if all_posts_filtered %}
46 |
(All content on this page has been filtered)
47 | {% else %}
48 |
49 | {% for post in posts %} {% if post.flags.nsfw && prefs.show_nsfw !=
50 | "on" %} {% else if !post.title.is_empty() %} {% call
51 | utils::post_in_list(post) %} {% else %}
52 |
84 | {% endif %} {% endfor %} {% if prefs.use_hls == "on" %}
85 |
86 |
87 | {% endif %}
88 |
89 | {% endif %}
90 |
91 |
92 | {% if ends.0 != "" %}
93 | PREV
98 | {% endif %} {% if ends.1 != "" %}
99 | NEXT
104 | {% endif %}
105 |
106 |
107 | {% endif %}
108 |
109 | {% if is_filtered %}
110 | (Content from u/{{ user.name }} has been filtered)
111 | {% endif %}
112 |
113 |
119 |
{{ user.title }}
120 |
u/{{ user.name }}
121 |
{{ user.description }}
122 |
123 |
Karma
124 |
Created
125 |
{{ user.karma }}
126 |
{{ user.created }}
127 |
128 |
129 | {% let name = ["u_", user.name.as_str()].join("") %}
130 |
131 | {% if prefs.subscriptions.contains(name) %}
132 |
138 | {% else %}
139 |
145 | {% endif %}
146 |
147 |
148 | {% if prefs.filters.contains(name) %}
149 |
155 | {% else %}
156 |
162 | {% endif %}
163 |
164 | {% if crate::utils::enable_rss() %}
165 |
173 | {% endif %}
174 |
175 |
176 |
177 |
178 | {% endblock %}
179 |
--------------------------------------------------------------------------------
/src/duplicates.rs:
--------------------------------------------------------------------------------
1 | //! Handler for post duplicates.
2 |
3 | use crate::client::json;
4 | use crate::server::RequestExt;
5 | use crate::subreddit::{can_access_quarantine, quarantine};
6 | use crate::utils::{error, filter_posts, get_filters, nsfw_landing, parse_post, template, Post, Preferences};
7 |
8 | use askama::Template;
9 | use hyper::{Body, Request, Response};
10 | use serde_json::Value;
11 | use std::borrow::ToOwned;
12 | use std::collections::HashSet;
13 | use std::vec::Vec;
14 |
15 | /// `DuplicatesParams` contains the parameters in the URL.
16 | struct DuplicatesParams {
17 | before: String,
18 | after: String,
19 | sort: String,
20 | }
21 |
22 | /// `DuplicatesTemplate` defines an Askama template for rendering duplicate
23 | /// posts.
24 | #[derive(Template)]
25 | #[template(path = "duplicates.html")]
26 | struct DuplicatesTemplate {
27 | /// params contains the relevant request parameters.
28 | params: DuplicatesParams,
29 |
30 | /// post is the post whose ID is specified in the reqeust URL. Note that
31 | /// this is not necessarily the "original" post.
32 | post: Post,
33 |
34 | /// duplicates is the list of posts that, per Reddit, are duplicates of
35 | /// Post above.
36 | duplicates: Vec,
37 |
38 | /// prefs are the user preferences.
39 | prefs: Preferences,
40 |
41 | /// url is the request URL.
42 | url: String,
43 |
44 | /// num_posts_filtered counts how many posts were filtered from the
45 | /// duplicates list.
46 | num_posts_filtered: u64,
47 |
48 | /// all_posts_filtered is true if every duplicate was filtered. This is an
49 | /// edge case but can still happen.
50 | all_posts_filtered: bool,
51 | }
52 |
53 | /// Make the GET request to Reddit. It assumes `req` is the appropriate Reddit
54 | /// REST endpoint for enumerating post duplicates.
55 | pub async fn item(req: Request) -> Result, String> {
56 | let path: String = format!("{}.json?{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default());
57 | let sub = req.param("sub").unwrap_or_default();
58 | let quarantined = can_access_quarantine(&req, &sub);
59 |
60 | // Log the request in debugging mode
61 | #[cfg(debug_assertions)]
62 | req.param("id").unwrap_or_default();
63 |
64 | // Send the GET, and await JSON.
65 | match json(path, quarantined).await {
66 | // Process response JSON.
67 | Ok(response) => {
68 | let post = parse_post(&response[0]["data"]["children"][0]).await;
69 |
70 | let req_url = req.uri().to_string();
71 | // Return landing page if this post if this Reddit deems this post
72 | // NSFW, but we have also disabled the display of NSFW content
73 | // or if the instance is SFW-only
74 | if post.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
75 | return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
76 | }
77 |
78 | let filters = get_filters(&req);
79 | let (duplicates, num_posts_filtered, all_posts_filtered) = parse_duplicates(&response[1], &filters).await;
80 |
81 | // These are the values for the "before=", "after=", and "sort="
82 | // query params, respectively.
83 | let mut before: String = String::new();
84 | let mut after: String = String::new();
85 | let mut sort: String = String::new();
86 |
87 | // FIXME: We have to perform a kludge to work around a Reddit API
88 | // bug.
89 | //
90 | // The JSON object in "data" will never contain a "before" value so
91 | // it is impossible to use it to determine our position in a
92 | // listing. We'll make do by getting the ID of the first post in
93 | // the listing, setting that as our "before" value, and ask Reddit
94 | // to give us a batch of duplicate posts up to that post.
95 | //
96 | // Likewise, if we provide a "before" request in the GET, the
97 | // result won't have an "after" in the JSON, in addition to missing
98 | // the "before." So we will have to use the final post in the list
99 | // of duplicates.
100 | //
101 | // That being said, we'll also need to capture the value of the
102 | // "sort=" parameter as well, so we will need to inspect the
103 | // query key-value pairs anyway.
104 | let l = duplicates.len();
105 | if l > 0 {
106 | // This gets set to true if "before=" is one of the GET params.
107 | let mut have_before: bool = false;
108 |
109 | // This gets set to true if "after=" is one of the GET params.
110 | let mut have_after: bool = false;
111 |
112 | // Inspect the query key-value pairs. We will need to record
113 | // the value of "sort=", along with checking to see if either
114 | // one of "before=" or "after=" are given.
115 | //
116 | // If we're in the middle of the batch (evidenced by the
117 | // presence of a "before=" or "after=" parameter in the GET),
118 | // then use the first post as the "before" reference.
119 | //
120 | // We'll do this iteratively. Better than with .map_or()
121 | // since a closure will continue to operate on remaining
122 | // elements even after we've determined one of "before=" or
123 | // "after=" (or both) are in the GET request.
124 | //
125 | // In practice, here should only ever be one of "before=" or
126 | // "after=" and never both.
127 | let query_str = req.uri().query().unwrap_or_default().to_string();
128 |
129 | if !query_str.is_empty() {
130 | for param in query_str.split('&') {
131 | let kv: Vec<&str> = param.split('=').collect();
132 | if kv.len() < 2 {
133 | // Reject invalid query parameter.
134 | continue;
135 | }
136 |
137 | let key: &str = kv[0];
138 | match key {
139 | "before" => have_before = true,
140 | "after" => have_after = true,
141 | "sort" => {
142 | let val: &str = kv[1];
143 | match val {
144 | "new" | "num_comments" => sort = val.to_string(),
145 | _ => {}
146 | }
147 | }
148 | _ => {}
149 | }
150 | }
151 | }
152 |
153 | if have_after {
154 | "t3_".clone_into(&mut before);
155 | before.push_str(&duplicates[0].id);
156 | }
157 |
158 | // Address potentially missing "after". If "before=" is in the
159 | // GET, then "after" will be null in the JSON (see FIXME
160 | // above).
161 | if have_before {
162 | // The next batch will need to start from one after the
163 | // last post in the current batch.
164 | "t3_".clone_into(&mut after);
165 | after.push_str(&duplicates[l - 1].id);
166 |
167 | // Here is where things get terrible. Notice that we
168 | // haven't set `before`. In order to do so, we will
169 | // need to know if there is a batch that exists before
170 | // this one, and doing so requires actually fetching the
171 | // previous batch. In other words, we have to do yet one
172 | // more GET to Reddit. There is no other way to determine
173 | // whether or not to define `before`.
174 | //
175 | // We'll mitigate that by requesting at most one duplicate.
176 | let new_path: String = format!(
177 | "{}.json?before=t3_{}&sort={}&limit=1&raw_json=1",
178 | req.uri().path(),
179 | &duplicates[0].id,
180 | if sort.is_empty() { "num_comments".to_string() } else { sort.clone() }
181 | );
182 | match json(new_path, true).await {
183 | Ok(response) => {
184 | if !response[1]["data"]["children"].as_array().unwrap_or(&Vec::new()).is_empty() {
185 | "t3_".clone_into(&mut before);
186 | before.push_str(&duplicates[0].id);
187 | }
188 | }
189 | Err(msg) => {
190 | // Abort entirely if we couldn't get the previous
191 | // batch.
192 | return error(req, &msg).await;
193 | }
194 | }
195 | } else {
196 | after = response[1]["data"]["after"].as_str().unwrap_or_default().to_string();
197 | }
198 | }
199 |
200 | Ok(template(&DuplicatesTemplate {
201 | params: DuplicatesParams { before, after, sort },
202 | post,
203 | duplicates,
204 | prefs: Preferences::new(&req),
205 | url: req_url,
206 | num_posts_filtered,
207 | all_posts_filtered,
208 | }))
209 | }
210 |
211 | // Process error.
212 | Err(msg) => {
213 | if msg == "quarantined" || msg == "gated" {
214 | let sub = req.param("sub").unwrap_or_default();
215 | Ok(quarantine(&req, sub, &msg))
216 | } else {
217 | error(req, &msg).await
218 | }
219 | }
220 | }
221 | }
222 |
223 | // DUPLICATES
224 | async fn parse_duplicates(json: &Value, filters: &HashSet) -> (Vec, u64, bool) {
225 | let post_duplicates: &Vec = &json["data"]["children"].as_array().map_or(Vec::new(), ToOwned::to_owned);
226 | let mut duplicates: Vec = Vec::new();
227 |
228 | // Process each post and place them in the Vec.
229 | for val in post_duplicates {
230 | let post: Post = parse_post(val).await;
231 | duplicates.push(post);
232 | }
233 |
234 | let (num_posts_filtered, all_posts_filtered) = filter_posts(&mut duplicates, filters);
235 | (duplicates, num_posts_filtered, all_posts_filtered)
236 | }
237 |
--------------------------------------------------------------------------------
/src/instance_info.rs:
--------------------------------------------------------------------------------
1 | use crate::{
2 | config::{Config, CONFIG},
3 | server::RequestExt,
4 | utils::{ErrorTemplate, Preferences},
5 | };
6 | use askama::Template;
7 | use build_html::{Container, Html, HtmlContainer, Table};
8 | use hyper::{http::Error, Body, Request, Response};
9 | use serde::{Deserialize, Serialize};
10 | use std::sync::LazyLock;
11 | use time::OffsetDateTime;
12 |
13 | /// This is the local static that is initialized at runtime (technically at
14 | /// the first request to the info endpoint) and contains the data
15 | /// retrieved from the info endpoint.
16 | pub static INSTANCE_INFO: LazyLock = LazyLock::new(InstanceInfo::new);
17 |
18 | /// Handles instance info endpoint
19 | pub async fn instance_info(req: Request) -> Result, String> {
20 | // This will retrieve the extension given, or create a new string - which will
21 | // simply become the last option, an HTML page.
22 | let extension = req.param("extension").unwrap_or_default();
23 | let response = match extension.as_str() {
24 | "yaml" | "yml" => info_yaml(),
25 | "txt" => info_txt(),
26 | "json" => info_json(),
27 | "html" | "" => info_html(&req),
28 | _ => {
29 | let error = ErrorTemplate {
30 | msg: "Error: Invalid info extension".into(),
31 | prefs: Preferences::new(&req),
32 | url: req.uri().to_string(),
33 | }
34 | .render()
35 | .unwrap();
36 | Response::builder().status(404).header("content-type", "text/html; charset=utf-8").body(error.into())
37 | }
38 | };
39 | response.map_err(|err| format!("{err}"))
40 | }
41 |
42 | fn info_json() -> Result, Error> {
43 | if let Ok(body) = serde_json::to_string(&*INSTANCE_INFO) {
44 | Response::builder().status(200).header("content-type", "application/json").body(body.into())
45 | } else {
46 | Response::builder()
47 | .status(500)
48 | .header("content-type", "text/plain")
49 | .body(Body::from("Error serializing JSON"))
50 | }
51 | }
52 |
53 | fn info_yaml() -> Result, Error> {
54 | if let Ok(body) = serde_yaml::to_string(&*INSTANCE_INFO) {
55 | // We can use `application/yaml` as media type, though there is no guarantee
56 | // that browsers will honor it. But we'll do it anyway. See:
57 | // https://github.com/ietf-wg-httpapi/mediatypes/blob/main/draft-ietf-httpapi-yaml-mediatypes.md#media-type-applicationyaml-application-yaml
58 | Response::builder().status(200).header("content-type", "application/yaml").body(body.into())
59 | } else {
60 | Response::builder()
61 | .status(500)
62 | .header("content-type", "text/plain")
63 | .body(Body::from("Error serializing YAML."))
64 | }
65 | }
66 |
67 | fn info_txt() -> Result, Error> {
68 | Response::builder()
69 | .status(200)
70 | .header("content-type", "text/plain")
71 | .body(Body::from(INSTANCE_INFO.to_string(&StringType::Raw)))
72 | }
73 | fn info_html(req: &Request) -> Result, Error> {
74 | let message = MessageTemplate {
75 | title: String::from("Instance information"),
76 | body: INSTANCE_INFO.to_string(&StringType::Html),
77 | prefs: Preferences::new(req),
78 | url: req.uri().to_string(),
79 | }
80 | .render()
81 | .unwrap();
82 | Response::builder().status(200).header("content-type", "text/html; charset=utf8").body(Body::from(message))
83 | }
84 | #[derive(Serialize, Deserialize, Default)]
85 | pub struct InstanceInfo {
86 | package_name: String,
87 | crate_version: String,
88 | pub git_commit: String,
89 | deploy_date: String,
90 | compile_mode: String,
91 | deploy_unix_ts: i64,
92 | config: Config,
93 | }
94 |
95 | impl InstanceInfo {
96 | pub fn new() -> Self {
97 | Self {
98 | package_name: env!("CARGO_PKG_NAME").to_string(),
99 | crate_version: env!("CARGO_PKG_VERSION").to_string(),
100 | git_commit: env!("GIT_HASH").to_string(),
101 | deploy_date: OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()).to_string(),
102 | #[cfg(debug_assertions)]
103 | compile_mode: "Debug".into(),
104 | #[cfg(not(debug_assertions))]
105 | compile_mode: "Release".into(),
106 | deploy_unix_ts: OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()).unix_timestamp(),
107 | config: CONFIG.clone(),
108 | }
109 | }
110 | fn to_table(&self) -> String {
111 | let mut container = Container::default();
112 | let convert = |o: &Option| -> String { o.clone().unwrap_or_else(|| "Unset ".to_owned()) };
113 | if let Some(banner) = &self.config.banner {
114 | container.add_header(3, "Instance banner");
115 | container.add_raw(" ");
116 | container.add_paragraph(banner);
117 | container.add_raw(" ");
118 | }
119 | container.add_table(
120 | Table::from([
121 | ["Package name", &self.package_name],
122 | ["Crate version", &self.crate_version],
123 | ["Git commit", &self.git_commit],
124 | ["Deploy date", &self.deploy_date],
125 | ["Deploy timestamp", &self.deploy_unix_ts.to_string()],
126 | ["Compile mode", &self.compile_mode],
127 | ["SFW only", &convert(&self.config.sfw_only)],
128 | ["Pushshift frontend", &convert(&self.config.pushshift)],
129 | ["RSS enabled", &convert(&self.config.enable_rss)],
130 | ["Full URL", &convert(&self.config.full_url)],
131 | ["Remove default feeds", &convert(&self.config.default_remove_default_feeds)],
132 | //TODO: fallback to crate::config::DEFAULT_PUSHSHIFT_FRONTEND
133 | ])
134 | .with_header_row(["Settings"]),
135 | );
136 | container.add_raw(" ");
137 | container.add_table(
138 | Table::from([
139 | ["Hide awards", &convert(&self.config.default_hide_awards)],
140 | ["Hide score", &convert(&self.config.default_hide_score)],
141 | ["Theme", &convert(&self.config.default_theme)],
142 | ["Front page", &convert(&self.config.default_front_page)],
143 | ["Layout", &convert(&self.config.default_layout)],
144 | ["Wide", &convert(&self.config.default_wide)],
145 | ["Comment sort", &convert(&self.config.default_comment_sort)],
146 | ["Post sort", &convert(&self.config.default_post_sort)],
147 | ["Blur Spoiler", &convert(&self.config.default_blur_spoiler)],
148 | ["Show NSFW", &convert(&self.config.default_show_nsfw)],
149 | ["Blur NSFW", &convert(&self.config.default_blur_nsfw)],
150 | ["Use HLS", &convert(&self.config.default_use_hls)],
151 | ["Hide HLS notification", &convert(&self.config.default_hide_hls_notification)],
152 | ["Subscriptions", &convert(&self.config.default_subscriptions)],
153 | ["Filters", &convert(&self.config.default_filters)],
154 | ])
155 | .with_header_row(["Default preferences"]),
156 | );
157 | container.to_html_string().replace("", " ")
158 | }
159 | fn to_string(&self, string_type: &StringType) -> String {
160 | match string_type {
161 | StringType::Raw => {
162 | format!(
163 | "Package name: {}\n
164 | Crate version: {}\n
165 | Git commit: {}\n
166 | Deploy date: {}\n
167 | Deploy timestamp: {}\n
168 | Compile mode: {}\n
169 | SFW only: {:?}\n
170 | Pushshift frontend: {:?}\n
171 | RSS enabled: {:?}\n
172 | Full URL: {:?}\n
173 | Remove default feeds: {:?}\n
174 | Config:\n
175 | Banner: {:?}\n
176 | Hide awards: {:?}\n
177 | Hide score: {:?}\n
178 | Default theme: {:?}\n
179 | Default front page: {:?}\n
180 | Default layout: {:?}\n
181 | Default wide: {:?}\n
182 | Default comment sort: {:?}\n
183 | Default post sort: {:?}\n
184 | Default blur Spoiler: {:?}\n
185 | Default show NSFW: {:?}\n
186 | Default blur NSFW: {:?}\n
187 | Default use HLS: {:?}\n
188 | Default hide HLS notification: {:?}\n
189 | Default subscriptions: {:?}\n
190 | Default filters: {:?}\n",
191 | self.package_name,
192 | self.crate_version,
193 | self.git_commit,
194 | self.deploy_date,
195 | self.deploy_unix_ts,
196 | self.compile_mode,
197 | self.config.sfw_only,
198 | self.config.enable_rss,
199 | self.config.full_url,
200 | self.config.default_remove_default_feeds,
201 | self.config.pushshift,
202 | self.config.banner,
203 | self.config.default_hide_awards,
204 | self.config.default_hide_score,
205 | self.config.default_theme,
206 | self.config.default_front_page,
207 | self.config.default_layout,
208 | self.config.default_wide,
209 | self.config.default_comment_sort,
210 | self.config.default_post_sort,
211 | self.config.default_blur_spoiler,
212 | self.config.default_show_nsfw,
213 | self.config.default_blur_nsfw,
214 | self.config.default_use_hls,
215 | self.config.default_hide_hls_notification,
216 | self.config.default_subscriptions,
217 | self.config.default_filters,
218 | )
219 | }
220 | StringType::Html => self.to_table(),
221 | }
222 | }
223 | }
224 | enum StringType {
225 | Raw,
226 | Html,
227 | }
228 | #[derive(Template)]
229 | #[template(path = "message.html")]
230 | struct MessageTemplate {
231 | title: String,
232 | body: String,
233 | prefs: Preferences,
234 | url: String,
235 | }
236 |
--------------------------------------------------------------------------------
/templates/settings.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 | {% import "utils.html" as utils %}
3 |
4 | {% block title %}Redlib Settings{% endblock %}
5 |
6 | {% block subscriptions %}
7 | {% call utils::sub_list("") %}
8 | {% endblock %}
9 |
10 | {% block search %}
11 | {% call utils::search("".to_owned(), "") %}
12 | {% endblock %}
13 |
14 | {% block content %}
15 |
16 |
152 | {% if prefs.subscriptions.len() > 0 %}
153 |
154 |
Subscribed Feeds
155 | {% for sub in prefs.subscriptions %}
156 |
157 | {% let feed -%}
158 | {% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed =
159 | format!("r/{}", sub) -%}{% endif -%}
160 |
{{ feed }}
161 |
164 |
165 | {% endfor %}
166 |
167 | {% endif %}
168 | {% if !prefs.filters.is_empty() %}
169 |
170 |
Filtered Feeds
171 | {% for sub in prefs.filters %}
172 |
173 | {% let feed -%}
174 | {% if sub.starts_with("u_") -%}{% let feed = format!("u/{}", &sub[2..]) -%}{% else -%}{% let feed =
175 | format!("r/{}", sub) -%}{% endif -%}
176 |
{{ feed }}
177 |
180 |
181 | {% endfor %}
182 |
183 | {% endif %}
184 |
185 |
186 |
Note: settings and subscriptions are saved in browser cookies. Clearing your cookies will reset them.
187 |
188 |
189 | {% match prefs.to_urlencoded() %}
190 | {% when Ok with (encoded_prefs) %}
191 |
You can restore your current settings and subscriptions after clearing your cookies using this link .
193 | {% when Err with (err) %}
194 |
There was an error creating your restore link: {{ err }}
195 |
Please report this issue
196 | {% endmatch %}
197 |
198 |
199 |
200 | Or, export/import here (be sure to save first):
201 |
202 |
205 | Copy
206 |
207 |
208 |
213 |
214 |
215 |
216 |
217 | {% endblock %}
--------------------------------------------------------------------------------
/src/post.rs:
--------------------------------------------------------------------------------
1 | #![allow(clippy::cmp_owned)]
2 |
3 | // CRATES
4 | use crate::client::json;
5 | use crate::config::get_setting;
6 | use crate::server::RequestExt;
7 | use crate::subreddit::{can_access_quarantine, quarantine};
8 | use crate::utils::{
9 | error, format_num, get_filters, nsfw_landing, param, parse_post, rewrite_emotes, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences,
10 | };
11 | use hyper::{Body, Request, Response};
12 |
13 | use askama::Template;
14 | use regex::Regex;
15 | use std::collections::{HashMap, HashSet};
16 | use std::sync::LazyLock;
17 |
18 | // STRUCTS
19 | #[derive(Template)]
20 | #[template(path = "post.html")]
21 | struct PostTemplate {
22 | comments: Vec,
23 | post: Post,
24 | sort: String,
25 | prefs: Preferences,
26 | single_thread: bool,
27 | url: String,
28 | url_without_query: String,
29 | comment_query: String,
30 | }
31 |
32 | static COMMENT_SEARCH_CAPTURE: LazyLock = LazyLock::new(|| Regex::new(r"\?q=(.*)&type=comment").unwrap());
33 |
34 | pub async fn item(req: Request) -> Result, String> {
35 | // Build Reddit API path
36 | let mut path: String = format!("{}.json?{}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default());
37 | let sub = req.param("sub").unwrap_or_default();
38 | let quarantined = can_access_quarantine(&req, &sub);
39 | let url = req.uri().to_string();
40 |
41 | // Set sort to sort query parameter
42 | let sort = param(&path, "sort").unwrap_or_else(|| {
43 | // Grab default comment sort method from Cookies
44 | let default_sort = setting(&req, "comment_sort");
45 |
46 | // If there's no sort query but there's a default sort, set sort to default_sort
47 | if default_sort.is_empty() {
48 | String::new()
49 | } else {
50 | path = format!("{}.json?{}&sort={}&raw_json=1", req.uri().path(), req.uri().query().unwrap_or_default(), default_sort);
51 | default_sort
52 | }
53 | });
54 |
55 | // Log the post ID being fetched in debug mode
56 | #[cfg(debug_assertions)]
57 | req.param("id").unwrap_or_default();
58 |
59 | let single_thread = req.param("comment_id").is_some();
60 | let highlighted_comment = &req.param("comment_id").unwrap_or_default();
61 |
62 | // Send a request to the url, receive JSON in response
63 | match json(path, quarantined).await {
64 | // Otherwise, grab the JSON output from the request
65 | Ok(response) => {
66 | // Parse the JSON into Post and Comment structs
67 | let post = parse_post(&response[0]["data"]["children"][0]).await;
68 |
69 | let req_url = req.uri().to_string();
70 | // Return landing page if this post if this Reddit deems this post
71 | // NSFW, but we have also disabled the display of NSFW content
72 | // or if the instance is SFW-only.
73 | if post.nsfw && crate::utils::should_be_nsfw_gated(&req, &req_url) {
74 | return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
75 | }
76 |
77 | let query_body = match COMMENT_SEARCH_CAPTURE.captures(&url) {
78 | Some(captures) => captures.get(1).unwrap().as_str().replace("%20", " ").replace('+', " "),
79 | None => String::new(),
80 | };
81 |
82 | let query_string = format!("q={query_body}&type=comment");
83 | let form = url::form_urlencoded::parse(query_string.as_bytes()).collect::>();
84 | let query = form.get("q").unwrap().clone().to_string();
85 |
86 | let comments = match query.as_str() {
87 | "" => parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &req),
88 | _ => query_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &query, &req),
89 | };
90 |
91 | // Use the Post and Comment structs to generate a website to show users
92 | Ok(template(&PostTemplate {
93 | comments,
94 | post,
95 | url_without_query: url.clone().trim_end_matches(&format!("?q={query}&type=comment")).to_string(),
96 | sort,
97 | prefs: Preferences::new(&req),
98 | single_thread,
99 | url: req_url,
100 | comment_query: query,
101 | }))
102 | }
103 | // If the Reddit API returns an error, exit and send error page to user
104 | Err(msg) => {
105 | if msg == "quarantined" || msg == "gated" {
106 | let sub = req.param("sub").unwrap_or_default();
107 | Ok(quarantine(&req, sub, &msg))
108 | } else {
109 | error(req, &msg).await
110 | }
111 | }
112 | }
113 | }
114 |
115 | // COMMENTS
116 |
117 | fn parse_comments(json: &serde_json::Value, post_link: &str, post_author: &str, highlighted_comment: &str, filters: &HashSet, req: &Request) -> Vec {
118 | // Parse the comment JSON into a Vector of Comments
119 | let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned);
120 |
121 | // For each comment, retrieve the values to build a Comment object
122 | comments
123 | .into_iter()
124 | .map(|comment| {
125 | let data = &comment["data"];
126 | let replies: Vec = if data["replies"].is_object() {
127 | parse_comments(&data["replies"], post_link, post_author, highlighted_comment, filters, req)
128 | } else {
129 | Vec::new()
130 | };
131 | build_comment(&comment, data, replies, post_link, post_author, highlighted_comment, filters, req)
132 | })
133 | .collect()
134 | }
135 |
136 | fn query_comments(
137 | json: &serde_json::Value,
138 | post_link: &str,
139 | post_author: &str,
140 | highlighted_comment: &str,
141 | filters: &HashSet,
142 | query: &str,
143 | req: &Request,
144 | ) -> Vec {
145 | let comments = json["data"]["children"].as_array().map_or(Vec::new(), std::borrow::ToOwned::to_owned);
146 | let mut results = Vec::new();
147 |
148 | for comment in comments {
149 | let data = &comment["data"];
150 |
151 | // If this comment contains replies, handle those too
152 | if data["replies"].is_object() {
153 | results.append(&mut query_comments(&data["replies"], post_link, post_author, highlighted_comment, filters, query, req));
154 | }
155 |
156 | let c = build_comment(&comment, data, Vec::new(), post_link, post_author, highlighted_comment, filters, req);
157 | if c.body.to_lowercase().contains(&query.to_lowercase()) {
158 | results.push(c);
159 | }
160 | }
161 |
162 | results
163 | }
164 | #[allow(clippy::too_many_arguments)]
165 | fn build_comment(
166 | comment: &serde_json::Value,
167 | data: &serde_json::Value,
168 | replies: Vec,
169 | post_link: &str,
170 | post_author: &str,
171 | highlighted_comment: &str,
172 | filters: &HashSet,
173 | req: &Request,
174 | ) -> Comment {
175 | let id = val(comment, "id");
176 |
177 | let body = if (val(comment, "author") == "[deleted]" && val(comment, "body") == "[removed]") || val(comment, "body") == "[ Removed by Reddit ]" {
178 | format!(
179 | "",
180 | get_setting("REDLIB_PUSHSHIFT_FRONTEND").unwrap_or_else(|| String::from(crate::config::DEFAULT_PUSHSHIFT_FRONTEND)),
181 | )
182 | } else {
183 | rewrite_emotes(&data["media_metadata"], val(comment, "body_html"))
184 | };
185 | let kind = comment["kind"].as_str().unwrap_or_default().to_string();
186 |
187 | let unix_time = data["created_utc"].as_f64().unwrap_or_default();
188 | let (rel_time, created) = time(unix_time);
189 |
190 | let edited = data["edited"].as_f64().map_or((String::new(), String::new()), time);
191 |
192 | let score = data["score"].as_i64().unwrap_or(0);
193 |
194 | // The JSON API only provides comments up to some threshold.
195 | // Further comments have to be loaded by subsequent requests.
196 | // The "kind" value will be "more" and the "count"
197 | // shows how many more (sub-)comments exist in the respective nesting level.
198 | // Note that in certain (seemingly random) cases, the count is simply wrong.
199 | let more_count = data["count"].as_i64().unwrap_or_default();
200 |
201 | let awards: Awards = Awards::parse(&data["all_awardings"]);
202 |
203 | let parent_kind_and_id = val(comment, "parent_id");
204 | let parent_info = parent_kind_and_id.split('_').collect::>();
205 |
206 | let highlighted = id == highlighted_comment;
207 |
208 | let author = Author {
209 | name: val(comment, "author"),
210 | flair: Flair {
211 | flair_parts: FlairPart::parse(
212 | data["author_flair_type"].as_str().unwrap_or_default(),
213 | data["author_flair_richtext"].as_array(),
214 | data["author_flair_text"].as_str(),
215 | ),
216 | text: val(comment, "link_flair_text"),
217 | background_color: val(comment, "author_flair_background_color"),
218 | foreground_color: val(comment, "author_flair_text_color"),
219 | },
220 | distinguished: val(comment, "distinguished"),
221 | };
222 | let is_filtered = filters.contains(&["u_", author.name.as_str()].concat());
223 |
224 | // Many subreddits have a default comment posted about the sub's rules etc.
225 | // Many Redlib users do not wish to see this kind of comment by default.
226 | // Reddit does not tell us which users are "bots", so a good heuristic is to
227 | // collapse stickied moderator comments.
228 | let is_moderator_comment = data["distinguished"].as_str().unwrap_or_default() == "moderator";
229 | let is_stickied = data["stickied"].as_bool().unwrap_or_default();
230 | let collapsed = (is_moderator_comment && is_stickied) || is_filtered;
231 |
232 | Comment {
233 | id,
234 | kind,
235 | parent_id: parent_info[1].to_string(),
236 | parent_kind: parent_info[0].to_string(),
237 | post_link: post_link.to_string(),
238 | post_author: post_author.to_string(),
239 | body,
240 | author,
241 | score: if data["score_hidden"].as_bool().unwrap_or_default() {
242 | ("\u{2022}".to_string(), "Hidden".to_string())
243 | } else {
244 | format_num(score)
245 | },
246 | rel_time,
247 | created,
248 | edited,
249 | replies,
250 | highlighted,
251 | awards,
252 | collapsed,
253 | is_filtered,
254 | more_count,
255 | prefs: Preferences::new(req),
256 | }
257 | }
258 |
--------------------------------------------------------------------------------
/src/settings.rs:
--------------------------------------------------------------------------------
1 | #![allow(clippy::cmp_owned)]
2 |
3 | use std::collections::HashMap;
4 |
5 | // CRATES
6 | use crate::server::ResponseExt;
7 | use crate::subreddit::join_until_size_limit;
8 | use crate::utils::{deflate_decompress, redirect, template, Preferences};
9 | use askama::Template;
10 | use cookie::Cookie;
11 | use futures_lite::StreamExt;
12 | use hyper::{Body, Request, Response};
13 | use time::{Duration, OffsetDateTime};
14 | use tokio::time::timeout;
15 | use url::form_urlencoded;
16 |
17 | // STRUCTS
18 | #[derive(Template)]
19 | #[template(path = "settings.html")]
20 | struct SettingsTemplate {
21 | prefs: Preferences,
22 | url: String,
23 | }
24 |
25 | // CONSTANTS
26 |
27 | const PREFS: [&str; 19] = [
28 | "theme",
29 | "front_page",
30 | "layout",
31 | "wide",
32 | "comment_sort",
33 | "post_sort",
34 | "blur_spoiler",
35 | "show_nsfw",
36 | "blur_nsfw",
37 | "use_hls",
38 | "hide_hls_notification",
39 | "autoplay_videos",
40 | "hide_sidebar_and_summary",
41 | "fixed_navbar",
42 | "hide_awards",
43 | "hide_score",
44 | "disable_visit_reddit_confirmation",
45 | "video_quality",
46 | "remove_default_feeds",
47 | ];
48 |
49 | // FUNCTIONS
50 |
51 | /// Retrieve cookies from request "Cookie" header
52 | pub async fn get(req: Request) -> Result, String> {
53 | let url = req.uri().to_string();
54 | Ok(template(&SettingsTemplate {
55 | prefs: Preferences::new(&req),
56 | url,
57 | }))
58 | }
59 |
60 | /// Set cookies using response "Set-Cookie" header
61 | pub async fn set(req: Request) -> Result, String> {
62 | // Split the body into parts
63 | let (parts, mut body) = req.into_parts();
64 |
65 | // Grab existing cookies
66 | let _cookies: Vec> = parts
67 | .headers
68 | .get_all("Cookie")
69 | .iter()
70 | .filter_map(|header| Cookie::parse(header.to_str().unwrap_or_default()).ok())
71 | .collect();
72 |
73 | // Aggregate the body...
74 | // let whole_body = hyper::body::aggregate(req).await.map_err(|e| e.to_string())?;
75 | let body_bytes = body
76 | .try_fold(Vec::new(), |mut data, chunk| {
77 | data.extend_from_slice(&chunk);
78 | Ok(data)
79 | })
80 | .await
81 | .map_err(|e| e.to_string())?;
82 |
83 | let form = url::form_urlencoded::parse(&body_bytes).collect::>();
84 |
85 | let mut response = redirect("/settings");
86 |
87 | for &name in &PREFS {
88 | match form.get(name) {
89 | Some(value) => response.insert_cookie(
90 | Cookie::build((name.to_owned(), value.clone()))
91 | .path("/")
92 | .http_only(true)
93 | .expires(OffsetDateTime::now_utc() + Duration::weeks(52))
94 | .into(),
95 | ),
96 | None => response.remove_cookie(name.to_string()),
97 | };
98 | }
99 |
100 | Ok(response)
101 | }
102 |
103 | fn set_cookies_method(req: Request, remove_cookies: bool) -> Response {
104 | // Split the body into parts
105 | let (parts, _) = req.into_parts();
106 |
107 | // Grab existing cookies
108 | let _cookies: Vec> = parts
109 | .headers
110 | .get_all("Cookie")
111 | .iter()
112 | .filter_map(|header| Cookie::parse(header.to_str().unwrap_or_default()).ok())
113 | .collect();
114 |
115 | let query = parts.uri.query().unwrap_or_default().as_bytes();
116 |
117 | let form = url::form_urlencoded::parse(query).collect::>();
118 |
119 | let path = match form.get("redirect") {
120 | Some(value) => {
121 | let value = value.replace("%26", "&").replace("%23", "#");
122 | if value.starts_with('/') {
123 | value
124 | } else {
125 | format!("/{value}")
126 | }
127 | }
128 | None => "/".to_string(),
129 | };
130 |
131 | let mut response = redirect(&path);
132 |
133 | for name in PREFS {
134 | match form.get(name) {
135 | Some(value) => response.insert_cookie(
136 | Cookie::build((name.to_owned(), value.clone()))
137 | .path("/")
138 | .http_only(true)
139 | .expires(OffsetDateTime::now_utc() + Duration::weeks(52))
140 | .into(),
141 | ),
142 | None => {
143 | if remove_cookies {
144 | response.remove_cookie(name.to_string());
145 | }
146 | }
147 | };
148 | }
149 |
150 | // Get subscriptions/filters to restore from query string
151 | let subscriptions = form.get("subscriptions");
152 | let filters = form.get("filters");
153 |
154 | // We can't search through the cookies directly like in subreddit.rs, so instead we have to make a string out of the request's headers to search through
155 | let cookies_string = parts
156 | .headers
157 | .get("cookie")
158 | .map(|hv| hv.to_str().unwrap_or("").to_string()) // Return String
159 | .unwrap_or_else(String::new); // Return an empty string if None
160 |
161 | // If there are subscriptions to restore set them and delete any old subscriptions cookies, otherwise delete them all
162 | if let Some(subscriptions) = subscriptions {
163 | let sub_list: Vec = subscriptions.split('+').map(str::to_string).collect();
164 |
165 | // Start at 0 to keep track of what number we need to start deleting old subscription cookies from
166 | let mut subscriptions_number_to_delete_from = 0;
167 |
168 | // Starting at 0 so we handle the subscription cookie without a number first
169 | for (subscriptions_number, list) in join_until_size_limit(&sub_list).into_iter().enumerate() {
170 | let subscriptions_cookie = if subscriptions_number == 0 {
171 | "subscriptions".to_string()
172 | } else {
173 | format!("subscriptions{subscriptions_number}")
174 | };
175 |
176 | response.insert_cookie(
177 | Cookie::build((subscriptions_cookie, list))
178 | .path("/")
179 | .http_only(true)
180 | .expires(OffsetDateTime::now_utc() + Duration::weeks(52))
181 | .into(),
182 | );
183 |
184 | subscriptions_number_to_delete_from += 1;
185 | }
186 |
187 | // While subscriptionsNUMBER= is in the string of cookies add a response removing that cookie
188 | while cookies_string.contains(&format!("subscriptions{subscriptions_number_to_delete_from}=")) {
189 | // Remove that subscriptions cookie
190 | response.remove_cookie(format!("subscriptions{subscriptions_number_to_delete_from}"));
191 |
192 | // Increment subscriptions cookie number
193 | subscriptions_number_to_delete_from += 1;
194 | }
195 | } else {
196 | // Remove unnumbered subscriptions cookie
197 | response.remove_cookie("subscriptions".to_string());
198 |
199 | // Starts at one to deal with the first numbered subscription cookie and onwards
200 | let mut subscriptions_number_to_delete_from = 1;
201 |
202 | // While subscriptionsNUMBER= is in the string of cookies add a response removing that cookie
203 | while cookies_string.contains(&format!("subscriptions{subscriptions_number_to_delete_from}=")) {
204 | // Remove that subscriptions cookie
205 | response.remove_cookie(format!("subscriptions{subscriptions_number_to_delete_from}"));
206 |
207 | // Increment subscriptions cookie number
208 | subscriptions_number_to_delete_from += 1;
209 | }
210 | }
211 |
212 | // If there are filters to restore set them and delete any old filters cookies, otherwise delete them all
213 | if let Some(filters) = filters {
214 | let filters_list: Vec = filters.split('+').map(str::to_string).collect();
215 |
216 | // Start at 0 to keep track of what number we need to start deleting old subscription cookies from
217 | let mut filters_number_to_delete_from = 0;
218 |
219 | // Starting at 0 so we handle the subscription cookie without a number first
220 | for (filters_number, list) in join_until_size_limit(&filters_list).into_iter().enumerate() {
221 | let filters_cookie = if filters_number == 0 {
222 | "filters".to_string()
223 | } else {
224 | format!("filters{filters_number}")
225 | };
226 |
227 | response.insert_cookie(
228 | Cookie::build((filters_cookie, list))
229 | .path("/")
230 | .http_only(true)
231 | .expires(OffsetDateTime::now_utc() + Duration::weeks(52))
232 | .into(),
233 | );
234 |
235 | filters_number_to_delete_from += 1;
236 | }
237 |
238 | // While filtersNUMBER= is in the string of cookies add a response removing that cookie
239 | while cookies_string.contains(&format!("filters{filters_number_to_delete_from}=")) {
240 | // Remove that filters cookie
241 | response.remove_cookie(format!("filters{filters_number_to_delete_from}"));
242 |
243 | // Increment filters cookie number
244 | filters_number_to_delete_from += 1;
245 | }
246 | } else {
247 | // Remove unnumbered filters cookie
248 | response.remove_cookie("filters".to_string());
249 |
250 | // Starts at one to deal with the first numbered subscription cookie and onwards
251 | let mut filters_number_to_delete_from = 1;
252 |
253 | // While filtersNUMBER= is in the string of cookies add a response removing that cookie
254 | while cookies_string.contains(&format!("filters{filters_number_to_delete_from}=")) {
255 | // Remove that sfilters cookie
256 | response.remove_cookie(format!("filters{filters_number_to_delete_from}"));
257 |
258 | // Increment filters cookie number
259 | filters_number_to_delete_from += 1;
260 | }
261 | }
262 |
263 | response
264 | }
265 |
266 | /// Set cookies using response "Set-Cookie" header
267 | pub async fn restore(req: Request) -> Result, String> {
268 | Ok(set_cookies_method(req, true))
269 | }
270 |
271 | pub async fn update(req: Request) -> Result, String> {
272 | Ok(set_cookies_method(req, false))
273 | }
274 |
275 | pub async fn encoded_restore(req: Request) -> Result, String> {
276 | let body = hyper::body::to_bytes(req.into_body())
277 | .await
278 | .map_err(|e| format!("Failed to get bytes from request body: {e}"))?;
279 |
280 | if body.len() > 1024 * 1024 {
281 | return Err("Request body too large".to_string());
282 | }
283 |
284 | let encoded_prefs = form_urlencoded::parse(&body)
285 | .find(|(key, _)| key == "encoded_prefs")
286 | .map(|(_, value)| value)
287 | .ok_or_else(|| "encoded_prefs parameter not found in request body".to_string())?;
288 |
289 | let bytes = base2048::decode(&encoded_prefs).ok_or_else(|| "Failed to decode base2048 encoded preferences".to_string())?;
290 |
291 | let out = timeout(std::time::Duration::from_secs(1), async { deflate_decompress(bytes) })
292 | .await
293 | .map_err(|e| format!("Failed to decompress bytes: {e}"))??;
294 |
295 | let mut prefs: Preferences = timeout(std::time::Duration::from_secs(1), async { bincode::deserialize(&out) })
296 | .await
297 | .map_err(|e| format!("Failed to deserialize preferences: {e}"))?
298 | .map_err(|e| format!("Failed to deserialize bytes into Preferences struct: {e}"))?;
299 |
300 | prefs.available_themes = vec![];
301 |
302 | let url = format!("/settings/restore/?{}", prefs.to_urlencoded()?);
303 |
304 | Ok(redirect(&url))
305 | }
306 |
--------------------------------------------------------------------------------
/src/config.rs:
--------------------------------------------------------------------------------
1 | use serde::{Deserialize, Serialize};
2 | use std::{env::var, fs::read_to_string, sync::LazyLock};
3 |
4 | /// This is the local static that is initialized at runtime (technically at
5 | /// first request) and contains the instance settings.
6 | pub static CONFIG: LazyLock = LazyLock::new(Config::load);
7 |
8 | /// This serves as the frontend for an archival API - on removed comments, this URL
9 | /// will be the base of a link, to display removed content (on another site).
10 | pub const DEFAULT_PUSHSHIFT_FRONTEND: &str = "undelete.pullpush.io";
11 |
12 | /// Stores the configuration parsed from the environment variables and the
13 | /// config file. `Config::Default()` contains None for each setting.
14 | /// When adding more config settings, add it to `Config::load`,
15 | /// `get_setting_from_config`, both below, as well as
16 | /// `instance_info::InstanceInfo.to_string`(), README.md and app.json.
17 | #[derive(Default, Serialize, Deserialize, Clone, Debug)]
18 | pub struct Config {
19 | #[serde(rename = "REDLIB_SFW_ONLY")]
20 | #[serde(alias = "LIBREDDIT_SFW_ONLY")]
21 | pub(crate) sfw_only: Option,
22 |
23 | #[serde(rename = "REDLIB_DEFAULT_THEME")]
24 | #[serde(alias = "LIBREDDIT_DEFAULT_THEME")]
25 | pub(crate) default_theme: Option,
26 |
27 | #[serde(rename = "REDLIB_DEFAULT_FRONT_PAGE")]
28 | #[serde(alias = "LIBREDDIT_DEFAULT_FRONT_PAGE")]
29 | pub(crate) default_front_page: Option,
30 |
31 | #[serde(rename = "REDLIB_DEFAULT_LAYOUT")]
32 | #[serde(alias = "LIBREDDIT_DEFAULT_LAYOUT")]
33 | pub(crate) default_layout: Option,
34 |
35 | #[serde(rename = "REDLIB_DEFAULT_WIDE")]
36 | #[serde(alias = "LIBREDDIT_DEFAULT_WIDE")]
37 | pub(crate) default_wide: Option,
38 |
39 | #[serde(rename = "REDLIB_DEFAULT_COMMENT_SORT")]
40 | #[serde(alias = "LIBREDDIT_DEFAULT_COMMENT_SORT")]
41 | pub(crate) default_comment_sort: Option,
42 |
43 | #[serde(rename = "REDLIB_DEFAULT_POST_SORT")]
44 | #[serde(alias = "LIBREDDIT_DEFAULT_POST_SORT")]
45 | pub(crate) default_post_sort: Option,
46 |
47 | #[serde(rename = "REDLIB_DEFAULT_BLUR_SPOILER")]
48 | #[serde(alias = "LIBREDDIT_DEFAULT_BLUR_SPOILER")]
49 | pub(crate) default_blur_spoiler: Option,
50 |
51 | #[serde(rename = "REDLIB_DEFAULT_SHOW_NSFW")]
52 | #[serde(alias = "LIBREDDIT_DEFAULT_SHOW_NSFW")]
53 | pub(crate) default_show_nsfw: Option,
54 |
55 | #[serde(rename = "REDLIB_DEFAULT_BLUR_NSFW")]
56 | #[serde(alias = "LIBREDDIT_DEFAULT_BLUR_NSFW")]
57 | pub(crate) default_blur_nsfw: Option,
58 |
59 | #[serde(rename = "REDLIB_DEFAULT_USE_HLS")]
60 | #[serde(alias = "LIBREDDIT_DEFAULT_USE_HLS")]
61 | pub(crate) default_use_hls: Option,
62 |
63 | #[serde(rename = "REDLIB_DEFAULT_HIDE_HLS_NOTIFICATION")]
64 | #[serde(alias = "LIBREDDIT_DEFAULT_HIDE_HLS_NOTIFICATION")]
65 | pub(crate) default_hide_hls_notification: Option,
66 |
67 | #[serde(rename = "REDLIB_DEFAULT_HIDE_AWARDS")]
68 | #[serde(alias = "LIBREDDIT_DEFAULT_HIDE_AWARDS")]
69 | pub(crate) default_hide_awards: Option,
70 |
71 | #[serde(rename = "REDLIB_DEFAULT_HIDE_SIDEBAR_AND_SUMMARY")]
72 | #[serde(alias = "LIBREDDIT_DEFAULT_HIDE_SIDEBAR_AND_SUMMARY")]
73 | pub(crate) default_hide_sidebar_and_summary: Option,
74 |
75 | #[serde(rename = "REDLIB_DEFAULT_HIDE_SCORE")]
76 | #[serde(alias = "LIBREDDIT_DEFAULT_HIDE_SCORE")]
77 | pub(crate) default_hide_score: Option,
78 |
79 | #[serde(rename = "REDLIB_DEFAULT_SUBSCRIPTIONS")]
80 | #[serde(alias = "LIBREDDIT_DEFAULT_SUBSCRIPTIONS")]
81 | pub(crate) default_subscriptions: Option,
82 |
83 | #[serde(rename = "REDLIB_DEFAULT_FILTERS")]
84 | #[serde(alias = "LIBREDDIT_DEFAULT_FILTERS")]
85 | pub(crate) default_filters: Option,
86 |
87 | #[serde(rename = "REDLIB_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION")]
88 | #[serde(alias = "LIBREDDIT_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION")]
89 | pub(crate) default_disable_visit_reddit_confirmation: Option,
90 |
91 | #[serde(rename = "REDLIB_BANNER")]
92 | #[serde(alias = "LIBREDDIT_BANNER")]
93 | pub(crate) banner: Option,
94 |
95 | #[serde(rename = "REDLIB_ROBOTS_DISABLE_INDEXING")]
96 | #[serde(alias = "LIBREDDIT_ROBOTS_DISABLE_INDEXING")]
97 | pub(crate) robots_disable_indexing: Option,
98 |
99 | #[serde(rename = "REDLIB_PUSHSHIFT_FRONTEND")]
100 | #[serde(alias = "LIBREDDIT_PUSHSHIFT_FRONTEND")]
101 | pub(crate) pushshift: Option,
102 |
103 | #[serde(rename = "REDLIB_ENABLE_RSS")]
104 | pub(crate) enable_rss: Option,
105 |
106 | #[serde(rename = "REDLIB_FULL_URL")]
107 | pub(crate) full_url: Option,
108 |
109 | #[serde(rename = "REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS")]
110 | pub(crate) default_remove_default_feeds: Option,
111 | }
112 |
113 | impl Config {
114 | /// Load the configuration from the environment variables and the config file.
115 | /// In the case that there are no environment variables set and there is no
116 | /// config file, this function returns a Config that contains all None values.
117 | pub fn load() -> Self {
118 | let load_config = |name: &str| {
119 | let new_file = read_to_string(name);
120 | new_file.ok().and_then(|new_file| toml::from_str::(&new_file).ok())
121 | };
122 |
123 | let config = load_config("redlib.toml").or_else(|| load_config("libreddit.toml")).unwrap_or_default();
124 |
125 | // This function defines the order of preference - first check for
126 | // environment variables with "REDLIB", then check the legacy LIBREDDIT
127 | // option, then check the config, then if all are `None`, return a `None`
128 | let parse = |key: &str| -> Option {
129 | // Return the first non-`None` value
130 | // If all are `None`, return `None`
131 | let legacy_key = key.replace("REDLIB_", "LIBREDDIT_");
132 | var(key).ok().or_else(|| var(legacy_key).ok()).or_else(|| get_setting_from_config(key, &config))
133 | };
134 | Self {
135 | sfw_only: parse("REDLIB_SFW_ONLY"),
136 | default_theme: parse("REDLIB_DEFAULT_THEME"),
137 | default_front_page: parse("REDLIB_DEFAULT_FRONT_PAGE"),
138 | default_layout: parse("REDLIB_DEFAULT_LAYOUT"),
139 | default_post_sort: parse("REDLIB_DEFAULT_POST_SORT"),
140 | default_wide: parse("REDLIB_DEFAULT_WIDE"),
141 | default_comment_sort: parse("REDLIB_DEFAULT_COMMENT_SORT"),
142 | default_blur_spoiler: parse("REDLIB_DEFAULT_BLUR_SPOILER"),
143 | default_show_nsfw: parse("REDLIB_DEFAULT_SHOW_NSFW"),
144 | default_blur_nsfw: parse("REDLIB_DEFAULT_BLUR_NSFW"),
145 | default_use_hls: parse("REDLIB_DEFAULT_USE_HLS"),
146 | default_hide_hls_notification: parse("REDLIB_DEFAULT_HIDE_HLS_NOTIFICATION"),
147 | default_hide_awards: parse("REDLIB_DEFAULT_HIDE_AWARDS"),
148 | default_hide_sidebar_and_summary: parse("REDLIB_DEFAULT_HIDE_SIDEBAR_AND_SUMMARY"),
149 | default_hide_score: parse("REDLIB_DEFAULT_HIDE_SCORE"),
150 | default_subscriptions: parse("REDLIB_DEFAULT_SUBSCRIPTIONS"),
151 | default_filters: parse("REDLIB_DEFAULT_FILTERS"),
152 | default_disable_visit_reddit_confirmation: parse("REDLIB_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION"),
153 | banner: parse("REDLIB_BANNER"),
154 | robots_disable_indexing: parse("REDLIB_ROBOTS_DISABLE_INDEXING"),
155 | pushshift: parse("REDLIB_PUSHSHIFT_FRONTEND"),
156 | enable_rss: parse("REDLIB_ENABLE_RSS"),
157 | full_url: parse("REDLIB_FULL_URL"),
158 | default_remove_default_feeds: parse("REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS"),
159 | }
160 | }
161 | }
162 |
163 | fn get_setting_from_config(name: &str, config: &Config) -> Option {
164 | match name {
165 | "REDLIB_SFW_ONLY" => config.sfw_only.clone(),
166 | "REDLIB_DEFAULT_THEME" => config.default_theme.clone(),
167 | "REDLIB_DEFAULT_FRONT_PAGE" => config.default_front_page.clone(),
168 | "REDLIB_DEFAULT_LAYOUT" => config.default_layout.clone(),
169 | "REDLIB_DEFAULT_COMMENT_SORT" => config.default_comment_sort.clone(),
170 | "REDLIB_DEFAULT_POST_SORT" => config.default_post_sort.clone(),
171 | "REDLIB_DEFAULT_BLUR_SPOILER" => config.default_blur_spoiler.clone(),
172 | "REDLIB_DEFAULT_SHOW_NSFW" => config.default_show_nsfw.clone(),
173 | "REDLIB_DEFAULT_BLUR_NSFW" => config.default_blur_nsfw.clone(),
174 | "REDLIB_DEFAULT_USE_HLS" => config.default_use_hls.clone(),
175 | "REDLIB_DEFAULT_HIDE_HLS_NOTIFICATION" => config.default_hide_hls_notification.clone(),
176 | "REDLIB_DEFAULT_WIDE" => config.default_wide.clone(),
177 | "REDLIB_DEFAULT_HIDE_AWARDS" => config.default_hide_awards.clone(),
178 | "REDLIB_DEFAULT_HIDE_SIDEBAR_AND_SUMMARY" => config.default_hide_sidebar_and_summary.clone(),
179 | "REDLIB_DEFAULT_HIDE_SCORE" => config.default_hide_score.clone(),
180 | "REDLIB_DEFAULT_SUBSCRIPTIONS" => config.default_subscriptions.clone(),
181 | "REDLIB_DEFAULT_FILTERS" => config.default_filters.clone(),
182 | "REDLIB_DEFAULT_DISABLE_VISIT_REDDIT_CONFIRMATION" => config.default_disable_visit_reddit_confirmation.clone(),
183 | "REDLIB_BANNER" => config.banner.clone(),
184 | "REDLIB_ROBOTS_DISABLE_INDEXING" => config.robots_disable_indexing.clone(),
185 | "REDLIB_PUSHSHIFT_FRONTEND" => config.pushshift.clone(),
186 | "REDLIB_ENABLE_RSS" => config.enable_rss.clone(),
187 | "REDLIB_FULL_URL" => config.full_url.clone(),
188 | "REDLIB_DEFAULT_REMOVE_DEFAULT_FEEDS" => config.default_remove_default_feeds.clone(),
189 | _ => None,
190 | }
191 | }
192 |
193 | /// Retrieves setting from environment variable or config file.
194 | pub fn get_setting(name: &str) -> Option {
195 | get_setting_from_config(name, &CONFIG)
196 | }
197 |
198 | #[cfg(test)]
199 | use {sealed_test::prelude::*, std::fs::write};
200 |
201 | #[test]
202 | fn test_deserialize() {
203 | // Must handle empty input
204 | let result = toml::from_str::("");
205 | assert!(result.is_ok(), "Error: {}", result.unwrap_err());
206 | }
207 |
208 | #[test]
209 | #[sealed_test(env = [("REDLIB_SFW_ONLY", "on")])]
210 | fn test_env_var() {
211 | assert!(crate::utils::sfw_only())
212 | }
213 |
214 | #[test]
215 | #[sealed_test]
216 | fn test_config() {
217 | let config_to_write = r#"REDLIB_DEFAULT_COMMENT_SORT = "best""#;
218 | write("redlib.toml", config_to_write).unwrap();
219 | assert_eq!(get_setting("REDLIB_DEFAULT_COMMENT_SORT"), Some("best".into()));
220 | }
221 |
222 | #[test]
223 | #[sealed_test]
224 | fn test_config_legacy() {
225 | let config_to_write = r#"LIBREDDIT_DEFAULT_COMMENT_SORT = "best""#;
226 | write("libreddit.toml", config_to_write).unwrap();
227 | assert_eq!(get_setting("REDLIB_DEFAULT_COMMENT_SORT"), Some("best".into()));
228 | }
229 |
230 | #[test]
231 | #[sealed_test(env = [("LIBREDDIT_SFW_ONLY", "on")])]
232 | fn test_env_var_legacy() {
233 | assert!(crate::utils::sfw_only())
234 | }
235 |
236 | #[test]
237 | #[sealed_test(env = [("REDLIB_DEFAULT_COMMENT_SORT", "top")])]
238 | fn test_env_config_precedence() {
239 | let config_to_write = r#"REDLIB_DEFAULT_COMMENT_SORT = "best""#;
240 | write("redlib.toml", config_to_write).unwrap();
241 | assert_eq!(get_setting("REDLIB_DEFAULT_COMMENT_SORT"), Some("top".into()))
242 | }
243 |
244 | #[test]
245 | #[sealed_test(env = [("REDLIB_DEFAULT_COMMENT_SORT", "top")])]
246 | fn test_alt_env_config_precedence() {
247 | let config_to_write = r#"REDLIB_DEFAULT_COMMENT_SORT = "best""#;
248 | write("redlib.toml", config_to_write).unwrap();
249 | assert_eq!(get_setting("REDLIB_DEFAULT_COMMENT_SORT"), Some("top".into()))
250 | }
251 | #[test]
252 | #[sealed_test(env = [("REDLIB_DEFAULT_SUBSCRIPTIONS", "news+bestof")])]
253 | fn test_default_subscriptions() {
254 | assert_eq!(get_setting("REDLIB_DEFAULT_SUBSCRIPTIONS"), Some("news+bestof".into()));
255 | }
256 |
257 | #[test]
258 | #[sealed_test(env = [("REDLIB_DEFAULT_FILTERS", "news+bestof")])]
259 | fn test_default_filters() {
260 | assert_eq!(get_setting("REDLIB_DEFAULT_FILTERS"), Some("news+bestof".into()));
261 | }
262 |
263 | #[test]
264 | #[sealed_test]
265 | fn test_pushshift() {
266 | let config_to_write = r#"REDLIB_PUSHSHIFT_FRONTEND = "https://api.pushshift.io""#;
267 | write("redlib.toml", config_to_write).unwrap();
268 | assert!(get_setting("REDLIB_PUSHSHIFT_FRONTEND").is_some());
269 | assert_eq!(get_setting("REDLIB_PUSHSHIFT_FRONTEND"), Some("https://api.pushshift.io".into()));
270 | }
271 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | // Global specifiers
2 | #![forbid(unsafe_code)]
3 | #![allow(clippy::cmp_owned)]
4 |
5 | use cached::proc_macro::cached;
6 | use clap::{Arg, ArgAction, Command};
7 | use std::str::FromStr;
8 | use std::sync::LazyLock;
9 |
10 | use futures_lite::FutureExt;
11 | use hyper::Uri;
12 | use hyper::{header::HeaderValue, Body, Request, Response};
13 | use log::{info, warn};
14 | use redlib::client::{canonical_path, proxy, rate_limit_check, CLIENT};
15 | use redlib::server::{self, RequestExt};
16 | use redlib::utils::{error, redirect, ThemeAssets};
17 | use redlib::{config, duplicates, headers, instance_info, post, search, settings, subreddit, user};
18 |
19 | use redlib::client::OAUTH_CLIENT;
20 |
21 | // Create Services
22 |
23 | // Required for the manifest to be valid
24 | async fn pwa_logo() -> Result, String> {
25 | Ok(
26 | Response::builder()
27 | .status(200)
28 | .header("content-type", "image/png")
29 | .body(include_bytes!("../static/logo.png").as_ref().into())
30 | .unwrap_or_default(),
31 | )
32 | }
33 |
34 | // Required for iOS App Icons
35 | async fn iphone_logo() -> Result, String> {
36 | Ok(
37 | Response::builder()
38 | .status(200)
39 | .header("content-type", "image/png")
40 | .body(include_bytes!("../static/apple-touch-icon.png").as_ref().into())
41 | .unwrap_or_default(),
42 | )
43 | }
44 |
45 | async fn favicon() -> Result, String> {
46 | Ok(
47 | Response::builder()
48 | .status(200)
49 | .header("content-type", "image/vnd.microsoft.icon")
50 | .header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
51 | .body(include_bytes!("../static/favicon.ico").as_ref().into())
52 | .unwrap_or_default(),
53 | )
54 | }
55 |
56 | async fn font() -> Result, String> {
57 | Ok(
58 | Response::builder()
59 | .status(200)
60 | .header("content-type", "font/woff2")
61 | .header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
62 | .body(include_bytes!("../static/Inter.var.woff2").as_ref().into())
63 | .unwrap_or_default(),
64 | )
65 | }
66 |
67 | async fn opensearch() -> Result, String> {
68 | Ok(
69 | Response::builder()
70 | .status(200)
71 | .header("content-type", "application/opensearchdescription+xml")
72 | .header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
73 | .body(include_bytes!("../static/opensearch.xml").as_ref().into())
74 | .unwrap_or_default(),
75 | )
76 | }
77 |
78 | async fn resource(body: &str, content_type: &str, cache: bool) -> Result, String> {
79 | let mut res = Response::builder()
80 | .status(200)
81 | .header("content-type", content_type)
82 | .body(body.to_string().into())
83 | .unwrap_or_default();
84 |
85 | if cache {
86 | if let Ok(val) = HeaderValue::from_str("public, max-age=1209600, s-maxage=86400") {
87 | res.headers_mut().insert("Cache-Control", val);
88 | }
89 | }
90 |
91 | Ok(res)
92 | }
93 |
94 | async fn style() -> Result, String> {
95 | let mut res = include_str!("../static/style.css").to_string();
96 | for file in ThemeAssets::iter() {
97 | res.push('\n');
98 | let theme = ThemeAssets::get(file.as_ref()).unwrap();
99 | res.push_str(std::str::from_utf8(theme.data.as_ref()).unwrap());
100 | }
101 | Ok(
102 | Response::builder()
103 | .status(200)
104 | .header("content-type", "text/css")
105 | .header("Cache-Control", "public, max-age=1209600, s-maxage=86400")
106 | .body(res.to_string().into())
107 | .unwrap_or_default(),
108 | )
109 | }
110 |
111 | #[tokio::main]
112 | async fn main() {
113 | // Load environment variables
114 | _ = dotenvy::dotenv();
115 |
116 | // Initialize logger
117 | pretty_env_logger::init();
118 |
119 | let matches = Command::new("Redlib")
120 | .version(env!("CARGO_PKG_VERSION"))
121 | .about("Private front-end for Reddit written in Rust ")
122 | .arg(Arg::new("ipv4-only").short('4').long("ipv4-only").help("Listen on IPv4 only").num_args(0))
123 | .arg(Arg::new("ipv6-only").short('6').long("ipv6-only").help("Listen on IPv6 only").num_args(0))
124 | .arg(
125 | Arg::new("redirect-https")
126 | .short('r')
127 | .long("redirect-https")
128 | .help("Redirect all HTTP requests to HTTPS (no longer functional)")
129 | .num_args(0),
130 | )
131 | .arg(
132 | Arg::new("address")
133 | .short('a')
134 | .long("address")
135 | .value_name("ADDRESS")
136 | .help("Sets address to listen on")
137 | .default_value("[::]")
138 | .num_args(1),
139 | )
140 | .arg(
141 | Arg::new("port")
142 | .short('p')
143 | .long("port")
144 | .value_name("PORT")
145 | .env("PORT")
146 | .help("Port to listen on")
147 | .default_value("8080")
148 | .action(ArgAction::Set)
149 | .num_args(1),
150 | )
151 | .arg(
152 | Arg::new("hsts")
153 | .short('H')
154 | .long("hsts")
155 | .value_name("EXPIRE_TIME")
156 | .help("HSTS header to tell browsers that this site should only be accessed over HTTPS")
157 | .default_value("604800")
158 | .num_args(1),
159 | )
160 | .get_matches();
161 |
162 | match rate_limit_check().await {
163 | Ok(()) => {
164 | info!("[✅] Rate limit check passed");
165 | }
166 | Err(e) => {
167 | let mut message = format!("Rate limit check failed: {e}");
168 | message += "\nThis may cause issues with the rate limit.";
169 | message += "\nPlease report this error with the above information.";
170 | message += "\nhttps://github.com/redlib-org/redlib/issues/new?assignees=sigaloid&labels=bug&title=%F0%9F%90%9B+Bug+Report%3A+Rate+limit+mismatch";
171 | warn!("{}", message);
172 | eprintln!("{message}");
173 | }
174 | }
175 |
176 | let address = matches.get_one::("address").unwrap();
177 | let port = matches.get_one::("port").unwrap();
178 | let hsts = matches.get_one("hsts").map(|m: &String| m.as_str());
179 |
180 | let ipv4_only = std::env::var("IPV4_ONLY").is_ok() || matches.get_flag("ipv4-only");
181 | let ipv6_only = std::env::var("IPV6_ONLY").is_ok() || matches.get_flag("ipv6-only");
182 |
183 | let listener = if ipv4_only {
184 | format!("0.0.0.0:{port}")
185 | } else if ipv6_only {
186 | format!("[::]:{port}")
187 | } else {
188 | [address, ":", port].concat()
189 | };
190 |
191 | println!("Starting Redlib...");
192 |
193 | // Begin constructing a server
194 | let mut app = server::Server::new();
195 |
196 | // Force evaluation of statics. In instance_info case, we need to evaluate
197 | // the timestamp so deploy date is accurate - in config case, we need to
198 | // evaluate the configuration to avoid paying penalty at first request -
199 | // in OAUTH case, we need to retrieve the token to avoid paying penalty
200 | // at first request
201 |
202 | info!("Evaluating config.");
203 | LazyLock::force(&config::CONFIG);
204 | info!("Evaluating instance info.");
205 | LazyLock::force(&instance_info::INSTANCE_INFO);
206 | info!("Creating OAUTH client.");
207 | LazyLock::force(&OAUTH_CLIENT);
208 |
209 | // Define default headers (added to all responses)
210 | app.default_headers = headers! {
211 | "Referrer-Policy" => "no-referrer",
212 | "X-Content-Type-Options" => "nosniff",
213 | "X-Frame-Options" => "DENY",
214 | "Content-Security-Policy" => "default-src 'none'; font-src 'self'; script-src 'self' blob:; manifest-src 'self'; media-src 'self' data: blob: about:; style-src 'self' 'unsafe-inline'; base-uri 'none'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; connect-src 'self'; worker-src blob:;"
215 | };
216 |
217 | if let Some(expire_time) = hsts {
218 | if let Ok(val) = HeaderValue::from_str(&format!("max-age={expire_time}")) {
219 | app.default_headers.insert("Strict-Transport-Security", val);
220 | }
221 | }
222 |
223 | // Read static files
224 | app.at("/style.css").get(|_| style().boxed());
225 | app
226 | .at("/manifest.json")
227 | .get(|_| resource(include_str!("../static/manifest.json"), "application/json", false).boxed());
228 | app.at("/robots.txt").get(|_| {
229 | resource(
230 | if match config::get_setting("REDLIB_ROBOTS_DISABLE_INDEXING") {
231 | Some(val) => val == "on",
232 | None => false,
233 | } {
234 | "User-agent: *\nDisallow: /"
235 | } else {
236 | "User-agent: *\nDisallow: /u/\nDisallow: /user/"
237 | },
238 | "text/plain",
239 | true,
240 | )
241 | .boxed()
242 | });
243 | app.at("/favicon.ico").get(|_| favicon().boxed());
244 | app.at("/logo.png").get(|_| pwa_logo().boxed());
245 | app.at("/Inter.var.woff2").get(|_| font().boxed());
246 | app.at("/touch-icon-iphone.png").get(|_| iphone_logo().boxed());
247 | app.at("/apple-touch-icon.png").get(|_| iphone_logo().boxed());
248 | app.at("/opensearch.xml").get(|_| opensearch().boxed());
249 | app
250 | .at("/playHLSVideo.js")
251 | .get(|_| resource(include_str!("../static/playHLSVideo.js"), "text/javascript", false).boxed());
252 | app
253 | .at("/hls.min.js")
254 | .get(|_| resource(include_str!("../static/hls.min.js"), "text/javascript", false).boxed());
255 | app
256 | .at("/highlighted.js")
257 | .get(|_| resource(include_str!("../static/highlighted.js"), "text/javascript", false).boxed());
258 | app
259 | .at("/check_update.js")
260 | .get(|_| resource(include_str!("../static/check_update.js"), "text/javascript", false).boxed());
261 | app.at("/copy.js").get(|_| resource(include_str!("../static/copy.js"), "text/javascript", false).boxed());
262 |
263 | app.at("/commits.atom").get(|_| async move { proxy_commit_info().await }.boxed());
264 | app.at("/instances.json").get(|_| async move { proxy_instances().await }.boxed());
265 |
266 | // Proxy media through Redlib
267 | app.at("/vid/:id/:size").get(|r| proxy(r, "https://v.redd.it/{id}/DASH_{size}").boxed());
268 | app.at("/hls/:id/*path").get(|r| proxy(r, "https://v.redd.it/{id}/{path}").boxed());
269 | app.at("/img/*path").get(|r| proxy(r, "https://i.redd.it/{path}").boxed());
270 | app.at("/thumb/:point/:id").get(|r| proxy(r, "https://{point}.thumbs.redditmedia.com/{id}").boxed());
271 | app.at("/emoji/:id/:name").get(|r| proxy(r, "https://emoji.redditmedia.com/{id}/{name}").boxed());
272 | app
273 | .at("/emote/:subreddit_id/:filename")
274 | .get(|r| proxy(r, "https://reddit-econ-prod-assets-permanent.s3.amazonaws.com/asset-manager/{subreddit_id}/{filename}").boxed());
275 | app
276 | .at("/preview/:loc/award_images/:fullname/:id")
277 | .get(|r| proxy(r, "https://{loc}view.redd.it/award_images/{fullname}/{id}").boxed());
278 | app.at("/preview/:loc/:id").get(|r| proxy(r, "https://{loc}view.redd.it/{id}").boxed());
279 | app.at("/style/*path").get(|r| proxy(r, "https://styles.redditmedia.com/{path}").boxed());
280 | app.at("/static/*path").get(|r| proxy(r, "https://www.redditstatic.com/{path}").boxed());
281 |
282 | // Browse user profile
283 | app
284 | .at("/u/:name")
285 | .get(|r| async move { Ok(redirect(&format!("/user/{}", r.param("name").unwrap_or_default()))) }.boxed());
286 | app.at("/u/:name/comments/:id/:title").get(|r| post::item(r).boxed());
287 | app.at("/u/:name/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
288 |
289 | app.at("/user/[deleted]").get(|req| error(req, "User has deleted their account").boxed());
290 | app.at("/user/:name.rss").get(|r| user::rss(r).boxed());
291 | app.at("/user/:name").get(|r| user::profile(r).boxed());
292 | app.at("/user/:name/:listing").get(|r| user::profile(r).boxed());
293 | app.at("/user/:name/comments/:id").get(|r| post::item(r).boxed());
294 | app.at("/user/:name/comments/:id/:title").get(|r| post::item(r).boxed());
295 | app.at("/user/:name/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
296 |
297 | // Configure settings
298 | app.at("/settings").get(|r| settings::get(r).boxed()).post(|r| settings::set(r).boxed());
299 | app.at("/settings/restore").get(|r| settings::restore(r).boxed());
300 | app.at("/settings/encoded-restore").post(|r| settings::encoded_restore(r).boxed());
301 | app.at("/settings/update").get(|r| settings::update(r).boxed());
302 |
303 | // RSS Subscriptions
304 | app.at("/r/:sub.rss").get(|r| subreddit::rss(r).boxed());
305 |
306 | // Subreddit services
307 | app
308 | .at("/r/:sub")
309 | .get(|r| subreddit::community(r).boxed())
310 | .post(|r| subreddit::add_quarantine_exception(r).boxed());
311 |
312 | app
313 | .at("/r/u_:name")
314 | .get(|r| async move { Ok(redirect(&format!("/user/{}", r.param("name").unwrap_or_default()))) }.boxed());
315 |
316 | app.at("/r/:sub/subscribe").post(|r| subreddit::subscriptions_filters(r).boxed());
317 | app.at("/r/:sub/unsubscribe").post(|r| subreddit::subscriptions_filters(r).boxed());
318 | app.at("/r/:sub/filter").post(|r| subreddit::subscriptions_filters(r).boxed());
319 | app.at("/r/:sub/unfilter").post(|r| subreddit::subscriptions_filters(r).boxed());
320 |
321 | app.at("/r/:sub/comments/:id").get(|r| post::item(r).boxed());
322 | app.at("/r/:sub/comments/:id/:title").get(|r| post::item(r).boxed());
323 | app.at("/r/:sub/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
324 | app.at("/comments/:id").get(|r| post::item(r).boxed());
325 | app.at("/comments/:id/comments").get(|r| post::item(r).boxed());
326 | app.at("/comments/:id/comments/:comment_id").get(|r| post::item(r).boxed());
327 | app.at("/comments/:id/:title").get(|r| post::item(r).boxed());
328 | app.at("/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
329 |
330 | app.at("/r/:sub/duplicates/:id").get(|r| duplicates::item(r).boxed());
331 | app.at("/r/:sub/duplicates/:id/:title").get(|r| duplicates::item(r).boxed());
332 | app.at("/duplicates/:id").get(|r| duplicates::item(r).boxed());
333 | app.at("/duplicates/:id/:title").get(|r| duplicates::item(r).boxed());
334 |
335 | app.at("/r/:sub/search").get(|r| search::find(r).boxed());
336 |
337 | app
338 | .at("/r/:sub/w")
339 | .get(|r| async move { Ok(redirect(&format!("/r/{}/wiki", r.param("sub").unwrap_or_default()))) }.boxed());
340 | app
341 | .at("/r/:sub/w/*page")
342 | .get(|r| async move { Ok(redirect(&format!("/r/{}/wiki/{}", r.param("sub").unwrap_or_default(), r.param("wiki").unwrap_or_default()))) }.boxed());
343 | app.at("/r/:sub/wiki").get(|r| subreddit::wiki(r).boxed());
344 | app.at("/r/:sub/wiki/*page").get(|r| subreddit::wiki(r).boxed());
345 |
346 | app.at("/r/:sub/about/sidebar").get(|r| subreddit::sidebar(r).boxed());
347 |
348 | app.at("/r/:sub/:sort").get(|r| subreddit::community(r).boxed());
349 |
350 | // Front page
351 | app.at("/").get(|r| subreddit::community(r).boxed());
352 |
353 | // View Reddit wiki
354 | app.at("/w").get(|_| async { Ok(redirect("/wiki")) }.boxed());
355 | app
356 | .at("/w/*page")
357 | .get(|r| async move { Ok(redirect(&format!("/wiki/{}", r.param("page").unwrap_or_default()))) }.boxed());
358 | app.at("/wiki").get(|r| subreddit::wiki(r).boxed());
359 | app.at("/wiki/*page").get(|r| subreddit::wiki(r).boxed());
360 |
361 | // Search all of Reddit
362 | app.at("/search").get(|r| search::find(r).boxed());
363 |
364 | // Handle about pages
365 | app.at("/about").get(|req| error(req, "About pages aren't added yet").boxed());
366 |
367 | // Instance info page
368 | app.at("/info").get(|r| instance_info::instance_info(r).boxed());
369 | app.at("/info.:extension").get(|r| instance_info::instance_info(r).boxed());
370 |
371 | // Handle obfuscated share links.
372 | // Note that this still forces the server to follow the share link to get to the post, so maybe this wants to be updated with a warning before it follow it
373 | app.at("/r/:sub/s/:id").get(|req: Request| {
374 | Box::pin(async move {
375 | let sub = req.param("sub").unwrap_or_default();
376 | match req.param("id").as_deref() {
377 | // Share link
378 | Some(id) if (8..12).contains(&id.len()) => match canonical_path(format!("/r/{sub}/s/{id}"), 3).await {
379 | Ok(Some(path)) => Ok(redirect(&path)),
380 | Ok(None) => error(req, "Post ID is invalid. It may point to a post on a community that has been banned.").await,
381 | Err(e) => error(req, &e).await,
382 | },
383 |
384 | // Error message for unknown pages
385 | _ => error(req, "Nothing here").await,
386 | }
387 | })
388 | });
389 |
390 | app.at("/:id").get(|req: Request| {
391 | Box::pin(async move {
392 | match req.param("id").as_deref() {
393 | // Sort front page
394 | Some("best" | "hot" | "new" | "top" | "rising" | "controversial") => subreddit::community(req).await,
395 |
396 | // Short link for post
397 | Some(id) if (5..8).contains(&id.len()) => match canonical_path(format!("/comments/{id}"), 3).await {
398 | Ok(path_opt) => match path_opt {
399 | Some(path) => Ok(redirect(&path)),
400 | None => error(req, "Post ID is invalid. It may point to a post on a community that has been banned.").await,
401 | },
402 | Err(e) => error(req, &e).await,
403 | },
404 |
405 | // Error message for unknown pages
406 | _ => error(req, "Nothing here").await,
407 | }
408 | })
409 | });
410 |
411 | // Default service in case no routes match
412 | app.at("/*").get(|req| error(req, "Nothing here").boxed());
413 |
414 | println!("Running Redlib v{} on {listener}!", env!("CARGO_PKG_VERSION"));
415 |
416 | let server = app.listen(&listener);
417 |
418 | // Run this server for... forever!
419 | if let Err(e) = server.await {
420 | eprintln!("Server error: {e}");
421 | }
422 | }
423 |
424 | pub async fn proxy_commit_info() -> Result, String> {
425 | Ok(
426 | Response::builder()
427 | .status(200)
428 | .header("content-type", "application/atom+xml")
429 | .body(Body::from(fetch_commit_info().await))
430 | .unwrap_or_default(),
431 | )
432 | }
433 |
434 | #[cached(time = 600)]
435 | async fn fetch_commit_info() -> String {
436 | let uri = Uri::from_str("https://github.com/redlib-org/redlib/commits/main.atom").expect("Invalid URI");
437 |
438 | let resp: Body = CLIENT.get(uri).await.expect("Failed to request GitHub").into_body();
439 |
440 | hyper::body::to_bytes(resp).await.expect("Failed to read body").iter().copied().map(|x| x as char).collect()
441 | }
442 |
443 | pub async fn proxy_instances() -> Result, String> {
444 | Ok(
445 | Response::builder()
446 | .status(200)
447 | .header("content-type", "application/json")
448 | .body(Body::from(fetch_instances().await))
449 | .unwrap_or_default(),
450 | )
451 | }
452 |
453 | #[cached(time = 600)]
454 | async fn fetch_instances() -> String {
455 | let uri = Uri::from_str("https://raw.githubusercontent.com/redlib-org/redlib-instances/refs/heads/main/instances.json").expect("Invalid URI");
456 |
457 | let resp: Body = CLIENT.get(uri).await.expect("Failed to request GitHub").into_body();
458 |
459 | hyper::body::to_bytes(resp).await.expect("Failed to read body").iter().copied().map(|x| x as char).collect()
460 | }
461 |
--------------------------------------------------------------------------------
9 | {% if prefs.hide_score != "on" %} 10 | {{ score.0 }} 11 | {% else %} 12 | • 13 | {% endif %} 14 |
15 | 16 |19 | {% if author.name != "[deleted]" %} 20 | u/{{ author.name }} 21 | {% else %} 22 | u/[deleted] 23 | {% endif %} 24 | {% if author.flair.flair_parts.len() > 0 %} 25 | {% call utils::render_flair(author.flair.flair_parts) %} 26 | {% endif %} 27 | {{ rel_time }} 28 | {% if edited.0 != "".to_string() %}edited {{ edited.0 }}{% endif %} 29 | {% if !awards.is_empty() && prefs.hide_awards != "on" %} 30 | • 31 | {% for award in awards.clone() %} 32 | 33 |
34 |
35 | {% endfor %}
36 | {% endif %}
37 |
38 | {% if is_filtered %} 39 |