fabrica

hanna/fabrica

19336 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! The web UI: server-rendered `maud` templates enhanced with htmx.
6//!
7//! [`AppState`] is the shared handle every route clones cheaply. [`serve`] builds
8//! the router, wires the tower-http middleware, and runs the server with graceful
9//! shutdown and a `SIGHUP` theme re-scan. Handlers live in [`pages`] (HTML) and
10//! [`serve`](mod@serve_assets) (assets/health). Every interactive element works
11//! without JavaScript; htmx only enhances.
12
13mod activity;
14mod admin;
15mod assets;
16mod avatar;
17mod diff;
18mod error;
19mod git_http;
20mod groups;
21mod icons;
22mod issues;
23mod languages;
24mod layout;
25mod lfs;
26mod license;
27mod markdown;
28mod notifications;
29mod pages;
30mod pulls;
31mod releases;
32mod repo;
33mod search;
34#[path = "serve.rs"]
35mod serve_assets;
36mod session;
37#[cfg(test)]
38mod tests;
39
40use std::net::SocketAddr;
41use std::sync::{Arc, PoisonError, RwLock, RwLockReadGuard};
42
43use axum::Router;
44use axum::http::header::HeaderName;
45use axum::routing::{get, post};
46use config::Config;
47use mail::Mailer;
48use store::Store;
49use tower::ServiceBuilder;
50use tower_http::compression::CompressionLayer;
51use tower_http::request_id::{MakeRequestUuid, PropagateRequestIdLayer, SetRequestIdLayer};
52use tower_http::trace::TraceLayer;
53
54use crate::assets::Assets;
55
56pub use crate::error::{AppError, AppResult};
57
58/// An error building or running the web server.
59#[derive(Debug, thiserror::Error)]
60pub enum WebError {
61 /// The mailer could not be constructed from configuration.
62 #[error("mail configuration error: {0}")]
63 Mail(String),
64 /// The upload storage backend could not be constructed from configuration.
65 #[error("storage configuration error: {0}")]
66 Blob(String),
67 /// The server socket failed to bind or serve.
68 #[error(transparent)]
69 Io(#[from] std::io::Error),
70}
71
72/// The shared application state, cloned into every request. All fields are cheap
73/// to clone (reference-counted or pooled).
74#[derive(Clone)]
75pub struct AppState {
76 /// The effective configuration.
77 pub config: Arc<Config>,
78 /// The database handle.
79 pub store: Store,
80 /// Upload storage (avatars, release assets, LFS) — local disk or S3.
81 pub blobs: blob::BlobStore,
82 /// The configured mailer (invite emails).
83 pub mailer: Arc<Mailer>,
84 /// The instance signing secret (API tokens, future session signing).
85 pub secret: Arc<String>,
86 /// Commit/tag signature-verification cache (verification is expensive; list
87 /// views reuse results, bounded and TTL'd).
88 pub signatures: git::SignatureCache,
89 /// Theme/asset registry, behind a lock so `SIGHUP` can re-scan it.
90 assets: Arc<RwLock<Assets>>,
91 /// Admin-set instance settings that override the config file, cached in
92 /// memory and refreshed via [`AppState::reload_settings`].
93 settings: Arc<RwLock<std::collections::HashMap<String, String>>>,
94}
95
96/// Keys for the admin-overridable instance settings.
97pub(crate) mod setting_keys {
98 /// Instance display name.
99 pub const NAME: &str = "instance.name";
100 /// Instance description.
101 pub const DESCRIPTION: &str = "instance.description";
102 /// Allow web self-registration.
103 pub const ALLOW_REGISTRATION: &str = "instance.allow_registration";
104 /// Allow anonymous browse/clone.
105 pub const ALLOW_ANONYMOUS: &str = "instance.allow_anonymous";
106 /// Default visibility for new repositories.
107 pub const DEFAULT_VISIBILITY: &str = "instance.default_visibility";
108}
109
110impl AppState {
111 /// Build the shared state from configuration and an open store.
112 ///
113 /// # Errors
114 ///
115 /// Returns [`WebError::Mail`] if the mailer cannot be constructed, or
116 /// [`WebError::Blob`] if the upload storage backend is misconfigured.
117 pub fn build(config: Arc<Config>, store: Store, secret: String) -> Result<Self, WebError> {
118 let mailer = Mailer::build(&config.mail).map_err(|e| WebError::Mail(e.to_string()))?;
119 let blobs = blob::BlobStore::from_config(&config.storage)
120 .map_err(|e| WebError::Blob(e.to_string()))?;
121 let assets = Assets::new(config.ui.assets_dir.clone(), config.ui.themes_dir.clone());
122 Ok(Self {
123 config,
124 store,
125 blobs,
126 mailer: Arc::new(mailer),
127 secret: Arc::new(secret),
128 signatures: git::SignatureCache::new(4096),
129 assets: Arc::new(RwLock::new(assets)),
130 settings: Arc::new(RwLock::new(std::collections::HashMap::new())),
131 })
132 }
133
134 /// Refresh the cached instance settings from the database. Call at startup and
135 /// after any admin settings change.
136 pub async fn reload_settings(&self) {
137 if let Ok(map) = self.store.all_settings().await {
138 *self
139 .settings
140 .write()
141 .unwrap_or_else(PoisonError::into_inner) = map;
142 }
143 }
144
145 /// A settings override for `key`, if an admin has set one.
146 fn setting(&self, key: &str) -> Option<String> {
147 self.settings
148 .read()
149 .unwrap_or_else(PoisonError::into_inner)
150 .get(key)
151 .cloned()
152 }
153
154 /// A string setting: admin override, else `fallback` (the config-file value).
155 pub(crate) fn setting_str(&self, key: &str, fallback: &str) -> String {
156 self.setting(key).unwrap_or_else(|| fallback.to_string())
157 }
158
159 /// A boolean setting: admin override (`"true"`/`"false"`), else `fallback`.
160 pub(crate) fn setting_bool(&self, key: &str, fallback: bool) -> bool {
161 self.setting(key).map_or(fallback, |v| v == "true")
162 }
163
164 /// Whether web self-registration is currently allowed (override or config).
165 pub(crate) fn allow_registration(&self) -> bool {
166 self.setting_bool(
167 setting_keys::ALLOW_REGISTRATION,
168 self.config.instance.allow_registration,
169 )
170 }
171
172 /// Whether anonymous browse/clone is currently allowed (override or config).
173 pub(crate) fn allow_anonymous(&self) -> bool {
174 self.setting_bool(
175 setting_keys::ALLOW_ANONYMOUS,
176 self.config.instance.allow_anonymous,
177 )
178 }
179
180 /// The effective instance display name (override or config).
181 pub(crate) fn instance_name(&self) -> String {
182 self.setting_str(setting_keys::NAME, &self.config.instance.name)
183 }
184
185 /// The effective default-visibility token for new repos (override or config).
186 pub(crate) fn default_visibility_token(&self) -> String {
187 self.setting_str(
188 setting_keys::DEFAULT_VISIBILITY,
189 self.config.instance.default_visibility.as_token(),
190 )
191 }
192
193 /// A read guard over the asset registry, recovering from a poisoned lock.
194 fn assets(&self) -> RwLockReadGuard<'_, Assets> {
195 self.assets.read().unwrap_or_else(PoisonError::into_inner)
196 }
197
198 /// Re-scan the themes directory (invoked on `SIGHUP`).
199 fn rescan_assets(&self) {
200 let mut guard = self.assets.write().unwrap_or_else(PoisonError::into_inner);
201 guard.rescan();
202 }
203}
204
205/// The current time in Unix milliseconds UTC.
206pub(crate) fn now_ms() -> i64 {
207 use std::time::{SystemTime, UNIX_EPOCH};
208 SystemTime::now()
209 .duration_since(UNIX_EPOCH)
210 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
211}
212
213/// Build the application router with all middleware wired.
214#[allow(clippy::too_many_lines)] // A flat route table; splitting it hurts readability.
215pub fn build_router(state: AppState) -> Router {
216 let x_request_id = HeaderName::from_static("x-request-id");
217
218 // The JSON API is a self-contained, fully-stated router nested under /api/v1.
219 let api = api::router(
220 state.store.clone(),
221 Arc::clone(&state.config),
222 (*state.secret).clone(),
223 );
224
225 Router::new()
226 .nest_service("/api/v1", api)
227 .route("/", get(pages::home))
228 .route(
229 "/new",
230 get(pages::new_repo_form).post(pages::new_repo_submit),
231 )
232 .route("/new/issue", get(pages::new_issue_chooser))
233 .route("/new/pull", get(pages::new_pull_chooser))
234 .route("/new/group", get(groups::new_form).post(groups::new_submit))
235 .route("/explore", get(pages::explore))
236 .route("/explore/users", get(pages::explore_users))
237 .route("/login", get(pages::login_form).post(pages::login_submit))
238 .route(
239 "/register",
240 get(pages::register_form).post(pages::register_submit),
241 )
242 .route("/register/{token}", get(pages::register_invite_form))
243 .route("/logout", post(pages::logout))
244 .route(
245 "/invite/{token}",
246 get(pages::invite_form).post(pages::invite_submit),
247 )
248 .route("/verify-email/{token}", get(pages::verify_email))
249 .route("/search", get(search::global))
250 .route(
251 "/settings",
252 get(pages::settings).post(pages::settings_submit),
253 )
254 .route("/settings/account", get(pages::settings_account))
255 .route("/settings/username", post(pages::account_username))
256 .route("/settings/emails", post(pages::email_add))
257 .route("/settings/emails/primary", post(pages::email_primary))
258 .route("/settings/emails/delete", post(pages::email_delete))
259 .route("/settings/emails/resend", post(pages::email_resend))
260 .route("/settings/emails/visibility", post(pages::email_visibility))
261 .route("/settings/password", post(pages::account_password))
262 .route(
263 "/settings/tokens",
264 get(pages::settings_tokens).post(pages::token_create),
265 )
266 .route("/settings/tokens/revoke", post(pages::token_revoke))
267 .route(
268 "/settings/keys",
269 get(pages::settings_keys).post(pages::key_add),
270 )
271 .route("/settings/keys/delete", post(pages::key_delete))
272 .route("/settings/keys/verify", post(pages::key_verify))
273 .route("/settings/avatar", post(avatar::upload))
274 // Admin dashboard (RequireAdmin-gated in each handler).
275 .route("/admin", get(admin::overview))
276 .route("/admin/users", get(admin::users).post(admin::user_create))
277 .route("/admin/users/password", post(admin::user_set_password))
278 .route("/admin/users/{id}/verify", post(admin::user_verify))
279 .route("/admin/users/{id}/disable", post(admin::user_disable))
280 .route("/admin/users/{id}/admin", post(admin::user_toggle_admin))
281 .route("/admin/users/{id}/delete", post(admin::user_delete))
282 .route("/admin/repos", get(admin::repos))
283 .route("/admin/repos/{id}/delete", post(admin::repo_delete))
284 .route("/admin/groups", get(admin::groups))
285 .route("/admin/groups/{id}/delete", post(admin::group_delete))
286 .route(
287 "/admin/invites",
288 get(admin::invites).post(admin::invite_create),
289 )
290 .route("/admin/invites/{id}/delete", post(admin::invite_delete))
291 .route(
292 "/admin/settings",
293 get(admin::settings).post(admin::settings_save),
294 )
295 .route("/settings/theme", get(pages::set_theme))
296 .route("/avatar/{username}", get(avatar::show))
297 // Repo administration (owner/admin only; enforced in the handlers). A
298 // fixed prefix so it never collides with the `/{owner}/{*rest}` catch-all.
299 .route("/repo-settings/{id}/general", post(repo::settings_general))
300 .route("/repo-settings/{id}/archive", post(repo::settings_archive))
301 .route("/repo-settings/{id}/delete", post(repo::settings_delete))
302 .route(
303 "/repo-settings/{id}/collaborators",
304 post(repo::settings_collaborator_add),
305 )
306 .route(
307 "/repo-settings/{id}/collaborators/remove",
308 post(repo::settings_collaborator_remove),
309 )
310 .route("/repo-settings/{id}/labels", post(repo::settings_label_add))
311 .route(
312 "/repo-settings/{id}/labels/delete",
313 post(repo::settings_label_delete),
314 )
315 .route(
316 "/repo-settings/{id}/mirror",
317 post(repo::settings_mirror_add),
318 )
319 .route(
320 "/repo-settings/{id}/mirror/{mid}/delete",
321 post(repo::settings_mirror_delete),
322 )
323 .route(
324 "/repo-settings/{id}/mirror/{mid}/sync",
325 post(repo::settings_mirror_sync),
326 )
327 .route(
328 "/new/migrate",
329 get(pages::migrate_form).post(pages::migrate_submit),
330 )
331 .route(
332 "/repo-fork/{id}",
333 get(repo::fork_confirm).post(repo::fork_create),
334 )
335 .route("/group-settings/{id}", get(groups::settings_view))
336 .route(
337 "/group-settings/{id}/general",
338 post(groups::settings_general),
339 )
340 .route(
341 "/group-settings/{id}/collaborators",
342 post(groups::settings_collaborator_add),
343 )
344 .route(
345 "/group-settings/{id}/collaborators/remove",
346 post(groups::settings_collaborator_remove),
347 )
348 .route("/group-settings/{id}/delete", post(groups::settings_delete))
349 .route("/repo-bookmark/{id}", get(repo::bookmark_toggle))
350 .route("/user-follow/{username}", get(repo::follow_toggle))
351 .route("/notifications", get(notifications::list))
352 .route("/notifications/read-all", post(notifications::read_all))
353 .route("/notifications/{id}", get(notifications::open))
354 // Issue/PR mutations: fixed prefixes so they never hit the repo catch-all.
355 .route("/issue-new/{repo_id}", post(issues::create))
356 .route("/issue/{id}/comment", post(issues::comment))
357 .route("/issue/{id}/state", post(issues::set_state))
358 .route("/issue/{id}/assignee", post(issues::set_assignee))
359 .route("/issue/{id}/labels", post(issues::set_labels))
360 .route("/issue/{id}/deps/add", post(issues::deps_add))
361 .route("/issue/{id}/deps/remove", post(issues::deps_remove))
362 .route("/issue/{id}/edit-title", post(issues::edit_title))
363 .route("/issue/{id}/edit-body", post(issues::edit_body))
364 .route("/issue/{id}/lock", post(issues::set_lock))
365 .route("/comment/{id}/edit", post(issues::comment_edit))
366 .route("/pull-new/{repo_id}", post(pulls::create))
367 .route("/pull/{id}/merge", post(pulls::merge))
368 .route("/release-new/{repo_id}", post(releases::create))
369 .route(
370 "/release/{id}/edit",
371 get(releases::edit_form).post(releases::edit),
372 )
373 .route("/release/{id}/delete", post(releases::delete))
374 .route("/release/{id}/asset", post(releases::asset_upload))
375 .route("/release-asset/{id}", get(releases::asset_download))
376 .route("/release-asset/{id}/delete", post(releases::asset_delete))
377 .route("/healthz", get(serve_assets::healthz))
378 .route("/version", get(serve_assets::version))
379 .route("/theme.css", get(serve_assets::theme_default))
380 .route("/theme/{file}", get(serve_assets::theme_named))
381 .route("/assets/{*path}", get(serve_assets::assets))
382 // Repository browsing. Variable-length paths split on `/-/` in the handler.
383 // The same catch-all serves smart-HTTP pack transport: GET handles
384 // `…​.git/info/refs` inside the dispatcher; POST is the pack RPC.
385 .route("/{owner}", get(repo::user_page))
386 .route(
387 "/{owner}/{*rest}",
388 get(repo::dispatch).post(git_http::pack_rpc).put(lfs::put),
389 )
390 .layer(
391 ServiceBuilder::new()
392 .layer(SetRequestIdLayer::new(
393 x_request_id.clone(),
394 MakeRequestUuid,
395 ))
396 .layer(TraceLayer::new_for_http())
397 .layer(PropagateRequestIdLayer::new(x_request_id))
398 // No global request timeout: pack-transport routes (§3.3) stream
399 // arbitrarily long clones and must never be cut off. Per-route
400 // timeouts on non-git routes land in the polish phase.
401 .layer(CompressionLayer::new()),
402 )
403 .with_state(state)
404}
405
406/// Run the web server on `addr` until a shutdown signal arrives.
407///
408/// Installs a `SIGHUP` handler that re-scans the themes directory, and drains
409/// in-flight requests on `SIGTERM`/`SIGINT`.
410///
411/// # Errors
412///
413/// Returns [`WebError::Io`] if the listener cannot bind or serving fails.
414pub async fn serve(state: AppState, addr: SocketAddr) -> Result<(), WebError> {
415 init_tracing(&state.config);
416 spawn_sighup_rescan(state.clone());
417
418 let router = build_router(state);
419 let listener = tokio::net::TcpListener::bind(addr).await?;
420 tracing::info!(%addr, "fabrica web server listening");
421 axum::serve(listener, router)
422 .with_graceful_shutdown(shutdown_signal())
423 .await?;
424 Ok(())
425}
426
427/// Initialize the global tracing subscriber from the log configuration. Honours
428/// `RUST_LOG` when set; idempotent (a second call is a no-op).
429fn init_tracing(config: &Config) {
430 use config::LogFormat;
431 use tracing_subscriber::EnvFilter;
432
433 let level = match config.log.level {
434 config::LogLevel::Trace => "trace",
435 config::LogLevel::Debug => "debug",
436 config::LogLevel::Info => "info",
437 config::LogLevel::Warn => "warn",
438 config::LogLevel::Error => "error",
439 };
440 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level));
441 let builder = tracing_subscriber::fmt().with_env_filter(filter);
442 // `try_init` returns Err if a subscriber is already set; ignore it.
443 match config.log.format {
444 LogFormat::Json => {
445 let _ = builder.json().try_init();
446 }
447 LogFormat::Pretty => {
448 let _ = builder.try_init();
449 }
450 }
451}
452
453/// Spawn a task that re-scans themes on every `SIGHUP` (no-op off Unix).
454fn spawn_sighup_rescan(state: AppState) {
455 #[cfg(unix)]
456 tokio::spawn(async move {
457 use tokio::signal::unix::{SignalKind, signal};
458 let Ok(mut hup) = signal(SignalKind::hangup()) else {
459 tracing::warn!("could not install SIGHUP handler; theme re-scan disabled");
460 return;
461 };
462 while hup.recv().await.is_some() {
463 tracing::info!("SIGHUP: re-scanning themes");
464 state.rescan_assets();
465 }
466 });
467 #[cfg(not(unix))]
468 let _ = state;
469}
470
471/// A future that resolves on `SIGINT` or `SIGTERM`.
472async fn shutdown_signal() {
473 let ctrl_c = async {
474 let _ = tokio::signal::ctrl_c().await;
475 };
476
477 #[cfg(unix)]
478 let terminate = async {
479 use tokio::signal::unix::{SignalKind, signal};
480 match signal(SignalKind::terminate()) {
481 Ok(mut term) => {
482 term.recv().await;
483 }
484 Err(_) => std::future::pending::<()>().await,
485 }
486 };
487 #[cfg(not(unix))]
488 let terminate = std::future::pending::<()>();
489
490 tokio::select! {
491 () = ctrl_c => {},
492 () = terminate => {},
493 }
494 tracing::info!("shutdown signal received; draining");
495}