diff --git a/Cargo.lock b/Cargo.lock index a62086e..23f9bc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -150,6 +150,12 @@ dependencies = [ "serde", ] +[[package]] +name = "build_html" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ef018b44d829e1b3364b4969059c098743595ec57a7eed176fbc9d909ac217" + [[package]] name = "bumpalo" version = "3.12.0" @@ -671,6 +677,7 @@ version = "0.27.2" dependencies = [ "askama", "brotli", + "build_html", "cached", "clap", "cookie", @@ -687,6 +694,7 @@ dependencies = [ "sealed_test", "serde", "serde_json", + "serde_yaml", "time", "tokio", "toml", @@ -782,6 +790,15 @@ dependencies = [ "libc", ] +[[package]] +name = "num_threads" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +dependencies = [ + "libc", +] + [[package]] name = "once_cell" version = "1.17.0" @@ -1165,6 +1182,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92b5b431e8907b50339b51223b97d102db8d987ced36f6e4d03621db9316c834" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.6" @@ -1274,6 +1304,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" dependencies = [ "itoa", + "libc", + "num_threads", "serde", "time-core", "time-macros", @@ -1442,6 +1474,12 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7ed8ba44ca06be78ea1ad2c3682a43349126c8818054231ee6f4748012aed2" + [[package]] name = "untrusted" version = "0.7.1" diff --git a/Cargo.toml b/Cargo.toml index 7a598f4..0768d07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,13 +21,15 @@ percent-encoding = "2.2.0" route-recognizer = "0.3.1" serde_json = "1.0.91" tokio = { version = "1.24.2", features = ["full"] } -time = "0.3.17" +time = { version = "0.3.17", features = ["local-offset"] } url = "2.3.1" rust-embed = { version = "6.4.2", features = ["include-exclude"] } libflate = "1.2.0" brotli = { version = "3.3.4", features = ["std"] } toml = "0.5.10" once_cell = "1.17.0" +serde_yaml = "0.9.16" +build_html = "2.2.0" [dev-dependencies] lipsum = "0.8.2" diff --git a/README.md b/README.md index 88382dc..3fec4cc 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,8 @@ Assign a default value for each instance-specific setting by passing environment |Name|Possible values|Default value|Description| |-|-|-|-| -| `SFW_ONLY` | `["on", "off"]` | `off` | Enables SFW-only mode for the instance, i.e. all NSFW content is filtered. | +| `SFW_ONLY` | `["on", "off"]` | `off` | Enables SFW-only mode for the instance, i.e. all NSFW content is filtered. | +| `BANNER` | String | (empty) | Allows the server to set a banner to be displayed. Currently this is displayed on the instance info page. | ## Default User Settings @@ -208,6 +209,7 @@ Assign a default value for each user-modifiable setting by passing environment v | `USE_HLS` | `["on", "off"]` | `off` | | `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` | | `AUTOPLAY_VIDEOS` | `["on", "off"]` | `off` | +| `HIDE_AWARDS` | `["on", "off"]` | `off` You can also configure Libreddit with a configuration file. An example `libreddit.toml` can be found below: diff --git a/app.json b/app.json index 48b6f1d..33a41fa 100644 --- a/app.json +++ b/app.json @@ -43,6 +43,12 @@ }, "LIBREDDIT_SFW_ONLY": { "required": false + }, + "LIBREDDIT_DEFAULT_HIDE_AWARDS": { + "required": false + } + "LIBREDDIT_BANNER": { + "required": false } } } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..8700cce --- /dev/null +++ b/build.rs @@ -0,0 +1,20 @@ +use std::{ + os::unix::process::ExitStatusExt, + process::{Command, ExitStatus, Output}, +}; +fn main() { + let output = String::from_utf8( + Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .unwrap_or(Output { + stdout: vec![], + stderr: vec![], + status: ExitStatus::from_raw(0), + }) + .stdout, + ) + .unwrap_or_default(); + let git_hash = if output == String::default() { "dev".into() } else { output }; + println!("cargo:rustc-env=GIT_HASH={git_hash}"); +} diff --git a/src/config.rs b/src/config.rs index 55a4580..90478b9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; use std::{env::var, fs::read_to_string}; // Waiting for https://github.com/rust-lang/rust/issues/74465 to land, so we @@ -6,44 +7,53 @@ use std::{env::var, fs::read_to_string}; // // This is the local static that is initialized at runtime (technically at // first request) and contains the instance settings. -static CONFIG: Lazy = Lazy::new(Config::load); +pub(crate) static CONFIG: Lazy = Lazy::new(Config::load); /// Stores the configuration parsed from the environment variables and the /// config file. `Config::Default()` contains None for each setting. -#[derive(Default, serde::Deserialize)] +/// When adding more config settings, add it to `Config::load`, +/// `get_setting_from_config`, both below, as well as +/// instance_info::InstanceInfo.to_string(), README.md and app.json. +#[derive(Default, Serialize, Deserialize, Clone)] pub struct Config { #[serde(rename = "LIBREDDIT_SFW_ONLY")] - sfw_only: Option, + pub(crate) sfw_only: Option, #[serde(rename = "LIBREDDIT_DEFAULT_THEME")] - default_theme: Option, + pub(crate) default_theme: Option, #[serde(rename = "LIBREDDIT_DEFAULT_FRONT_PAGE")] - default_front_page: Option, + pub(crate) default_front_page: Option, #[serde(rename = "LIBREDDIT_DEFAULT_LAYOUT")] - default_layout: Option, + pub(crate) default_layout: Option, #[serde(rename = "LIBREDDIT_DEFAULT_WIDE")] - default_wide: Option, + pub(crate) default_wide: Option, #[serde(rename = "LIBREDDIT_DEFAULT_COMMENT_SORT")] - default_comment_sort: Option, + pub(crate) default_comment_sort: Option, #[serde(rename = "LIBREDDIT_DEFAULT_POST_SORT")] - default_post_sort: Option, + pub(crate) default_post_sort: Option, #[serde(rename = "LIBREDDIT_DEFAULT_SHOW_NSFW")] - default_show_nsfw: Option, + pub(crate) default_show_nsfw: Option, #[serde(rename = "LIBREDDIT_DEFAULT_BLUR_NSFW")] - default_blur_nsfw: Option, + pub(crate) default_blur_nsfw: Option, #[serde(rename = "LIBREDDIT_DEFAULT_USE_HLS")] - default_use_hls: Option, + pub(crate) default_use_hls: Option, #[serde(rename = "LIBREDDIT_DEFAULT_HIDE_HLS_NOTIFICATION")] - default_hide_hls_notification: Option, + pub(crate) default_hide_hls_notification: Option, + + #[serde(rename = "LIBREDDIT_DEFAULT_HIDE_AWARDS")] + pub(crate) default_hide_awards: Option, + + #[serde(rename = "LIBREDDIT_BANNER")] + pub(crate) banner: Option, } impl Config { @@ -70,6 +80,8 @@ impl Config { default_blur_nsfw: parse("LIBREDDIT_DEFAULT_BLUR_NSFW"), default_use_hls: parse("LIBREDDIT_DEFAULT_USE_HLS"), default_hide_hls_notification: parse("LIBREDDIT_DEFAULT_HIDE_HLS"), + default_hide_awards: parse("LIBREDDIT_DEFAULT_HIDE_AWARDS"), + banner: parse("LIBREDDIT_BANNER"), } } } @@ -87,6 +99,8 @@ fn get_setting_from_config(name: &str, config: &Config) -> Option { "LIBREDDIT_DEFAULT_USE_HLS" => config.default_use_hls.clone(), "LIBREDDIT_DEFAULT_HIDE_HLS_NOTIFICATION" => config.default_hide_hls_notification.clone(), "LIBREDDIT_DEFAULT_WIDE" => config.default_wide.clone(), + "LIBREDDIT_DEFAULT_HIDE_AWARDS" => config.default_hide_awards.clone(), + "LIBREDDIT_BANNER" => config.banner.clone(), _ => None, } } diff --git a/src/instance_info.rs b/src/instance_info.rs new file mode 100644 index 0000000..19b93f1 --- /dev/null +++ b/src/instance_info.rs @@ -0,0 +1,205 @@ +use crate::{ + config::{Config, CONFIG}, + server::RequestExt, + utils::{ErrorTemplate, Preferences}, +}; +use askama::Template; +use build_html::{Container, Html, HtmlContainer, Table}; +use hyper::{http::Error, Body, Request, Response}; +use once_cell::sync::Lazy; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; + +// This is the local static that is intialized at runtime (technically at +// the first request to the info endpoint) and contains the data +// retrieved from the info endpoint. +pub(crate) static INSTANCE_INFO: Lazy = Lazy::new(InstanceInfo::new); + +/// Handles instance info endpoint +pub async fn instance_info(req: Request) -> Result, String> { + // This will retrieve the extension given, or create a new string - which will + // simply become the last option, an HTML page. + let extension = req.param("extension").unwrap_or(String::new()); + let response = match extension.as_str() { + "yaml" | "yml" => info_yaml(), + "txt" => info_txt(), + "json" => info_json(), + "html" | "" => info_html(req), + _ => { + let error = ErrorTemplate { + msg: "Error: Invalid info extension".into(), + prefs: Preferences::new(&req), + url: req.uri().to_string(), + } + .render() + .unwrap(); + Response::builder().status(404).header("content-type", "text/html; charset=utf-8").body(error.into()) + } + }; + response.map_err(|err| format!("{err}")) +} + +fn info_json() -> Result, Error> { + if let Ok(body) = serde_json::to_string(&*INSTANCE_INFO) { + Response::builder().status(200).header("content-type", "application/json").body(body.into()) + } else { + Response::builder() + .status(500) + .header("content-type", "text/plain") + .body(Body::from("Error serializing JSON")) + } +} + +fn info_yaml() -> Result, Error> { + if let Ok(body) = serde_yaml::to_string(&*INSTANCE_INFO) { + // We can use `application/yaml` as media type, though there is no guarantee + // that browsers will honor it. But we'll do it anyway. See: + // https://github.com/ietf-wg-httpapi/mediatypes/blob/main/draft-ietf-httpapi-yaml-mediatypes.md#media-type-applicationyaml-application-yaml + Response::builder().status(200).header("content-type", "application/yaml").body(body.into()) + } else { + Response::builder() + .status(500) + .header("content-type", "text/plain") + .body(Body::from("Error serializing YAML.")) + } +} + +fn info_txt() -> Result, Error> { + Response::builder() + .status(200) + .header("content-type", "text/plain") + .body(Body::from(INSTANCE_INFO.to_string(StringType::Raw))) +} +fn info_html(req: Request) -> Result, Error> { + let message = MessageTemplate { + title: String::from("Instance information"), + body: INSTANCE_INFO.to_string(StringType::Html), + prefs: Preferences::new(&req), + url: req.uri().to_string(), + } + .render() + .unwrap(); + Response::builder().status(200).header("content-type", "text/html; charset=utf8").body(Body::from(message)) +} +#[derive(Serialize, Deserialize, Default)] +pub(crate) struct InstanceInfo { + crate_version: String, + git_commit: String, + deploy_date: String, + compile_mode: String, + deploy_unix_ts: i64, + config: Config, +} + +impl InstanceInfo { + pub fn new() -> Self { + Self { + crate_version: env!("CARGO_PKG_VERSION").to_string(), + git_commit: env!("GIT_HASH").to_string(), + deploy_date: OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()).to_string(), + #[cfg(debug_assertions)] + compile_mode: "Debug".into(), + #[cfg(not(debug_assertions))] + compile_mode: "Release".into(), + deploy_unix_ts: OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc()).unix_timestamp(), + config: CONFIG.clone(), + } + } + fn to_table(&self) -> String { + let mut container = Container::default(); + let convert = |o: &Option| -> String { o.clone().unwrap_or("Unset".to_owned()) }; + if let Some(banner) = &self.config.banner { + container.add_header(3, "Instance banner"); + container.add_raw("
"); + container.add_paragraph(banner); + container.add_raw("
"); + } + container.add_table( + Table::from([ + ["Crate version", &self.crate_version], + ["Git commit", &self.git_commit], + ["Deploy date", &self.deploy_date], + ["Deploy timestamp", &self.deploy_unix_ts.to_string()], + ["Compile mode", &self.compile_mode], + ["SFW only", &convert(&self.config.sfw_only)], + ]) + .with_header_row(["Settings"]), + ); + container.add_raw("
"); + container.add_table( + Table::from([ + ["Hide awards", &convert(&self.config.default_hide_awards)], + ["Theme", &convert(&self.config.default_theme)], + ["Front page", &convert(&self.config.default_front_page)], + ["Layout", &convert(&self.config.default_layout)], + ["Wide", &convert(&self.config.default_wide)], + ["Comment sort", &convert(&self.config.default_comment_sort)], + ["Post sort", &convert(&self.config.default_post_sort)], + ["Show NSFW", &convert(&self.config.default_show_nsfw)], + ["Blur NSFW", &convert(&self.config.default_blur_nsfw)], + ["Use HLS", &convert(&self.config.default_use_hls)], + ["Hide HLS notification", &convert(&self.config.default_hide_hls_notification)], + ]) + .with_header_row(["Default preferences"]), + ); + container.to_html_string().replace("", "") + } + fn to_string(&self, string_type: StringType) -> String { + match string_type { + StringType::Raw => { + format!( + "Crate version: {}\n + Git commit: {}\n + Deploy date: {}\n + Deploy timestamp: {}\n + Compile mode: {}\n + Config:\n + Banner: {:?}\n + Hide awards: {:?}\n + SFW only: {:?}\n + Default theme: {:?}\n + Default front page: {:?}\n + Default layout: {:?}\n + Default wide: {:?}\n + Default comment sort: {:?}\n + Default post sort: {:?}\n + Default show NSFW: {:?}\n + Default blur NSFW: {:?}\n + Default use HLS: {:?}\n + Default hide HLS notification: {:?}\n", + self.crate_version, + self.git_commit, + self.deploy_date, + self.deploy_unix_ts, + self.compile_mode, + self.config.banner, + self.config.default_hide_awards, + self.config.sfw_only, + self.config.default_theme, + self.config.default_front_page, + self.config.default_layout, + self.config.default_wide, + self.config.default_comment_sort, + self.config.default_post_sort, + self.config.default_show_nsfw, + self.config.default_blur_nsfw, + self.config.default_use_hls, + self.config.default_hide_hls_notification + ) + } + StringType::Html => self.to_table(), + } + } +} +enum StringType { + Raw, + Html, +} +#[derive(Template)] +#[template(path = "message.html")] +struct MessageTemplate { + title: String, + body: String, + prefs: Preferences, + url: String, +} diff --git a/src/main.rs b/src/main.rs index 2d848ad..7489aa0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ // Reference local files mod config; mod duplicates; +mod instance_info; mod post; mod search; mod settings; @@ -20,6 +21,7 @@ use hyper::{header::HeaderValue, Body, Request, Response}; mod client; use client::{canonical_path, proxy}; +use once_cell::sync::Lazy; use server::RequestExt; use utils::{error, redirect, ThemeAssets}; @@ -158,6 +160,13 @@ async fn main() { // Begin constructing a server let mut app = server::Server::new(); + // Force evaluation of statics. In instance_info case, we need to evaluate + // the timestamp so deploy date is accurate - in config case, we need to + // evaluate the configuration to avoid paying penalty at first request. + + Lazy::force(&config::CONFIG); + Lazy::force(&instance_info::INSTANCE_INFO); + // Define default headers (added to all responses) app.default_headers = headers! { "Referrer-Policy" => "no-referrer", @@ -285,6 +294,10 @@ async fn main() { // Handle about pages app.at("/about").get(|req| error(req, "About pages aren't added yet".to_string()).boxed()); + // Instance info page + app.at("/info").get(|r| instance_info::instance_info(r).boxed()); + app.at("/info.:extension").get(|r| instance_info::instance_info(r).boxed()); + app.at("/:id").get(|req: Request| { Box::pin(async move { match req.param("id").as_deref() { diff --git a/static/style.css b/static/style.css index 3ff48c4..f0877c3 100644 --- a/static/style.css +++ b/static/style.css @@ -173,13 +173,22 @@ body > footer { margin: 20px; } -body > footer > div#sfw-only { - color: var(--green); - border: 1px solid var(--green); - padding: 5px; +.info-button { + align-items: center; + border-radius: .25rem; box-sizing: border-box; - border-radius: 5px; + color: var(--text); + cursor: pointer; + display: inline-flex; + font-size: 300%; + font-weight: bold; + padding: 0.5em; } + +.info-button > a:hover { + text-decoration: none; +} + /* / Body footer. */ /* Footer in content block. */ @@ -1238,6 +1247,10 @@ input[type="submit"] { width: 250px; background: var(--highlighted) !important; } +/* Info page */ +.unset { + color: lightslategrey; +} /* Markdown */ diff --git a/templates/base.html b/templates/base.html index dd882d8..63c76a4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -66,9 +66,11 @@ {% endblock %} {% block footer %} - {% if crate::utils::sfw_only() %} -
This instance of Libreddit is SFW-only.
- {% endif %} + {% endblock %} diff --git a/templates/message.html b/templates/message.html new file mode 100644 index 0000000..b424e9a --- /dev/null +++ b/templates/message.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}{{ title }}{% endblock %} +{% block sortstyle %}{% endblock %} +{% block content %} +
+

{{ title }}

+
+ {{ body|safe }} +
+{% endblock %}