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:
parent
7efa26e811
commit
8be5fdee2d
38
Cargo.lock
generated
38
Cargo.lock
generated
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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:
|
||||
|
||||
|
6
app.json
6
app.json
@ -43,6 +43,12 @@
|
||||
},
|
||||
"LIBREDDIT_SFW_ONLY": {
|
||||
"required": false
|
||||
},
|
||||
"LIBREDDIT_DEFAULT_HIDE_AWARDS": {
|
||||
"required": false
|
||||
}
|
||||
"LIBREDDIT_BANNER": {
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
20
build.rs
Normal file
20
build.rs
Normal 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}");
|
||||
}
|
@ -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<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
|
||||
/// 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<String>,
|
||||
pub(crate) sfw_only: Option<String>,
|
||||
|
||||
#[serde(rename = "LIBREDDIT_DEFAULT_THEME")]
|
||||
default_theme: Option<String>,
|
||||
pub(crate) default_theme: Option<String>,
|
||||
|
||||
#[serde(rename = "LIBREDDIT_DEFAULT_FRONT_PAGE")]
|
||||
default_front_page: Option<String>,
|
||||
pub(crate) default_front_page: Option<String>,
|
||||
|
||||
#[serde(rename = "LIBREDDIT_DEFAULT_LAYOUT")]
|
||||
default_layout: Option<String>,
|
||||
pub(crate) default_layout: Option<String>,
|
||||
|
||||
#[serde(rename = "LIBREDDIT_DEFAULT_WIDE")]
|
||||
default_wide: Option<String>,
|
||||
pub(crate) default_wide: Option<String>,
|
||||
|
||||
#[serde(rename = "LIBREDDIT_DEFAULT_COMMENT_SORT")]
|
||||
default_comment_sort: Option<String>,
|
||||
pub(crate) default_comment_sort: Option<String>,
|
||||
|
||||
#[serde(rename = "LIBREDDIT_DEFAULT_POST_SORT")]
|
||||
default_post_sort: Option<String>,
|
||||
pub(crate) default_post_sort: Option<String>,
|
||||
|
||||
#[serde(rename = "LIBREDDIT_DEFAULT_SHOW_NSFW")]
|
||||
default_show_nsfw: Option<String>,
|
||||
pub(crate) default_show_nsfw: Option<String>,
|
||||
|
||||
#[serde(rename = "LIBREDDIT_DEFAULT_BLUR_NSFW")]
|
||||
default_blur_nsfw: Option<String>,
|
||||
pub(crate) default_blur_nsfw: Option<String>,
|
||||
|
||||
#[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")]
|
||||
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 {
|
||||
@ -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<String> {
|
||||
"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,
|
||||
}
|
||||
}
|
||||
|
205
src/instance_info.rs
Normal file
205
src/instance_info.rs
Normal 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,
|
||||
}
|
13
src/main.rs
13
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<Body>| {
|
||||
Box::pin(async move {
|
||||
match req.param("id").as_deref() {
|
||||
|
@ -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 */
|
||||
|
||||
|
@ -66,9 +66,11 @@
|
||||
</main>
|
||||
{% endblock %}
|
||||
{% block footer %}
|
||||
{% if crate::utils::sfw_only() %}
|
||||
<footer><div id="sfw-only">This instance of Libreddit is SFW-only.</div></footer>
|
||||
{% endif %}
|
||||
<footer>
|
||||
<div class="info-button">
|
||||
<a href="/info" title="View instance information">ⓘ</a>
|
||||
</div>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
10
templates/message.html
Normal file
10
templates/message.html
Normal 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 %}
|
Loading…
Reference in New Issue
Block a user