goblin: Authorize, one-shot site-requested event signing with per-event approval (Build 151)
goblin:authorize?e=<base64url template>&d=<domain>&cb=<https callback>&c=<64-hex nonce>
(and the nostr: QR twin) asks the wallet to sign exactly one Nostr event.
Fail-closed parser beside loginuri: strict three-key template (kind, content,
tags; any pubkey/created_at/id/sig injection rejects), kind allowlist 1/6/7/30023
(22242 only via login), 4096/2048 size caps, unpadded base64url only, delegation
tags rejected, and the new cb-host-must-belong-to-d binding. Approval modal
mirrors the 150 login machinery: Authorize <domain>? verb, plain-language kind
rendering, 240-char escaped preview with truncation marker and show-full view,
identity picker, wallet password gate, single pending request, 120 s expiry,
single-use. Wallet alone sets pubkey/created_at/id/sig, NIP-01 canonical only;
delivery POSTs {c, d, event} to cb with the shared HttpClient and 15 s timeout,
consumed regardless of outcome, quiet toasts. Dispatchers check authorize before
pay on both the deep-link and QR paths. goblin.authorize.* strings in all six
locales; 29 new authuri tests.
This commit is contained in:
@@ -692,6 +692,30 @@ goblin:
|
||||
confirm: "Anmelden"
|
||||
sent: "Bei %{domain} angemeldet"
|
||||
failed: "%{domain} war nicht erreichbar. Die Anmeldung wurde nicht zugestellt."
|
||||
authorize:
|
||||
title: "Autorisieren"
|
||||
headline: "%{domain} autorisieren?"
|
||||
kind_post: "Öffentlicher Beitrag"
|
||||
kind_repost: "Teilen"
|
||||
kind_reaction: "Reaktion"
|
||||
kind_article: "Artikel (Langform)"
|
||||
kind_unknown: "Art %{n} (unbekannt)"
|
||||
unknown_caution: "Goblin kennt diesen Ereignistyp nicht. Nur bestätigen, wenn du %{domain} vertraust."
|
||||
identity: "Signieren als"
|
||||
explain: "Beim Bestätigen wird dieses eine Ereignis signiert und an %{domain} übergeben. Goblin veröffentlicht nichts, teilt keinen Schlüssel, und die Seite erhält keine Sitzung und keine künftige Berechtigung."
|
||||
pass_prompt: "Gib dein Wallet-Passwort ein, um zu autorisieren."
|
||||
confirm: "Autorisieren"
|
||||
sent: "%{domain} autorisiert"
|
||||
failed: "%{domain} war nicht erreichbar. Es wurde nichts zugestellt."
|
||||
truncated: "gekürzt, %{n} weitere Zeichen"
|
||||
show_full: "Vollständig anzeigen"
|
||||
show_less: "Weniger anzeigen"
|
||||
replying_to: "Antwort auf %{id}"
|
||||
mentions: "erwähnt %{id}"
|
||||
repost_of: "teilt %{id}"
|
||||
by_author: "von %{id}"
|
||||
reacts_to: "reagiert auf %{id}"
|
||||
article_title: "Titel: %{title}"
|
||||
identities:
|
||||
title: "Identitäten"
|
||||
switch_hint: "Identität wechseln"
|
||||
|
||||
@@ -692,6 +692,30 @@ goblin:
|
||||
confirm: "Sign in"
|
||||
sent: "Signed in to %{domain}"
|
||||
failed: "Could not reach %{domain}. The sign-in was not delivered."
|
||||
authorize:
|
||||
title: "Authorize"
|
||||
headline: "Authorize %{domain}?"
|
||||
kind_post: "Public post"
|
||||
kind_repost: "Repost"
|
||||
kind_reaction: "Reaction"
|
||||
kind_article: "Article (long-form)"
|
||||
kind_unknown: "Kind %{n} (unrecognized)"
|
||||
unknown_caution: "Goblin does not recognize this event type. Only approve if you trust %{domain}."
|
||||
identity: "Signing as"
|
||||
explain: "Approving signs this one event and hands it to %{domain}. Nothing is published by Goblin, no key is shared, and the site gets no session or future capability."
|
||||
pass_prompt: "Enter your wallet password to authorize."
|
||||
confirm: "Authorize"
|
||||
sent: "Authorized %{domain}"
|
||||
failed: "Could not reach %{domain}. Nothing was delivered."
|
||||
truncated: "truncated, %{n} more characters"
|
||||
show_full: "Show full"
|
||||
show_less: "Show less"
|
||||
replying_to: "replying to %{id}"
|
||||
mentions: "mentions %{id}"
|
||||
repost_of: "reposts %{id}"
|
||||
by_author: "by %{id}"
|
||||
reacts_to: "reacts to %{id}"
|
||||
article_title: "Title: %{title}"
|
||||
identities:
|
||||
title: "Identities"
|
||||
switch_hint: "Switch identity"
|
||||
|
||||
@@ -692,6 +692,30 @@ goblin:
|
||||
confirm: "Se connecter"
|
||||
sent: "Connecté à %{domain}"
|
||||
failed: "Impossible de joindre %{domain}. La connexion n'a pas été transmise."
|
||||
authorize:
|
||||
title: "Autoriser"
|
||||
headline: "Autoriser %{domain} ?"
|
||||
kind_post: "Publication publique"
|
||||
kind_repost: "Partage"
|
||||
kind_reaction: "Réaction"
|
||||
kind_article: "Article (format long)"
|
||||
kind_unknown: "Type %{n} (non reconnu)"
|
||||
unknown_caution: "Goblin ne reconnaît pas ce type d'événement. N'approuvez que si vous faites confiance à %{domain}."
|
||||
identity: "Signer en tant que"
|
||||
explain: "Approuver signe cet unique événement et le transmet à %{domain}. Goblin ne publie rien, aucune clé n'est partagée, et le site n'obtient ni session ni capacité future."
|
||||
pass_prompt: "Saisissez le mot de passe de votre portefeuille pour autoriser."
|
||||
confirm: "Autoriser"
|
||||
sent: "%{domain} autorisé"
|
||||
failed: "Impossible de joindre %{domain}. Rien n'a été transmis."
|
||||
truncated: "tronqué, %{n} caractères de plus"
|
||||
show_full: "Tout afficher"
|
||||
show_less: "Afficher moins"
|
||||
replying_to: "en réponse à %{id}"
|
||||
mentions: "mentionne %{id}"
|
||||
repost_of: "partage %{id}"
|
||||
by_author: "par %{id}"
|
||||
reacts_to: "réagit à %{id}"
|
||||
article_title: "Titre : %{title}"
|
||||
identities:
|
||||
title: "Identités"
|
||||
switch_hint: "Changer d'identité"
|
||||
|
||||
@@ -692,6 +692,30 @@ goblin:
|
||||
confirm: "Войти"
|
||||
sent: "Выполнен вход на %{domain}"
|
||||
failed: "Не удалось связаться с %{domain}. Вход не был выполнен."
|
||||
authorize:
|
||||
title: "Авторизовать"
|
||||
headline: "Авторизовать %{domain}?"
|
||||
kind_post: "Публичная запись"
|
||||
kind_repost: "Репост"
|
||||
kind_reaction: "Реакция"
|
||||
kind_article: "Статья (лонгрид)"
|
||||
kind_unknown: "Тип %{n} (не распознан)"
|
||||
unknown_caution: "Goblin не распознаёт этот тип события. Подтверждайте, только если доверяете %{domain}."
|
||||
identity: "Подписать как"
|
||||
explain: "Подтверждение подпишет это одно событие и передаст его %{domain}. Goblin ничего не публикует, ключ не передаётся, и сайт не получает ни сессии, ни будущих полномочий."
|
||||
pass_prompt: "Введите пароль кошелька, чтобы авторизовать."
|
||||
confirm: "Авторизовать"
|
||||
sent: "%{domain} авторизован"
|
||||
failed: "Не удалось связаться с %{domain}. Ничего не было передано."
|
||||
truncated: "обрезано, ещё %{n} символов"
|
||||
show_full: "Показать полностью"
|
||||
show_less: "Свернуть"
|
||||
replying_to: "в ответ на %{id}"
|
||||
mentions: "упоминает %{id}"
|
||||
repost_of: "репост %{id}"
|
||||
by_author: "автор %{id}"
|
||||
reacts_to: "реакция на %{id}"
|
||||
article_title: "Заголовок: %{title}"
|
||||
identities:
|
||||
title: "Личности"
|
||||
switch_hint: "Сменить личность"
|
||||
|
||||
@@ -692,6 +692,30 @@ goblin:
|
||||
confirm: "Giriş yap"
|
||||
sent: "%{domain} girişi yapıldı"
|
||||
failed: "%{domain} adresine ulaşılamadı. Giriş iletilemedi."
|
||||
authorize:
|
||||
title: "Yetkilendir"
|
||||
headline: "%{domain} yetkilendirilsin mi?"
|
||||
kind_post: "Herkese açık gönderi"
|
||||
kind_repost: "Yeniden paylaşım"
|
||||
kind_reaction: "Tepki"
|
||||
kind_article: "Makale (uzun biçim)"
|
||||
kind_unknown: "Tür %{n} (tanınmıyor)"
|
||||
unknown_caution: "Goblin bu olay türünü tanımıyor. Yalnızca %{domain} adresine güveniyorsan onayla."
|
||||
identity: "İmzalayan kimlik"
|
||||
explain: "Onaylamak bu tek olayı imzalar ve %{domain} adresine verir. Goblin hiçbir şey yayımlamaz, hiçbir anahtar paylaşılmaz ve site oturum ya da gelecekteki bir yetki elde etmez."
|
||||
pass_prompt: "Yetkilendirmek için cüzdan parolanı gir."
|
||||
confirm: "Yetkilendir"
|
||||
sent: "%{domain} yetkilendirildi"
|
||||
failed: "%{domain} adresine ulaşılamadı. Hiçbir şey iletilmedi."
|
||||
truncated: "kısaltıldı, %{n} karakter daha"
|
||||
show_full: "Tamamını göster"
|
||||
show_less: "Daha az göster"
|
||||
replying_to: "%{id} yanıtlanıyor"
|
||||
mentions: "%{id} anılıyor"
|
||||
repost_of: "%{id} yeniden paylaşılıyor"
|
||||
by_author: "yazan %{id}"
|
||||
reacts_to: "%{id} tepki veriliyor"
|
||||
article_title: "Başlık: %{title}"
|
||||
identities:
|
||||
title: "Kimlikler"
|
||||
switch_hint: "Kimlik değiştir"
|
||||
|
||||
@@ -692,6 +692,30 @@ goblin:
|
||||
confirm: "登录"
|
||||
sent: "已登录 %{domain}"
|
||||
failed: "无法连接 %{domain},登录未送达。"
|
||||
authorize:
|
||||
title: "授权"
|
||||
headline: "授权 %{domain}?"
|
||||
kind_post: "公开帖子"
|
||||
kind_repost: "转发"
|
||||
kind_reaction: "回应"
|
||||
kind_article: "文章(长文)"
|
||||
kind_unknown: "类型 %{n}(无法识别)"
|
||||
unknown_caution: "Goblin 无法识别此事件类型。仅在你信任 %{domain} 时才批准。"
|
||||
identity: "签名身份"
|
||||
explain: "批准将签署这一个事件并交给 %{domain}。Goblin 不会发布任何内容,不会分享密钥,网站也不会获得会话或未来的权限。"
|
||||
pass_prompt: "输入钱包密码以授权。"
|
||||
confirm: "授权"
|
||||
sent: "已授权 %{domain}"
|
||||
failed: "无法连接 %{domain},未送达任何内容。"
|
||||
truncated: "已截断,还有 %{n} 个字符"
|
||||
show_full: "显示全部"
|
||||
show_less: "收起"
|
||||
replying_to: "回复 %{id}"
|
||||
mentions: "提及 %{id}"
|
||||
repost_of: "转发 %{id}"
|
||||
by_author: "作者 %{id}"
|
||||
reacts_to: "回应 %{id}"
|
||||
article_title: "标题:%{title}"
|
||||
identities:
|
||||
title: "身份"
|
||||
switch_hint: "切换身份"
|
||||
|
||||
+535
-2
@@ -141,7 +141,13 @@ pub struct GoblinWalletView {
|
||||
/// scanned QR), including the callback POST once approved. While this is
|
||||
/// `Some`, every new incoming login URI is ignored (one at a time).
|
||||
login: Option<LoginState>,
|
||||
/// Quiet toast for the login outcome: text and when it appeared.
|
||||
/// An "Authorize with Goblin" request awaiting approval (deep link or scanned
|
||||
/// QR): the wallet signs one arbitrary event and hands it to the site. While
|
||||
/// this OR `login` is `Some`, every new incoming authorize/login URI is
|
||||
/// ignored (one approval at a time).
|
||||
authorize: Option<AuthorizeState>,
|
||||
/// Quiet toast for the login outcome: text and when it appeared. Reused for
|
||||
/// the authorize outcome (same quiet pill, different strings).
|
||||
login_toast: Option<(String, std::time::Instant)>,
|
||||
}
|
||||
|
||||
@@ -276,6 +282,7 @@ impl Default for GoblinWalletView {
|
||||
back_hint: None,
|
||||
batch_invoice: None,
|
||||
login: None,
|
||||
authorize: None,
|
||||
login_toast: None,
|
||||
}
|
||||
}
|
||||
@@ -361,6 +368,12 @@ const BATCH_INVOICE_MODAL: &str = "goblin_batch_invoice_modal";
|
||||
/// wallet password before the one-time challenge is signed.
|
||||
const LOGIN_MODAL: &str = "goblin_login_modal";
|
||||
|
||||
/// Id of the "Authorize with Goblin" approval modal: the user reviews the
|
||||
/// requesting domain and the exact event to be signed, picks the signing
|
||||
/// identity, and confirms with the wallet password before the one event is
|
||||
/// signed and handed to the site. Shares the login expiry and POST timeout.
|
||||
const AUTHORIZE_MODAL: &str = "goblin_authorize_modal";
|
||||
|
||||
/// A pending, untouched login approval expires after this many seconds (the
|
||||
/// modal closes and the request is dropped).
|
||||
const LOGIN_EXPIRY_SECS: u64 = 120;
|
||||
@@ -390,6 +403,32 @@ struct LoginState {
|
||||
result: std::sync::Arc<std::sync::Mutex<Option<Result<(), String>>>>,
|
||||
}
|
||||
|
||||
/// One pending "Authorize with Goblin" approval. Mirrors [`LoginState`]
|
||||
/// field-for-field, plus a `show_full` toggle for the complete-content view.
|
||||
/// Single-use by construction: once the event is signed (`posting`), or on
|
||||
/// cancel/expiry, the state is dropped and only a fresh URI can start another.
|
||||
struct AuthorizeState {
|
||||
/// The validated request (challenge, domain, callback, event template).
|
||||
uri: crate::nostr::authuri::AuthorizeUri,
|
||||
/// The CHOSEN signing identity (pubkey hex); defaults to the active one.
|
||||
selected: String,
|
||||
/// When the request arrived, for the [`LOGIN_EXPIRY_SECS`] deadline.
|
||||
created: std::time::Instant,
|
||||
/// Wallet password typed into the modal; cleared as soon as consumed.
|
||||
pass: String,
|
||||
/// The typed password did not verify: show the wrong-password line. The
|
||||
/// request itself stays pending (a typo never consumes it).
|
||||
wrong_pass: bool,
|
||||
/// The event is signed and the callback POST is in flight; the request is
|
||||
/// consumed either way once the worker reports back.
|
||||
posting: bool,
|
||||
/// Whether the complete (escaped) content is expanded in a scrollable view.
|
||||
/// Approval is never blocked on it; it only reveals what truncation hid.
|
||||
show_full: bool,
|
||||
/// Result slot the POST worker thread fills.
|
||||
result: std::sync::Arc<std::sync::Mutex<Option<Result<(), String>>>>,
|
||||
}
|
||||
|
||||
/// A password-gated identity action the modal executes. Switching no longer uses
|
||||
/// the modal (it is instant and local); only these need the wallet password.
|
||||
#[derive(Clone)]
|
||||
@@ -662,6 +701,7 @@ impl GoblinWalletView {
|
||||
self.advanced = AdvancedState::default();
|
||||
self.identity_switch = IdentitySwitchState::default();
|
||||
self.login = None;
|
||||
self.authorize = None;
|
||||
self.login_toast = None;
|
||||
}
|
||||
|
||||
@@ -721,7 +761,7 @@ impl GoblinWalletView {
|
||||
// time: while one is pending (modal open or POST in flight), any new
|
||||
// incoming login request is dropped; re-triggering needs a fresh URI.
|
||||
if let Some(login) = crate::take_pending_login() {
|
||||
if self.login.is_none() {
|
||||
if self.login.is_none() && self.authorize.is_none() {
|
||||
if let Some(active) = wallet.active_nostr_pubkey() {
|
||||
// The approval takes over from any open scan/send flow.
|
||||
self.send = None;
|
||||
@@ -786,6 +826,79 @@ impl GoblinWalletView {
|
||||
});
|
||||
}
|
||||
|
||||
// A pending "Authorize with Goblin" request (deep link or scanned QR,
|
||||
// already fully validated at the dispatch site: kind on the allowlist,
|
||||
// template shape and domain binding checked). One approval at a time:
|
||||
// while a login OR an authorize is pending, any new incoming request is
|
||||
// dropped; re-triggering needs a fresh URI.
|
||||
if let Some(authorize) = crate::take_pending_authorize() {
|
||||
if self.login.is_none() && self.authorize.is_none() {
|
||||
if let Some(active) = wallet.active_nostr_pubkey() {
|
||||
// The approval takes over from any open scan/send flow.
|
||||
self.send = None;
|
||||
self.authorize = Some(AuthorizeState {
|
||||
uri: authorize,
|
||||
selected: active,
|
||||
created: std::time::Instant::now(),
|
||||
pass: String::new(),
|
||||
wrong_pass: false,
|
||||
posting: false,
|
||||
show_full: false,
|
||||
result: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
});
|
||||
Modal::new(AUTHORIZE_MODAL)
|
||||
.position(ModalPosition::CenterTop)
|
||||
.title(t!("goblin.authorize.title"))
|
||||
.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
// An untouched approval dies after 2 minutes: modal closed, request
|
||||
// dropped. The signed-and-posting phase is governed by the POST timeout
|
||||
// instead, so it is exempt here.
|
||||
let authorize_expired = matches!(&self.authorize, Some(st)
|
||||
if !st.posting && st.created.elapsed().as_secs() >= LOGIN_EXPIRY_SECS);
|
||||
if authorize_expired {
|
||||
self.authorize = None;
|
||||
if Modal::opened() == Some(AUTHORIZE_MODAL) {
|
||||
Modal::close();
|
||||
}
|
||||
}
|
||||
// Poll the callback POST; the request is consumed either way and the
|
||||
// outcome shows as the same quiet toast (distinct success/failure
|
||||
// strings).
|
||||
let mut authorize_outcome = None;
|
||||
if let Some(st) = &self.authorize {
|
||||
if st.posting {
|
||||
authorize_outcome = st
|
||||
.result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.take()
|
||||
.map(|res| (res, st.uri.domain.clone()));
|
||||
if authorize_outcome.is_none() {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((res, domain)) = authorize_outcome {
|
||||
let text = match res {
|
||||
Ok(()) => t!("goblin.authorize.sent", domain => domain).to_string(),
|
||||
Err(e) => {
|
||||
log::warn!("authorize callback failed: {e}");
|
||||
t!("goblin.authorize.failed", domain => domain).to_string()
|
||||
}
|
||||
};
|
||||
self.login_toast = Some((text, std::time::Instant::now()));
|
||||
self.authorize = None;
|
||||
}
|
||||
// The authorize approval modal itself.
|
||||
if Modal::opened() == Some(AUTHORIZE_MODAL) {
|
||||
Modal::ui(ui.ctx(), cb, |ui, modal, cb| {
|
||||
self.authorize_modal_content(ui, modal, wallet, cb);
|
||||
});
|
||||
}
|
||||
|
||||
// Send flow takes the full surface when active.
|
||||
if let Some(send) = &mut self.send {
|
||||
let done = send.ui(ui, wallet, cb, &mut self.avatars);
|
||||
@@ -6276,6 +6389,426 @@ impl GoblinWalletView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The "Authorize with Goblin" approval modal: headline, the rendered event
|
||||
/// (kind label, escaped content preview with an optional show-full view, and
|
||||
/// per-kind key-tag summary), the identity picker, the password gate, and the
|
||||
/// Cancel/Authorize buttons. Mirrors [`Self::login_modal_content`]; on
|
||||
/// confirm it signs one event with the chosen identity and POSTs it off the
|
||||
/// UI thread. Signed = consumed, whatever the POST outcome.
|
||||
fn authorize_modal_content(
|
||||
&mut self,
|
||||
ui: &mut egui::Ui,
|
||||
modal: &Modal,
|
||||
wallet: &Wallet,
|
||||
cb: &dyn PlatformCallbacks,
|
||||
) {
|
||||
use crate::nostr::authuri;
|
||||
let Some(st) = self.authorize.as_mut() else {
|
||||
Modal::close();
|
||||
return;
|
||||
};
|
||||
if st.posting {
|
||||
// Already signed and in flight; nothing left to gate here.
|
||||
Modal::close();
|
||||
return;
|
||||
}
|
||||
let domain = st.uri.domain.clone();
|
||||
// Clone the small template once so the read-only render borrows nothing
|
||||
// from `st` while its password/show-full fields are mutated below.
|
||||
let template = st.uri.template.clone();
|
||||
let kind = template.kind;
|
||||
let label = authuri::kind_label(kind);
|
||||
let (preview, remaining) = authuri::content_preview(&template.content);
|
||||
let preview_esc = authuri::escape_for_display(&preview);
|
||||
let full_esc = authuri::escape_for_display(&template.content);
|
||||
// Key tags, all escaped before display.
|
||||
let e_tag = template
|
||||
.first_tag_value("e")
|
||||
.map(|v| authuri::escape_for_display(&authuri::truncate_id(v)));
|
||||
let p_tag = template
|
||||
.first_tag_value("p")
|
||||
.map(|v| authuri::escape_for_display(&authuri::truncate_id(v)));
|
||||
let title = template
|
||||
.first_tag_value("title")
|
||||
.map(authuri::escape_for_display);
|
||||
let identities = wallet.nostr_identities();
|
||||
let mut go = false;
|
||||
let mut cancel = false;
|
||||
ui.vertical_centered(|ui| {
|
||||
ui.add_space(6.0);
|
||||
// Headline with the requesting domain prominent.
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.headline", domain => domain.clone()))
|
||||
.size(17.0)
|
||||
.color(Colors::title(false)),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
// The plain-language kind label (and, for an off-allowlist kind that
|
||||
// v1 cannot actually reach, a caution line).
|
||||
let kind_text = match label {
|
||||
authuri::KindLabel::Post => t!("goblin.authorize.kind_post"),
|
||||
authuri::KindLabel::Repost => t!("goblin.authorize.kind_repost"),
|
||||
authuri::KindLabel::Reaction => t!("goblin.authorize.kind_reaction"),
|
||||
authuri::KindLabel::Article => t!("goblin.authorize.kind_article"),
|
||||
authuri::KindLabel::Unknown => {
|
||||
t!("goblin.authorize.kind_unknown", n => kind.to_string())
|
||||
}
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(kind_text)
|
||||
.size(15.0)
|
||||
.color(Colors::text(false)),
|
||||
);
|
||||
if label == authuri::KindLabel::Unknown {
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.unknown_caution", domain => domain.clone()))
|
||||
.size(12.5)
|
||||
.color(Colors::red()),
|
||||
);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// Per-kind rendering of the event body and its key tags. All
|
||||
// requester-controlled text is escaped before it hits a label.
|
||||
let show_preview = |ui: &mut egui::Ui| {
|
||||
if !preview_esc.is_empty() {
|
||||
ui.label(RichText::new(&preview_esc).size(13.5).color(Colors::gray()));
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
7 => {
|
||||
// Reaction: the reaction glyph (emoji or "+"), then target.
|
||||
let reaction = if full_esc.is_empty() {
|
||||
"+".to_string()
|
||||
} else {
|
||||
full_esc.clone()
|
||||
};
|
||||
ui.label(
|
||||
RichText::new(reaction)
|
||||
.size(20.0)
|
||||
.color(Colors::text(false)),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
if let Some(id) = &e_tag {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.reacts_to", id => id.clone()))
|
||||
.size(12.5)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
}
|
||||
if let Some(id) = &p_tag {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.by_author", id => id.clone()))
|
||||
.size(12.5)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
// Repost: reposted event id and author.
|
||||
if let Some(id) = &e_tag {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.repost_of", id => id.clone()))
|
||||
.size(12.5)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
}
|
||||
if let Some(id) = &p_tag {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.by_author", id => id.clone()))
|
||||
.size(12.5)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
}
|
||||
}
|
||||
30023 => {
|
||||
// Article: title tag if present, then the content preview.
|
||||
if let Some(t) = &title {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.article_title", title => t.clone()))
|
||||
.size(14.0)
|
||||
.color(Colors::text(false)),
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
show_preview(ui);
|
||||
}
|
||||
_ => {
|
||||
// Kind 1 (and the unreachable fallback): content preview, then
|
||||
// the reply/mention tags.
|
||||
show_preview(ui);
|
||||
if let Some(id) = &e_tag {
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.replying_to", id => id.clone()))
|
||||
.size(12.5)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
}
|
||||
if let Some(id) = &p_tag {
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.mentions", id => id.clone()))
|
||||
.size(12.5)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Truncation marker plus the mandatory show-full affordance. Approval
|
||||
// is never blocked on opening it.
|
||||
if remaining > 0 {
|
||||
ui.add_space(6.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.truncated", n => remaining.to_string()))
|
||||
.size(12.0)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
let toggle = if st.show_full {
|
||||
t!("goblin.authorize.show_less")
|
||||
} else {
|
||||
t!("goblin.authorize.show_full")
|
||||
};
|
||||
let rect = ui
|
||||
.label(RichText::new(toggle).size(13.0).color(Colors::green()))
|
||||
.rect;
|
||||
let hit = ui.interact(
|
||||
rect,
|
||||
egui::Id::from(modal.id).with("auth_showfull"),
|
||||
Sense::click(),
|
||||
);
|
||||
if hit
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
{
|
||||
st.show_full = !st.show_full;
|
||||
}
|
||||
if st.show_full {
|
||||
ui.add_space(6.0);
|
||||
ScrollArea::vertical()
|
||||
.max_height(160.0)
|
||||
.auto_shrink([false, true])
|
||||
.show(ui, |ui| {
|
||||
ui.label(RichText::new(&full_esc).size(13.0).color(Colors::gray()));
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
// The signing identity. Display precedence is the switcher's: private
|
||||
// tag, else bare claimed name, else truncated npub, and the truncated
|
||||
// npub is ALWAYS shown as the anchor. Defaults to the active identity.
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.identity"))
|
||||
.size(13.0)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
if identities.len() > 1 {
|
||||
for id in &identities {
|
||||
let selected = st.selected == id.pubkey_hex;
|
||||
let name = id.display();
|
||||
let short = data::short_npub(&id.pubkey_hex);
|
||||
let row = ui
|
||||
.scope(|ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(if selected {
|
||||
crate::gui::icons::CHECK_CIRCLE
|
||||
} else {
|
||||
crate::gui::icons::CIRCLE
|
||||
})
|
||||
.size(18.0)
|
||||
.color(if selected {
|
||||
Colors::green()
|
||||
} else {
|
||||
Colors::gray()
|
||||
}),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
ui.vertical(|ui| {
|
||||
if name != short {
|
||||
ui.label(
|
||||
RichText::new(&name)
|
||||
.size(15.0)
|
||||
.color(Colors::text(false)),
|
||||
);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(&short).size(12.5).color(Colors::gray()),
|
||||
);
|
||||
});
|
||||
});
|
||||
})
|
||||
.response
|
||||
.rect;
|
||||
let hit = ui.interact(
|
||||
row,
|
||||
egui::Id::from(modal.id).with(("auth_id", id.pubkey_hex.as_str())),
|
||||
Sense::click(),
|
||||
);
|
||||
if hit
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.clicked()
|
||||
{
|
||||
st.selected = id.pubkey_hex.clone();
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
} else if let Some(id) = identities.first() {
|
||||
let name = id.display();
|
||||
let short = data::short_npub(&id.pubkey_hex);
|
||||
if name != short {
|
||||
ui.label(RichText::new(&name).size(15.0).color(Colors::text(false)));
|
||||
}
|
||||
ui.label(RichText::new(&short).size(12.5).color(Colors::gray()));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
// One plain line on what approving does (and does not do).
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.explain", domain => domain.clone()))
|
||||
.size(13.0)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
// The wallet password gates the signature, mirroring the identity
|
||||
// password modal (masked field, same wrong-password line).
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.authorize.pass_prompt"))
|
||||
.size(16.0)
|
||||
.color(Colors::gray()),
|
||||
);
|
||||
ui.add_space(10.0);
|
||||
let mut field = TextEdit::new(egui::Id::from(modal.id).with("auth_pass")).password();
|
||||
field.ui(ui, &mut st.pass, cb);
|
||||
if field.enter_pressed {
|
||||
go = true;
|
||||
}
|
||||
if st.pass.is_empty() {
|
||||
st.wrong_pass = false;
|
||||
} else if st.wrong_pass {
|
||||
ui.add_space(10.0);
|
||||
ui.label(
|
||||
RichText::new(t!("goblin.advanced.wrong_password"))
|
||||
.size(16.0)
|
||||
.color(Colors::red()),
|
||||
);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
});
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing = egui::Vec2::new(8.0, 0.0);
|
||||
ui.columns(2, |columns| {
|
||||
columns[0].vertical_centered_justified(|ui| {
|
||||
View::button(
|
||||
ui,
|
||||
t!("modal.cancel"),
|
||||
Colors::white_or_black(false),
|
||||
|| {
|
||||
cancel = true;
|
||||
},
|
||||
);
|
||||
});
|
||||
columns[1].vertical_centered_justified(|ui| {
|
||||
View::button(
|
||||
ui,
|
||||
t!("goblin.authorize.confirm"),
|
||||
Colors::white_or_black(false),
|
||||
|| {
|
||||
go = true;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
});
|
||||
if cancel {
|
||||
// Cancel drops the request: it is single-use, no retry.
|
||||
self.authorize = None;
|
||||
Modal::close();
|
||||
return;
|
||||
}
|
||||
if go {
|
||||
let (pass, selected) = match self.authorize.as_ref() {
|
||||
Some(st) => (st.pass.clone(), st.selected.clone()),
|
||||
None => return,
|
||||
};
|
||||
if pass.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !wallet.verify_nostr_password(&pass) {
|
||||
// Wrong password: stay in the modal, request NOT consumed.
|
||||
if let Some(st) = self.authorize.as_mut() {
|
||||
st.wrong_pass = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The chosen identity's unlocked in-memory keys, from the running
|
||||
// service (the Build-145 model: every held identity is unlocked).
|
||||
let keys = wallet.nostr_service().and_then(|s| {
|
||||
s.recv_snapshot()
|
||||
.into_iter()
|
||||
.find(|h| h.keys.public_key().to_hex() == selected)
|
||||
.map(|h| h.keys)
|
||||
});
|
||||
let Some(keys) = keys else {
|
||||
// No running service / identity gone: drop the request.
|
||||
self.authorize = None;
|
||||
Modal::close();
|
||||
return;
|
||||
};
|
||||
let st = self.authorize.as_mut().unwrap();
|
||||
st.pass.clear();
|
||||
st.wrong_pass = false;
|
||||
match crate::nostr::authuri::build_authorize_event(&keys, &st.uri.template) {
|
||||
Ok(event) => {
|
||||
// Signed: the request is consumed from here on, whatever the
|
||||
// POST outcome. Deliver off the UI thread with the shared HTTP
|
||||
// client and a hard timeout.
|
||||
st.posting = true;
|
||||
let callback = st.uri.callback.clone();
|
||||
let challenge = st.uri.challenge.clone();
|
||||
let domain = st.uri.domain.clone();
|
||||
let slot = st.result.clone();
|
||||
std::thread::spawn(move || {
|
||||
let res = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt.block_on(async {
|
||||
let post = crate::nostr::authuri::post_authorize_event(
|
||||
&callback, &challenge, &domain, &event,
|
||||
);
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(LOGIN_POST_TIMEOUT_SECS),
|
||||
post,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => Err("timeout".to_string()),
|
||||
}
|
||||
}),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
*slot.lock().unwrap() = Some(res);
|
||||
});
|
||||
Modal::close();
|
||||
}
|
||||
Err(e) => {
|
||||
// Signing failed (never expected): consume the request and
|
||||
// surface the quiet failure toast.
|
||||
log::error!("authorize event signing failed: {e}");
|
||||
self.login_toast = Some((
|
||||
t!("goblin.authorize.failed", domain => domain).to_string(),
|
||||
std::time::Instant::now(),
|
||||
));
|
||||
self.authorize = None;
|
||||
Modal::close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the transient login-outcome toast: the same quiet pill as the back
|
||||
/// hint (solid soft pill, no border, small dim text), bottom-anchored,
|
||||
/// non-blocking, fading out.
|
||||
|
||||
@@ -250,6 +250,17 @@ impl SendFlow {
|
||||
}
|
||||
return;
|
||||
}
|
||||
// An "Authorize with Goblin" request is likewise grabbed BEFORE any pay
|
||||
// parsing: it signs one arbitrary event, never a payment, and an
|
||||
// authorize-shaped payload that fails validation is dropped entirely. A
|
||||
// valid one is stashed for the Goblin surface, whose per-frame router
|
||||
// closes this flow and opens the approval modal.
|
||||
if crate::nostr::authuri::is_authorize_shaped(text) {
|
||||
if let Some(a) = crate::nostr::authuri::parse(text) {
|
||||
crate::set_pending_authorize(a);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Proof-on-request context (frozen contract 4.1) rides alongside the
|
||||
// recipient/amount/memo routing and is independent of whether we land on
|
||||
// Review or Search, so pull it from the same pure parse. `order` is a
|
||||
|
||||
@@ -497,6 +497,11 @@ impl WalletsContent {
|
||||
// always starts `npub1`/`nprofile1`). A login-shaped URI that fails
|
||||
// validation is dropped entirely: no modal, no send, no message.
|
||||
//
|
||||
// Then the `authorize` keyword, checked BEFORE the pay path for the same
|
||||
// reason: a `goblin:authorize?...` request signs one arbitrary event and
|
||||
// is never a payment. An authorize-shaped URI that fails validation is
|
||||
// likewise dropped entirely (no modal, no send, no fallthrough to pay).
|
||||
//
|
||||
// Then a Goblin payment deep link (`goblin:` / `nostr:` pay URI) routes
|
||||
// to the send-review flow instead of the slatepack message handler:
|
||||
// stash it for the Goblin surface to open, and select/open the wallet
|
||||
@@ -509,6 +514,12 @@ impl WalletsContent {
|
||||
}
|
||||
None
|
||||
}
|
||||
Some(d) if crate::nostr::authuri::is_authorize_shaped(&d) => {
|
||||
if let Some(a) = crate::nostr::authuri::parse(&d) {
|
||||
crate::set_pending_authorize(a);
|
||||
}
|
||||
None
|
||||
}
|
||||
Some(d) if crate::nostr::payuri::is_pay_uri(&d) => {
|
||||
crate::set_pending_pay_uri(d);
|
||||
None
|
||||
|
||||
+22
@@ -566,6 +566,14 @@ lazy_static! {
|
||||
/// site and never reach the UI.
|
||||
static ref PENDING_LOGIN: Arc<RwLock<Option<nostr::loginuri::LoginUri>>> =
|
||||
Arc::new(RwLock::new(None));
|
||||
/// A pending, already-VALIDATED "Authorize with Goblin" request
|
||||
/// (`goblin:authorize?...` deep link or QR), waiting for the Goblin surface
|
||||
/// to open its approval modal. Only a fully validated
|
||||
/// [`nostr::authuri::AuthorizeUri`] (kind on the allowlist, template shape
|
||||
/// and domain binding checked) is ever stashed here; rejected authorize URIs
|
||||
/// are dropped at the dispatch site and never reach the UI.
|
||||
static ref PENDING_AUTHORIZE: Arc<RwLock<Option<nostr::authuri::AuthorizeUri>>> =
|
||||
Arc::new(RwLock::new(None));
|
||||
}
|
||||
|
||||
/// Stash a payment deep link for the Goblin surface to open (see
|
||||
@@ -593,6 +601,20 @@ pub fn take_pending_login() -> Option<nostr::loginuri::LoginUri> {
|
||||
PENDING_LOGIN.write().take()
|
||||
}
|
||||
|
||||
/// Stash a VALIDATED authorize request for the Goblin surface to open its
|
||||
/// approval modal (see [`take_pending_authorize`]). The most recent request
|
||||
/// wins here; the surface itself ignores new requests while one approval (login
|
||||
/// or authorize) is already pending.
|
||||
pub fn set_pending_authorize(authorize: nostr::authuri::AuthorizeUri) {
|
||||
*PENDING_AUTHORIZE.write() = Some(authorize);
|
||||
}
|
||||
|
||||
/// Take (and clear) a pending authorize request, if any. The Goblin wallet view
|
||||
/// polls this each frame and opens the one-shot event-signing approval modal.
|
||||
pub fn take_pending_authorize() -> Option<nostr::authuri::AuthorizeUri> {
|
||||
PENDING_AUTHORIZE.write().take()
|
||||
}
|
||||
|
||||
/// Callback from Java code with passed data.
|
||||
#[allow(dead_code)]
|
||||
#[allow(non_snake_case)]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -48,5 +48,6 @@ pub use client::{HeldIdentityKeys, NostrProfile, NostrService, send_phase};
|
||||
pub mod avatar;
|
||||
pub mod nip05;
|
||||
|
||||
pub mod authuri;
|
||||
pub mod loginuri;
|
||||
pub mod payuri;
|
||||
|
||||
Reference in New Issue
Block a user