use std::{fmt::Display, str::FromStr};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use pubky_common::capabilities::Capabilities;
use url::Url;
use crate::actors::auth::deep_links::{DEEP_LINK_SCHEMES, error::DeepLinkParseError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SigninDeepLink {
capabilities: Capabilities,
relay: Url,
secret: [u8; 32],
}
impl SigninDeepLink {
#[must_use]
pub fn new(capabilities: Capabilities, relay: Url, secret: [u8; 32]) -> Self {
Self {
capabilities,
relay,
secret,
}
}
pub fn capabilities(&self) -> &Capabilities {
&self.capabilities
}
#[must_use]
pub fn relay(&self) -> &Url {
&self.relay
}
#[must_use]
pub fn secret(&self) -> &[u8; 32] {
&self.secret
}
}
impl Display for SigninDeepLink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"pubkyauth://signin?caps={}&relay={}&secret={}",
self.capabilities,
self.relay,
URL_SAFE_NO_PAD.encode(self.secret)
)
}
}
impl FromStr for SigninDeepLink {
type Err = DeepLinkParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let url = Url::parse(s)?;
if !DEEP_LINK_SCHEMES.contains(&url.scheme()) {
return Err(DeepLinkParseError::InvalidSchema("pubkyauth or pubkyring"));
}
let intent = url.host_str().unwrap_or("").to_string();
if intent != "signin" {
return Err(DeepLinkParseError::InvalidIntent("signin"));
}
let raw_caps = url
.query_pairs()
.find(|(key, _)| key == "caps")
.ok_or(DeepLinkParseError::MissingQueryParameter("caps"))?
.1
.to_string();
let capabilities: Capabilities = raw_caps
.as_str()
.try_into()
.map_err(|e| DeepLinkParseError::InvalidQueryParameter("caps", Box::new(e)))?;
let raw_relay = url
.query_pairs()
.find(|(key, _)| key == "relay")
.ok_or(DeepLinkParseError::MissingQueryParameter("relay"))?
.1
.to_string();
let relay = Url::parse(&raw_relay)
.map_err(|e| DeepLinkParseError::InvalidQueryParameter("relay", Box::new(e)))?;
let raw_secret = url
.query_pairs()
.find(|(key, _)| key == "secret")
.ok_or(DeepLinkParseError::MissingQueryParameter("secret"))?
.1
.to_string();
let secret = URL_SAFE_NO_PAD
.decode(raw_secret.as_str())
.map_err(|e| DeepLinkParseError::InvalidQueryParameter("secret", Box::new(e)))?;
let secret: [u8; 32] = secret.try_into().map_err(|e: Vec<u8>| {
let msg = format!("Expected 32 bytes, got {}", e.len());
DeepLinkParseError::InvalidQueryParameter(
"secret",
Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, msg)),
)
})?;
Ok(SigninDeepLink {
capabilities,
relay,
secret,
})
}
}
impl From<SigninDeepLink> for Url {
fn from(val: SigninDeepLink) -> Self {
Url::parse(&val.to_string()).expect("Should be able to parse the deep link")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_signin_deep_link_parse() {
let capabilities = Capabilities::builder()
.read_write("/")
.read("/test")
.finish();
let relay = Url::parse("https://httprelay.pubky.app/inbox/").unwrap();
let secret = [123; 32];
let deep_link = SigninDeepLink::new(capabilities.clone(), relay.clone(), secret);
let deep_link_str = deep_link.to_string();
assert_eq!(
deep_link_str,
format!(
"pubkyauth://signin?caps={}&relay={}&secret={}",
capabilities,
relay,
URL_SAFE_NO_PAD.encode(secret)
)
);
let deep_link_parsed = SigninDeepLink::from_str(&deep_link_str).unwrap();
assert_eq!(deep_link_parsed, deep_link);
}
}