//! Page: GPG keys (`/settings/gpg`). Keys are imported into the forge-managed keyring and stored //! per user; a signed commit is badged Verified only when its signer's key is listed here. use forge_core::AuditAction; use forge_core::store::signatures::GpgKey; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, not_found, see_other}; use topcoat::router::{page, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::{self, Key, Lang, added_line, t}; use crate::signatures; use crate::state::AppState; use crate::views::{alert, shell, user_facing_message}; #[derive(Deserialize)] struct AddKeyForm { #[serde(default)] csrf: String, #[serde(default)] armored: String, } #[derive(Deserialize)] struct DeleteKeyForm { #[serde(default)] csrf: String, #[serde(default)] id: String, } #[component] async fn gpg_view( lang: Lang, csrf: &str, keys: Vec, armored: &str, error: Option, available: bool, ) -> Result { Ok(view! {

(t(lang, Key::GpgKeys))

if !available {

(t(lang, Key::GpgUnavailable))

}
if keys.is_empty() {

(t(lang, Key::NoGpgKeys))

} else {
    for key in keys.into_iter() {
  • (key.fingerprint.clone()) (key.emails.join(", ")) (added_line(lang, key.created_at.as_str(), None))
  • }
}

(t(lang, Key::AddGpgKeyTitle))

if let Some(message) = error { alert(message: message.as_str()) }
}) } #[page("/settings/gpg")] async fn gpg_page(cx: &Cx) -> Result { let viewer = require_user(cx).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let keys = state.store.list_gpg_keys(&viewer.0.id).await?; let available = signatures::gpg_available(state).await; let csrf = viewer.1.csrf_token.clone(); Ok(view! { shell(title: t(lang, Key::GpgKeys), viewer: Some(&viewer), gpg_view(lang: lang, csrf: csrf.as_str(), keys: keys, armored: "", error: None, available: available)) }) } #[page(POST "/settings/gpg")] async fn gpg_add(cx: &Cx, Form(form): Form) -> Result { let viewer = require_csrf(cx, &form.csrf).await?; let lang = i18n::lang(cx, Some(&viewer.0)); let state = app_context::(cx); let message: String = match signatures::import_gpg_key(state, &form.armored).await { Err(message) => message, Ok((fingerprint, emails)) => { match state .store .add_gpg_key(&viewer.0.id, &fingerprint, &form.armored, &emails) .await { Ok(key) => { signatures::on_keys_changed(state, &viewer.0.id).await; audit::record( &state.store, Some(cx), Actor::User(&viewer.0), AuditAction::GpgKeyAdded, Target { kind: "gpg_key", id: &key.id, name: &key.fingerprint, }, serde_json::Value::Null, ) .await; return Err(see_other("/settings/gpg").into()); } Err(e) => match user_facing_message(&e) { Some(message) => message, None => return Err(e.into()), }, } } }; let keys = state.store.list_gpg_keys(&viewer.0.id).await?; let available = signatures::gpg_available(state).await; let csrf = viewer.1.csrf_token.clone(); Ok(view! { shell(title: t(lang, Key::GpgKeys), viewer: Some(&viewer), gpg_view(lang: lang, csrf: csrf.as_str(), keys: keys, armored: form.armored.as_str(), error: Some(message), available: available)) }) } #[route(POST "/settings/gpg/delete")] async fn gpg_delete(cx: &Cx, Form(form): Form) -> Result { let (user, _) = require_csrf(cx, &form.csrf).await?; let state = app_context::(cx); // `delete_gpg_key` only matches the viewer's own keys, so anything else is a 404. let Some(key) = state.store.delete_gpg_key(&user.id, &form.id).await? else { return Err(not_found().into()); }; signatures::remove_gpg_key(state, &key.fingerprint).await; signatures::on_keys_changed(state, &user.id).await; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::GpgKeyDeleted, Target { kind: "gpg_key", id: &key.id, name: &key.fingerprint, }, serde_json::Value::Null, ) .await; Ok(see_other("/settings/gpg")) }