web: add urlize() and heading() template filters

axum-login-upgrade
Manos Pitsidianakis 2023-05-09 13:38:41 +03:00
parent 2b250e144c
commit f8cc3852bb
Signed by: Manos Pitsidianakis
GPG Key ID: 7729C7707F7E09D0
23 changed files with 278 additions and 128 deletions

1
Cargo.lock generated
View File

@ -1844,6 +1844,7 @@ dependencies = [
"build-info",
"build-info-build",
"chrono",
"convert_case",
"dyn-clone",
"eyre",
"http",

View File

@ -494,7 +494,7 @@ impl Connection {
) -> Result<Vec<DbVal<Post>>> {
let mut stmt = self.connection.prepare(
"SELECT *, strftime('%Y-%m', CAST(timestamp AS INTEGER), 'unixepoch') AS month_year \
FROM post WHERE list = ?;",
FROM post WHERE list = ? ORDER BY timestamp ASC;",
)?;
let iter = stmt.query_map(rusqlite::params![&list_pk], |row| {
let pk = row.get("pk")?;

View File

@ -152,17 +152,25 @@
#[macro_use]
extern crate error_chain;
/// Error library
pub extern crate anyhow;
/// Date library
pub extern crate chrono;
/// Sql library
pub extern crate rusqlite;
/// Alias for [`chrono::DateTime<chrono::Utc>`].
pub type DateTime = chrono::DateTime<chrono::Utc>;
/// Serde
#[macro_use]
pub extern crate serde;
/// Log
pub extern crate log;
/// melib
pub extern crate melib;
/// serde_json
pub extern crate serde_json;
mod config;

View File

@ -25,6 +25,7 @@ axum-login = { version = "^0.5" }
axum-sessions = { version = "^0.5" }
build-info = { version = "0.0.31" }
chrono = { version = "^0.4" }
convert_case = { version = "^0.4" }
dyn-clone = { version = "^1" }
eyre = { version = "0.6" }
http = "0.2"

View File

@ -128,7 +128,6 @@ pub async fn ssh_signin(
);
let timeout_left = ((timestamp + EXPIRY_IN_SECS) - now) as f64 / 60.0;
let root_url_prefix = &state.root_url_prefix;
let crumbs = vec![
Crumb {
label: "Home".into(),
@ -142,10 +141,7 @@ pub async fn ssh_signin(
let context = minijinja::context! {
namespace => &state.public_url,
site_title => state.site_title.as_ref(),
site_subtitle => state.site_subtitle.as_ref(),
page_title => "Log in",
root_url_prefix => &root_url_prefix,
ssh_challenge => token,
timeout_left => timeout_left,
current_user => auth.current_user,

View File

@ -24,7 +24,6 @@ pub async fn help(
_: HelpPath,
mut session: WritableSession,
auth: AuthContext,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, ResponseError> {
let crumbs = vec![
Crumb {
@ -37,10 +36,7 @@ pub async fn help(
},
];
let context = minijinja::context! {
site_title => state.site_title.as_ref(),
site_subtitle => state.site_subtitle.as_ref(),
page_title => "Help & Documentation",
root_url_prefix => &state.root_url_prefix,
current_user => auth.current_user,
messages => session.drain_messages(),
crumbs => crumbs,

View File

@ -119,23 +119,20 @@ pub async fn list(
},
];
let context = minijinja::context! {
site_title => state.site_title.as_ref(),
site_subtitle => state.site_subtitle.as_ref(),
canonical_url => ListPath::from(&list).to_crumb(),
page_title => &list.name,
description => &list.description,
post_policy => &post_policy,
subscription_policy => &subscription_policy,
post_policy,
subscription_policy,
preamble => true,
months => &months,
months,
hists => &hist,
posts => posts_ctx,
root_url_prefix => &state.root_url_prefix,
list => Value::from_object(MailingList::from(list)),
current_user => auth.current_user,
user_context => user_context,
user_context,
messages => session.drain_messages(),
crumbs => crumbs,
crumbs,
};
Ok(Html(
TEMPLATES.get_template("lists/list.html")?.render(context)?,
@ -200,8 +197,6 @@ pub async fn list_post(
},
];
let context = minijinja::context! {
site_title => state.site_title.as_ref(),
site_subtitle => state.site_subtitle.as_ref(),
canonical_url => ListPostPath(ListPathIdentifier::from(list.id.clone()), msg_id.to_string()).to_crumb(),
page_title => subject_ref,
description => &list.description,
@ -220,7 +215,6 @@ pub async fn list_post(
timestamp => post.timestamp,
datetime => post.datetime,
thread => thread,
root_url_prefix => &state.root_url_prefix,
current_user => auth.current_user,
user_context => user_context,
messages => session.drain_messages(),
@ -302,27 +296,24 @@ pub async fn list_edit(
url: ListPath(list.id.to_string().into()).to_crumb(),
},
Crumb {
label: list.name.clone().into(),
label: format!("Edit {}", list.name).into(),
url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
},
];
let context = minijinja::context! {
site_title => state.site_title.as_ref(),
site_subtitle => state.site_subtitle.as_ref(),
canonical_url => ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
page_title => format!("Edit {} settings", list.name),
description => &list.description,
post_policy => &post_policy,
subscription_policy => &subscription_policy,
list_owners => list_owners,
post_count => post_count,
subs_count => subs_count,
sub_requests_count => sub_requests_count,
root_url_prefix => &state.root_url_prefix,
post_policy,
subscription_policy,
list_owners,
post_count,
subs_count,
sub_requests_count,
list => Value::from_object(MailingList::from(list)),
current_user => auth.current_user,
messages => session.drain_messages(),
crumbs => crumbs,
crumbs,
};
Ok(Html(
TEMPLATES.get_template("lists/edit.html")?.render(context)?,
@ -661,7 +652,7 @@ pub async fn list_subscribers(
url: ListPath(list.id.to_string().into()).to_crumb(),
},
Crumb {
label: list.name.clone().into(),
label: format!("Edit {}", list.name).into(),
url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
},
Crumb {
@ -670,12 +661,9 @@ pub async fn list_subscribers(
},
];
let context = minijinja::context! {
site_title => state.site_title.as_ref(),
site_subtitle => state.site_subtitle.as_ref(),
canonical_url => ListEditSubscribersPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
page_title => format!("Subscribers of {}", list.name),
subs,
root_url_prefix => &state.root_url_prefix,
list => Value::from_object(MailingList::from(list)),
current_user => auth.current_user,
messages => session.drain_messages(),
@ -747,7 +735,7 @@ pub async fn list_candidates(
url: ListPath(list.id.to_string().into()).to_crumb(),
},
Crumb {
label: list.name.clone().into(),
label: format!("Edit {}", list.name).into(),
url: ListEditPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
},
Crumb {
@ -756,12 +744,9 @@ pub async fn list_candidates(
},
];
let context = minijinja::context! {
site_title => state.site_title.as_ref(),
site_subtitle => state.site_subtitle.as_ref(),
canonical_url => ListEditCandidatesPath(ListPathIdentifier::from(list.id.clone())).to_crumb(),
page_title => format!("Requests of {}", list.name),
subs,
root_url_prefix => &state.root_url_prefix,
list => Value::from_object(MailingList::from(list)),
current_user => auth.current_user,
messages => session.drain_messages(),

View File

@ -19,6 +19,7 @@
use std::{collections::HashMap, sync::Arc};
use chrono::TimeZone;
use mailpot::{Configuration, Connection};
use mailpot_web::*;
use minijinja::value::Value;
@ -175,12 +176,18 @@ async fn root(
.map(|list| {
let months = db.months(list.pk)?;
let posts = db.list_posts(list.pk, None)?;
let newest = posts.last().and_then(|p| {
chrono::Utc
.timestamp_opt(p.timestamp as i64, 0)
.earliest()
.map(|d| d.to_string())
});
Ok(minijinja::context! {
name => &list.name,
newest,
posts => &posts,
months => &months,
description => &list.description.as_deref().unwrap_or_default(),
root_url_prefix => &state.root_url_prefix,
list => Value::from_object(MailingList::from(list.clone())),
})
})
@ -191,11 +198,8 @@ async fn root(
}];
let context = minijinja::context! {
site_title => state.site_title.as_ref(),
site_subtitle => state.site_subtitle.as_ref(),
page_title => Option::<&'static str>::None,
lists => &lists,
root_url_prefix => &state.root_url_prefix,
current_user => auth.current_user,
messages => session.drain_messages(),
crumbs => crumbs,

View File

@ -38,6 +38,8 @@ lazy_static::lazy_static! {
}
add!(function calendarize,
strip_carets,
urlize,
heading,
login_path,
logout_path,
settings_path,
@ -74,6 +76,10 @@ lazy_static::lazy_static! {
}
env.set_source(source);
}
env.add_global("root_url_prefix", Value::from_safe_string( std::env::var("ROOT_URL_PREFIX").unwrap_or_default()));
env.add_global("public_url",Value::from_safe_string(std::env::var("PUBLIC_URL").unwrap_or_default()));
env.add_global("site_title", Value::from_safe_string(std::env::var("SITE_TITLE").unwrap_or_else(|_| "mailing list archive".to_string())));
env.add_global("site_subtitle", std::env::var("SITE_SUBTITLE").ok().map(Value::from_safe_string).unwrap_or_default());
env
};
@ -232,7 +238,7 @@ pub fn calendarize(
year => date.year(),
weeks => cal::calendarize_with_offset(date, 1),
hist => hist,
sum => sum,
sum,
})
}
@ -387,6 +393,29 @@ pub fn pluralize(
})
}
/// `strip_carets` filter for [`minijinja`].
///
/// Removes `[<>]` from message ids.
///
/// # Examples
///
/// ```rust
/// # use mailpot_web::strip_carets;
/// # use minijinja::Environment;
///
/// let mut env = Environment::new();
/// env.add_filter("strip_carets", strip_carets);
/// assert_eq!(
/// &env.render_str(
/// "{{ msg_id | strip_carets }}",
/// minijinja::context! {
/// msg_id => "<hello1@example.com>",
/// }
/// )
/// .unwrap(),
/// "hello1@example.com",
/// );
/// ```
pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
Ok(Value::from(
arg.as_str()
@ -399,3 +428,136 @@ pub fn strip_carets(_state: &minijinja::State, arg: Value) -> std::result::Resul
.strip_carets(),
))
}
/// `urlize` filter for [`minijinja`].
///
/// Returns a safe string for use in <a> href= attributes.
///
/// # Examples
///
/// ```rust
/// # use mailpot_web::urlize;
/// # use minijinja::Environment;
/// # use minijinja::value::Value;
///
/// let mut env = Environment::new();
/// env.add_function("urlize", urlize);
/// env.add_global(
/// "root_url_prefix",
/// Value::from_safe_string("/lists/prefix/".to_string()),
/// );
/// assert_eq!(
/// &env.render_str(
/// "<a href=\"{{ urlize(\"path/index.html\") }}\">link</a>",
/// minijinja::context! {}
/// )
/// .unwrap(),
/// "<a href=\"/lists/prefix/path/index.html\">link</a>",
/// );
/// ```
pub fn urlize(state: &minijinja::State, arg: Value) -> std::result::Result<Value, Error> {
let Some(prefix) = state.lookup("root_url_prefix") else {
return Ok(arg);
};
Ok(Value::from_safe_string(format!("{prefix}{arg}")))
}
/// Make an html heading: `h1, h2, h3` etc.
///
/// # Example
/// ```
/// use mailpot_web::minijinja_utils::heading;
/// use minijinja::value::Value;
///
/// assert_eq!(
/// "<h1 id=\"bl-bfa-b-ah-b-asdb-hadas-d\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#bl-bfa-b-ah-b-asdb-hadas-d\"></a></h1>",
/// &heading(1.into(), "bl bfa B AH bAsdb hadas d".into(), None).unwrap().to_string()
/// );
/// assert_eq!(
/// "<h2 id=\"short\">bl bfa B AH bAsdb hadas d<a class=\"self-link\" href=\"#short\"></a></h2>",
/// &heading(2.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap().to_string()
/// );
/// assert_eq!(
/// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
/// &heading(0.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
/// );
/// assert_eq!(
/// r#"invalid operation: first heading() argument must be an unsigned integer less than 7 and positive"#,
/// &heading(8.into(), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
/// );
/// assert_eq!(
/// r#"invalid operation: first heading() argument is not an integer < 7 but of type sequence"#,
/// &heading(Value::from(vec![Value::from(1)]), "bl bfa B AH bAsdb hadas d".into(), Some("short".into())).unwrap_err().to_string()
/// );
/// ```
pub fn heading(level: Value, text: Value, id: Option<Value>) -> std::result::Result<Value, Error> {
use convert_case::{Case, Casing};
macro_rules! test {
() => {
|n| *n > 0 && *n < 7
};
}
macro_rules! int_try_from {
($ty:ty) => {
<$ty>::try_from(level.clone()).ok().filter(test!{}).map(|n| n as u8)
};
($fty:ty, $($ty:ty),*) => {
int_try_from!($fty).or_else(|| int_try_from!($($ty),*))
}
}
let level: u8 = level
.as_str()
.and_then(|s| s.parse::<i128>().ok())
.filter(test! {})
.map(|n| n as u8)
.or_else(|| int_try_from!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128, usize))
.ok_or_else(|| {
if matches!(level.kind(), minijinja::value::ValueKind::Number) {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
"first heading() argument must be an unsigned integer less than 7 and positive",
)
} else {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"first heading() argument is not an integer < 7 but of type {}",
level.kind()
),
)
}
})?;
let text = text.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"second heading() argument is not a string but of type {}",
text.kind()
),
)
})?;
if let Some(v) = id {
let kebab = v.as_str().ok_or_else(|| {
minijinja::Error::new(
minijinja::ErrorKind::InvalidOperation,
format!(
"third heading() argument is not a string but of type {}",
v.kind()
),
)
})?;
Ok(Value::from_safe_string(format!(
"<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
href=\"#{kebab}\"></a></h{level}>"
)))
} else {
let kebab_v = text.to_case(Case::Kebab);
let kebab =
percent_encoding::utf8_percent_encode(&kebab_v, crate::typed_paths::PATH_SEGMENT);
Ok(Value::from_safe_string(format!(
"<h{level} id=\"{kebab}\">{text}<a class=\"self-link\" \
href=\"#{kebab}\"></a></h{level}>"
)))
}
}

View File

@ -30,7 +30,6 @@ pub async fn settings(
Extension(user): Extension<User>,
state: Arc<AppState>,
) -> Result<Html<String>, ResponseError> {
let root_url_prefix = &state.root_url_prefix;
let crumbs = vec![
Crumb {
label: "Home".into(),
@ -66,10 +65,7 @@ pub async fn settings(
>>()?;
let context = minijinja::context! {
site_title => state.site_title.as_ref(),
site_subtitle => state.site_subtitle.as_ref(),
page_title => "Account settings",
root_url_prefix => &root_url_prefix,
user => user,
subscriptions => subscriptions,
current_user => user,
@ -252,7 +248,6 @@ pub async fn user_list_subscription(
Extension(user): Extension<User>,
State(state): State<Arc<AppState>>,
) -> Result<Html<String>, ResponseError> {
let root_url_prefix = &state.root_url_prefix;
let db = Connection::open_db(state.conf.clone())?;
let Some(list) = (match id {
ListPathIdentifier::Pk(id) => db.list(id)?,
@ -307,10 +302,7 @@ pub async fn user_list_subscription(
];
let context = minijinja::context! {
site_title => state.site_title.as_ref(),
site_subtitle => state.site_subtitle.as_ref(),
page_title => "Subscription settings",
root_url_prefix => &root_url_prefix,
user => user,
list => list,
subscription => subscription,

View File

@ -1,4 +1,4 @@
{% macro cal(date, hists, root_url_prefix, pk) %}
{% macro cal(date, hists) %}
{% set c=calendarize(date, hists) %}
{% if c.sum > 0 %}
<table>

View File

@ -1,5 +1,5 @@
<footer>
<p>Generated by <a href="https://github.com/meli/mailpot">mailpot</a>.</p>
<p>Generated by <a href="https://github.com/meli/mailpot" target="_blank">mailpot</a>.</p>
</footer>
</main>
</body>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title if title else page_title if page_title else site_title }}</title>{% if canonical_url %}
<link href="{{ root_url_prefix }}{{ canonical_url | safe }}" rel="canonical" />{% endif %}
<link href="{{ urlize(canonical_url) }}" rel="canonical" />{% endif %}
{% include "css.html" %}
</head>
<body>
@ -17,7 +17,7 @@
{% include "menu.html" %}
<div class="page-header">
{% if crumbs|length > 1 %}<nav aria-labelledby="breadcrumb-menu" class="breadcrumbs">
<ol id="breadcrumb-menu" role="menu" aria-label="Breadcrumb menu">{% for crumb in crumbs %}<li class="crumb" aria-describedby="bread_{{ loop.index }}">{% if loop.last %}<span role="menuitem" id="bread_{{ loop.index }}" aria-current="page" title="current page">{{ crumb.label }}</span>{% else %}<a role="menuitem" id="bread_{{ loop.index }}" href="{{ root_url_prefix }}{{ crumb.url }}" tabindex="0">{{ crumb.label }}</a>{% endif %}</li>{% endfor %}</ol>
<ol id="breadcrumb-menu" role="menu" aria-label="Breadcrumb menu">{% for crumb in crumbs %}<li class="crumb" aria-describedby="bread_{{ loop.index }}">{% if loop.last %}<span role="menuitem" id="bread_{{ loop.index }}" aria-current="page" title="current page">{{ crumb.label }}</span>{% else %}<a role="menuitem" id="bread_{{ loop.index }}" href="{{ urlize(crumb.url) }}" tabindex="0">{{ crumb.label }}</a>{% endif %}</li>{% endfor %}</ol>
</nav>{% endif %}
{% if page_title %}
<h2 class="page-title">{{ page_title }}</h2>

View File

@ -1,18 +1,18 @@
{% include "header.html" %}
<div class="body body-grid">
<h3 id="subscribe">Subscribing to a list<a class="self-link" href="#subscribe"></a></h3>
{{ heading(3, "Subscribing to a list") }}
<p>A mailing list can have different subscription policies, or none at all (which would disable subscriptions). If subscriptions are open or require manual approval by the list owners, you can send an e-mail request to its <code>+request</code> sub-address with the subject <code>subscribe</code>.</p>
<h3 id="unsubscribe">Unsubscribing from a list<a class="self-link" href="#unsubscribe"></a></h3>
{{ heading(3, "Unsubscribing from a list") }}
<p>Similarly to subscribing, send an e-mail request to the list's <code>+request</code> sub-address with the subject <code>unsubscribe</code>.</p>
<h3 id="do-i-need-an-account">Do I need an account?<a class="self-link" href="#do-i-need-an-account"></a></h3>
{{ heading(3, "Do I need an account?") }}
<p>An account's utility is only to manage your subscriptions and preferences from the web interface. Thus you don't need one if you want to perform all list operations from your e-mail client instead.</p>
<h3 id="create-account">Creating an account<a class="self-link" href="#create-account"></a></h3>
{{ heading(3, "Creating an account") }}
<p>After successfully subscribing to a list, simply send an e-mail request to its <code>+request</code> sub-address with the subject <code>password</code> and an SSH public key in the e-mail body as plain text.</p>
<p>This will either create you an account with this key, or change your existing key if you already have one.</p>

View File

@ -3,7 +3,7 @@
<div class="body">
<ul>
{% for l in lists %}
<li><a href="{{ root_url_prefix }}{{ list_path(l.list.pk) }}">{{ l.list.name }}</a></li>
<li><a href="{{ list_path(l.list.id) }}">{{ l.list.name }}</a></li>
{% endfor %}
</ul>
</div>

View File

@ -4,8 +4,8 @@
<div class="entry">
<dl class="lists" aria-label="list of mailing lists">
{% for l in lists %}
<dt aria-label="mailing list name"><a href="{{ root_url_prefix }}{{ list_path(l.list.id) }}">{{ l.list.name }}</a></dt>
<dd aria-label="mailing list description"{% if not l.list.description %} class="no-description"{% endif %}>{{ l.list.description if l.list.description else "no description" }}</dd>
<dt aria-label="mailing list name"><a href="{{ list_path(l.list.id) }}">{{ l.list.name }}</a></dt>
<dd><span aria-label="mailing list description"{% if not l.list.description %} class="no-description"{% endif %}>{{ l.list.description if l.list.description else "no description" }}</span> | {{ l.posts|length }} post{{ l.posts|length|pluralize("","s") }}{% if l.newest %} | <time datetime="{{ l.newest }}">{{ l.newest }}</time>{% endif %}</dd>
{% endfor %}
</dl>
</div>

View File

@ -1,6 +1,6 @@
{% include "header.html" %}
<div class="body body-grid">
<h3>Edit <a href="{{ root_url_prefix }}{{ list_path(list.pk) }}">{{ list.id }}</a>.</h3>
{{ heading(3, "Edit <a href=\"" ~list_path(list.id) ~ "\">"~ list.id ~"</a>","edit") }}
<address>
{{ list.name }} <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
</address>
@ -10,8 +10,8 @@
{% if list.archive_url %}
<p><a href="{{ list.archive_url }}">{{ list.archive_url }}</a></p>
{% endif %}
<p><a href="{{ root_url_prefix }}{{ list_subscribers_path(list.pk) }}">{{ subs_count }} subscription{{ subs_count|pluralize }}.</a></p>
<p><a href="{{ root_url_prefix }}{{ list_candidates_path(list.pk) }}">{{ sub_requests_count }} subscription request{{ sub_requests_count|pluralize }}.</a></p>
<p><a href="{{ list_subscribers_path(list.id) }}">{{ subs_count }} subscription{{ subs_count|pluralize }}.</a></p>
<p><a href="{{ list_candidates_path(list.id) }}">{{ sub_requests_count }} subscription request{{ sub_requests_count|pluralize }}.</a></p>
<p>{{ post_count }} post{{ post_count|pluralize }}.</p>
<form method="post" class="settings-form">
<fieldset>
@ -80,7 +80,7 @@
<input type="submit" name="metadata" value="Update list">
</form>
<form method="post" action="{{ root_url_prefix }}{{ list_edit_path(list.id) }}" class="settings-form">
<form method="post" action="{{ list_edit_path(list.id) }}" class="settings-form">
<fieldset>
<input type="hidden" name="type" value="post-policy">
<legend>Post Policy <input type="submit" name="delete-post-policy" value="Delete" disabled></legend>
@ -114,7 +114,7 @@
</fieldset>
<input type="submit" value="{{ "Update" if post_policy else "Create" }} Post Policy">
</form>
<form method="post" action="{{ root_url_prefix }}{{ list_edit_path(list.id) }}" class="settings-form">
<form method="post" action="{{ list_edit_path(list.id) }}" class="settings-form">
<fieldset>
<input type="hidden" name="type" value="subscription-policy">
<legend>Subscription Policy <input type="submit" name="delete-post-policy" value="Delete" disabled></legend>

View File

@ -29,22 +29,22 @@
{% if in_reply_to %}
<tr>
<th scope="row">In-Reply-To:</th>
<td class="faded message-id"><a href="{{ root_url_prefix }}{{ list_post_path(list.id, in_reply_to) }}">{{ in_reply_to }}</a></td>
<td class="faded message-id"><a href="{{ list_post_path(list.id, in_reply_to) }}">{{ in_reply_to }}</a></td>
</tr>
{% endif %}
{% if references %}
<tr>
<th scope="row">References:</th>
<td>{% for r in references %}<span class="faded message-id"><a href="{{ root_url_prefix }}{{ list_post_path(list.id, r) }}">{{ r }}</a></span>{% endfor %}</td>
<td>{% for r in references %}<span class="faded message-id"><a href="{{ list_post_path(list.id, r) }}">{{ r }}</a></span>{% endfor %}</td>
</tr>
{% endif %}
{% else %}
<th scope="row">Message-ID:</th>
<td class="faded message-id"><a href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
<td class="faded message-id"><a href="{{ list_post_path(list.id, post.message_id) }}">{{ strip_carets(post.message_id) }}</a></td>
</tr>
{% endif %}
<tr>
<td colspan="2"><details class="reply-details"><summary>more …</summary><a href="{{ root_url_prefix }}{{ post_raw_path(list.id, post.message_id) }}">View raw</a> <a href="{{ root_url_prefix }}{{ post_eml_path(list.id, post.message_id) }}">Download as <code>eml</code> (RFC 5322 format)</a></details></td>
<td colspan="2"><details class="reply-details"><summary>more …</summary><a href="{{ post_raw_path(list.id, post.message_id) }}">View raw</a> <a href="{{ post_eml_path(list.id, post.message_id) }}">Download as <code>eml</code> (RFC 5322 format)</a></details></td>
</tr>
</table>
<div class="post-body">

View File

@ -8,13 +8,13 @@
<br aria-hidden="true">
{% if current_user and not post_policy.no_subscriptions and subscription_policy.open %}
{% if user_context %}
<form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
<form method="post" action="{{ settings_path() }}" class="settings-form">
<input type="hidden" name="type", value="unsubscribe">
<input type="hidden" name="list_pk", value="{{ list.pk }}">
<input type="submit" name="unsubscribe" value="Unsubscribe as {{ current_user.address }}">
</form>
{% else %}
<form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
<form method="post" action="{{ settings_path() }}" class="settings-form">
<input type="hidden" name="type", value="subscribe">
<input type="hidden" name="list_pk", value="{{ list.pk }}">
<input type="submit" name="subscribe" value="Subscribe as {{ current_user.address }}">
@ -27,7 +27,7 @@
{{ preamble.custom|safe }}
{% else %}
{% if not post_policy.no_subscriptions %}
<h3 id="subscribe">Subscribe<a class="self-link" href="#subscribe"></a></h3>
{{ heading(3, "Subscribe") }}
{% set subscription_mailto=list.subscription_mailto() %}
{% if subscription_mailto %}
{% if subscription_mailto.subject %}
@ -45,7 +45,7 @@
{% set unsubscription_mailto=list.unsubscription_mailto() %}
{% if unsubscription_mailto %}
<h3 id="unsubscribe">Unsubscribe<a class="self-link" href="#unsubscribe"></a></h3>
{{ heading(3, "Unsubscribe") }}
{% if unsubscription_mailto.subject %}
<p>
<a href="mailto:{{ unsubscription_mailto.address|safe }}?subject={{ unsubscription_mailto.subject|safe }}"><code>{{ unsubscription_mailto.address }}</code></a> with the following subject: <code>{{unsubscription_mailto.subject}}</code>
@ -58,7 +58,7 @@
{% endif %}
{% endif %}
<h3 id="post">Post<a class="self-link" href="#post"></a></h3>
{{ heading(3, "Post") }}
{% if post_policy.announce_only %}
<p>List is <em>announce-only</em>, i.e. you can only subscribe to receive announcements.</p>
{% elif post_policy.subscription_only %}
@ -78,21 +78,21 @@
</section>
{% endif %}
<section class="list" aria-hidden="true">
<h3 id="calendar">Calendar<a class="self-link" href="#calendar"></a></h3>
{{ heading(3, "Calendar") }}
<div class="calendar">
{%- from "calendar.html" import cal %}
{% for date in months %}
{{ cal(date, hists, root_url_prefix, list.pk) }}
{{ cal(date, hists) }}
{% endfor %}
</div>
</section>
<section aria-label="mailing list posts">
<h3 id="posts">Posts<a class="self-link" href="#posts"></a></h3>
{{ heading(3, "Posts") }}
<div class="posts entries" role="list" aria-label="list of mailing list posts">
<p>{{ posts | length }} post{{ posts|length|pluralize }}</p>
{% for post in posts %}
<div class="entry" role="listitem" aria-labelledby="post_link_{{ loop.index }}">
<span class="subject"><a id="post_link_{{ loop.index }}" href="{{ root_url_prefix }}{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a>&nbsp;<span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span></span>
<span class="subject"><a id="post_link_{{ loop.index }}" href="{{ list_post_path(list.id, post.message_id) }}">{{ post.subject }}</a>&nbsp;<span class="metadata replies" title="reply count">{{ post.replies }} repl{{ post.replies|pluralize("y","ies") }}</span></span>
<span class="metadata"><span aria-hidden="true">👤&nbsp;</span><span class="from" title="post author">{{ post.address }}</span><span aria-hidden="true"> 📆&nbsp;</span><span class="date" title="post date">{{ post.datetime }}</span></span>
{% if post.replies > 0 %}<span class="metadata"><span aria-hidden="true">&#x1F493;&nbsp;</span><span class="last-active" title="latest thread activity">{{ post.last_active }}</span></span>{% endif %}
<span class="metadata"><span aria-hidden="true">🪪 </span><span class="message-id" title="e-mail Message-ID">{{ post.message_id }}</span></span>

View File

@ -1,11 +1,11 @@
<nav class="main-nav" aria-label="main menu" role="menu">
<ul>
<li><a role="menuitem" href="{{ root_url_prefix }}/">Index</a></li>
<li><a role="menuitem" href="{{ root_url_prefix }}{{ help_path() }}">Help&nbsp;&amp; Documentation</a></li>
<li><a role="menuitem" href="{{ urlize("") }}/">Index</a></li>
<li><a role="menuitem" href="{{ help_path() }}">Help&nbsp;&amp; Documentation</a></li>
{% if current_user %}
<li class="push">Settings: <a role="menuitem" href="{{ root_url_prefix }}{{ settings_path() }}" title="User settings">{{ current_user.address }}</a></li>
<li class="push">Settings: <a role="menuitem" href="{{ settings_path() }}" title="User settings">{{ current_user.address }}</a></li>
{% else %}
<li class="push"><a role="menuitem" href="{{ root_url_prefix }}{{ login_path() }}" title="login with one time password using your SSH key">Login with SSH OTP</a></li>
<li class="push"><a role="menuitem" href="{{ login_path() }}" title="login with one time password using your SSH key">Login with SSH OTP</a></li>
{% endif %}
</ul>
</nav>

View File

@ -1,6 +1,6 @@
{% include "header.html" %}
<div class="body body-grid">
<h3 id="account">Your account<a class="self-link" href="#account"></a></h3>
{{ heading(3,"Your account","account") }}
<div class="entries">
<div class="entry">
<span>Display name: <span class="value{% if not user.name %} empty{% endif %}">{{ user.name if user.name else "None" }}</span></span>
@ -16,19 +16,19 @@
</div>
</div>
<h4 id="list-subscriptions">List Subscriptions<a class="self-link" href="#list-subscriptions"></a></h4>
{{ heading(4,"List Subscriptions") }}
<div class="entries">
<p>{{ subscriptions | length }} subscription(s)</p>
{% for (s, list) in subscriptions %}
<div class="entry">
<span class="subject"><a href="{{ root_url_prefix }}{{ list_settings_path(list.id) }}">{{ list.name }}</a></span>
<span class="subject"><a href="{{ list_settings_path(list.id) }}">{{ list.name }}</a></span>
<!-- span class="metadata">📆&nbsp;<span>{{ s.created }}</span></span -->
</div>
{% endfor %}
</div>
<h4 id="account-settings">Account Settings<a class="self-link" href="#account-settings"></a></h4>
<form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
{{ heading(4,"Account Settings") }}
<form method="post" action="{{ settings_path() }}" class="settings-form">
<input type="hidden" name="type" value="change-name">
<fieldset>
<legend>Change display name</legend>
@ -41,7 +41,7 @@
<input type="submit" name="change" value="Change">
</form>
<form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
<form method="post" action="{{ settings_path() }}" class="settings-form">
<input type="hidden" name="type" value="change-password">
<fieldset>
<legend>Change SSH public key</legend>
@ -54,7 +54,7 @@
<input type="submit" name="change" value="Change">
</form>
<form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
<form method="post" action="{{ settings_path() }}" class="settings-form">
<input type="hidden" name="type" value="change-public-key">
<fieldset>
<legend>Change PGP public key</legend>
@ -67,7 +67,7 @@
<input type="submit" name="change-public-key" value="Change">
</form>
<form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
<form method="post" action="{{ settings_path() }}" class="settings-form">
<input type="hidden" name="type" value="remove-public-key">
<fieldset>
<legend>Remove PGP public key</legend>

View File

@ -1,6 +1,6 @@
{% include "header.html" %}
<div class="body body-grid">
<h3>Your subscription to <a href="{{ root_url_prefix }}{{ list_path(list.pk) }}">{{ list.id }}</a>.</h3>
{{ heading(3, "Your subscription to <a href=\"" ~ list_path(list.id) ~ "\">" ~ list.id ~ "</a>.","subscription") }}
<address>
{{ list.name }} <a href="mailto:{{ list.address | safe }}"><code>{{ list.address }}</code></a>
</address>
@ -43,7 +43,7 @@
<input type="submit" value="Update settings">
<input type="hidden" name="next" value="">
</form>
<form method="post" action="{{ root_url_prefix }}{{ settings_path() }}" class="settings-form">
<form method="post" action="{{ settings_path() }}" class="settings-form">
<fieldset>
<input type="hidden" name="type" value="unsubscribe">
<input type="hidden" name="list_pk" value="{{ list.pk }}">

View File

@ -132,8 +132,8 @@ pub struct HelpPath;
macro_rules! unit_impl {
($ident:ident, $ty:expr) => {
pub fn $ident() -> Value {
Value::from_safe_string($ty.to_crumb().to_string())
pub fn $ident(state: &minijinja::State) -> std::result::Result<Value, Error> {
urlize(state, Value::from($ty.to_crumb().to_string()))
}
};
}
@ -145,18 +145,20 @@ unit_impl!(help_path, HelpPath);
macro_rules! list_id_impl {
($ident:ident, $ty:tt) => {
pub fn $ident(id: Value) -> std::result::Result<Value, Error> {
if let Some(id) = id.as_str() {
return Ok(Value::from_safe_string(
$ty(ListPathIdentifier::Id(id.to_string()))
.to_crumb()
.to_string(),
));
}
let pk = id.try_into()?;
Ok(Value::from_safe_string(
$ty(ListPathIdentifier::Pk(pk)).to_crumb().to_string(),
))
pub fn $ident(state: &minijinja::State, id: Value) -> std::result::Result<Value, Error> {
urlize(
state,
if let Some(id) = id.as_str() {
Value::from(
$ty(ListPathIdentifier::Id(id.to_string()))
.to_crumb()
.to_string(),
)
} else {
let pk = id.try_into()?;
Value::from($ty(ListPathIdentifier::Pk(pk)).to_crumb().to_string())
},
)
}
};
}
@ -169,29 +171,32 @@ list_id_impl!(list_candidates_path, ListEditCandidatesPath);
macro_rules! list_post_impl {
($ident:ident, $ty:tt) => {
pub fn $ident(id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
format!("<{s}>")
}) else {
return Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
"Second argument of list_post_path must be a string."
));
};
pub fn $ident(state: &minijinja::State, id: Value, msg_id: Value) -> std::result::Result<Value, Error> {
urlize(state, {
let Some(msg_id) = msg_id.as_str().map(|s| if s.starts_with('<') && s.ends_with('>') { s.to_string() } else {
format!("<{s}>")
}) else {
return Err(Error::new(
minijinja::ErrorKind::UnknownMethod,
"Second argument of list_post_path must be a string."
));
};
if let Some(id) = id.as_str() {
return Ok(Value::from_safe_string(
if let Some(id) = id.as_str() {
Value::from(
$ty(ListPathIdentifier::Id(id.to_string()), msg_id)
.to_crumb()
.to_string(),
));
}
let pk = id.try_into()?;
Ok(Value::from_safe_string(
$ty(ListPathIdentifier::Pk(pk), msg_id)
.to_crumb()
.to_string(),
))
)
} else {
let pk = id.try_into()?;
Value::from(
$ty(ListPathIdentifier::Pk(pk), msg_id)
.to_crumb()
.to_string(),
)
}
})
}
};
}