forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.5 KiB
1//! Page: GPG keys (`/settings/gpg`). Keys are imported into the forge-managed keyring and stored
2//! per user; a signed commit is badged Verified only when its signer's key is listed here.
3
4use forge_core::AuditAction;
5use forge_core::store::signatures::GpgKey;
6use serde::Deserialize;
7use topcoat::Result;
8use topcoat::context::{Cx, app_context};
9use topcoat::router::content::Form;
10use topcoat::router::error::{SeeOther, not_found, see_other};
11use topcoat::router::{page, route};
12use topcoat::view::{View, component, view};
13
14use crate::audit::{self, Actor, Target};
15use crate::auth::{require_csrf, require_user};
16use crate::i18n::{self, Key, Lang, added_line, t};
17use crate::signatures;
18use crate::state::AppState;
19use crate::views::{alert, shell, user_facing_message};
20
21#[derive(Deserialize)]
22struct AddKeyForm {
23 #[serde(default)]
24 csrf: String,
25 #[serde(default)]
26 armored: String,
27}
28
29#[derive(Deserialize)]
30struct DeleteKeyForm {
31 #[serde(default)]
32 csrf: String,
33 #[serde(default)]
34 id: String,
35}
36
37#[component]
38async fn gpg_view(
39 lang: Lang,
40 csrf: &str,
41 keys: Vec<GpgKey>,
42 armored: &str,
43 error: Option<String>,
44 available: bool,
45) -> Result<impl View> {
46 Ok(view! {
47 <section class="page-head">
48 <h1>(t(lang, Key::GpgKeys))</h1>
49 </section>
50 if !available {
51 <p class="notice">(t(lang, Key::GpgUnavailable))</p>
52 }
53 <section class="card">
54 if keys.is_empty() {
55 <p class="muted">(t(lang, Key::NoGpgKeys))</p>
56 } else {
57 <ul class="item-list">
58 for key in keys.into_iter() {
59 <li class="item">
60 <div class="item-main">
61 <code class="small">(key.fingerprint.clone())</code>
62 <span class="muted small">(key.emails.join(", "))</span>
63 <span class="muted small">(added_line(lang, key.created_at.as_str(), None))</span>
64 </div>
65 <form method="post" action="/settings/gpg/delete">
66 <input type="hidden" name="csrf" value=(csrf)>
67 <input type="hidden" name="id" value=(key.id.clone())>
68 <button type="submit" class="danger">(t(lang, Key::Delete))</button>
69 </form>
70 </li>
71 }
72 </ul>
73 }
74 </section>
75 <section class="card">
76 <h2>(t(lang, Key::AddGpgKeyTitle))</h2>
77 if let Some(message) = error {
78 alert(message: message.as_str())
79 }
80 <form method="post" action="/settings/gpg" class="stack">
81 <input type="hidden" name="csrf" value=(csrf)>
82 <label>
83 <span>(t(lang, Key::ArmoredPublicKeyLabel))</span>
84 <textarea name="armored" rows="10" spellcheck="false" required=(true)>(armored)</textarea>
85 <small class="muted">(t(lang, Key::ArmoredHint))</small>
86 </label>
87 <button type="submit" class="primary">(t(lang, Key::AddKeyButton))</button>
88 </form>
89 </section>
90 })
91}
92
93#[page("/settings/gpg")]
94async fn gpg_page(cx: &Cx) -> Result<impl View> {
95 let viewer = require_user(cx).await?;
96 let lang = i18n::lang(cx, Some(&viewer.0));
97 let state = app_context::<AppState>(cx);
98 let keys = state.store.list_gpg_keys(&viewer.0.id).await?;
99 let available = signatures::gpg_available(state).await;
100 let csrf = viewer.1.csrf_token.clone();
101 Ok(view! {
102 shell(title: t(lang, Key::GpgKeys), viewer: Some(&viewer), gpg_view(lang: lang, csrf: csrf.as_str(), keys: keys, armored: "", error: None, available: available))
103 })
104}
105
106#[page(POST "/settings/gpg")]
107async fn gpg_add(cx: &Cx, Form(form): Form<AddKeyForm>) -> Result<impl View> {
108 let viewer = require_csrf(cx, &form.csrf).await?;
109 let lang = i18n::lang(cx, Some(&viewer.0));
110 let state = app_context::<AppState>(cx);
111
112 let message: String = match signatures::import_gpg_key(state, &form.armored).await {
113 Err(message) => message,
114 Ok((fingerprint, emails)) => {
115 match state
116 .store
117 .add_gpg_key(&viewer.0.id, &fingerprint, &form.armored, &emails)
118 .await
119 {
120 Ok(key) => {
121 signatures::on_keys_changed(state, &viewer.0.id).await;
122 audit::record(
123 &state.store,
124 Some(cx),
125 Actor::User(&viewer.0),
126 AuditAction::GpgKeyAdded,
127 Target {
128 kind: "gpg_key",
129 id: &key.id,
130 name: &key.fingerprint,
131 },
132 serde_json::Value::Null,
133 )
134 .await;
135 return Err(see_other("/settings/gpg").into());
136 }
137 Err(e) => match user_facing_message(&e) {
138 Some(message) => message,
139 None => return Err(e.into()),
140 },
141 }
142 }
143 };
144
145 let keys = state.store.list_gpg_keys(&viewer.0.id).await?;
146 let available = signatures::gpg_available(state).await;
147 let csrf = viewer.1.csrf_token.clone();
148 Ok(view! {
149 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))
150 })
151}
152
153#[route(POST "/settings/gpg/delete")]
154async fn gpg_delete(cx: &Cx, Form(form): Form<DeleteKeyForm>) -> Result<SeeOther> {
155 let (user, _) = require_csrf(cx, &form.csrf).await?;
156 let state = app_context::<AppState>(cx);
157 // `delete_gpg_key` only matches the viewer's own keys, so anything else is a 404.
158 let Some(key) = state.store.delete_gpg_key(&user.id, &form.id).await? else {
159 return Err(not_found().into());
160 };
161 signatures::remove_gpg_key(state, &key.fingerprint).await;
162 signatures::on_keys_changed(state, &user.id).await;
163 audit::record(
164 &state.store,
165 Some(cx),
166 Actor::User(&user),
167 AuditAction::GpgKeyDeleted,
168 Target {
169 kind: "gpg_key",
170 id: &key.id,
171 name: &key.fingerprint,
172 },
173 serde_json::Value::Null,
174 )
175 .await;
176 Ok(see_other("/settings/gpg"))
177}