mirror of
https://github.com/CloudNebulaProject/webfingerd.git
synced 2026-04-10 13:10:41 +00:00
feat: add WebFinger query endpoint with rel filtering and CORS
This commit is contained in:
parent
4b04cf9b76
commit
697c84accf
6 changed files with 282 additions and 1 deletions
|
|
@ -38,5 +38,6 @@ urlencoding = "2"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
axum-test = "16"
|
axum-test = "20"
|
||||||
|
migration = { path = "migration" }
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
13
src/handler/health.rs
Normal file
13
src/handler/health.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::Router;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
async fn healthz() -> StatusCode {
|
||||||
|
StatusCode::OK
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new().route("/healthz", get(healthz))
|
||||||
|
}
|
||||||
12
src/handler/mod.rs
Normal file
12
src/handler/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
mod health;
|
||||||
|
mod webfinger;
|
||||||
|
|
||||||
|
use axum::Router;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn router(state: AppState) -> Router {
|
||||||
|
Router::new()
|
||||||
|
.merge(webfinger::router())
|
||||||
|
.merge(health::router())
|
||||||
|
.with_state(state)
|
||||||
|
}
|
||||||
109
src/handler/webfinger.rs
Normal file
109
src/handler/webfinger.rs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::http::{header, Uri};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::routing::get;
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::error::{AppError, AppResult};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
/// Parse resource and rel params from query string manually,
|
||||||
|
/// because serde_urlencoded can't handle repeated keys into Vec.
|
||||||
|
fn parse_webfinger_query(uri: &Uri) -> (Option<String>, Vec<String>) {
|
||||||
|
let query_str = uri.query().unwrap_or("");
|
||||||
|
let mut resource = None;
|
||||||
|
let mut rels = Vec::new();
|
||||||
|
|
||||||
|
for pair in query_str.split('&') {
|
||||||
|
if let Some((key, value)) = pair.split_once('=') {
|
||||||
|
let value = urlencoding::decode(value)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_owned();
|
||||||
|
match key {
|
||||||
|
"resource" => resource = Some(value),
|
||||||
|
"rel" => rels.push(value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(resource, rels)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn webfinger(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
uri: Uri,
|
||||||
|
) -> AppResult<Response> {
|
||||||
|
let (resource_opt, rels) = parse_webfinger_query(&uri);
|
||||||
|
|
||||||
|
let resource = resource_opt
|
||||||
|
.ok_or_else(|| AppError::BadRequest("missing resource parameter".into()))?;
|
||||||
|
|
||||||
|
let cached = state
|
||||||
|
.cache
|
||||||
|
.get(&resource)
|
||||||
|
.ok_or(AppError::NotFound)?;
|
||||||
|
|
||||||
|
let links: Vec<serde_json::Value> = cached
|
||||||
|
.links
|
||||||
|
.iter()
|
||||||
|
.filter(|link| {
|
||||||
|
if rels.is_empty() {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
rels.iter().any(|r| r == &link.rel)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(|link| {
|
||||||
|
let mut obj = serde_json::Map::new();
|
||||||
|
obj.insert("rel".into(), json!(link.rel));
|
||||||
|
if let Some(href) = &link.href {
|
||||||
|
obj.insert("href".into(), json!(href));
|
||||||
|
}
|
||||||
|
if let Some(t) = &link.link_type {
|
||||||
|
obj.insert("type".into(), json!(t));
|
||||||
|
}
|
||||||
|
if let Some(titles) = &link.titles {
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(titles) {
|
||||||
|
obj.insert("titles".into(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(props) = &link.properties {
|
||||||
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(props) {
|
||||||
|
obj.insert("properties".into(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(template) = &link.template {
|
||||||
|
obj.insert("template".into(), json!(template));
|
||||||
|
}
|
||||||
|
serde_json::Value::Object(obj)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut response_body = serde_json::Map::new();
|
||||||
|
response_body.insert("subject".into(), json!(cached.subject));
|
||||||
|
|
||||||
|
if let Some(aliases) = &cached.aliases {
|
||||||
|
response_body.insert("aliases".into(), json!(aliases));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(properties) = &cached.properties {
|
||||||
|
response_body.insert("properties".into(), properties.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
response_body.insert("links".into(), json!(links));
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
[
|
||||||
|
(header::CONTENT_TYPE, "application/jrd+json"),
|
||||||
|
(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
|
||||||
|
],
|
||||||
|
Json(serde_json::Value::Object(response_body)),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new().route("/.well-known/webfinger", get(webfinger))
|
||||||
|
}
|
||||||
|
|
@ -4,4 +4,5 @@ pub mod challenge;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod entity;
|
pub mod entity;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod handler;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
|
|
|
||||||
145
tests/test_webfinger.rs
Normal file
145
tests/test_webfinger.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
use axum_test::TestServer;
|
||||||
|
use webfingerd::handler;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_webfinger_returns_404_for_unknown_resource() {
|
||||||
|
let state = common::test_state().await;
|
||||||
|
let app = handler::router(state);
|
||||||
|
let server = TestServer::new(app);
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.get("/.well-known/webfinger")
|
||||||
|
.add_query_param("resource", "acct:nobody@example.com")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
response.assert_status_not_found();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_webfinger_returns_400_without_resource_param() {
|
||||||
|
let state = common::test_state().await;
|
||||||
|
let app = handler::router(state);
|
||||||
|
let server = TestServer::new(app);
|
||||||
|
|
||||||
|
let response = server.get("/.well-known/webfinger").await;
|
||||||
|
|
||||||
|
response.assert_status_bad_request();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_webfinger_returns_jrd_for_known_resource() {
|
||||||
|
let state = common::test_state().await;
|
||||||
|
|
||||||
|
// Seed cache directly for this test
|
||||||
|
state.cache.set(
|
||||||
|
"acct:alice@example.com".into(),
|
||||||
|
webfingerd::cache::CachedResource {
|
||||||
|
subject: "acct:alice@example.com".into(),
|
||||||
|
aliases: Some(vec!["https://example.com/@alice".into()]),
|
||||||
|
properties: None,
|
||||||
|
links: vec![webfingerd::cache::CachedLink {
|
||||||
|
rel: "self".into(),
|
||||||
|
href: Some("https://example.com/users/alice".into()),
|
||||||
|
link_type: Some("application/activity+json".into()),
|
||||||
|
titles: None,
|
||||||
|
properties: None,
|
||||||
|
template: None,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let app = handler::router(state);
|
||||||
|
let server = TestServer::new(app);
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.get("/.well-known/webfinger")
|
||||||
|
.add_query_param("resource", "acct:alice@example.com")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
response.assert_status_ok();
|
||||||
|
let body: serde_json::Value = response.json();
|
||||||
|
assert_eq!(body["subject"], "acct:alice@example.com");
|
||||||
|
assert_eq!(body["aliases"][0], "https://example.com/@alice");
|
||||||
|
assert_eq!(body["links"][0]["rel"], "self");
|
||||||
|
assert_eq!(
|
||||||
|
body["links"][0]["href"],
|
||||||
|
"https://example.com/users/alice"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_webfinger_filters_by_rel() {
|
||||||
|
let state = common::test_state().await;
|
||||||
|
|
||||||
|
state.cache.set(
|
||||||
|
"acct:alice@example.com".into(),
|
||||||
|
webfingerd::cache::CachedResource {
|
||||||
|
subject: "acct:alice@example.com".into(),
|
||||||
|
aliases: None,
|
||||||
|
properties: None,
|
||||||
|
links: vec![
|
||||||
|
webfingerd::cache::CachedLink {
|
||||||
|
rel: "self".into(),
|
||||||
|
href: Some("https://example.com/users/alice".into()),
|
||||||
|
link_type: Some("application/activity+json".into()),
|
||||||
|
titles: None,
|
||||||
|
properties: None,
|
||||||
|
template: None,
|
||||||
|
},
|
||||||
|
webfingerd::cache::CachedLink {
|
||||||
|
rel: "http://openid.net/specs/connect/1.0/issuer".into(),
|
||||||
|
href: Some("https://auth.example.com".into()),
|
||||||
|
link_type: None,
|
||||||
|
titles: None,
|
||||||
|
properties: None,
|
||||||
|
template: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let app = handler::router(state);
|
||||||
|
let server = TestServer::new(app);
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.get("/.well-known/webfinger")
|
||||||
|
.add_query_param("resource", "acct:alice@example.com")
|
||||||
|
.add_query_param("rel", "self")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
response.assert_status_ok();
|
||||||
|
let body: serde_json::Value = response.json();
|
||||||
|
let links = body["links"].as_array().unwrap();
|
||||||
|
assert_eq!(links.len(), 1);
|
||||||
|
assert_eq!(links[0]["rel"], "self");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_webfinger_cors_headers() {
|
||||||
|
let state = common::test_state().await;
|
||||||
|
|
||||||
|
state.cache.set(
|
||||||
|
"acct:alice@example.com".into(),
|
||||||
|
webfingerd::cache::CachedResource {
|
||||||
|
subject: "acct:alice@example.com".into(),
|
||||||
|
aliases: None,
|
||||||
|
properties: None,
|
||||||
|
links: vec![],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let app = handler::router(state);
|
||||||
|
let server = TestServer::new(app);
|
||||||
|
|
||||||
|
let response = server
|
||||||
|
.get("/.well-known/webfinger")
|
||||||
|
.add_query_param("resource", "acct:alice@example.com")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response.header("access-control-allow-origin"),
|
||||||
|
"*"
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue