use reqwest::{Method, StatusCode};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use pubky_common::session::SessionInfo;
use crate::actors::storage::resource::resolve_pubky;
use crate::errors::AuthError;
use crate::errors::RequestError;
use crate::{
AuthToken, Error, PubkyHttpClient, Result, SessionStorage, cross_log, util::check_http_status,
};
#[derive(Clone)]
pub struct PubkySession {
pub(crate) client: PubkyHttpClient,
pub(crate) info: SessionInfo,
#[cfg(not(target_arch = "wasm32"))]
pub(crate) cookie: String,
}
impl PubkySession {
pub(crate) async fn new(token: &AuthToken, client: PubkyHttpClient) -> Result<Self> {
let url = format!("pubky://{}/session", token.public_key().z32());
cross_log!(
info,
"Establishing new session exchange for {}",
token.public_key()
);
let resolved = resolve_pubky(&url)?;
let response = client
.cross_request(Method::POST, resolved)
.await?
.body(token.serialize())
.send()
.await?;
let response = check_http_status(response).await?;
cross_log!(
info,
"Session exchange for {} succeeded; constructing session",
token.public_key()
);
Self::new_from_response(client.clone(), response).await
}
pub(crate) async fn new_from_response(
client: PubkyHttpClient,
response: reqwest::Response,
) -> Result<Self> {
#[cfg(target_arch = "wasm32")]
{
let bytes = response.bytes().await?;
let info = SessionInfo::deserialize(&bytes)?;
cross_log!(info, "Hydrated WASM session for {}", info.public_key());
Ok(Self { client, info })
}
#[cfg(not(target_arch = "wasm32"))]
{
let mut raw_set_cookies = Vec::new();
for val in &response.headers().get_all(reqwest::header::SET_COOKIE) {
if let Ok(raw) = std::str::from_utf8(val.as_bytes()) {
raw_set_cookies.push(raw.to_owned());
}
}
let bytes = response.bytes().await?;
let info = SessionInfo::deserialize(&bytes)?;
let cookie_name = info.public_key().z32();
let cookie = raw_set_cookies
.iter()
.filter_map(|raw| cookie::Cookie::parse(raw.clone()).ok())
.find(|c| c.name() == cookie_name)
.map(|c| c.value().to_string())
.ok_or_else(|| AuthError::Validation("missing session cookie".into()))?;
cross_log!(info, "Hydrated native session for {}", info.public_key());
Ok(Self {
client,
info,
cookie,
})
}
}
#[must_use]
pub const fn info(&self) -> &SessionInfo {
&self.info
}
#[must_use]
pub const fn client(&self) -> &PubkyHttpClient {
&self.client
}
pub async fn revalidate(&self) -> Result<Option<SessionInfo>> {
cross_log!(info, "Revalidating session for {}", self.info.public_key());
let response = self.send_revalidate_request().await?;
if Self::session_missing(&response) {
cross_log!(
warn,
"Session for {} no longer valid (404)",
self.info.public_key()
);
return Ok(None);
}
let info = Self::parse_session_info(response).await?;
cross_log!(info, "Session for {} remains valid", self.info.public_key());
Ok(Some(info))
}
async fn send_revalidate_request(&self) -> Result<reqwest::Response> {
self.storage()
.request(Method::GET, "/session")
.await?
.send()
.await
.map_err(Error::from)
}
fn session_missing(response: &reqwest::Response) -> bool {
response.status() == StatusCode::NOT_FOUND
}
async fn parse_session_info(response: reqwest::Response) -> Result<SessionInfo> {
let response = check_http_status(response).await?;
let bytes = response.bytes().await?;
Ok(SessionInfo::deserialize(&bytes)?)
}
pub async fn signout(self) -> std::result::Result<(), (Error, Self)> {
cross_log!(info, "Signing out session for {}", self.info.public_key());
let resp = match self.storage().delete("/session").await {
Ok(r) => r,
Err(e) => return Err((e, self)),
};
if let Err(e) = check_http_status(resp).await {
cross_log!(
error,
"Signout for {} failed: {}",
self.info.public_key(),
e
);
return Err((e, self));
}
cross_log!(info, "Session for {} signed out", self.info.public_key());
Ok(()) }
#[must_use]
pub fn export(&self) -> String {
cross_log!(info, "Exporting session for {}", self.info.public_key());
STANDARD.encode(self.info.serialize())
}
#[cfg(target_arch = "wasm32")]
pub async fn import(export: &str, client: Option<PubkyHttpClient>) -> Result<Self> {
let client = match client {
Some(c) => c,
None => PubkyHttpClient::new()?,
};
let bytes = STANDARD
.decode(export)
.map_err(|e| RequestError::Validation {
message: format!("invalid session export: {e}"),
})?;
let info = SessionInfo::deserialize(&bytes).map_err(|e| RequestError::Validation {
message: format!("invalid session export: {e}"),
})?;
let mut session = Self { client, info };
let info = session
.revalidate()
.await?
.ok_or(AuthError::RequestExpired)?;
session.info = info;
cross_log!(info, "Rehydrated session for {}", session.info.public_key());
Ok(session)
}
#[cfg(not(target_arch = "wasm32"))]
#[allow(
clippy::unused_async,
reason = "keep async signature aligned with WASM build"
)]
pub async fn import(_export: &str, _client: Option<PubkyHttpClient>) -> Result<Self> {
Err(RequestError::Validation {
message: "session import is only supported on WASM targets".into(),
}
.into())
}
#[must_use]
pub fn storage(&self) -> SessionStorage {
cross_log!(
debug,
"Creating session storage handle for {}",
self.info.public_key()
);
SessionStorage::new(self)
}
}
impl std::fmt::Debug for PubkySession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut ds = f.debug_struct("PubkySession");
ds.field("client", &self.client);
ds.field("info", &self.info);
#[cfg(not(target_arch = "wasm32"))]
ds.field("cookie", &"<redacted>");
ds.finish()
}
}