Author:
Hash:
Timestamp:
+231 -65 +/-8 browse
Kevin Schoon [me@kevinschoon.com]
1144320ef666f595bb3649993f03b4a0c4de78b7
Sun, 13 Jul 2025 21:04:16 +0000 (4 months ago)
| 1 | diff --git a/Cargo.lock b/Cargo.lock |
| 2 | index 73ada26..feb3abb 100644 |
| 3 | --- a/Cargo.lock |
| 4 | +++ b/Cargo.lock |
| 5 | @@ -339,6 +339,7 @@ dependencies = [ |
| 6 | "log", |
| 7 | "mime", |
| 8 | "mime_guess", |
| 9 | + "nix", |
| 10 | "openssh-keys", |
| 11 | "plotters", |
| 12 | "quick-xml 0.38.0", |
| 13 | @@ -549,6 +550,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 14 | checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" |
| 15 | |
| 16 | [[package]] |
| 17 | + name = "cfg_aliases" |
| 18 | + version = "0.2.1" |
| 19 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 20 | + checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" |
| 21 | + |
| 22 | + [[package]] |
| 23 | name = "chrono" |
| 24 | version = "0.4.41" |
| 25 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 26 | @@ -2159,6 +2166,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" |
| 27 | checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" |
| 28 | |
| 29 | [[package]] |
| 30 | + name = "nix" |
| 31 | + version = "0.30.1" |
| 32 | + source = "registry+https://github.com/rust-lang/crates.io-index" |
| 33 | + checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" |
| 34 | + dependencies = [ |
| 35 | + "bitflags 2.9.1", |
| 36 | + "cfg-if", |
| 37 | + "cfg_aliases", |
| 38 | + "libc", |
| 39 | + ] |
| 40 | + |
| 41 | + [[package]] |
| 42 | name = "nu-ansi-term" |
| 43 | version = "0.46.0" |
| 44 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 45 | diff --git a/ayllu/Cargo.toml b/ayllu/Cargo.toml |
| 46 | index 98f3fe2..8d7b6f2 100644 |
| 47 | --- a/ayllu/Cargo.toml |
| 48 | +++ b/ayllu/Cargo.toml |
| 49 | @@ -59,6 +59,7 @@ webfinger-rs = { version = "0.0.13", features = ["axum"] } |
| 50 | quick-xml = { version = "0.38.0", features = ["encoding"] } |
| 51 | askama = { version = "0.14.0" } |
| 52 | openssh-keys = "0.6.4" |
| 53 | + nix = { version = "0.30.1", default-features = false, features = ["user"] } |
| 54 | |
| 55 | [build-dependencies] |
| 56 | cc="*" |
| 57 | diff --git a/ayllu/src/config.rs b/ayllu/src/config.rs |
| 58 | index d52b9fa..0bba471 100644 |
| 59 | --- a/ayllu/src/config.rs |
| 60 | +++ b/ayllu/src/config.rs |
| 61 | @@ -1,9 +1,10 @@ |
| 62 | - use std::collections::HashMap; |
| 63 | - use std::error::Error; |
| 64 | + use std::error::Error as StdError; |
| 65 | use std::fs::metadata; |
| 66 | + use std::path::Path; |
| 67 | + use std::{collections::HashMap, path::PathBuf}; |
| 68 | use url::Url; |
| 69 | |
| 70 | - use ayllu_config::Configurable; |
| 71 | + use ayllu_config::{Configurable, Reader}; |
| 72 | |
| 73 | use serde::{Deserialize, Serialize}; |
| 74 | |
| 75 | @@ -11,9 +12,7 @@ pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml"); |
| 76 | |
| 77 | // names that cannot be used in collections because they conflict with certain |
| 78 | // internal urls within the service. |
| 79 | - const BANNED_COLLECTION_NAMES: &[&str] = &[ |
| 80 | - "authors", "about", "api", "browse", "config", "rss", "static", |
| 81 | - ]; |
| 82 | + const BANNED_COLLECTION_NAMES: &[&str] = &["about", "api", "browse", "config", "rss", "static"]; |
| 83 | |
| 84 | const DEFAULT_ROBOTS_TXT: &str = r#" |
| 85 | User-agent: * |
| 86 | @@ -60,6 +59,16 @@ const DEFAULT_TREE_SITTER_KEYWORDS: &[&str] = &[ |
| 87 | "removal", |
| 88 | ]; |
| 89 | |
| 90 | + #[derive(thiserror::Error, Debug)] |
| 91 | + pub enum Error { |
| 92 | + #[error("Nix: {0}")] |
| 93 | + Nix(#[from] nix::Error), |
| 94 | + #[error("User {0} was not found")] |
| 95 | + UserNotFound(String), |
| 96 | + #[error("User {0} has invalid configuration")] |
| 97 | + InvalidConfig(String), |
| 98 | + } |
| 99 | + |
| 100 | fn default_false() -> bool { |
| 101 | false |
| 102 | } |
| 103 | @@ -227,27 +236,106 @@ pub struct Lfs { |
| 104 | pub url_template: String, |
| 105 | } |
| 106 | |
| 107 | - #[derive(Deserialize, Serialize, Clone, Debug, Default)] |
| 108 | - pub struct Link { |
| 109 | - pub rel: String, |
| 110 | - pub href: Option<String>, |
| 111 | - pub template: Option<String>, |
| 112 | - pub mime_template: Option<String>, |
| 113 | - } |
| 114 | + pub mod identity { |
| 115 | + use super::*; |
| 116 | |
| 117 | - #[derive(Deserialize, Serialize, Clone, Debug, Default)] |
| 118 | - pub struct UrlLink { |
| 119 | - pub url: String, |
| 120 | - pub mime_type: Option<String>, |
| 121 | - } |
| 122 | + #[derive(Deserialize, Serialize, Clone, Debug, Default)] |
| 123 | + pub struct Link { |
| 124 | + pub rel: String, |
| 125 | + pub href: Option<String>, |
| 126 | + pub template: Option<String>, |
| 127 | + pub mime_template: Option<String>, |
| 128 | + } |
| 129 | |
| 130 | - #[derive(Deserialize, Serialize, Clone, Debug, Default)] |
| 131 | - pub struct Author { |
| 132 | - pub email: String, |
| 133 | - pub tagline: Option<String>, |
| 134 | - pub avatar: Option<UrlLink>, |
| 135 | - pub profiles: Option<Vec<UrlLink>>, |
| 136 | - pub keys: Option<Vec<PublicKey>>, |
| 137 | + #[derive(Deserialize, Serialize, Clone, Debug, Default)] |
| 138 | + pub struct UrlLink { |
| 139 | + pub url: String, |
| 140 | + pub mime_type: Option<String>, |
| 141 | + } |
| 142 | + |
| 143 | + #[derive(Deserialize, Serialize, Clone, Debug, Default)] |
| 144 | + pub struct Profile { |
| 145 | + pub email: String, |
| 146 | + pub tagline: Option<String>, |
| 147 | + pub avatar: Option<UrlLink>, |
| 148 | + pub profiles: Option<Vec<UrlLink>>, |
| 149 | + pub keys: Option<Vec<PublicKey>>, |
| 150 | + } |
| 151 | + |
| 152 | + /// An identity that has been verified and loaded from the host |
| 153 | + #[derive(Clone, Debug)] |
| 154 | + #[allow(dead_code)] |
| 155 | + pub struct Resolved { |
| 156 | + // user configuration in ~/.config/ayllu/config.toml (me) |
| 157 | + pub profile: Profile, |
| 158 | + pub collections: Vec<Collection>, |
| 159 | + // system user account |
| 160 | + pub user: nix::unistd::User, |
| 161 | + // fully qualified repositories path |
| 162 | + pub repositories: PathBuf, |
| 163 | + } |
| 164 | + |
| 165 | + #[derive(Deserialize, Serialize, Clone, Debug, Default)] |
| 166 | + pub struct Identity { |
| 167 | + pub username: String, |
| 168 | + #[serde(default = "Identity::default_repositories_path")] |
| 169 | + pub repositories_path: PathBuf, |
| 170 | + } |
| 171 | + |
| 172 | + impl Identity { |
| 173 | + fn default_repositories_path() -> PathBuf { |
| 174 | + Path::new("repos").to_path_buf() |
| 175 | + } |
| 176 | + } |
| 177 | + |
| 178 | + /// Configuration exposed to users |
| 179 | + #[derive(Deserialize, Serialize, Clone, Debug)] |
| 180 | + pub struct Config { |
| 181 | + #[serde(rename = "me")] |
| 182 | + pub self_: Option<Profile>, |
| 183 | + #[serde(default = "Vec::new")] |
| 184 | + pub collections: Vec<Collection>, |
| 185 | + } |
| 186 | + |
| 187 | + impl Configurable for Config { |
| 188 | + fn validate(&mut self) -> Result<(), Box<dyn StdError>> { |
| 189 | + Ok(()) |
| 190 | + } |
| 191 | + } |
| 192 | + |
| 193 | + pub(crate) fn resolve(identity: &Identity) -> Result<Resolved, Error> { |
| 194 | + let name = &identity.username; |
| 195 | + match nix::unistd::User::from_name(&identity.username)? { |
| 196 | + Some(user) => { |
| 197 | + match Reader::<Config>::load(Some( |
| 198 | + user.dir.join(".config/ayllu/config.toml").as_path(), |
| 199 | + )) { |
| 200 | + Ok(user_config) => { |
| 201 | + match user_config.self_ { |
| 202 | + Some(profile) => Ok(Resolved { |
| 203 | + profile, |
| 204 | + collections: user_config.collections, |
| 205 | + repositories: user.dir.join(&identity.repositories_path), |
| 206 | + user, |
| 207 | + }), |
| 208 | + None => { |
| 209 | + // If the user did not specify their identity skip |
| 210 | + // them entirely. |
| 211 | + eprintln!("User {name} has no identity configuration, skipping",); |
| 212 | + Err(Error::InvalidConfig(name.to_string())) |
| 213 | + } |
| 214 | + } |
| 215 | + } |
| 216 | + Err(err) => { |
| 217 | + // invalid user config will just be ignored |
| 218 | + eprintln!("Failed to load user {name} configuration {err}",); |
| 219 | + Err(Error::InvalidConfig(name.to_string())) |
| 220 | + } |
| 221 | + } |
| 222 | + } |
| 223 | + None => Err(Error::UserNotFound(name.to_string())), |
| 224 | + } |
| 225 | + } |
| 226 | } |
| 227 | |
| 228 | #[derive(Deserialize, Serialize, Clone, Debug)] |
| 229 | @@ -307,11 +395,13 @@ pub struct Config { |
| 230 | pub tree_sitter: Option<TreeSitter>, |
| 231 | pub languages: Option<Languages>, |
| 232 | pub lfs: Option<Lfs>, |
| 233 | - pub authors: Option<Vec<Author>>, |
| 234 | + pub identities: Option<Vec<identity::Identity>>, |
| 235 | + #[serde(skip)] |
| 236 | + profiles: Vec<identity::Profile>, |
| 237 | } |
| 238 | |
| 239 | impl Configurable for Config { |
| 240 | - fn validate(&mut self) -> Result<(), Box<dyn Error>> { |
| 241 | + fn validate(&mut self) -> Result<(), Box<dyn StdError>> { |
| 242 | let parsed_url = Url::parse(&self.origin)?; |
| 243 | self.domain = parsed_url.domain().map(|domain| domain.to_string()); |
| 244 | |
| 245 | @@ -346,11 +436,26 @@ impl Configurable for Config { |
| 246 | ); |
| 247 | } |
| 248 | |
| 249 | + // resolve user collections |
| 250 | + if let Some(identities) = &self.identities { |
| 251 | + self.collections.extend(identities.iter().try_fold( |
| 252 | + Vec::new(), |
| 253 | + |mut accm, ident| { |
| 254 | + let resolved = identity::resolve(ident)?; |
| 255 | + accm.extend(resolved.collections); |
| 256 | + Ok::<Vec<Collection>, Error>(accm) |
| 257 | + }, |
| 258 | + )?); |
| 259 | + } |
| 260 | Ok(()) |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | impl Config { |
| 265 | + pub fn profiles(&self) -> &[identity::Profile] { |
| 266 | + self.profiles.as_slice() |
| 267 | + } |
| 268 | + |
| 269 | fn default_robots_txt() -> String { |
| 270 | String::from(DEFAULT_ROBOTS_TXT.trim_start()) |
| 271 | } |
| 272 | diff --git a/ayllu/src/web2/middleware/sites.rs b/ayllu/src/web2/middleware/sites.rs |
| 273 | index 664befe..65e1459 100644 |
| 274 | --- a/ayllu/src/web2/middleware/sites.rs |
| 275 | +++ b/ayllu/src/web2/middleware/sites.rs |
| 276 | @@ -20,7 +20,7 @@ pub type State = extract::State<(Config, Vec<(String, (String, String))>)>; |
| 277 | pub type Sites = Vec<(String, (String, String))>; |
| 278 | |
| 279 | // array of all the repositories in each collection |
| 280 | - fn repositories(collections: Vec<Collection>) -> Result<Vec<PathBuf>, Error> { |
| 281 | + fn repositories(collections: &[Collection]) -> Result<Vec<PathBuf>, Error> { |
| 282 | let mut paths: Vec<PathBuf> = Vec::new(); |
| 283 | for collection in collections.iter() { |
| 284 | let collection_path = Path::new(&collection.path); |
| 285 | @@ -37,7 +37,7 @@ fn repositories(collections: Vec<Collection>) -> Result<Vec<PathBuf>, Error> { |
| 286 | |
| 287 | pub fn sites(cfg: &Config) -> Result<Sites, Error> { |
| 288 | let mut sites: Vec<(String, (String, String))> = Vec::new(); |
| 289 | - for path in repositories(cfg.collections.clone())? { |
| 290 | + for path in repositories(&cfg.collections)? { |
| 291 | let repository = Repository::new(path.as_path())?; |
| 292 | let repo_config = repository.config()?; |
| 293 | if repo_config.sites.header.is_some() { |
| 294 | diff --git a/ayllu/src/web2/routes/finger.rs b/ayllu/src/web2/routes/finger.rs |
| 295 | index 39674bb..6e79724 100644 |
| 296 | --- a/ayllu/src/web2/routes/finger.rs |
| 297 | +++ b/ayllu/src/web2/routes/finger.rs |
| 298 | @@ -6,7 +6,7 @@ use url::Url; |
| 299 | use webfinger_rs::{Link, WebFingerRequest, WebFingerResponse}; |
| 300 | |
| 301 | use crate::{ |
| 302 | - config::{Author, Collection, Config}, |
| 303 | + config::{identity::Profile, Collection, Config}, |
| 304 | web2::error::Error, |
| 305 | }; |
| 306 | use ayllu_git::{name, Error as GitError, Wrapper as Repository}; |
| 307 | @@ -47,7 +47,7 @@ fn get_all(collections: &[Collection]) -> Result<Vec<Pair>, ayllu_git::Error> { |
| 308 | |
| 309 | #[derive(Clone, Debug)] |
| 310 | enum Resource { |
| 311 | - Acct(Author), |
| 312 | + Acct(Profile), |
| 313 | Collection((Collection, Vec<String>)), |
| 314 | Repository((Collection, PathBuf)), |
| 315 | Index, |
| 316 | @@ -58,25 +58,22 @@ enum Resource { |
| 317 | #[derive(Clone)] |
| 318 | pub struct Resolver { |
| 319 | pub collections: Vec<Collection>, |
| 320 | - pub authors: HashMap<String, Author>, |
| 321 | + pub identities: HashMap<String, Profile>, |
| 322 | origin: Url, |
| 323 | } |
| 324 | |
| 325 | impl Resolver { |
| 326 | pub fn new(config: &Config) -> Self { |
| 327 | - let authors = if let Some(authors) = config.authors.as_ref() { |
| 328 | - HashMap::from_iter( |
| 329 | - authors |
| 330 | - .iter() |
| 331 | - .map(|author| (author.email.clone(), author.clone())), |
| 332 | - ) |
| 333 | - } else { |
| 334 | - HashMap::default() |
| 335 | - }; |
| 336 | + let identities = HashMap::from_iter( |
| 337 | + config |
| 338 | + .profiles() |
| 339 | + .iter() |
| 340 | + .map(|profile| (profile.email.clone(), profile.clone())), |
| 341 | + ); |
| 342 | Resolver { |
| 343 | origin: Url::parse(&config.origin).expect("Origin URL is invalid"), |
| 344 | collections: config.collections.clone(), |
| 345 | - authors, |
| 346 | + identities, |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | @@ -92,7 +89,7 @@ impl Resolver { |
| 351 | None => None, |
| 352 | }; |
| 353 | if let Some(account) = account { |
| 354 | - if let Some(author) = self.authors.get(account) { |
| 355 | + if let Some(author) = self.identities.get(account) { |
| 356 | return Ok(Resource::Acct(author.clone())); |
| 357 | } else { |
| 358 | return Err::<Resource, Error>(Error::NotFound(resource.to_string())); |
| 359 | diff --git a/ayllu/src/web2/routes/rest/discovery.rs b/ayllu/src/web2/routes/rest/discovery.rs |
| 360 | index 2bf6dd4..f825d04 100644 |
| 361 | --- a/ayllu/src/web2/routes/rest/discovery.rs |
| 362 | +++ b/ayllu/src/web2/routes/rest/discovery.rs |
| 363 | @@ -8,7 +8,7 @@ use ayllu_git::{Scanner, Wrapper}; |
| 364 | |
| 365 | pub async fn serve(Extension(cfg): Extension<Config>) -> Result<Json<Vec<Collection>>, Error> { |
| 366 | let mut collections: Vec<Collection> = Vec::new(); |
| 367 | - for config in cfg.collections { |
| 368 | + for config in cfg.collections.iter() { |
| 369 | if config.hidden.is_some_and(|hidden| hidden) { |
| 370 | continue; |
| 371 | } |
| 372 | diff --git a/ayllu/src/web2/routes/rss.rs b/ayllu/src/web2/routes/rss.rs |
| 373 | index 98891e2..f742768 100644 |
| 374 | --- a/ayllu/src/web2/routes/rss.rs |
| 375 | +++ b/ayllu/src/web2/routes/rss.rs |
| 376 | @@ -123,7 +123,7 @@ struct Builder { |
| 377 | impl Builder { |
| 378 | fn scan_repositories( |
| 379 | &self, |
| 380 | - collections: Vec<Collection>, |
| 381 | + collections: &[Collection], |
| 382 | ) -> Result<Vec<(PathBuf, String)>, Error> { |
| 383 | let mut entries: Vec<(PathBuf, String)> = Vec::new(); |
| 384 | for collection in collections.iter() { |
| 385 | @@ -288,13 +288,13 @@ impl Builder { |
| 386 | |
| 387 | pub async fn feed_firehose(Extension(cfg): Extension<Config>) -> Result<Response, Error> { |
| 388 | let builder = Builder { |
| 389 | - origin: cfg.origin, |
| 390 | + origin: cfg.origin.clone(), |
| 391 | title: String::from("Firehose"), |
| 392 | time_to_live: cfg.rss_time_to_live.map(Duration::seconds), |
| 393 | current_time: OffsetDateTime::now_utc(), |
| 394 | }; |
| 395 | let channel = builder.firehose( |
| 396 | - builder.scan_repositories(cfg.collections)?, |
| 397 | + builder.scan_repositories(&cfg.collections)?, |
| 398 | Duration::days(7), |
| 399 | )?; |
| 400 | let response = Response::builder() |
| 401 | @@ -306,13 +306,13 @@ pub async fn feed_firehose(Extension(cfg): Extension<Config>) -> Result<Response |
| 402 | |
| 403 | pub async fn feed_1d(Extension(cfg): Extension<Config>) -> Result<Response, Error> { |
| 404 | let builder = Builder { |
| 405 | - origin: cfg.origin, |
| 406 | + origin: cfg.origin.clone(), |
| 407 | title: String::from("Daily Update Summary"), |
| 408 | time_to_live: cfg.rss_time_to_live.map(Duration::seconds), |
| 409 | current_time: OffsetDateTime::now_utc(), |
| 410 | }; |
| 411 | let channel = builder.summary( |
| 412 | - builder.scan_repositories(cfg.collections)?, |
| 413 | + builder.scan_repositories(&cfg.collections)?, |
| 414 | Timeframe::Daily, |
| 415 | )?; |
| 416 | let response = Response::builder() |
| 417 | @@ -324,13 +324,13 @@ pub async fn feed_1d(Extension(cfg): Extension<Config>) -> Result<Response, Erro |
| 418 | |
| 419 | pub async fn feed_1w(Extension(cfg): Extension<Config>) -> Result<Response, Error> { |
| 420 | let builder = Builder { |
| 421 | - origin: cfg.origin, |
| 422 | + origin: cfg.origin.clone(), |
| 423 | title: String::from("Weekly Update Summary"), |
| 424 | time_to_live: cfg.rss_time_to_live.map(Duration::seconds), |
| 425 | current_time: OffsetDateTime::now_utc(), |
| 426 | }; |
| 427 | let channel = builder.summary( |
| 428 | - builder.scan_repositories(cfg.collections)?, |
| 429 | + builder.scan_repositories(&cfg.collections)?, |
| 430 | Timeframe::Weekly, |
| 431 | )?; |
| 432 | let response = Response::builder() |
| 433 | @@ -342,13 +342,13 @@ pub async fn feed_1w(Extension(cfg): Extension<Config>) -> Result<Response, Erro |
| 434 | |
| 435 | pub async fn feed_1m(Extension(cfg): Extension<Config>) -> Result<Response, Error> { |
| 436 | let builder = Builder { |
| 437 | - origin: cfg.origin, |
| 438 | + origin: cfg.origin.clone(), |
| 439 | title: String::from("Monthly Update Summary"), |
| 440 | time_to_live: cfg.rss_time_to_live.map(Duration::seconds), |
| 441 | current_time: OffsetDateTime::now_utc(), |
| 442 | }; |
| 443 | let channel = builder.summary( |
| 444 | - builder.scan_repositories(cfg.collections)?, |
| 445 | + builder.scan_repositories(&cfg.collections)?, |
| 446 | Timeframe::Monthly, |
| 447 | )?; |
| 448 | let response = Response::builder() |
| 449 | diff --git a/config.example.toml b/config.example.toml |
| 450 | index df6ede3..146046f 100644 |
| 451 | --- a/config.example.toml |
| 452 | +++ b/config.example.toml |
| 453 | @@ -70,19 +70,63 @@ export_all = false |
| 454 | # job for every commit in the repository. |
| 455 | timeout = 1800 |
| 456 | |
| 457 | + ## |
| 458 | + ## Identities |
| 459 | + ## |
| 460 | + |
| 461 | + # Users are associated with "Identities" in Ayllu where each identity refers |
| 462 | + # to a particular code author and system account on a given server. If the |
| 463 | + # Ayllu HTTP server configured with [[ identities ]] then the deligated account |
| 464 | + # may maintain it's own configuration and collection of repositories. |
| 465 | + |
| 466 | + # [[identities]] |
| 467 | + # username = "demo" |
| 468 | + # # optional, relative to the user's home directory |
| 469 | + # repositories_path = "repos" |
| 470 | + |
| 471 | + # A deligated account may also manage their own identity information by |
| 472 | + # creating another Ayllu configuration file at ~/.config/ayllu/config.toml. |
| 473 | + # A deligated account may not further deligate other identities and must |
| 474 | + # include a "me" section in their configuration. |
| 475 | + # [me] |
| 476 | + # # E-mail address of the author |
| 477 | + # email = "example@example.org" |
| 478 | + # # Optional "tagline" associated with the author |
| 479 | + # tagline = "Programmer interested free software" |
| 480 | + # # Optional link to an avatar containing an image representing the author |
| 481 | + # avatar = { url = "https://example.org/avatar.png", mime_type = "image/png" } |
| 482 | + # # Array of personal websites, social media, etc. associated with the author |
| 483 | + # profiles = [ |
| 484 | + # { url = "https://example.com", mime_type = "text/html"}, |
| 485 | + # { url = "https://example.org/@example", mime_type = "text/html"}, |
| 486 | + # ] |
| 487 | + |
| 488 | + # Deligated accounts may specify their own collections but they must be |
| 489 | + # relative to the global identities.repositories_path set by the site |
| 490 | + # administrator. Note that the repository path MUST be at least readable by |
| 491 | + # the system ayllu account such that the web server can serve the repository |
| 492 | + # contents. |
| 493 | + # [[ collection ]] |
| 494 | + # name = "my-code" |
| 495 | + # description = "random software" |
| 496 | + # path = "./projects/fuu" |
| 497 | + |
| 498 | + # A configuration that specifies both [[ identities ]] and [ me ] is considered |
| 499 | + # invalid and will prevent the server from starting up. |
| 500 | + |
| 501 | # List of authors associated with this site |
| 502 | - [[authors]] |
| 503 | - # E-mail address of the author |
| 504 | - email = "example@example.org" |
| 505 | - # Optional "tagline" associated with the author |
| 506 | - tagline = "Programmer interested free software" |
| 507 | - # Optional link to an avatar containing an image representing the author |
| 508 | - avatar = { url = "https://example.org/avatar.png", mime_type = "image/png" } |
| 509 | - # Array of personal websites, social media, etc. associated with the author |
| 510 | - profiles = [ |
| 511 | - { url = "https://example.com", mime_type = "text/html"}, |
| 512 | - { url = "https://example.org/@example", mime_type = "text/html"}, |
| 513 | - ] |
| 514 | + # [[authors]] |
| 515 | + # # E-mail address of the author |
| 516 | + # email = "example@example.org" |
| 517 | + # # Optional "tagline" associated with the author |
| 518 | + # tagline = "Programmer interested free software" |
| 519 | + # # Optional link to an avatar containing an image representing the author |
| 520 | + # avatar = { url = "https://example.org/avatar.png", mime_type = "image/png" } |
| 521 | + # # Array of personal websites, social media, etc. associated with the author |
| 522 | + # profiles = [ |
| 523 | + # { url = "https://example.com", mime_type = "text/html"}, |
| 524 | + # { url = "https://example.org/@example", mime_type = "text/html"}, |
| 525 | + # ] |
| 526 | |
| 527 | [http] |
| 528 | # interface and port to listen on |