diff --git a/Cargo.lock b/Cargo.lock index e730504..1b08839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,13 +95,12 @@ dependencies = [ "axum", "feder-core", "feder-vocab", + "percent-encoding", "serde", "serde_json", "thiserror", "tokio", "tower", - "tracing", - "tracing-subscriber", ] [[package]] @@ -449,6 +448,17 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "single-user-server" +version = "0.1.0" +dependencies = [ + "axum", + "feder-runtime-server", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "slab" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index f889890..7fba940 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/feder-core", "crates/feder-vocab", "crates/feder-runtime-server", + "examples/single-user-server", ] resolver = "3" diff --git a/crates/feder-core/src/lib.rs b/crates/feder-core/src/lib.rs index 4808aea..3e59dfa 100644 --- a/crates/feder-core/src/lib.rs +++ b/crates/feder-core/src/lib.rs @@ -293,6 +293,12 @@ pub struct UserCreateNote { pub published: Option, } +impl Input { + pub fn received_follow(follow: vocab::Follow, accept_id: vocab::Iri) -> Self { + Self::ReceivedFollow(ReceivedFollow { follow, accept_id }) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct Follower { pub follower: vocab::Iri, diff --git a/crates/feder-runtime-server/Cargo.toml b/crates/feder-runtime-server/Cargo.toml index d32af2b..8ab56d1 100644 --- a/crates/feder-runtime-server/Cargo.toml +++ b/crates/feder-runtime-server/Cargo.toml @@ -8,14 +8,14 @@ license.workspace = true feder-core = { path = "../feder-core" } feder-vocab = { path = "../feder-vocab" } axum = "0.8" -thiserror = "2" -tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde.workspace = true +thiserror = "2" +serde_json.workspace = true +percent-encoding = "2.3.2" [dev-dependencies] serde_json.workspace = true +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tower.workspace = true [lints] diff --git a/crates/feder-runtime-server/README.md b/crates/feder-runtime-server/README.md index 77a93c1..0ffdebc 100644 --- a/crates/feder-runtime-server/README.md +++ b/crates/feder-runtime-server/README.md @@ -1,54 +1,49 @@ Feder Runtime Server ==================== -Runnable server runtime for Feder on standard operating systems. - -This crate currently starts a local development server with one configured -actor and a health check endpoint. ActivityPub discovery, inbox handling, -storage, signing, and delivery are intentionally left to later issues. - - -Run ---- - -On Unix shells: - -~~~~ sh -RUST_LOG=info cargo run -p feder-runtime-server +Reusable Axum/Tokio server integration for Feder on standard operating +systems. + +This crate builds an Axum router from caller-provided runtime configuration. +It provides a health check endpoint, WebFinger discovery, and a local actor +route. The caller chooses concrete bind addresses, actor IRIs, usernames, and +handle hosts. + +ActivityPub inbox handling for supported Follow activities is included. Storage, +signature verification, and delivery are intentionally left to later issues. + + +Example +------- + +~~~~ rust +use feder_runtime_server::{InboxAuthPolicy, RuntimeConfig, build_router}; + +let config = RuntimeConfig { + bind: "127.0.0.1:3000".parse().expect("valid bind address"), + actor_id: "http://127.0.0.1:3000/users/alice" + .parse() + .expect("valid actor IRI"), + inbox: "http://127.0.0.1:3000/users/alice/inbox" + .parse() + .expect("valid inbox IRI"), + outbox: "http://127.0.0.1:3000/users/alice/outbox" + .parse() + .expect("valid outbox IRI"), + username: "alice".to_string(), + handle_host: "127.0.0.1:3000".to_string(), + inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, +}; + +let app = build_router(config); ~~~~ -On PowerShell: - -~~~~ powershell -$env:RUST_LOG = "info"; cargo run -p feder-runtime-server -~~~~ -On cmd.exe: - -~~~~ bat -set RUST_LOG=info && cargo run -p feder-runtime-server -~~~~ +Demo +---- -The default local actor is: - -~~~~ text -http://127.0.0.1:3000/users/alice -~~~~ - -The server listens on: - -~~~~ text -127.0.0.1:3000 -~~~~ - -Check the process: +A runnable single-user demo lives in `examples/single-user-server`: ~~~~ sh -curl -i http://127.0.0.1:3000/healthz -~~~~ - -Expected response: - -~~~~ text -HTTP/1.1 204 No Content +RUST_LOG=info cargo run -p single-user-server ~~~~ diff --git a/crates/feder-runtime-server/src/actor.rs b/crates/feder-runtime-server/src/actor.rs index daf6028..e686b66 100644 --- a/crates/feder-runtime-server/src/actor.rs +++ b/crates/feder-runtime-server/src/actor.rs @@ -47,11 +47,11 @@ mod tests { use serde_json::Value; use tower::ServiceExt; - use crate::{app::build_router, config::RuntimeConfig}; + use crate::{build_router, config::test_config}; #[tokio::test] async fn returns_local_actor() { - let app = build_router(&RuntimeConfig::default_local()); + let app = build_router(test_config()); let response = app .oneshot( @@ -85,7 +85,7 @@ mod tests { #[tokio::test] async fn rejects_unknown_actor() { - let app = build_router(&RuntimeConfig::default_local()); + let app = build_router(test_config()); let response = app .oneshot( diff --git a/crates/feder-runtime-server/src/app.rs b/crates/feder-runtime-server/src/app.rs index fa591b9..953705c 100644 --- a/crates/feder-runtime-server/src/app.rs +++ b/crates/feder-runtime-server/src/app.rs @@ -15,12 +15,13 @@ use std::sync::{Arc, Mutex}; -use crate::config::RuntimeConfig; +use crate::config::{InboxAuthPolicy, RuntimeConfig}; use crate::webfinger::webfinger; -use crate::{actor::actor, note::note}; -use axum::{Router, http::StatusCode, routing::get}; +use crate::{actor::actor, inbox::inbox}; +use axum::routing::post; +use axum::{Router, extract::DefaultBodyLimit, http::StatusCode, routing::get}; use feder_core::{FederConfig, FederCore}; -use feder_vocab::{Actor, Note, Reference}; +use feder_vocab::Actor; #[derive(Clone)] pub struct AppState { @@ -28,50 +29,67 @@ pub struct AppState { pub local_actor: Actor, pub username: String, pub handle_host: String, - // TODO(#25): Replace this seeded preview note with durable runtime storage. - pub notes: Vec, + pub inbox_auth_policy: InboxAuthPolicy, } impl AppState { - pub fn from_config(config: &RuntimeConfig) -> Self { - let mut actor = Actor::person( - config.actor_id.clone(), - config.inbox.clone(), - config.outbox.clone(), - ); - actor.preferred_username = Some(config.preferred_username.clone()); + pub fn from_config(config: RuntimeConfig) -> Self { + let mut actor = Actor::person(config.actor_id, config.inbox, config.outbox); + actor.preferred_username = Some(config.username.clone()); actor.name = Some(config.username.clone()); - // TODO(#25): Replace this seeded preview note with durable runtime storage. - let mut note = Note::new(config.note_id.clone()); - note.attributed_to = Some(Reference::id(config.actor_id.clone())); - note.content = - Some("Hello, World! This is Feder, a portable AP core for many runtimes.".to_string()); - let notes = vec![note]; - let core = FederCore::new(FederConfig::new(actor.clone())); Self { core: Arc::new(Mutex::new(core)), local_actor: actor, - username: config.username.clone(), - handle_host: config.handle_host.clone(), - notes, + username: config.username, + handle_host: config.handle_host, + inbox_auth_policy: config.inbox_auth_policy, } } } -pub fn build_router(config: &RuntimeConfig) -> Router { +pub fn build_router(config: RuntimeConfig) -> Router { let state = AppState::from_config(config); Router::new() .route("/healthz", get(healthz)) .route("/.well-known/webfinger", get(webfinger)) .route("/users/{username}", get(actor)) - .route("/notes/{id}", get(note)) + .route("/users/{username}/inbox", post(inbox)) + .layer(DefaultBodyLimit::max(1_048_576)) .with_state(state) } async fn healthz() -> StatusCode { StatusCode::NO_CONTENT } + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use tower::ServiceExt; + + use crate::{build_router, config::test_config}; + + #[tokio::test] + async fn returns_health_check() { + let app = build_router(test_config()); + + let response = app + .oneshot( + Request::builder() + .uri("/healthz") + .body(Body::empty()) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + } +} diff --git a/crates/feder-runtime-server/src/config.rs b/crates/feder-runtime-server/src/config.rs index 9039c9d..59bf8c1 100644 --- a/crates/feder-runtime-server/src/config.rs +++ b/crates/feder-runtime-server/src/config.rs @@ -17,40 +17,37 @@ use std::net::SocketAddr; use feder_vocab::Iri; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InboxAuthPolicy { + RequireSigned, + AllowUnsignedInsecureDev, +} + pub struct RuntimeConfig { pub bind: SocketAddr, pub actor_id: Iri, pub inbox: Iri, pub outbox: Iri, pub username: String, - pub preferred_username: String, pub handle_host: String, - // TODO(#25): Replace this seeded preview note with durable runtime storage. - pub note_id: Iri, + pub inbox_auth_policy: InboxAuthPolicy, } -impl RuntimeConfig { - pub fn default_local() -> Self { - Self { - actor_id: "http://127.0.0.1:3000/users/alice" - .parse() - .expect("valid default actor IRI"), - inbox: "http://127.0.0.1:3000/users/alice/inbox" - .parse() - .expect("valid default inbox IRI"), - outbox: "http://127.0.0.1:3000/users/alice/outbox" - .parse() - .expect("valid default outbox IRI"), - bind: "127.0.0.1:3000" - .parse() - .expect("valid default bind address"), - username: "alice".to_string(), - preferred_username: "alice".to_string(), - handle_host: "127.0.0.1:3000".to_string(), - // TODO(#25): Replace this seeded preview note with durable runtime storage. - note_id: "http://127.0.0.1:3000/notes/1" - .parse() - .expect("valid default note IRI"), - } +#[cfg(test)] +pub(crate) fn test_config() -> RuntimeConfig { + RuntimeConfig { + actor_id: "http://127.0.0.1:3000/users/alice" + .parse() + .expect("valid actor IRI"), + inbox: "http://127.0.0.1:3000/users/alice/inbox" + .parse() + .expect("valid inbox IRI"), + outbox: "http://127.0.0.1:3000/users/alice/outbox" + .parse() + .expect("valid outbox IRI"), + bind: "127.0.0.1:3000".parse().expect("valid bind address"), + username: "alice".to_string(), + handle_host: "127.0.0.1:3000".to_string(), + inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, } } diff --git a/crates/feder-runtime-server/src/inbox.rs b/crates/feder-runtime-server/src/inbox.rs new file mode 100644 index 0000000..6416d79 --- /dev/null +++ b/crates/feder-runtime-server/src/inbox.rs @@ -0,0 +1,357 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use axum::{ + body::Bytes, + extract::{Path, State}, + http::{HeaderMap, Method, StatusCode, Uri, header::CONTENT_TYPE}, + response::{IntoResponse, Response}, +}; + +use feder_core::Input; +use feder_vocab::Follow; +use serde_json::{Value, from_slice, from_value}; + +use crate::app::AppState; +use crate::config::InboxAuthPolicy; + +pub struct InboxRequest { + pub username: String, + pub headers: HeaderMap, + pub method: Method, + pub uri: Uri, + pub body: Bytes, +} + +fn accept_id_for_follow( + local_actor_id: &feder_vocab::Iri, + follow_id: &feder_vocab::Iri, +) -> Result { + let encoded_follow_id = percent_encoding::utf8_percent_encode( + follow_id.as_str(), + percent_encoding::NON_ALPHANUMERIC, + ); + + format!("{local_actor_id}#accepts/{encoded_follow_id}") + .parse() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +fn verify_inbox_request(app_state: &AppState, _req: &InboxRequest) -> Result<(), StatusCode> { + match app_state.inbox_auth_policy { + InboxAuthPolicy::AllowUnsignedInsecureDev => Ok(()), + InboxAuthPolicy::RequireSigned => Err(StatusCode::UNAUTHORIZED), + } +} + +pub async fn inbox( + State(app_state): State, + Path(username): Path, + headers: HeaderMap, + method: Method, + uri: Uri, + body: Bytes, +) -> Result { + if username != app_state.username { + return Err(StatusCode::NOT_FOUND); + } + let content_type = headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + + if !content_type.starts_with("application/activity+json") + && !content_type.starts_with("application/ld+json") + { + return Err(StatusCode::UNSUPPORTED_MEDIA_TYPE); + } + + let req = InboxRequest { + username, + headers, + method, + uri, + body, + }; + + verify_inbox_request(&app_state, &req)?; + + let value: Value = from_slice(&req.body).map_err(|_| StatusCode::BAD_REQUEST)?; + + let activity_type = value.get("type").and_then(|value| value.as_str()); + + // Unsupported activity types will be ignored + if activity_type != Some("Follow") { + return Ok(StatusCode::ACCEPTED.into_response()); + } + let follow: Follow = from_value(value).map_err(|_| StatusCode::BAD_REQUEST)?; + let accept_id = accept_id_for_follow(&app_state.local_actor.id, &follow.id)?; + let input = Input::received_follow(follow, accept_id); + + let mut core = app_state + .core + .lock() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let _result = core.handle(input); + + Ok(StatusCode::ACCEPTED.into_response()) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::{Body, Bytes}, + extract::Path, + http::{HeaderMap, Method, Request, StatusCode, Uri, header::CONTENT_TYPE}, + response::Response, + }; + use serde_json::json; + use tower::ServiceExt; + + use crate::{ + app::AppState, + build_router, + config::{InboxAuthPolicy, test_config}, + }; + + use super::inbox; + + fn activity_json_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, "application/activity+json".parse().unwrap()); + headers + } + + fn follow_body() -> Bytes { + Bytes::from( + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Follow", + "id": "https://remote.example/activities/follow-1", + "actor": { + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Person", + "id": "https://remote.example/users/bob", + "inbox": "https://remote.example/users/bob/inbox", + "outbox": "https://remote.example/users/bob/outbox" + }, + "object": "http://127.0.0.1:3000/users/alice" + })) + .expect("serialize follow"), + ) + } + + async fn post_inbox( + app_state: AppState, + username: &str, + headers: HeaderMap, + body: Bytes, + ) -> Result { + inbox( + axum::extract::State(app_state), + Path(username.to_string()), + headers, + Method::POST, + Uri::from_static("/users/alice/inbox"), + body, + ) + .await + } + + #[tokio::test] + async fn valid_follow_reaches_core() { + let app_state = AppState::from_config(test_config()); + + let response = post_inbox( + app_state.clone(), + "alice", + activity_json_headers(), + follow_body(), + ) + .await + .expect("accepted follow"); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + + let core = app_state.core.lock().expect("core lock"); + assert_eq!(core.state().followers().len(), 1); + assert_eq!( + core.state().followers()[0].follower.as_str(), + "https://remote.example/users/bob" + ); + assert_eq!( + core.state().followers()[0].following.as_str(), + "http://127.0.0.1:3000/users/alice" + ); + assert_eq!(core.state().delivery_targets().len(), 1); + assert_eq!( + core.state().delivery_targets()[0].inbox.as_str(), + "https://remote.example/users/bob/inbox" + ); + } + + #[tokio::test] + async fn require_signed_rejects_unsigned_follow_before_core() { + let mut config = test_config(); + config.inbox_auth_policy = InboxAuthPolicy::RequireSigned; + let app_state = AppState::from_config(config); + + let error = post_inbox( + app_state.clone(), + "alice", + activity_json_headers(), + follow_body(), + ) + .await + .expect_err("unsigned follow should be rejected"); + + assert_eq!(error, StatusCode::UNAUTHORIZED); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } + + #[tokio::test] + async fn rejects_unknown_inbox_actor() { + let app_state = AppState::from_config(test_config()); + + let error = post_inbox( + app_state.clone(), + "bob", + activity_json_headers(), + follow_body(), + ) + .await + .expect_err("unknown inbox actor should be rejected"); + + assert_eq!(error, StatusCode::NOT_FOUND); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } + + #[tokio::test] + async fn rejects_unsupported_content_type() { + let app_state = AppState::from_config(test_config()); + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, "application/json".parse().unwrap()); + + let error = post_inbox(app_state.clone(), "alice", headers, follow_body()) + .await + .expect_err("unsupported content type should be rejected"); + + assert_eq!(error, StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } + + #[tokio::test] + async fn rejects_malformed_json() { + let app_state = AppState::from_config(test_config()); + + let error = post_inbox( + app_state.clone(), + "alice", + activity_json_headers(), + Bytes::from_static(b"{not json"), + ) + .await + .expect_err("malformed json should be rejected"); + + assert_eq!(error, StatusCode::BAD_REQUEST); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } + + #[tokio::test] + async fn ignores_unsupported_activity_without_mutating_core() { + let app_state = AppState::from_config(test_config()); + let body = Bytes::from( + serde_json::to_vec(&json!({ + "@context": "https://www.w3.org/ns/activitystreams", + "type": "Create", + "id": "https://remote.example/activities/create-1", + "actor": "https://remote.example/users/bob", + "object": { + "type": "Note", + "id": "https://remote.example/notes/1" + } + })) + .expect("serialize create"), + ); + + let response = post_inbox(app_state.clone(), "alice", activity_json_headers(), body) + .await + .expect("unsupported activity is accepted but ignored"); + + assert_eq!(response.status(), StatusCode::ACCEPTED); + assert!( + app_state + .core + .lock() + .expect("core lock") + .state() + .followers() + .is_empty() + ); + } + + #[tokio::test] + async fn rejects_oversized_inbox_body() { + let app = build_router(test_config()); + let oversized_body = vec![b' '; 1_048_577]; + + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/users/alice/inbox") + .header(CONTENT_TYPE, "application/activity+json") + .body(Body::from(oversized_body)) + .expect("valid request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } +} diff --git a/crates/feder-runtime-server/src/lib.rs b/crates/feder-runtime-server/src/lib.rs index 58c1a85..952cfb4 100644 --- a/crates/feder-runtime-server/src/lib.rs +++ b/crates/feder-runtime-server/src/lib.rs @@ -17,5 +17,9 @@ pub mod actor; pub mod app; pub mod config; pub mod error; -pub mod note; +pub mod inbox; pub mod webfinger; + +pub use app::{AppState, build_router}; +pub use config::{InboxAuthPolicy, RuntimeConfig}; +pub use error::Error; diff --git a/crates/feder-runtime-server/src/main.rs b/crates/feder-runtime-server/src/main.rs deleted file mode 100644 index a13027b..0000000 --- a/crates/feder-runtime-server/src/main.rs +++ /dev/null @@ -1,35 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use feder_runtime_server::{app::build_router, config::RuntimeConfig, error::Error}; - -#[tokio::main] -async fn main() -> Result<(), Error> { - tracing_subscriber::fmt() - .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) - .init(); - - let config = RuntimeConfig::default_local(); - let app = build_router(&config); - - tracing::info!(bind = %&config.bind, actor = %&config.actor_id, "starting Feder runtime"); - - let listener = tokio::net::TcpListener::bind(config.bind) - .await - .map_err(Error::Bind)?; - axum::serve(listener, app).await.map_err(Error::Serve)?; - - Ok(()) -} diff --git a/crates/feder-runtime-server/src/note.rs b/crates/feder-runtime-server/src/note.rs deleted file mode 100644 index 7fa92ef..0000000 --- a/crates/feder-runtime-server/src/note.rs +++ /dev/null @@ -1,107 +0,0 @@ -// Feder: A portable ActivityPub core for many runtimes. -// Copyright (C) 2026 Feder contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, version 3. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use axum::{ - Json, - extract::{Path, State}, - http::{StatusCode, header}, - response::{IntoResponse, Response}, -}; - -use crate::app::AppState; - -pub async fn note( - State(app_state): State, - Path(id): Path, -) -> Result { - // TODO(#25): Replace this seeded preview note with durable runtime storage. - let expected_suffix = format!("/notes/{id}"); - let note = app_state - .notes - .iter() - .find(|note| note.id.as_str().ends_with(&expected_suffix)) - .cloned() - .ok_or(StatusCode::NOT_FOUND)?; - - Ok(( - [(header::CONTENT_TYPE, "application/activity+json")], - Json(note), - ) - .into_response()) -} - -#[cfg(test)] -mod tests { - use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode, header}, - }; - use serde_json::Value; - use tower::ServiceExt; - - use crate::{app::build_router, config::RuntimeConfig}; - - #[tokio::test] - async fn returns_public_note() { - let app = build_router(&RuntimeConfig::default_local()); - - let response = app - .oneshot( - Request::builder() - .uri("/notes/1") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).unwrap(), - "application/activity+json" - ); - - let body = to_bytes(response.into_body(), 2048) - .await - .expect("read response body"); - let json: Value = serde_json::from_slice(&body).expect("valid json"); - - assert_eq!(json["@context"], "https://www.w3.org/ns/activitystreams"); - assert_eq!(json["type"], "Note"); - assert_eq!(json["id"], "http://127.0.0.1:3000/notes/1"); - assert_eq!(json["attributedTo"], "http://127.0.0.1:3000/users/alice"); - assert_eq!( - json["content"], - "Hello, World! This is Feder, a portable AP core for many runtimes." - ); - } - - #[tokio::test] - async fn rejects_unknown_note() { - let app = build_router(&RuntimeConfig::default_local()); - - let response = app - .oneshot( - Request::builder() - .uri("/notes/missing") - .body(Body::empty()) - .expect("valid request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } -} diff --git a/crates/feder-runtime-server/src/webfinger.rs b/crates/feder-runtime-server/src/webfinger.rs index 5eeee15..9d4e5cf 100644 --- a/crates/feder-runtime-server/src/webfinger.rs +++ b/crates/feder-runtime-server/src/webfinger.rs @@ -82,13 +82,13 @@ mod tests { use serde_json::Value; use tower::ServiceExt; - use crate::{app::build_router, config::RuntimeConfig}; + use crate::{build_router, config::test_config}; const WEBFINGER_PATH: &str = "/.well-known/webfinger?resource=acct:alice@127.0.0.1:3000"; #[tokio::test] async fn returns_webfinger_descriptor_for_local_actor() { - let app = build_router(&RuntimeConfig::default_local()); + let app = build_router(test_config()); let response = app .oneshot( @@ -123,7 +123,7 @@ mod tests { #[tokio::test] async fn rejects_missing_resource() { - let app = build_router(&RuntimeConfig::default_local()); + let app = build_router(test_config()); let response = app .oneshot( @@ -140,7 +140,7 @@ mod tests { #[tokio::test] async fn rejects_non_local_actor_resource() { - let app = build_router(&RuntimeConfig::default_local()); + let app = build_router(test_config()); let response = app .oneshot( diff --git a/examples/single-user-server/Cargo.toml b/examples/single-user-server/Cargo.toml new file mode 100644 index 0000000..5cac0c3 --- /dev/null +++ b/examples/single-user-server/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "single-user-server" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +axum = "0.8" +feder-runtime-server = { path = "../../crates/feder-runtime-server" } +tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[lints] +workspace = true diff --git a/examples/single-user-server/README.md b/examples/single-user-server/README.md new file mode 100644 index 0000000..b0a3297 --- /dev/null +++ b/examples/single-user-server/README.md @@ -0,0 +1,50 @@ +Single-User Server Example +========================== + +Demo app using `feder-runtime-server` with one hardcoded local actor. + + +Run +--- + +On Unix shells: + +~~~~ sh +RUST_LOG=info cargo run -p single-user-server +~~~~ + +On PowerShell: + +~~~~ powershell +$env:RUST_LOG = "info"; cargo run -p single-user-server +~~~~ + +On cmd.exe: + +~~~~ bat +set RUST_LOG=info && cargo run -p single-user-server +~~~~ + +The demo actor is: + +~~~~ text +http://127.0.0.1:3000/users/alice +~~~~ + +The server listens on: + +~~~~ text +127.0.0.1:3000 +~~~~ + +Check the process: + +~~~~ sh +curl -i http://127.0.0.1:3000/healthz +~~~~ + +Expected response: + +~~~~ text +HTTP/1.1 204 No Content +~~~~ diff --git a/examples/single-user-server/src/main.rs b/examples/single-user-server/src/main.rs new file mode 100644 index 0000000..0e44006 --- /dev/null +++ b/examples/single-user-server/src/main.rs @@ -0,0 +1,57 @@ +// Feder: A portable ActivityPub core for many runtimes. +// Copyright (C) 2026 Feder contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use feder_runtime_server::{Error, InboxAuthPolicy, RuntimeConfig, build_router}; + +fn default_local() -> RuntimeConfig { + RuntimeConfig { + bind: "127.0.0.1:3000" + .parse() + .expect("valid default bind address"), + actor_id: "http://127.0.0.1:3000/users/alice" + .parse() + .expect("valid default actor IRI"), + inbox: "http://127.0.0.1:3000/users/alice/inbox" + .parse() + .expect("valid default inbox IRI"), + outbox: "http://127.0.0.1:3000/users/alice/outbox" + .parse() + .expect("valid default outbox IRI"), + username: "alice".to_string(), + handle_host: "127.0.0.1:3000".to_string(), + inbox_auth_policy: InboxAuthPolicy::AllowUnsignedInsecureDev, + } +} + +#[tokio::main] +async fn main() -> Result<(), Error> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let config = default_local(); + let bind = config.bind; + let actor_id = config.actor_id.clone(); + let app = build_router(config); + + tracing::info!(bind = %bind, actor = %actor_id, "starting Feder single-user example"); + + let listener = tokio::net::TcpListener::bind(bind) + .await + .map_err(Error::Bind)?; + axum::serve(listener, app).await.map_err(Error::Serve)?; + + Ok(()) +}