Commit
Author: Kevin Schoon [me@kevinschoon.com]
Hash: dc88d0c5b80e0d3abe0920993e336906fc0a88cd
Timestamp: Sun, 20 Oct 2024 20:06:14 +0000 (1 month ago)

+558 -161 +/-16 browse
improve webfinger support
improve webfinger support

Implements a mostly full featured webfinger server exposing collections,
repositories, authors, and mailing lists. Useful for use as a rest api, for
discovery, and an eventual centrally managed index server.
1diff --git a/Cargo.lock b/Cargo.lock
2index 595cb35..0d4e9d6 100644
3--- a/Cargo.lock
4+++ b/Cargo.lock
5 @@ -151,6 +151,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
6 checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
7
8 [[package]]
9+ name = "async-convert"
10+ version = "1.0.0"
11+ source = "registry+https://github.com/rust-lang/crates.io-index"
12+ checksum = "6d416feee97712e43152cd42874de162b8f9b77295b1c85e5d92725cc8310bae"
13+ dependencies = [
14+ "async-trait",
15+ ]
16+
17+ [[package]]
18 name = "async-trait"
19 version = "0.1.83"
20 source = "registry+https://github.com/rust-lang/crates.io-index"
21 @@ -280,6 +289,7 @@ dependencies = [
22 "mime",
23 "pin-project-lite",
24 "serde",
25+ "serde_html_form",
26 "tower",
27 "tower-layer",
28 "tower-service",
29 @@ -353,7 +363,7 @@ dependencies = [
30 "tree-sitter",
31 "tree-sitter-highlight",
32 "url",
33- "webfinger",
34+ "webfinger-rs",
35 ]
36
37 [[package]]
38 @@ -673,6 +683,7 @@ dependencies = [
39 "iana-time-zone",
40 "js-sys",
41 "num-traits",
42+ "serde",
43 "wasm-bindgen",
44 "windows-targets 0.52.6",
45 ]
46 @@ -1114,6 +1125,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
47 checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
48 dependencies = [
49 "powerfmt",
50+ "serde",
51 ]
52
53 [[package]]
54 @@ -1225,6 +1237,15 @@ dependencies = [
55 ]
56
57 [[package]]
58+ name = "document-features"
59+ version = "0.2.10"
60+ source = "registry+https://github.com/rust-lang/crates.io-index"
61+ checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0"
62+ dependencies = [
63+ "litrs",
64+ ]
65+
66+ [[package]]
67 name = "dotenvy"
68 version = "0.15.7"
69 source = "registry+https://github.com/rust-lang/crates.io-index"
70 @@ -2204,6 +2225,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
71 dependencies = [
72 "autocfg",
73 "hashbrown 0.12.3",
74+ "serde",
75 ]
76
77 [[package]]
78 @@ -2214,6 +2236,7 @@ checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da"
79 dependencies = [
80 "equivalent",
81 "hashbrown 0.15.0",
82+ "serde",
83 ]
84
85 [[package]]
86 @@ -2277,6 +2300,27 @@ dependencies = [
87 ]
88
89 [[package]]
90+ name = "kinded"
91+ version = "0.3.0"
92+ source = "registry+https://github.com/rust-lang/crates.io-index"
93+ checksum = "ce4bdbb2f423660b19f0e9f7115182214732d8dd5f840cd0a3aee3e22562f34c"
94+ dependencies = [
95+ "kinded_macros",
96+ ]
97+
98+ [[package]]
99+ name = "kinded_macros"
100+ version = "0.3.0"
101+ source = "registry+https://github.com/rust-lang/crates.io-index"
102+ checksum = "a13b4ddc5dcb32f45dac3d6f606da2a52fdb9964a18427e63cd5ef6c0d13288d"
103+ dependencies = [
104+ "convert_case",
105+ "proc-macro2",
106+ "quote",
107+ "syn 2.0.79",
108+ ]
109+
110+ [[package]]
111 name = "lazy_static"
112 version = "1.5.0"
113 source = "registry+https://github.com/rust-lang/crates.io-index"
114 @@ -2419,6 +2463,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
115 checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
116
117 [[package]]
118+ name = "litrs"
119+ version = "0.4.1"
120+ source = "registry+https://github.com/rust-lang/crates.io-index"
121+ checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
122+
123+ [[package]]
124 name = "lock_api"
125 version = "0.4.12"
126 source = "registry+https://github.com/rust-lang/crates.io-index"
127 @@ -2651,6 +2701,29 @@ dependencies = [
128 ]
129
130 [[package]]
131+ name = "nutype"
132+ version = "0.5.0"
133+ source = "registry+https://github.com/rust-lang/crates.io-index"
134+ checksum = "d8789358e2d6cdffb0cb170c7802ee7548beb8067ed643f3122fa36c335f3c64"
135+ dependencies = [
136+ "nutype_macros",
137+ ]
138+
139+ [[package]]
140+ name = "nutype_macros"
141+ version = "0.5.0"
142+ source = "registry+https://github.com/rust-lang/crates.io-index"
143+ checksum = "93a3e222ba1f06a03552910fe89a232a1661dcf8ad4c837531fb199828d0916b"
144+ dependencies = [
145+ "cfg-if",
146+ "kinded",
147+ "proc-macro2",
148+ "quote",
149+ "syn 2.0.79",
150+ "urlencoding",
151+ ]
152+
153+ [[package]]
154 name = "object"
155 version = "0.36.5"
156 source = "registry+https://github.com/rust-lang/crates.io-index"
157 @@ -3283,6 +3356,7 @@ dependencies = [
158 "tracing",
159 "tracing-subscriber",
160 "url",
161+ "webfinger-rs",
162 ]
163
164 [[package]]
165 @@ -3760,6 +3834,19 @@ dependencies = [
166 ]
167
168 [[package]]
169+ name = "serde_html_form"
170+ version = "0.2.6"
171+ source = "registry+https://github.com/rust-lang/crates.io-index"
172+ checksum = "8de514ef58196f1fc96dcaef80fe6170a1ce6215df9687a93fe8300e773fefc5"
173+ dependencies = [
174+ "form_urlencoded",
175+ "indexmap 2.6.0",
176+ "itoa",
177+ "ryu",
178+ "serde",
179+ ]
180+
181+ [[package]]
182 name = "serde_json"
183 version = "1.0.128"
184 source = "registry+https://github.com/rust-lang/crates.io-index"
185 @@ -3803,6 +3890,36 @@ dependencies = [
186 ]
187
188 [[package]]
189+ name = "serde_with"
190+ version = "3.11.0"
191+ source = "registry+https://github.com/rust-lang/crates.io-index"
192+ checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817"
193+ dependencies = [
194+ "base64 0.22.1",
195+ "chrono",
196+ "hex",
197+ "indexmap 1.9.3",
198+ "indexmap 2.6.0",
199+ "serde",
200+ "serde_derive",
201+ "serde_json",
202+ "serde_with_macros",
203+ "time",
204+ ]
205+
206+ [[package]]
207+ name = "serde_with_macros"
208+ version = "3.11.0"
209+ source = "registry+https://github.com/rust-lang/crates.io-index"
210+ checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d"
211+ dependencies = [
212+ "darling",
213+ "proc-macro2",
214+ "quote",
215+ "syn 2.0.79",
216+ ]
217+
218+ [[package]]
219 name = "sha1"
220 version = "0.10.6"
221 source = "registry+https://github.com/rust-lang/crates.io-index"
222 @@ -4972,6 +5089,12 @@ dependencies = [
223 ]
224
225 [[package]]
226+ name = "urlencoding"
227+ version = "2.1.3"
228+ source = "registry+https://github.com/rust-lang/crates.io-index"
229+ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
230+
231+ [[package]]
232 name = "utf8parse"
233 version = "0.2.2"
234 source = "registry+https://github.com/rust-lang/crates.io-index"
235 @@ -5132,6 +5255,27 @@ dependencies = [
236 ]
237
238 [[package]]
239+ name = "webfinger-rs"
240+ version = "0.0.12"
241+ source = "registry+https://github.com/rust-lang/crates.io-index"
242+ checksum = "a824b603913432754acb5eb98dc33ba96e35a2889a4efe72e0c8b6d6ec815cef"
243+ dependencies = [
244+ "async-convert",
245+ "axum",
246+ "axum-extra",
247+ "document-features",
248+ "http 1.1.0",
249+ "nutype",
250+ "percent-encoding",
251+ "reqwest 0.12.8",
252+ "serde",
253+ "serde_json",
254+ "serde_with",
255+ "thiserror",
256+ "tracing",
257+ ]
258+
259+ [[package]]
260 name = "webpki-roots"
261 version = "0.26.6"
262 source = "registry+https://github.com/rust-lang/crates.io-index"
263 diff --git a/ayllu/Cargo.toml b/ayllu/Cargo.toml
264index e55335b..9565dcf 100644
265--- a/ayllu/Cargo.toml
266+++ b/ayllu/Cargo.toml
267 @@ -52,7 +52,6 @@ tracing = "0.1.40"
268 tower = { version = "0.5.1", features = ["util", "timeout", "tracing"] }
269 mime = "0.3.17"
270 async-trait = "0.1.83"
271- webfinger = "0.5.1"
272 tarpc = { version = "0.34.0", features = ["full"] }
273 bytes = "1.7.2"
274 tokio-stream = "0.1.16"
275 @@ -60,6 +59,7 @@ httparse = "1.9.5"
276 thiserror = "1.0.64"
277 headers = "0.4.0"
278 include_dir = { version = "0.7.4", features = ["glob"] }
279+ webfinger-rs = { version = "0.0.12", features = ["axum"] }
280
281 # NOTE: this must be cautiously updated along with sqlx and rusqlite.
282 [dependencies.libsqlite3-sys]
283 diff --git a/ayllu/src/config.rs b/ayllu/src/config.rs
284index e3f2cd5..78ed315 100644
285--- a/ayllu/src/config.rs
286+++ b/ayllu/src/config.rs
287 @@ -176,9 +176,21 @@ impl Xmpp {
288 }
289
290 #[derive(Deserialize, Serialize, Clone, Debug, Default)]
291+ pub struct MailingList {
292+ pub id: String,
293+ pub name: Option<String>,
294+ pub address: String,
295+ pub description: Option<String>,
296+ pub topics: Vec<String>,
297+ // pub post_policy: PostPolicy,
298+ // pub subscription_policy: SubscriptionPolicy,
299+ }
300+
301+ #[derive(Deserialize, Serialize, Clone, Debug, Default)]
302 pub struct Mail {
303 #[serde(default = "Mail::default_socket_path")]
304 pub socket_path: String,
305+ pub lists: Vec<MailingList>,
306 }
307
308 impl Mail {
309 @@ -196,9 +208,19 @@ pub struct Link {
310 }
311
312 #[derive(Deserialize, Serialize, Clone, Debug, Default)]
313+ pub struct UrlLink {
314+ pub url: String,
315+ pub mime_type: Option<String>
316+ }
317+
318+
319+ #[derive(Deserialize, Serialize, Clone, Debug, Default)]
320 pub struct Author {
321 pub email: String,
322- pub links: Vec<Link>,
323+ pub tagline: Option<String>,
324+ pub avatar: Option<UrlLink>,
325+ pub profiles: Option<Vec<UrlLink>>,
326+ // TODO: PGP Keys, SSH Keys
327 }
328
329 #[derive(Deserialize, Serialize, Clone, Debug)]
330 @@ -255,6 +277,7 @@ pub struct Config {
331 #[serde(default = "Config::default_site_name")]
332 pub site_name: String,
333 pub origin: String,
334+ // TODO: Deprecate this
335 pub domain: Option<String>,
336 pub git: Git,
337 #[serde(default = "Config::default_worker_threads")]
338 diff --git a/ayllu/src/web2/error.rs b/ayllu/src/web2/error.rs
339index 14b3954..4f20162 100644
340--- a/ayllu/src/web2/error.rs
341+++ b/ayllu/src/web2/error.rs
342 @@ -1,6 +1,10 @@
343 use std::io::Error as IoError;
344
345- use axum::{body::Body, response::IntoResponse, response::Response};
346+ use axum::{
347+ body::Body,
348+ response::{IntoResponse, Response},
349+ };
350+ use serde::Serialize;
351 use tera::Error as TeraError;
352
353 use ayllu_api::error::ApiError;
354 @@ -10,7 +14,7 @@ use ayllu_rpc::tarpc::client::RpcError;
355
356 /// Error maps known error types into errors that can be translated into HTTP
357 /// status codes, e.g. Io::NotFound -> 404
358- #[derive(thiserror::Error, Debug, Clone)]
359+ #[derive(thiserror::Error, Debug, Clone, Serialize)]
360 pub enum Error {
361 #[error("Generic failure: {0}")]
362 Message(String),
363 diff --git a/ayllu/src/web2/middleware/sites.rs b/ayllu/src/web2/middleware/sites.rs
364index c711298..41e5660 100644
365--- a/ayllu/src/web2/middleware/sites.rs
366+++ b/ayllu/src/web2/middleware/sites.rs
367 @@ -133,6 +133,7 @@ pub async fn middleware(
368
369 // special case if running a static site in-front of the forge
370 // where requests can fall through to the backend.
371+ // TODO: Change this to use the origin URL
372 if hostname.is_some_and(|name| name == cfg.domain.unwrap()) {
373 let response = next.run(req).await;
374 Ok(response)
375 diff --git a/ayllu/src/web2/routes/finger.rs b/ayllu/src/web2/routes/finger.rs
376index ab8c4d1..2c3eb33 100644
377--- a/ayllu/src/web2/routes/finger.rs
378+++ b/ayllu/src/web2/routes/finger.rs
379 @@ -1,93 +1,313 @@
380- use axum::{
381- extract::{Extension, Query},
382- http::StatusCode,
383- response::IntoResponse,
384- response::Json,
385- response::Response,
386+ use std::collections::HashMap;
387+ use std::path::PathBuf;
388+
389+ use axum::{extract::Extension, http::Uri, response::Json};
390+ use url::Url;
391+ use webfinger_rs::{Link, Rel, WebFingerRequest, WebFingerResponse};
392+
393+ use crate::{
394+ config::{Author, Collection, Config, MailingList},
395+ web2::error::Error,
396 };
397- use serde::Deserialize;
398- use webfinger::{Link, Prefix, Resolver, ResolverError, Webfinger};
399+ use ayllu_git::{name, Error as GitError, Wrapper as Repository};
400
401- use std::sync::Arc;
402+ const AVATAR: &str = "http://webfinger.net/rel/avatar";
403+ const PROFILE_PAGE: &str = "http://webfinger.net/rel/profile-page";
404
405- use crate::config::Config;
406- // use crate::web2::error::Error;
407+ const COLLECTION: &str = "http://ayllu-forge.org/rel/collection";
408+ const DESCRIPTION: &str = "http://ayllu-forge.org/rel/description";
409+ const MAILING_LIST: &str = "http://ayllu-forge.org/rel/mailing-list";
410+ const REPOSITORY: &str = "http://ayllu-forge.org/rel/repository";
411
412- #[derive(Clone)]
413- pub struct CResolver {
414- pub domain: &'static str,
415- pub config: Arc<Config>,
416+ struct Pair(Collection, Vec<(String, ayllu_git::Config)>);
417+
418+ fn get_all(collections: &[Collection]) -> Result<Vec<Pair>, ayllu_git::Error> {
419+ collections
420+ .iter()
421+ .filter(|collection| {
422+ collection.hidden.is_none() || collection.hidden.is_some_and(|hidden| !hidden)
423+ })
424+ .try_fold(Vec::new(), |mut accm, collection| {
425+ let scanner = ayllu_git::Scanner::from_path(&collection.path)?;
426+ let reposoitories: Result<Vec<(String, ayllu_git::Config)>, GitError> = scanner
427+ .map(|repo_path| {
428+ let repository = Repository::new(repo_path.as_path())?;
429+ let repository_config = repository.config()?;
430+ let repository_name = name(&repo_path);
431+ Ok::<(String, ayllu_git::Config), GitError>((
432+ repository_name,
433+ repository_config,
434+ ))
435+ })
436+ .collect();
437+ accm.push(Pair(collection.clone(), reposoitories?));
438+ Ok::<Vec<Pair>, GitError>(accm)
439+ })
440 }
441
442- #[derive(Deserialize, Clone)]
443- pub struct FingerParams {
444- pub resource: String,
445+ #[derive(Clone, Debug)]
446+ enum Resource {
447+ Acct(Author),
448+ MailingList(MailingList),
449+ Collection((Collection, Vec<String>)),
450+ Repository((Collection, PathBuf)),
451+ Index,
452 }
453
454- pub struct Error(ResolverError);
455+ /// In-memory resolver for handling finger queries. Uses a combination of static
456+ /// configuration in the Ayllu config file and repositories on the file system.
457+ #[derive(Clone)]
458+ pub struct Resolver {
459+ pub collections: Vec<Collection>,
460+ pub authors: HashMap<String, Author>,
461+ pub mailing_lists: HashMap<String, MailingList>,
462+ origin: Url,
463+ }
464
465- impl IntoResponse for Error {
466- fn into_response(self) -> Response {
467- let status_code = match self.0 {
468- ResolverError::InvalidResource => StatusCode::INTERNAL_SERVER_ERROR,
469- ResolverError::WrongDomain => StatusCode::INTERNAL_SERVER_ERROR,
470- ResolverError::NotFound => StatusCode::NOT_FOUND,
471- };
472- (status_code, format!("resolver: {:?}", self.0)).into_response()
473+ impl Resolver {
474+ pub fn new(config: &Config) -> Self {
475+ Resolver {
476+ origin: Url::parse(&config.origin).expect("Origin URL is invalid"),
477+ collections: config.collections.clone(),
478+ authors: HashMap::from_iter(
479+ config
480+ .authors
481+ .iter()
482+ .map(|author| (author.email.clone(), author.clone())),
483+ ),
484+ mailing_lists: HashMap::from_iter(config.mail.as_ref().map_or(
485+ Vec::new(),
486+ |mail_config| {
487+ mail_config
488+ .lists
489+ .iter()
490+ .map(|mailing_list| (mailing_list.address.clone(), mailing_list.clone()))
491+ .collect()
492+ },
493+ )),
494+ }
495 }
496- }
497
498- impl Resolver<()> for CResolver {
499- fn instance_domain<'a>(&self) -> &'a str {
500- self.domain
501+ /// Determine the type of resource the caller wants to resolve but don't
502+ /// resolve it yet
503+ fn hint(&self, resource: &Uri) -> Result<Resource, Error> {
504+ let authority_str = resource.authority().map(|authority| authority.as_str());
505+ let account = match authority_str {
506+ Some(authority) => match authority.split_once(':') {
507+ Some(("acct", address)) => Some(address),
508+ _ => None,
509+ },
510+ None => None,
511+ };
512+ if let Some(account) = account {
513+ if let Some(author) = self.authors.get(account) {
514+ return Ok(Resource::Acct(author.clone()));
515+ } else {
516+ return Err::<Resource, Error>(Error::NotFound(resource.to_string()));
517+ };
518+ };
519+
520+ let mut collections = self.collections.iter().filter(|collection| {
521+ collection.hidden.is_none() || collection.hidden.is_some_and(|hidden| !hidden)
522+ });
523+
524+ if resource.to_string().eq(&self.origin.to_string()) {
525+ return Ok(Resource::Index);
526+ }
527+
528+ if let Some(mailing_list) = self.mailing_lists.get(&resource.to_string()) {
529+ return Ok(Resource::MailingList(mailing_list.clone()));
530+ }
531+
532+ if let Some(resource) = collections.find_map(|collection| {
533+ if collection.name.eq(resource.path().trim_start_matches("/")) {
534+ let scanner = ayllu_git::Scanner::from_path(&collection.path).ok()?;
535+ Some(Resource::Collection((
536+ collection.clone(),
537+ scanner
538+ .into_iter()
539+ .map(|repo_path| ayllu_git::name(repo_path.as_path()))
540+ .collect(),
541+ )))
542+ } else {
543+ None
544+ }
545+ }) {
546+ return Ok(resource);
547+ }
548+
549+ if let Some(resource) = collections.find_map(|collection| {
550+ let mut scanner = ayllu_git::Scanner::from_path(&collection.path).ok()?;
551+ scanner.find_map(|repo_path| {
552+ let (collection_str, name) = ayllu_git::collection_and_name(repo_path.as_path());
553+ if resource
554+ .path()
555+ .trim_start_matches("/")
556+ .eq(&format!("{}/{}", collection_str, name))
557+ && resource.host().is_some_and(|resource_host| {
558+ resource_host.eq(self.origin.host_str().unwrap_or_default())
559+ })
560+ {
561+ Some(Ok::<Resource, ayllu_git::Error>(Resource::Repository((
562+ collection.clone(),
563+ repo_path.clone(),
564+ ))))
565+ } else {
566+ None
567+ }
568+ })
569+ }) {
570+ return Ok(resource?);
571+ }
572+
573+ Err(Error::NotFound(resource.to_string()))
574 }
575
576- fn find(
577- &self,
578- prefix: Prefix,
579- acct: String,
580- _resource_repo: (),
581- ) -> Result<Webfinger, ResolverError> {
582- match prefix {
583- Prefix::Acct => {
584- // TODO: expand this to use in addition to the static config
585- // objects also "author repositories" where each author has a
586- // repo that contains a static json response for this.
587- let author = self
588- .config
589- .authors
590+ pub async fn resolve(&self, resource: &Uri) -> Result<WebFingerResponse, Error> {
591+ match self.hint(resource)? {
592+ Resource::Acct(author) => {
593+ let mut links: Vec<Link> = Vec::new();
594+ if let Some(profiles) = author.profiles.as_ref() {
595+ profiles.iter().for_each(|profile| {
596+ let mut link = Link::new(PROFILE_PAGE.into());
597+ link.href = Some(profile.url.clone());
598+ link.r#type = profile.mime_type.clone();
599+ links.push(link);
600+ });
601+ }
602+ if let Some(avatar) = author.avatar.as_ref() {
603+ links.push(Link::builder(AVATAR).href(avatar.url.to_string()).build());
604+ }
605+ links.push(
606+ Link::builder(DESCRIPTION)
607+ .properties(HashMap::from_iter(vec![(
608+ "text".to_string(),
609+ author.tagline.clone(),
610+ )]))
611+ .build(),
612+ );
613+ Ok(WebFingerResponse {
614+ subject: author.email.clone(),
615+ aliases: None,
616+ properties: None,
617+ links,
618+ })
619+ }
620+ Resource::MailingList(mailing_list) => {
621+ // TODO: Subscribe, unsubscribe, etc.
622+ Ok(WebFingerResponse {
623+ subject: resource.to_string(),
624+ aliases: None,
625+ properties: None,
626+ links: vec![Link::builder(DESCRIPTION)
627+ .href(format!("mailto://{}", mailing_list.address))
628+ .properties(HashMap::from_iter(vec![(
629+ "description".to_string(),
630+ mailing_list.description.clone(),
631+ )]))
632+ .build()],
633+ })
634+ }
635+ Resource::Collection((collection, repositories)) => {
636+ let mut links: Vec<Link> = repositories
637 .iter()
638- .find(|author| author.email == acct);
639- match author {
640- Some(author) => Ok(Webfinger {
641- subject: acct.clone(),
642- aliases: vec![acct.clone()],
643- links: author
644- .links
645- .iter()
646- .map(|link| Link {
647- rel: link.rel.clone(),
648- href: link.href.clone(),
649- template: link.template.clone(),
650- mime_type: link.mime_template.clone(),
651- })
652- .collect(),
653- }),
654- None => Err(ResolverError::NotFound),
655+ .map(|repo_name| {
656+ Link::builder(REPOSITORY)
657+ .href(
658+ self.origin
659+ .join(&format!("{}/{}", collection.name, repo_name))
660+ .unwrap(),
661+ )
662+ .build()
663+ })
664+ .collect();
665+ links.push(
666+ Link::builder(DESCRIPTION)
667+ .properties(HashMap::from_iter(vec![(
668+ "description".to_string(),
669+ collection.description.clone(),
670+ )]))
671+ .build(),
672+ );
673+ Ok(WebFingerResponse {
674+ subject: resource.to_string(),
675+ aliases: None,
676+ properties: None,
677+ links,
678+ })
679+ }
680+ Resource::Repository((collection, repo_path)) => {
681+ let collection_link = self.origin.join(&collection.name).unwrap();
682+ let repository = ayllu_git::Wrapper::new(repo_path.as_path())?;
683+ let config = repository.config()?;
684+ let mut links: Vec<Link> = vec![
685+ Link::builder(COLLECTION)
686+ .href(collection_link.to_string())
687+ .build(),
688+ Link::builder(DESCRIPTION)
689+ .properties(HashMap::from_iter(vec![(
690+ "description".to_string(),
691+ config.description.clone(),
692+ )]))
693+ .build(),
694+ ];
695+ if let Some(mailing_lists) = config.mail {
696+ mailing_lists.iter().for_each(|mailing_list| {
697+ links.push(
698+ Link::builder(MAILING_LIST)
699+ .href(format!("mailto://{}", mailing_list.0))
700+ .build(),
701+ );
702+ });
703 }
704+ Ok(WebFingerResponse {
705+ subject: resource.to_string(),
706+ aliases: None,
707+ properties: None,
708+ links,
709+ })
710+ }
711+ Resource::Index => {
712+ let index = get_all(&self.collections)?;
713+ let links = index.iter().fold(Vec::new(), |mut accm, pair| {
714+ let collection_link = self.origin.join(&pair.0.name).unwrap();
715+ accm.push(
716+ Link::builder(COLLECTION)
717+ .href(collection_link.clone())
718+ .properties(HashMap::from_iter(vec![(
719+ "description".to_string(),
720+ pair.0.description.clone(),
721+ )]))
722+ .build(),
723+ );
724+ accm.extend(pair.1.iter().map(|(name, config)| {
725+ let repository_link = collection_link.join(name).unwrap();
726+ Link::builder(REPOSITORY)
727+ .href(repository_link)
728+ .properties(HashMap::from_iter(vec![(
729+ "description".to_string(),
730+ config.description.clone(),
731+ )]))
732+ .build()
733+ }));
734+ accm
735+ });
736+ Ok(WebFingerResponse::builder(resource.to_string())
737+ .links(links)
738+ .build())
739 }
740- Prefix::Group => Err(ResolverError::InvalidResource),
741- Prefix::Custom(_) => Err(ResolverError::InvalidResource),
742 }
743 }
744 }
745
746 pub async fn serve(
747- Extension(resolver): Extension<CResolver>,
748- Query(params): Query<FingerParams>,
749- ) -> Result<Json<Webfinger>, Error> {
750- match resolver.find(webfinger::Prefix::Acct, params.resource, ()) {
751- Ok(result) => Ok(Json(result)),
752- Err(e) => Err(Error(e)),
753- }
754+ Extension(resolver): Extension<Resolver>,
755+ request: WebFingerRequest,
756+ ) -> Result<WebFingerResponse, Json<Error>> {
757+ let mut response = resolver.resolve(&request.resource).await?;
758+ // filter response if rels is provided in query
759+ response
760+ .links
761+ .retain(|link| request.rels.is_empty() || request.rels.contains(&link.rel));
762+ Ok(response)
763 }
764 diff --git a/ayllu/src/web2/server.rs b/ayllu/src/web2/server.rs
765index eda1e10..f03b1f1 100644
766--- a/ayllu/src/web2/server.rs
767+++ b/ayllu/src/web2/server.rs
768 @@ -230,10 +230,7 @@ pub async fn serve(cfg: &Config) -> Result<(), Box<dyn Error>> {
769 )
770 .route(
771 "/.well-known/webfinger",
772- routing::get(finger::serve).layer(Extension(finger::CResolver {
773- domain: "todo",
774- config: Arc::new(cfg.clone()),
775- })),
776+ routing::get(finger::serve).layer(Extension(finger::Resolver::new(cfg))),
777 )
778 .nest(
779 "/static",
780 diff --git a/config.example.toml b/config.example.toml
781index 7066083..2607305 100644
782--- a/config.example.toml
783+++ b/config.example.toml
784 @@ -1,14 +1,8 @@
785 # site name used in various places across the instance
786 site_name = "🌄 Ayllu"
787
788- # string that is used in constructing URLs on the frontend, often different
789- # from a listen address when using a reverse proxy.
790- origin = "http://localhost:8080"
791-
792- # optional domain name to use for site matching when the code forge is being
793- # served behind a static website. This value will be loaded from the origin
794- # url by default
795- # domain = "fuubar.com"
796+ # A valid URI that identifies this server on the global internet
797+ origin = "localhost"
798
799 # sysadmin contact address
800 sysadmin = "admin@ayllu-forge.org"
801 diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml
802index b717f8f..fc90767 100644
803--- a/crates/git/Cargo.toml
804+++ b/crates/git/Cargo.toml
805 @@ -2,6 +2,7 @@
806 name = "ayllu_git"
807 version = "0.2.1"
808 edition = "2021"
809+ rust-version = "1.70.0"
810
811 [dependencies]
812 git2 = "0.19.0"
813 diff --git a/crates/git/src/lib.rs b/crates/git/src/lib.rs
814index 0d7a151..01d47ac 100644
815--- a/crates/git/src/lib.rs
816+++ b/crates/git/src/lib.rs
817 @@ -1,7 +1,7 @@
818 pub use config::{ChatKind, ChatLink, Config};
819 pub use error::{Error, ErrorKind};
820 pub use lite::{Blob, Branch, Commit, Kind, Stats, Tag, TreeEntry};
821- pub use scanner::{contains, git_dir, Scanner};
822+ pub use scanner::{collection, collection_and_name, contains, git_dir, name, Scanner};
823 pub use wrapper::{Selector, Wrapper};
824
825 mod clone;
826 diff --git a/crates/git/src/scanner.rs b/crates/git/src/scanner.rs
827index e7c961a..ba94cf3 100644
828--- a/crates/git/src/scanner.rs
829+++ b/crates/git/src/scanner.rs
830 @@ -41,6 +41,26 @@ pub fn contains(collections: Vec<&Path>, path: &Path) -> bool {
831 false
832 }
833
834+ /// Return the name of the repository path
835+ pub fn name(path: &Path) -> String {
836+ path.parent()
837+ .expect("path has no parent")
838+ .to_string_lossy()
839+ .to_string()
840+ }
841+
842+ /// Return the collection of the repository path
843+ pub fn collection(path: &Path) -> String {
844+ let parent = path.parent().expect("path has no parent");
845+ let file_name = parent.file_name().expect("parent has no file name");
846+ file_name.to_string_lossy().to_string()
847+ }
848+
849+ /// Return the collection and name of the repository path
850+ pub fn collection_and_name(path: &Path) -> (String, String) {
851+ (collection(path), name(path))
852+ }
853+
854 // wrap around a directory listing returning each result that looks like a
855 // git repository.
856 pub struct Scanner {
857 @@ -76,7 +96,6 @@ impl Iterator for Scanner {
858 }
859
860 #[cfg(test)]
861-
862 mod tests {
863
864 use crate::testing;
865 diff --git a/quipu/Cargo.toml b/quipu/Cargo.toml
866index b1d9f2e..cb3cd62 100644
867--- a/quipu/Cargo.toml
868+++ b/quipu/Cargo.toml
869 @@ -21,3 +21,4 @@ clap_complete = "4.5.33"
870 thiserror = "1.0.64"
871 serde = { version = "1.0.210", features = ["derive"] }
872 url = { version = "2.5.2", features = ["serde"] }
873+ webfinger-rs = { version = "0.0.12", features = ["reqwest"] }
874 diff --git a/quipu/src/client.rs b/quipu/src/client.rs
875new file mode 100644
876index 0000000..300e24e
877--- /dev/null
878+++ b/quipu/src/client.rs
879 @@ -0,0 +1,41 @@
880+ use reqwest::{Client, ClientBuilder};
881+ use url::Url;
882+
883+ use ayllu_api::ping::Status;
884+
885+ use crate::error::QuipuError;
886+
887+ const QUIPU_USER_AGENT: &str = "Quipu 0.0.0";
888+
889+ pub struct Quipu {
890+ endpoint: Url,
891+ client: Client,
892+ }
893+
894+ impl Quipu {
895+ pub fn new(endpoint: Url) -> Self {
896+ let client = ClientBuilder::new()
897+ .user_agent(QUIPU_USER_AGENT)
898+ .build()
899+ .unwrap();
900+ Self { endpoint, client }
901+ }
902+
903+ pub async fn ping(&self) -> Result<Status, QuipuError> {
904+ let response = self
905+ .client
906+ .get(self.endpoint.join("/0/ping")?)
907+ .send()
908+ .await?;
909+ Ok(response.json().await?)
910+ }
911+
912+ pub async fn finger(&self, resource: &str) -> Result<(), QuipuError> {
913+ let request = webfinger_rs::RequestBuilder::new(resource)?
914+ .host(self.endpoint.authority())
915+ .build();
916+ let result = request.execute_reqwest_with_client(&self.client).await?;
917+ println!("{}", result);
918+ Ok(())
919+ }
920+ }
921 diff --git a/quipu/src/client_rest.rs b/quipu/src/client_rest.rs
922deleted file mode 100644
923index aa49e95..0000000
924--- a/quipu/src/client_rest.rs
925+++ /dev/null
926 @@ -1,41 +0,0 @@
927- use reqwest::{Client, ClientBuilder};
928- use url::Url;
929-
930- use ayllu_api::{discovery::Collection, ping::Status};
931-
932- use crate::error::QuipuError;
933-
934- const QUIPU_USER_AGENT: &str = "Quipu 0.0.0";
935-
936- pub struct Quipu {
937- endpoint: Url,
938- client: Client,
939- }
940-
941- impl Quipu {
942- pub fn new(endpoint: Url) -> Self {
943- let client = ClientBuilder::new()
944- .user_agent(QUIPU_USER_AGENT)
945- .build()
946- .unwrap();
947- Self { endpoint, client }
948- }
949-
950- pub async fn get_index(&self) -> Result<Vec<Collection>, QuipuError> {
951- let response = self
952- .client
953- .get(self.endpoint.join("/0/index")?)
954- .send()
955- .await?;
956- Ok(response.json().await?)
957- }
958-
959- pub async fn ping(&self) -> Result<Status, QuipuError> {
960- let response = self
961- .client
962- .get(self.endpoint.join("/0/ping")?)
963- .send()
964- .await?;
965- Ok(response.json().await?)
966- }
967- }
968 diff --git a/quipu/src/error.rs b/quipu/src/error.rs
969index 6d1ef8a..95c986f 100644
970--- a/quipu/src/error.rs
971+++ b/quipu/src/error.rs
972 @@ -7,16 +7,18 @@ use ayllu_config::Error as ConfigError;
973
974 #[derive(Error, Debug)]
975 pub enum QuipuError {
976- #[error("IO Error")]
977+ #[error("IO Error: {0}")]
978 Disconnect(#[from] std::io::Error),
979- #[error("Configuration Error")]
980+ #[error("Configuration Error: {0}")]
981 Config(#[from] ConfigError),
982- #[error("Invalid Log Level")]
983+ #[error("Invalid Log Level: {0}")]
984 LogLevel(#[from] ParseLevelError),
985- #[error("Invalid Url")]
986+ #[error("Invalid Url: {0}")]
987 URLParsing(#[from] ParseUrlError),
988- #[error("Request Error")]
989+ #[error("Request Error: {0}")]
990 Request(#[from] ReqwestError),
991- #[error("Internal Quipu Error")]
992- Message(String)
993+ #[error("Internal Quipu Error: {0}")]
994+ Message(String),
995+ #[error("Finger Error: {0}")]
996+ Finger(#[from] webfinger_rs::Error),
997 }
998 diff --git a/quipu/src/main.rs b/quipu/src/main.rs
999index 6da65c6..c4bc76e 100644
1000--- a/quipu/src/main.rs
1001+++ b/quipu/src/main.rs
1002 @@ -2,14 +2,14 @@ use std::io::stdout;
1003 use std::path::PathBuf;
1004 use std::str::FromStr;
1005
1006- use clap::{arg, Command, CommandFactory, Parser, Subcommand, ValueEnum};
1007+ use clap::{arg, Command, CommandFactory, Parser, Subcommand};
1008 use clap_complete::{generate, Generator, Shell};
1009 use tracing::Level;
1010 use url::Url;
1011
1012 use ayllu_config::Reader;
1013
1014- mod client_rest;
1015+ mod client;
1016 mod config;
1017 mod error;
1018 mod output;
1019 @@ -17,7 +17,7 @@ mod output;
1020 #[derive(Parser)]
1021 #[command(author, version, about, long_about = None)]
1022 #[command(name = "quipu")]
1023- #[command(about = "Ayllu RPC Client")]
1024+ #[command(about = "Ayllu Client")]
1025 struct Cli {
1026 /// Path to your configuration file
1027 #[arg(short, long, value_name = "FILE")]
1028 @@ -38,27 +38,17 @@ struct Cli {
1029 command: Commands,
1030 }
1031
1032- #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
1033- /// a resource that exists on the remote server
1034- enum Resource {
1035- /// an amalgamation of collections and repositories
1036- Index,
1037- }
1038-
1039 #[derive(Subcommand, Debug, PartialEq)]
1040 enum Commands {
1041- /// generate autocomplete commands for common shells
1042+ /// Generate autocomplete commands for common shells
1043 Complete {
1044 #[arg(long)]
1045 shell: Shell,
1046 },
1047- /// get resources from a remote Ayllu server
1048- Get {
1049- /// resource to request from remote server
1050- resource: Resource,
1051- },
1052- /// verify the remote server is functional
1053+ /// Verify the remote server is functional
1054 Ping,
1055+ /// Perform a webfinger query against the Ayllu instance
1056+ Finger { resource: String },
1057 }
1058
1059 fn print_completions<G: Generator>(gen: G, cmd: &mut Command) {
1060 @@ -111,19 +101,20 @@ async fn main() -> Result<(), error::QuipuError> {
1061 print_completions(shell, &mut cmd);
1062 Ok(())
1063 }
1064- Commands::Get { resource: _ } => {
1065+ Commands::Finger { resource } => {
1066 let instance = get_instance(&cfg, cli.url, cli.instance)?;
1067- let client = client_rest::Quipu::new(instance.url);
1068- let collections = client.get_index().await?;
1069- output::pretty(output::Resource::Collections(collections))?;
1070+ let client = client::Quipu::new(instance.url);
1071+ client.finger(&resource).await?;
1072 Ok(())
1073 }
1074 Commands::Ping => {
1075 let instance = get_instance(&cfg, cli.url, cli.instance)?;
1076- let client = client_rest::Quipu::new(instance.url);
1077+ let client = client::Quipu::new(instance.url);
1078 let status = client.ping().await?;
1079 if !status.ok {
1080- return Err(error::QuipuError::Message("server is not ready".to_string()));
1081+ return Err(error::QuipuError::Message(
1082+ "server is not ready".to_string(),
1083+ ));
1084 }
1085 Ok(())
1086 }