use std::io::Write;
use std::sync::Arc;
use camino::Utf8Path;
use crabrave::{CookieJar, Crabrave, oauth::OAuthScope};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Auth {
pub access_token: String,
pub refresh_token: Option<String>,
#[serde(default)]
pub expires_at: Option<i64>,
}
impl Auth {
pub fn is_expired(&self) -> bool {
match self.expires_at {
Some(expires_at) => chrono::Utc::now().timestamp() >= expires_at - 60,
None => false,
}
}
}
fn compute_expires_at(token: &crabrave::oauth::OAuth2Token) -> Option<i64> {
token
.expires_in
.map(|secs| chrono::Utc::now().timestamp() + secs as i64)
}
fn save_auth(auth: &Auth, path: &Utf8Path) -> anyhow::Result<()> {
fs_err::write(path, serde_json::to_string(auth)?)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs_err::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
fn make_oauth_config(
consumer_key: &str,
consumer_secret: &str,
) -> crabrave::CrabResult<crabrave::oauth::OAuth2Config> {
crabrave::oauth::OAuth2Config::new(
consumer_key.to_owned(),
consumer_secret.to_owned(),
format!("http://localhost:{}/redirect", crate::DEFAULT_CALLBACK_PORT),
vec![OAuthScope::Basic, OAuthScope::Offline],
)
}
const DASHBOARD_API_URL: &str = "https://www.tumblr.com/api/v2";
fn build_client(
consumer_key: &str,
consumer_secret: &str,
access_token: &str,
cookie_jar: Option<Arc<CookieJar>>,
dashboard: bool,
) -> anyhow::Result<Crabrave> {
let mut builder = Crabrave::builder()
.consumer_key(consumer_key.to_owned())
.consumer_secret(consumer_secret.to_owned())
.access_token(access_token);
if let Some(jar) = cookie_jar {
builder = builder.cookie_jar(jar);
}
if dashboard {
builder = builder.base_url(DASHBOARD_API_URL);
}
let client = builder.build()?;
Ok(client)
}
async fn interactive_auth(
consumer_key: &str,
consumer_secret: &str,
auth_file_path: &Utf8Path,
cookie_jar: Option<Arc<CookieJar>>,
dashboard: bool,
headless: bool,
) -> anyhow::Result<Crabrave> {
let oauth_config = make_oauth_config(consumer_key, consumer_secret)?;
let (auth_url, csrf_token) = oauth_config.authorize_url();
writeln!(
std::io::stdout(),
"Please navigate to this URL to authenticate:\n {auth_url}"
)?;
let (code, state) = if headless {
let url = crate::read_callback_url_from_stdin()?;
crate::parse_code_from_url(&url)?
} else {
match open::that(auth_url.as_str()) {
Ok(()) => log::debug!("opened browser for authentication"),
Err(_e) => log::debug!("could not open browser automatically"),
}
crate::capture_callback().await?
};
match state {
Some(ref s) if s != csrf_token.secret() => {
return Err(crate::ArchivrError::CsrfMismatch {
expected: csrf_token.secret().clone(),
actual: s.clone(),
}
.into());
}
None => {
log::warn!("no state parameter in OAuth callback; skipping CSRF verification");
}
Some(_) => {}
}
let oauth2_token = oauth_config.exchange_code(code).await?;
let expires_at = compute_expires_at(&oauth2_token);
let auth = Auth {
access_token: oauth2_token.access_token.clone(),
refresh_token: oauth2_token.refresh_token,
expires_at,
};
save_auth(&auth, auth_file_path)?;
build_client(
consumer_key,
consumer_secret,
&auth.access_token,
cookie_jar,
dashboard,
)
}
fn parse_cookie_file(path: &Utf8Path) -> anyhow::Result<Arc<CookieJar>> {
let contents = fs_err::read_to_string(path)?;
let jar = CookieJar::default();
let tumblr_url: url::Url = "https://www.tumblr.com"
.parse()
.map_err(|e| anyhow::anyhow!("failed to parse tumblr URL: {e}"))?;
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let fields: Vec<&str> = line.split('\t').collect();
if fields.len() < 7 {
log::warn!("skipping malformed cookie line: {line}");
continue;
}
let domain = fields[0];
if !domain.contains("tumblr.com") {
continue;
}
let name = fields[5];
let value = fields[6];
jar.add_cookie_str(&format!("{name}={value}"), &tumblr_url);
}
Ok(Arc::new(jar))
}
pub async fn authenticate(
consumer_key: &str,
consumer_secret: &str,
data_dir: &Utf8Path,
reauth: bool,
cookies_file: Option<&Utf8Path>,
dashboard: bool,
headless: bool,
) -> anyhow::Result<Crabrave> {
fs_err::create_dir_all(data_dir)?;
let auth_file_path = data_dir.join("auth.json");
let cookie_jar = cookies_file.map(parse_cookie_file).transpose()?;
if reauth {
return interactive_auth(
consumer_key,
consumer_secret,
&auth_file_path,
cookie_jar,
dashboard,
headless,
)
.await;
}
if fs_err::exists(&auth_file_path)? {
let auth_str = fs_err::read_to_string(&auth_file_path)?;
let auth: Auth = serde_json::from_str(&auth_str)?;
if !auth.is_expired() {
return build_client(
consumer_key,
consumer_secret,
&auth.access_token,
cookie_jar,
dashboard,
);
}
if let Some(refresh_token) = auth.refresh_token.clone() {
log::info!("access token expired, attempting refresh");
let oauth_config = make_oauth_config(consumer_key, consumer_secret)?;
match oauth_config.refresh_access_token(refresh_token).await {
Ok(new_token) => {
let expires_at = compute_expires_at(&new_token);
let refresh_token = new_token.refresh_token.or(auth.refresh_token);
let refreshed_auth = Auth {
access_token: new_token.access_token,
refresh_token,
expires_at,
};
save_auth(&refreshed_auth, &auth_file_path)?;
return build_client(
consumer_key,
consumer_secret,
&refreshed_auth.access_token,
cookie_jar,
dashboard,
);
}
Err(e) => {
log::warn!("token refresh failed: {e}, falling back to interactive auth");
}
}
}
}
interactive_auth(
consumer_key,
consumer_secret,
&auth_file_path,
cookie_jar,
dashboard,
headless,
)
.await
}