| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | |
| 9 | use std::time::Instant; |
| 10 | |
| 11 | use serde::{Deserialize, Serialize}; |
| 12 | use topcoat::{ |
| 13 | Result, |
| 14 | context::{Cx, try_app_context}, |
| 15 | router::{ |
| 16 | Body, IntoResponse, Response, StatusCode, content::Json, header::CONTENT_TYPE, route, |
| 17 | }, |
| 18 | }; |
| 19 | |
| 20 | use crate::{ |
| 21 | application::{OwnerSpec, claim_instance, describe_instance, instance_state}, |
| 22 | infrastructure::{ |
| 23 | password::Argon2Hasher, |
| 24 | repository::{SqliteMembershipRepo, SqliteOrgRepo, SqliteUserRepo}, |
| 25 | }, |
| 26 | }; |
| 27 | |
| 28 | use super::{ |
| 29 | context::{git_features, pool, repos, setup_token, users}, |
| 30 | operator::operator_authorized, |
| 31 | rate_limit::{instance_throttled, throttled}, |
| 32 | }; |
| 33 | |
| 34 | |
| 35 | |
| 36 | |
| 37 | |
| 38 | |
| 39 | #[derive(Debug, Clone, Copy)] |
| 40 | pub struct StartedAt(pub Instant); |
| 41 | |
| 42 | impl StartedAt { |
| 43 | |
| 44 | #[must_use] |
| 45 | pub fn now() -> Self { |
| 46 | Self(Instant::now()) |
| 47 | } |
| 48 | } |
| 49 | |
| 50 | |
| 51 | |
| 52 | |
| 53 | |
| 54 | |
| 55 | |
| 56 | #[derive(Debug, Serialize)] |
| 57 | struct Instance { |
| 58 | claimed: bool, |
| 59 | |
| 60 | |
| 61 | |
| 62 | #[serde(skip_serializing_if = "Option::is_none")] |
| 63 | version: Option<&'static str>, |
| 64 | |
| 65 | |
| 66 | #[serde(skip_serializing_if = "Option::is_none")] |
| 67 | git_version: Option<String>, |
| 68 | |
| 69 | |
| 70 | #[serde(skip_serializing_if = "Option::is_none")] |
| 71 | merge_supported: Option<bool>, |
| 72 | |
| 73 | #[serde(skip_serializing_if = "Option::is_none")] |
| 74 | repositories: Option<u64>, |
| 75 | |
| 76 | #[serde(skip_serializing_if = "Option::is_none")] |
| 77 | users: Option<u64>, |
| 78 | |
| 79 | #[serde(skip_serializing_if = "Option::is_none")] |
| 80 | uptime_seconds: Option<u64>, |
| 81 | } |
| 82 | |
| 83 | impl Instance { |
| 84 | |
| 85 | fn public(claimed: bool) -> Self { |
| 86 | Self { |
| 87 | claimed, |
| 88 | version: None, |
| 89 | git_version: None, |
| 90 | merge_supported: None, |
| 91 | repositories: None, |
| 92 | users: None, |
| 93 | uptime_seconds: None, |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | |
| 99 | |
| 100 | |
| 101 | |
| 102 | |
| 103 | |
| 104 | |
| 105 | |
| 106 | |
| 107 | |
| 108 | |
| 109 | |
| 110 | |
| 111 | #[route(GET "/api/instance")] |
| 112 | async fn instance(cx: &Cx) -> Result<Response> { |
| 113 | if operator_authorized(cx) { |
| 114 | let report = describe_instance(&users(cx), &repos(cx)) |
| 115 | .await |
| 116 | .map_err(super::context::server_error)?; |
| 117 | let features = git_features(cx); |
| 118 | |
| 119 | return Json(Instance { |
| 120 | claimed: report.claimed, |
| 121 | version: Some(env!("CARGO_PKG_VERSION")), |
| 122 | git_version: features.version_string(), |
| 123 | merge_supported: Some(features.merge_tree), |
| 124 | repositories: Some(report.repositories), |
| 125 | users: Some(report.users), |
| 126 | uptime_seconds: Some(uptime_seconds(cx)), |
| 127 | }) |
| 128 | .into_response(cx); |
| 129 | } |
| 130 | |
| 131 | if let Some(response) = instance_throttled(cx) { |
| 132 | return Ok(response); |
| 133 | } |
| 134 | |
| 135 | let state = instance_state(&users(cx)) |
| 136 | .await |
| 137 | .map_err(super::context::server_error)?; |
| 138 | |
| 139 | Json(Instance::public(state.claimed)).into_response(cx) |
| 140 | } |
| 141 | |
| 142 | |
| 143 | |
| 144 | |
| 145 | |
| 146 | |
| 147 | fn uptime_seconds(cx: &Cx) -> u64 { |
| 148 | try_app_context::<StartedAt>(cx).map_or(0, |started| started.0.elapsed().as_secs()) |
| 149 | } |
| 150 | |
| 151 | |
| 152 | #[derive(Debug, Deserialize)] |
| 153 | struct SetupRequest { |
| 154 | setup_token: String, |
| 155 | email: String, |
| 156 | handle: String, |
| 157 | password: String, |
| 158 | } |
| 159 | |
| 160 | |
| 161 | |
| 162 | |
| 163 | |
| 164 | #[derive(Debug, Serialize)] |
| 165 | struct Claimed { |
| 166 | handle: String, |
| 167 | } |
| 168 | |
| 169 | |
| 170 | |
| 171 | |
| 172 | |
| 173 | |
| 174 | |
| 175 | |
| 176 | |
| 177 | |
| 178 | |
| 179 | #[route(POST "/api/setup")] |
| 180 | async fn setup(cx: &Cx, Json(request): Json<SetupRequest>) -> Result<Response> { |
| 181 | if let Some(response) = throttled(cx) { |
| 182 | return Ok(response); |
| 183 | } |
| 184 | |
| 185 | |
| 186 | |
| 187 | let Some(token) = setup_token(cx) else { |
| 188 | return Ok(refused()); |
| 189 | }; |
| 190 | |
| 191 | let pool = pool(cx).clone(); |
| 192 | let users = SqliteUserRepo::new(pool.clone()); |
| 193 | let orgs = SqliteOrgRepo::new(pool.clone()); |
| 194 | let memberships = SqliteMembershipRepo::new(pool); |
| 195 | |
| 196 | let handle = request.handle.clone(); |
| 197 | let claim = claim_instance( |
| 198 | &request.setup_token, |
| 199 | token, |
| 200 | &OwnerSpec { |
| 201 | handle: request.handle, |
| 202 | email: request.email, |
| 203 | password: request.password, |
| 204 | }, |
| 205 | &users, |
| 206 | &orgs, |
| 207 | &memberships, |
| 208 | &Argon2Hasher::new(), |
| 209 | ) |
| 210 | .await; |
| 211 | |
| 212 | match claim { |
| 213 | |
| 214 | |
| 215 | Ok(_) => (StatusCode::CREATED, Json(Claimed { handle })).into_response(cx), |
| 216 | Err(error) => { |
| 217 | |
| 218 | |
| 219 | |
| 220 | eprintln!("steid: headless claim rejected: {error}"); |
| 221 | Ok(refused()) |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | |
| 227 | |
| 228 | fn refused() -> Response { |
| 229 | let mut response = Response::new(Body::from("{\"error\":\"not found\"}\n")); |
| 230 | *response.status_mut() = StatusCode::NOT_FOUND; |
| 231 | response |
| 232 | .headers_mut() |
| 233 | .insert(CONTENT_TYPE, "application/json".parse().expect("static")); |
| 234 | |
| 235 | response |
| 236 | } |
| 237 | |
| 238 | #[cfg(test)] |
| 239 | mod tests { |
| 240 | use super::*; |
| 241 | |
| 242 | #[test] |
| 243 | fn the_public_answer_carries_nothing_but_claimed() { |
| 244 | let body = serde_json::to_string(&Instance::public(false)).expect("serialise"); |
| 245 | |
| 246 | assert_eq!(body, r#"{"claimed":false}"#); |
| 247 | } |
| 248 | |
| 249 | #[test] |
| 250 | fn the_operator_answer_carries_every_field() { |
| 251 | let body = serde_json::to_string(&Instance { |
| 252 | claimed: true, |
| 253 | version: Some("0.4.0"), |
| 254 | git_version: Some("2.47".to_owned()), |
| 255 | merge_supported: Some(true), |
| 256 | repositories: Some(3), |
| 257 | users: Some(1), |
| 258 | uptime_seconds: Some(42), |
| 259 | }) |
| 260 | .expect("serialise"); |
| 261 | |
| 262 | assert_eq!( |
| 263 | body, |
| 264 | r#"{"claimed":true,"version":"0.4.0","git_version":"2.47","merge_supported":true,"repositories":3,"users":1,"uptime_seconds":42}"# |
| 265 | ); |
| 266 | } |
| 267 | |
| 268 | #[test] |
| 269 | fn a_git_that_could_not_be_read_omits_its_version_rather_than_guessing() { |
| 270 | let body = serde_json::to_string(&Instance { |
| 271 | claimed: true, |
| 272 | version: Some("0.4.0"), |
| 273 | git_version: None, |
| 274 | merge_supported: Some(false), |
| 275 | repositories: Some(0), |
| 276 | users: Some(1), |
| 277 | uptime_seconds: Some(1), |
| 278 | }) |
| 279 | .expect("serialise"); |
| 280 | |
| 281 | assert!(!body.contains("git_version"), "{body}"); |
| 282 | assert!(body.contains(r#""merge_supported":false"#), "{body}"); |
| 283 | } |
| 284 | |
| 285 | #[test] |
| 286 | fn every_refusal_is_the_same_404() { |
| 287 | let first = refused(); |
| 288 | let second = refused(); |
| 289 | |
| 290 | assert_eq!(first.status(), StatusCode::NOT_FOUND); |
| 291 | assert_eq!(first.status(), second.status()); |
| 292 | assert_eq!(first.headers(), second.headers()); |
| 293 | } |
| 294 | } |