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"
|
||||
|
||||
[dev-dependencies]
|
||||
axum-test = "16"
|
||||
axum-test = "20"
|
||||
migration = { path = "migration" }
|
||||
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 entity;
|
||||
pub mod error;
|
||||
pub mod handler;
|
||||
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