Implement instance info endpoint (JSON, YAML, TXT) (#685)

Co-authored-by: Daniel Valentine <daniel@vielle.ws>
Co-authored-by: spikecodes <19519553+spikecodes@users.noreply.github.com>
This commit is contained in:
Matthew Esposito 2023-01-30 04:02:43 -05:00 committed by GitHub
parent 7efa26e811
commit 8be5fdee2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 348 additions and 23 deletions

38
Cargo.lock generated
View File

@ -150,6 +150,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "build_html"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3ef018b44d829e1b3364b4969059c098743595ec57a7eed176fbc9d909ac217"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.12.0" version = "3.12.0"
@ -671,6 +677,7 @@ version = "0.27.2"
dependencies = [ dependencies = [
"askama", "askama",
"brotli", "brotli",
"build_html",
"cached", "cached",
"clap", "clap",
"cookie", "cookie",
@ -687,6 +694,7 @@ dependencies = [
"sealed_test", "sealed_test",
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml",
"time", "time",
"tokio", "tokio",
"toml", "toml",
@ -782,6 +790,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "num_threads"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.17.0" version = "1.17.0"
@ -1165,6 +1182,19 @@ dependencies = [
"serde", "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]] [[package]]
name = "sha2" name = "sha2"
version = "0.10.6" version = "0.10.6"
@ -1274,6 +1304,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376" checksum = "a561bf4617eebd33bca6434b988f39ed798e527f51a1e797d0ee4f61c0a38376"
dependencies = [ dependencies = [
"itoa", "itoa",
"libc",
"num_threads",
"serde", "serde",
"time-core", "time-core",
"time-macros", "time-macros",
@ -1442,6 +1474,12 @@ dependencies = [
"tinyvec", "tinyvec",
] ]
[[package]]
name = "unsafe-libyaml"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7ed8ba44ca06be78ea1ad2c3682a43349126c8818054231ee6f4748012aed2"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.7.1" version = "0.7.1"

View File

@ -21,13 +21,15 @@ percent-encoding = "2.2.0"
route-recognizer = "0.3.1" route-recognizer = "0.3.1"
serde_json = "1.0.91" serde_json = "1.0.91"
tokio = { version = "1.24.2", features = ["full"] } tokio = { version = "1.24.2", features = ["full"] }
time = "0.3.17" time = { version = "0.3.17", features = ["local-offset"] }
url = "2.3.1" url = "2.3.1"
rust-embed = { version = "6.4.2", features = ["include-exclude"] } rust-embed = { version = "6.4.2", features = ["include-exclude"] }
libflate = "1.2.0" libflate = "1.2.0"
brotli = { version = "3.3.4", features = ["std"] } brotli = { version = "3.3.4", features = ["std"] }
toml = "0.5.10" toml = "0.5.10"
once_cell = "1.17.0" once_cell = "1.17.0"
serde_yaml = "0.9.16"
build_html = "2.2.0"
[dev-dependencies] [dev-dependencies]
lipsum = "0.8.2" lipsum = "0.8.2"

View File

@ -190,6 +190,7 @@ Assign a default value for each instance-specific setting by passing environment
|Name|Possible values|Default value|Description| |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 ## 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` | | `USE_HLS` | `["on", "off"]` | `off` |
| `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` | | `HIDE_HLS_NOTIFICATION` | `["on", "off"]` | `off` |
| `AUTOPLAY_VIDEOS` | `["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: You can also configure Libreddit with a configuration file. An example `libreddit.toml` can be found below:

View File

@ -43,6 +43,12 @@
}, },
"LIBREDDIT_SFW_ONLY": { "LIBREDDIT_SFW_ONLY": {
"required": false "required": false
},
"LIBREDDIT_DEFAULT_HIDE_AWARDS": {
"required": false
}
"LIBREDDIT_BANNER": {
"required": false
} }
} }
} }

20
build.rs Normal file
View File

@ -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}");
}

View File

@ -1,4 +1,5 @@
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::{env::var, fs::read_to_string}; use std::{env::var, fs::read_to_string};
// Waiting for https://github.com/rust-lang/rust/issues/74465 to land, so we // 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 // This is the local static that is initialized at runtime (technically at
// first request) and contains the instance settings. // first request) and contains the instance settings.
static CONFIG: Lazy<Config> = Lazy::new(Config::load); pub(crate) static CONFIG: Lazy<Config> = Lazy::new(Config::load);
/// Stores the configuration parsed from the environment variables and the /// Stores the configuration parsed from the environment variables and the
/// config file. `Config::Default()` contains None for each setting. /// 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 { pub struct Config {
#[serde(rename = "LIBREDDIT_SFW_ONLY")] #[serde(rename = "LIBREDDIT_SFW_ONLY")]
sfw_only: Option<String>, pub(crate) sfw_only: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_THEME")] #[serde(rename = "LIBREDDIT_DEFAULT_THEME")]
default_theme: Option<String>, pub(crate) default_theme: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_FRONT_PAGE")] #[serde(rename = "LIBREDDIT_DEFAULT_FRONT_PAGE")]
default_front_page: Option<String>, pub(crate) default_front_page: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_LAYOUT")] #[serde(rename = "LIBREDDIT_DEFAULT_LAYOUT")]
default_layout: Option<String>, pub(crate) default_layout: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_WIDE")] #[serde(rename = "LIBREDDIT_DEFAULT_WIDE")]
default_wide: Option<String>, pub(crate) default_wide: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_COMMENT_SORT")] #[serde(rename = "LIBREDDIT_DEFAULT_COMMENT_SORT")]
default_comment_sort: Option<String>, pub(crate) default_comment_sort: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_POST_SORT")] #[serde(rename = "LIBREDDIT_DEFAULT_POST_SORT")]
default_post_sort: Option<String>, pub(crate) default_post_sort: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_SHOW_NSFW")] #[serde(rename = "LIBREDDIT_DEFAULT_SHOW_NSFW")]
default_show_nsfw: Option<String>, pub(crate) default_show_nsfw: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_BLUR_NSFW")] #[serde(rename = "LIBREDDIT_DEFAULT_BLUR_NSFW")]
default_blur_nsfw: Option<String>, pub(crate) default_blur_nsfw: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_USE_HLS")] #[serde(rename = "LIBREDDIT_DEFAULT_USE_HLS")]
default_use_hls: Option<String>, pub(crate) default_use_hls: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_HIDE_HLS_NOTIFICATION")] #[serde(rename = "LIBREDDIT_DEFAULT_HIDE_HLS_NOTIFICATION")]
default_hide_hls_notification: Option<String>, pub(crate) default_hide_hls_notification: Option<String>,
#[serde(rename = "LIBREDDIT_DEFAULT_HIDE_AWARDS")]
pub(crate) default_hide_awards: Option<String>,
#[serde(rename = "LIBREDDIT_BANNER")]
pub(crate) banner: Option<String>,
} }
impl Config { impl Config {
@ -70,6 +80,8 @@ impl Config {
default_blur_nsfw: parse("LIBREDDIT_DEFAULT_BLUR_NSFW"), default_blur_nsfw: parse("LIBREDDIT_DEFAULT_BLUR_NSFW"),
default_use_hls: parse("LIBREDDIT_DEFAULT_USE_HLS"), default_use_hls: parse("LIBREDDIT_DEFAULT_USE_HLS"),
default_hide_hls_notification: parse("LIBREDDIT_DEFAULT_HIDE_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<String> {
"LIBREDDIT_DEFAULT_USE_HLS" => config.default_use_hls.clone(), "LIBREDDIT_DEFAULT_USE_HLS" => config.default_use_hls.clone(),
"LIBREDDIT_DEFAULT_HIDE_HLS_NOTIFICATION" => config.default_hide_hls_notification.clone(), "LIBREDDIT_DEFAULT_HIDE_HLS_NOTIFICATION" => config.default_hide_hls_notification.clone(),
"LIBREDDIT_DEFAULT_WIDE" => config.default_wide.clone(), "LIBREDDIT_DEFAULT_WIDE" => config.default_wide.clone(),
"LIBREDDIT_DEFAULT_HIDE_AWARDS" => config.default_hide_awards.clone(),
"LIBREDDIT_BANNER" => config.banner.clone(),
_ => None, _ => None,
} }
} }

205
src/instance_info.rs Normal file
View File

@ -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<InstanceInfo> = Lazy::new(InstanceInfo::new);
/// Handles instance info endpoint
pub async fn instance_info(req: Request<Body>) -> Result<Response<Body>, 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<Response<Body>, 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<Response<Body>, 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<Response<Body>, Error> {
Response::builder()
.status(200)
.header("content-type", "text/plain")
.body(Body::from(INSTANCE_INFO.to_string(StringType::Raw)))
}
fn info_html(req: Request<Body>) -> Result<Response<Body>, 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>| -> String { o.clone().unwrap_or("<span class=\"unset\"><i>Unset</i></span>".to_owned()) };
if let Some(banner) = &self.config.banner {
container.add_header(3, "Instance banner");
container.add_raw("<br />");
container.add_paragraph(banner);
container.add_raw("<br />");
}
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("<br />");
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("<th>", "<th colspan=\"2\">")
}
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,
}

View File

@ -5,6 +5,7 @@
// Reference local files // Reference local files
mod config; mod config;
mod duplicates; mod duplicates;
mod instance_info;
mod post; mod post;
mod search; mod search;
mod settings; mod settings;
@ -20,6 +21,7 @@ use hyper::{header::HeaderValue, Body, Request, Response};
mod client; mod client;
use client::{canonical_path, proxy}; use client::{canonical_path, proxy};
use once_cell::sync::Lazy;
use server::RequestExt; use server::RequestExt;
use utils::{error, redirect, ThemeAssets}; use utils::{error, redirect, ThemeAssets};
@ -158,6 +160,13 @@ async fn main() {
// Begin constructing a server // Begin constructing a server
let mut app = server::Server::new(); 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) // Define default headers (added to all responses)
app.default_headers = headers! { app.default_headers = headers! {
"Referrer-Policy" => "no-referrer", "Referrer-Policy" => "no-referrer",
@ -285,6 +294,10 @@ async fn main() {
// Handle about pages // Handle about pages
app.at("/about").get(|req| error(req, "About pages aren't added yet".to_string()).boxed()); 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<Body>| { app.at("/:id").get(|req: Request<Body>| {
Box::pin(async move { Box::pin(async move {
match req.param("id").as_deref() { match req.param("id").as_deref() {

View File

@ -173,13 +173,22 @@ body > footer {
margin: 20px; margin: 20px;
} }
body > footer > div#sfw-only { .info-button {
color: var(--green); align-items: center;
border: 1px solid var(--green); border-radius: .25rem;
padding: 5px;
box-sizing: border-box; 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. */ /* / Body footer. */
/* Footer in content block. */ /* Footer in content block. */
@ -1238,6 +1247,10 @@ input[type="submit"] {
width: 250px; width: 250px;
background: var(--highlighted) !important; background: var(--highlighted) !important;
} }
/* Info page */
.unset {
color: lightslategrey;
}
/* Markdown */ /* Markdown */

View File

@ -66,9 +66,11 @@
</main> </main>
{% endblock %} {% endblock %}
{% block footer %} {% block footer %}
{% if crate::utils::sfw_only() %} <footer>
<footer><div id="sfw-only">This instance of Libreddit is SFW-only.</div></footer> <div class="info-button">
{% endif %} <a href="/info" title="View instance information">&#x24D8;</a>
</div>
</footer>
{% endblock %} {% endblock %}
</body> </body>
</html> </html>

10
templates/message.html Normal file
View File

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block sortstyle %}{% endblock %}
{% block content %}
<div id="message">
<h1>{{ title }}</h1>
<br>
{{ body|safe }}
</div>
{% endblock %}