Git hosting and a container registry in one Rust binary (axum + Astro)
Merge branch 'worktree-agent-aba31e5de1ae4b7c3'
20 files changed, +4065 -10
+1-0Cargo.lock
| @@ -1876,6 +1876,7 @@ name = "ig" | ||
| 1876 | 1876 | version = "0.1.0" |
| 1877 | 1877 | dependencies = [ |
| 1878 | 1878 | "anyhow", |
| 1879 | + "chrono", | |
| 1879 | 1880 | "clap", |
| 1880 | 1881 | "dirs", |
| 1881 | 1882 | "hex", |
+361-0backend/src/api/device.rs
| @@ -0,0 +1,361 @@ | ||
| 1 | +//! Device authorization for `ig login`, modeled on RFC 8628. | |
| 2 | +//! | |
| 3 | +//! The CLI asks for a code pair, shows the person a short user code, and | |
| 4 | +//! polls with the long device code. The person signs in on the web, types or | |
| 5 | +//! confirms the user code at /login/device and approves. The next poll gets a | |
| 6 | +//! personal access token. Only hashes of device codes are stored. | |
| 7 | + | |
| 8 | +use std::{ | |
| 9 | + collections::HashMap, | |
| 10 | + sync::Mutex, | |
| 11 | + time::{Duration as StdDuration, Instant}, | |
| 12 | +}; | |
| 13 | + | |
| 14 | +use axum::{ | |
| 15 | + Form, Json, | |
| 16 | + extract::{Query, State}, | |
| 17 | + http::StatusCode, | |
| 18 | + response::{IntoResponse, Response}, | |
| 19 | +}; | |
| 20 | +use chrono::{DateTime, Duration, Utc}; | |
| 21 | +use irongit_shared::{self as shared, device_errors}; | |
| 22 | +use maud::{Markup, html}; | |
| 23 | +use once_cell::sync::Lazy; | |
| 24 | +use serde::Deserialize; | |
| 25 | +use serde_json::json; | |
| 26 | + | |
| 27 | +use super::ApiJson; | |
| 28 | +use crate::{ | |
| 29 | + analytics, | |
| 30 | + auth::{self, RequireViewer}, | |
| 31 | + error::{ApiError, ApiResult, AppError}, | |
| 32 | + models::audit, | |
| 33 | + state::AppState, | |
| 34 | + web::layout::{Ctx, Page}, | |
| 35 | +}; | |
| 36 | + | |
| 37 | +const CODE_TTL_SECS: i64 = 900; | |
| 38 | +const POLL_INTERVAL_SECS: u64 = 5; | |
| 39 | +/// Consonants only: no 0/O, 1/I confusion and no accidental words. | |
| 40 | +const USER_CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ"; | |
| 41 | + | |
| 42 | +/// Last poll per device code, to answer `slow_down` to clients that ignore | |
| 43 | +/// the interval. In memory: losing it on restart only forgives one poll. | |
| 44 | +static LAST_POLL: Lazy<Mutex<HashMap<String, Instant>>> = Lazy::new(|| Mutex::new(HashMap::new())); | |
| 45 | + | |
| 46 | +pub fn generate_user_code() -> String { | |
| 47 | + let chars: String = (0..8).map(|_| USER_CODE_ALPHABET[rand::random_range(0..USER_CODE_ALPHABET.len())] as char).collect(); | |
| 48 | + format!("{}-{}", &chars[..4], &chars[4..]) | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Accepts what people type: any case, spaces, with or without the hyphen. | |
| 52 | +pub fn normalize_user_code(input: &str) -> Option<String> { | |
| 53 | + let chars: String = input.chars().filter(|c| c.is_ascii_alphanumeric()).map(|c| c.to_ascii_uppercase()).collect(); | |
| 54 | + if chars.len() != 8 || !chars.bytes().all(|b| USER_CODE_ALPHABET.contains(&b)) { | |
| 55 | + return None; | |
| 56 | + } | |
| 57 | + Some(format!("{}-{}", &chars[..4], &chars[4..])) | |
| 58 | +} | |
| 59 | + | |
| 60 | +/// Device-flow errors need exact status codes, so they bypass `ApiError`. | |
| 61 | +fn poll_reply(status: StatusCode, code: &str) -> Response { | |
| 62 | + (status, Json(shared::ErrorBody { error: code.into(), status: status.as_u16() })).into_response() | |
| 63 | +} | |
| 64 | + | |
| 65 | +pub async fn code(State(state): State<AppState>, ApiJson(body): ApiJson<shared::DeviceCodeRequest>) -> ApiResult<Json<shared::DeviceCodeResponse>> { | |
| 66 | + let client_name: String = body.client_name.trim().chars().filter(|c| !c.is_control()).take(80).collect(); | |
| 67 | + let device_code = auth::random_token(32); | |
| 68 | + let expires_at = Utc::now() + Duration::seconds(CODE_TTL_SECS); | |
| 69 | + // User codes are unique among live codes; retry on the rare collision. | |
| 70 | + let mut user_code = generate_user_code(); | |
| 71 | + for attempt in 0..5 { | |
| 72 | + let inserted = sqlx::query( | |
| 73 | + "insert into device_codes (device_code_hash, user_code, client_name, expires_at) values ($1, $2, $3, $4) | |
| 74 | + on conflict (user_code) do nothing", | |
| 75 | + ) | |
| 76 | + .bind(auth::sha256_hex(&device_code)) | |
| 77 | + .bind(&user_code) | |
| 78 | + .bind(&client_name) | |
| 79 | + .bind(expires_at) | |
| 80 | + .execute(&state.db) | |
| 81 | + .await? | |
| 82 | + .rows_affected(); | |
| 83 | + if inserted == 1 { | |
| 84 | + break; | |
| 85 | + } | |
| 86 | + if attempt == 4 { | |
| 87 | + return Err(ApiError(AppError::Internal(anyhow::anyhow!("could not allocate a unique user code")))); | |
| 88 | + } | |
| 89 | + user_code = generate_user_code(); | |
| 90 | + } | |
| 91 | + let verification_uri = format!("{}/login/device", state.config.base_url()); | |
| 92 | + tracing::info!(client = %client_name, "device code issued"); | |
| 93 | + analytics::track(&state, "cli_login_started", None, "/api/v1/device/code", json!({})); | |
| 94 | + Ok(Json(shared::DeviceCodeResponse { | |
| 95 | + verification_uri_complete: format!("{verification_uri}?code={user_code}"), | |
| 96 | + verification_uri, | |
| 97 | + device_code, | |
| 98 | + user_code, | |
| 99 | + interval: POLL_INTERVAL_SECS, | |
| 100 | + expires_in: CODE_TTL_SECS as u64, | |
| 101 | + })) | |
| 102 | +} | |
| 103 | + | |
| 104 | +#[derive(sqlx::FromRow)] | |
| 105 | +struct CodeRow { | |
| 106 | + client_name: String, | |
| 107 | + approved_at: Option<DateTime<Utc>>, | |
| 108 | + denied_at: Option<DateTime<Utc>>, | |
| 109 | + consumed_at: Option<DateTime<Utc>>, | |
| 110 | + expires_at: DateTime<Utc>, | |
| 111 | +} | |
| 112 | + | |
| 113 | +pub async fn token(State(state): State<AppState>, ApiJson(body): ApiJson<shared::DeviceTokenRequest>) -> ApiResult<Response> { | |
| 114 | + let hash = auth::sha256_hex(body.device_code.trim()); | |
| 115 | + let row: Option<CodeRow> = sqlx::query_as( | |
| 116 | + "select client_name, approved_at, denied_at, consumed_at, expires_at from device_codes where device_code_hash = $1", | |
| 117 | + ) | |
| 118 | + .bind(&hash) | |
| 119 | + .fetch_optional(&state.db) | |
| 120 | + .await?; | |
| 121 | + let Some(row) = row else { | |
| 122 | + return Ok(poll_reply(StatusCode::BAD_REQUEST, "invalid_device_code")); | |
| 123 | + }; | |
| 124 | + if row.consumed_at.is_some() || row.expires_at < Utc::now() { | |
| 125 | + return Ok(poll_reply(StatusCode::GONE, device_errors::EXPIRED)); | |
| 126 | + } | |
| 127 | + if row.denied_at.is_some() { | |
| 128 | + return Ok(poll_reply(StatusCode::FORBIDDEN, device_errors::DENIED)); | |
| 129 | + } | |
| 130 | + if row.approved_at.is_none() { | |
| 131 | + let too_fast = { | |
| 132 | + let mut polls = LAST_POLL.lock().expect("poll map"); | |
| 133 | + let now = Instant::now(); | |
| 134 | + polls.retain(|_, t| now.duration_since(*t) < StdDuration::from_secs(CODE_TTL_SECS as u64 + 60)); | |
| 135 | + let fast = polls.get(&hash).is_some_and(|t| now.duration_since(*t) < StdDuration::from_secs(POLL_INTERVAL_SECS - 1)); | |
| 136 | + polls.insert(hash.clone(), now); | |
| 137 | + fast | |
| 138 | + }; | |
| 139 | + return Ok(if too_fast { | |
| 140 | + poll_reply(StatusCode::TOO_MANY_REQUESTS, device_errors::SLOW_DOWN) | |
| 141 | + } else { | |
| 142 | + poll_reply(StatusCode::PRECONDITION_REQUIRED, device_errors::PENDING) | |
| 143 | + }); | |
| 144 | + } | |
| 145 | + | |
| 146 | + // Approved: consume atomically so two racing polls cannot both mint a token. | |
| 147 | + let user_id: Option<i64> = sqlx::query_scalar( | |
| 148 | + "update device_codes set consumed_at = now() | |
| 149 | + where device_code_hash = $1 and consumed_at is null and approved_at is not null and expires_at > now() | |
| 150 | + returning user_id", | |
| 151 | + ) | |
| 152 | + .bind(&hash) | |
| 153 | + .fetch_optional(&state.db) | |
| 154 | + .await? | |
| 155 | + .flatten(); | |
| 156 | + let Some(user_id) = user_id else { | |
| 157 | + return Ok(poll_reply(StatusCode::GONE, device_errors::EXPIRED)); | |
| 158 | + }; | |
| 159 | + let (username, is_admin): (String, bool) = sqlx::query_as( | |
| 160 | + "select a.name::text, u.is_admin from users u join accounts a on a.id = u.account_id where u.account_id = $1 and u.suspended_at is null", | |
| 161 | + ) | |
| 162 | + .bind(user_id) | |
| 163 | + .fetch_optional(&state.db) | |
| 164 | + .await? | |
| 165 | + .ok_or(ApiError(AppError::forbidden("This account is suspended.")))?; | |
| 166 | + let mut scopes = vec!["repo", "packages", "user"]; | |
| 167 | + if is_admin { | |
| 168 | + scopes.push("admin"); | |
| 169 | + } | |
| 170 | + let client = if row.client_name.is_empty() { "ig".to_string() } else { row.client_name.clone() }; | |
| 171 | + let name = format!("ig on {client}"); | |
| 172 | + let token = auth::create_token(&state.db, user_id, &name, &scopes, None).await?; | |
| 173 | + LAST_POLL.lock().expect("poll map").remove(&hash); | |
| 174 | + audit(&state.db, Some(user_id), "token.create", &name, json!({ "via": "device", "scopes": scopes }), None).await; | |
| 175 | + analytics::track(&state, "cli_login_completed", Some(&username), "/api/v1/device/token", json!({ "admin": is_admin })); | |
| 176 | + tracing::info!(user = %username, client = %client, "cli login completed"); | |
| 177 | + Ok(Json(shared::DeviceTokenResponse { token: token.secret, username }).into_response()) | |
| 178 | +} | |
| 179 | + | |
| 180 | +// --------------------------------------------------------------------------- | |
| 181 | +// Web page | |
| 182 | + | |
| 183 | +#[derive(Deserialize)] | |
| 184 | +pub struct PageQuery { | |
| 185 | + code: Option<String>, | |
| 186 | +} | |
| 187 | + | |
| 188 | +#[derive(Deserialize)] | |
| 189 | +pub struct Decision { | |
| 190 | + user_code: String, | |
| 191 | + action: String, | |
| 192 | +} | |
| 193 | + | |
| 194 | +/// A pending (not yet decided, not expired) code by user code. | |
| 195 | +async fn pending(state: &AppState, user_code: &str) -> Result<Option<(String, DateTime<Utc>)>, AppError> { | |
| 196 | + Ok(sqlx::query_as( | |
| 197 | + "select client_name, expires_at from device_codes | |
| 198 | + where user_code = $1 and approved_at is null and denied_at is null and consumed_at is null and expires_at > now()", | |
| 199 | + ) | |
| 200 | + .bind(user_code) | |
| 201 | + .fetch_optional(&state.db) | |
| 202 | + .await?) | |
| 203 | +} | |
| 204 | + | |
| 205 | +fn shell(title: &str, inner: Markup) -> Markup { | |
| 206 | + html! { | |
| 207 | + div class="mx-auto w-full max-w-[440px] px-4 py-12" { | |
| 208 | + div class="box" { | |
| 209 | + div class="box-head font-semibold" { (title) } | |
| 210 | + div class="p-4" { (inner) } | |
| 211 | + } | |
| 212 | + p class="mt-3 text-center text-xs text-ink-faint" { | |
| 213 | + "Device login connects the " code { "ig" } " command line tool to your account. " | |
| 214 | + a href="/docs/cli" { "About ig" } | |
| 215 | + } | |
| 216 | + } | |
| 217 | + } | |
| 218 | +} | |
| 219 | + | |
| 220 | +fn entry_form(error: Option<&str>, value: &str) -> Markup { | |
| 221 | + shell( | |
| 222 | + "Connect a device", | |
| 223 | + html! { | |
| 224 | + @if let Some(error) = error { div class="alert alert-error mb-3" role="alert" { (error) } } | |
| 225 | + p class="mb-3 text-ink-dim" { "Enter the code shown in your terminal by " code { "ig login" } "." } | |
| 226 | + form method="get" action="/login/device" class="flex gap-2" { | |
| 227 | + input class="input font-mono text-base tracking-widest uppercase" name="code" value=(value) | |
| 228 | + placeholder="XXXX-XXXX" autocomplete="off" autocapitalize="characters" spellcheck="false" required autofocus; | |
| 229 | + button class="btn btn-primary" type="submit" { "Continue" } | |
| 230 | + } | |
| 231 | + }, | |
| 232 | + ) | |
| 233 | +} | |
| 234 | + | |
| 235 | +pub async fn page(RequireViewer(viewer): RequireViewer, ctx: Ctx, Query(query): Query<PageQuery>) -> Result<Response, AppError> { | |
| 236 | + let raw = query.code.unwrap_or_default(); | |
| 237 | + if raw.trim().is_empty() { | |
| 238 | + return Ok(ctx.render(Page::new("Connect a device", entry_form(None, "")).noindex())); | |
| 239 | + } | |
| 240 | + let Some(user_code) = normalize_user_code(&raw) else { | |
| 241 | + return Ok(ctx.render(Page::new("Connect a device", entry_form(Some("That code is not valid. Codes look like BCDF-GHJK."), &raw)).noindex())); | |
| 242 | + }; | |
| 243 | + let Some((client_name, expires_at)) = pending(&ctx.state, &user_code).await? else { | |
| 244 | + return Ok(ctx.render( | |
| 245 | + Page::new("Connect a device", entry_form(Some("That code has expired or was already used. Run ig login again."), &user_code)).noindex(), | |
| 246 | + )); | |
| 247 | + }; | |
| 248 | + let minutes = ((expires_at - Utc::now()).num_seconds().max(0) + 59) / 60; | |
| 249 | + let client = if client_name.is_empty() { "ig" } else { client_name.as_str() }; | |
| 250 | + let body = shell( | |
| 251 | + "Authorize ig", | |
| 252 | + html! { | |
| 253 | + div class="flex items-center gap-3" { | |
| 254 | + (crate::web::ui::avatar(&viewer.name, viewer.avatar_key.as_deref(), 36)) | |
| 255 | + div { | |
| 256 | + div class="font-semibold" { "ig on " (client) } | |
| 257 | + div class="text-xs text-ink-dim" { "wants to sign in as " strong class="text-ink" { (viewer.name) } } | |
| 258 | + } | |
| 259 | + } | |
| 260 | + div class="mt-4 rounded-[4px] border border-edge bg-surface-sunken px-3 py-2 text-center font-mono text-lg tracking-[0.3em]" { (user_code) } | |
| 261 | + p class="mt-2 text-xs text-ink-dim" { "Check that this matches the code in your terminal. Expires in " (minutes) " min." } | |
| 262 | + ul class="mt-3 space-y-1 text-[13px] text-ink-dim" { | |
| 263 | + li { "Clone, push and manage your repositories" } | |
| 264 | + li { "Push, pull and manage your container images" } | |
| 265 | + li { "Manage your SSH keys, tokens and organizations" } | |
| 266 | + @if viewer.is_admin { li { "Site administration (you are an admin)" } } | |
| 267 | + } | |
| 268 | + p class="mt-2 text-xs text-ink-faint" { "A personal access token named \"ig on " (client) "\" is created. Revoke it any time in Settings." } | |
| 269 | + form method="post" action="/login/device" class="mt-4 flex gap-2" { | |
| 270 | + input type="hidden" name="user_code" value=(user_code); | |
| 271 | + button class="btn btn-primary flex-1" type="submit" name="action" value="approve" data-track="cli_login_approve_clicked" { "Authorize" } | |
| 272 | + button class="btn" type="submit" name="action" value="deny" data-track="cli_login_deny_clicked" { "Deny" } | |
| 273 | + } | |
| 274 | + }, | |
| 275 | + ); | |
| 276 | + Ok(ctx.render(Page::new("Authorize ig", body).noindex())) | |
| 277 | +} | |
| 278 | + | |
| 279 | +pub async fn submit(RequireViewer(viewer): RequireViewer, ctx: Ctx, Form(decision): Form<Decision>) -> Result<Response, AppError> { | |
| 280 | + let Some(user_code) = normalize_user_code(&decision.user_code) else { | |
| 281 | + return Ok(ctx.render(Page::new("Connect a device", entry_form(Some("That code is not valid."), &decision.user_code)).noindex())); | |
| 282 | + }; | |
| 283 | + let approve = match decision.action.as_str() { | |
| 284 | + "approve" => true, | |
| 285 | + "deny" => false, | |
| 286 | + _ => return Err(AppError::bad("Unknown action.")), | |
| 287 | + }; | |
| 288 | + let updated = if approve { | |
| 289 | + sqlx::query( | |
| 290 | + "update device_codes set approved_at = now(), user_id = $2 | |
| 291 | + where user_code = $1 and approved_at is null and denied_at is null and consumed_at is null and expires_at > now()", | |
| 292 | + ) | |
| 293 | + .bind(&user_code) | |
| 294 | + .bind(viewer.id) | |
| 295 | + .execute(&ctx.state.db) | |
| 296 | + .await? | |
| 297 | + } else { | |
| 298 | + sqlx::query( | |
| 299 | + "update device_codes set denied_at = now(), user_id = $2 | |
| 300 | + where user_code = $1 and approved_at is null and denied_at is null and consumed_at is null and expires_at > now()", | |
| 301 | + ) | |
| 302 | + .bind(&user_code) | |
| 303 | + .bind(viewer.id) | |
| 304 | + .execute(&ctx.state.db) | |
| 305 | + .await? | |
| 306 | + } | |
| 307 | + .rows_affected(); | |
| 308 | + if updated == 0 { | |
| 309 | + return Ok(ctx.render( | |
| 310 | + Page::new("Connect a device", entry_form(Some("That code has expired or was already used. Run ig login again."), &user_code)).noindex(), | |
| 311 | + )); | |
| 312 | + } | |
| 313 | + let event = if approve { "cli_login_approved" } else { "cli_login_denied" }; | |
| 314 | + analytics::track(&ctx.state, event, Some(&viewer.name), "/login/device", json!({})); | |
| 315 | + audit(&ctx.state.db, Some(viewer.id), if approve { "device.approve" } else { "device.deny" }, &user_code, json!({}), None).await; | |
| 316 | + tracing::info!(user = %viewer.name, approved = approve, "device code decided"); | |
| 317 | + | |
| 318 | + let body = if approve { | |
| 319 | + shell( | |
| 320 | + "Device connected", | |
| 321 | + html! { | |
| 322 | + div class="alert alert-ok" { "ig is signed in as " strong { (viewer.name) } "." } | |
| 323 | + p class="mt-3 text-ink-dim" { "You can close this tab and return to your terminal." } | |
| 324 | + }, | |
| 325 | + ) | |
| 326 | + } else { | |
| 327 | + shell( | |
| 328 | + "Request denied", | |
| 329 | + html! { | |
| 330 | + div class="alert alert-info" { "The request was denied. No token was created." } | |
| 331 | + p class="mt-3 text-ink-dim" { "If you did not run " code { "ig login" } " yourself, someone may have sent you this code. Nothing was shared with them." } | |
| 332 | + }, | |
| 333 | + ) | |
| 334 | + }; | |
| 335 | + Ok(ctx.render(Page::new(if approve { "Device connected" } else { "Request denied" }, body).noindex())) | |
| 336 | +} | |
| 337 | + | |
| 338 | +#[cfg(test)] | |
| 339 | +mod tests { | |
| 340 | + use super::*; | |
| 341 | + | |
| 342 | + #[test] | |
| 343 | + fn user_codes_have_the_documented_shape() { | |
| 344 | + for _ in 0..200 { | |
| 345 | + let code = generate_user_code(); | |
| 346 | + assert_eq!(code.len(), 9); | |
| 347 | + assert_eq!(&code[4..5], "-"); | |
| 348 | + assert!(code.bytes().filter(|b| *b != b'-').all(|b| USER_CODE_ALPHABET.contains(&b))); | |
| 349 | + assert_eq!(normalize_user_code(&code).as_deref(), Some(code.as_str())); | |
| 350 | + } | |
| 351 | + } | |
| 352 | + | |
| 353 | + #[test] | |
| 354 | + fn user_codes_normalize_typed_input() { | |
| 355 | + assert_eq!(normalize_user_code("bcdf ghjk").as_deref(), Some("BCDF-GHJK")); | |
| 356 | + assert_eq!(normalize_user_code(" BCDFGHJK ").as_deref(), Some("BCDF-GHJK")); | |
| 357 | + assert_eq!(normalize_user_code("BCDF-GHJ"), None); | |
| 358 | + assert_eq!(normalize_user_code("BCDF-GHJ0"), None, "0 is not in the alphabet"); | |
| 359 | + assert_eq!(normalize_user_code("AEIO-UBCD"), None, "vowels are not in the alphabet"); | |
| 360 | + } | |
| 361 | +} |
+187-0backend/src/api/keys.rs
| @@ -0,0 +1,187 @@ | ||
| 1 | +//! The caller's SSH keys and personal access tokens. | |
| 2 | + | |
| 3 | +use axum::{ | |
| 4 | + Json, | |
| 5 | + extract::{Path, State}, | |
| 6 | + http::StatusCode, | |
| 7 | + response::Response, | |
| 8 | +}; | |
| 9 | +use chrono::{DateTime, Duration, Utc}; | |
| 10 | +use irongit_shared as shared; | |
| 11 | +use serde_json::json; | |
| 12 | + | |
| 13 | +use super::{ApiJson, Scope, created, need_scope}; | |
| 14 | +use crate::{ | |
| 15 | + analytics, | |
| 16 | + auth::{self, ApiViewer, Viewer}, | |
| 17 | + error::{ApiError, ApiResult, AppError}, | |
| 18 | + models::audit, | |
| 19 | + sshkeys, | |
| 20 | + state::AppState, | |
| 21 | +}; | |
| 22 | + | |
| 23 | +type KeyRow = (i64, String, String, String, DateTime<Utc>, Option<DateTime<Utc>>); | |
| 24 | + | |
| 25 | +fn key_dto((id, title, fingerprint, public_key, created_at, last_used_at): KeyRow) -> shared::SshKey { | |
| 26 | + shared::SshKey { id, title, fingerprint, public_key, created_at, last_used_at } | |
| 27 | +} | |
| 28 | + | |
| 29 | +pub async fn list_keys(State(state): State<AppState>, ApiViewer(viewer): ApiViewer) -> ApiResult<Json<Vec<shared::SshKey>>> { | |
| 30 | + need_scope(&viewer, Scope::User)?; | |
| 31 | + let rows: Vec<KeyRow> = sqlx::query_as( | |
| 32 | + "select id, title, fingerprint, public_key, created_at, last_used_at from ssh_keys where user_id = $1 order by created_at", | |
| 33 | + ) | |
| 34 | + .bind(viewer.id) | |
| 35 | + .fetch_all(&state.db) | |
| 36 | + .await?; | |
| 37 | + Ok(Json(rows.into_iter().map(key_dto).collect())) | |
| 38 | +} | |
| 39 | + | |
| 40 | +pub async fn create_key(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, ApiJson(body): ApiJson<shared::CreateSshKey>) -> ApiResult<Response> { | |
| 41 | + need_scope(&viewer, Scope::User)?; | |
| 42 | + let parsed = sshkeys::parse_public_key(&body.key).map_err(|m| ApiError(AppError::bad(m)))?; | |
| 43 | + let title = body | |
| 44 | + .title | |
| 45 | + .as_deref() | |
| 46 | + .map(str::trim) | |
| 47 | + .filter(|t| !t.is_empty()) | |
| 48 | + .map(str::to_string) | |
| 49 | + .or_else(|| Some(parsed.comment.clone()).filter(|c| !c.is_empty())) | |
| 50 | + .unwrap_or_else(|| format!("{} key added {}", parsed.algorithm, Utc::now().format("%Y-%m-%d"))); | |
| 51 | + let title: String = title.chars().take(100).collect(); | |
| 52 | + let row: Result<KeyRow, sqlx::Error> = sqlx::query_as( | |
| 53 | + "insert into ssh_keys (user_id, title, public_key, fingerprint) values ($1, $2, $3, $4) | |
| 54 | + returning id, title, fingerprint, public_key, created_at, last_used_at", | |
| 55 | + ) | |
| 56 | + .bind(viewer.id) | |
| 57 | + .bind(&title) | |
| 58 | + .bind(&parsed.normalized) | |
| 59 | + .bind(&parsed.fingerprint) | |
| 60 | + .fetch_one(&state.db) | |
| 61 | + .await; | |
| 62 | + let row = match row { | |
| 63 | + Ok(row) => row, | |
| 64 | + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => { | |
| 65 | + return Err(ApiError(AppError::conflict("That key is already registered to an account."))); | |
| 66 | + } | |
| 67 | + Err(e) => return Err(e.into()), | |
| 68 | + }; | |
| 69 | + audit(&state.db, Some(viewer.id), "ssh_key.add", &parsed.fingerprint, json!({ "title": title }), None).await; | |
| 70 | + analytics::track(&state, "ssh_key_added", Some(&viewer.name), "/api/v1/user/keys", json!({ "algorithm": parsed.algorithm, "via": "api" })); | |
| 71 | + tracing::info!(user = %viewer.name, fingerprint = %parsed.fingerprint, "ssh key added"); | |
| 72 | + Ok(created(key_dto(row))) | |
| 73 | +} | |
| 74 | + | |
| 75 | +pub async fn delete_key(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, Path(id): Path<i64>) -> ApiResult<StatusCode> { | |
| 76 | + need_scope(&viewer, Scope::User)?; | |
| 77 | + let fingerprint: Option<String> = sqlx::query_scalar("delete from ssh_keys where id = $1 and user_id = $2 returning fingerprint") | |
| 78 | + .bind(id) | |
| 79 | + .bind(viewer.id) | |
| 80 | + .fetch_optional(&state.db) | |
| 81 | + .await?; | |
| 82 | + let fingerprint = fingerprint.ok_or(ApiError(AppError::NotFound))?; | |
| 83 | + audit(&state.db, Some(viewer.id), "ssh_key.remove", &fingerprint, json!({}), None).await; | |
| 84 | + Ok(StatusCode::NO_CONTENT) | |
| 85 | +} | |
| 86 | + | |
| 87 | +type TokenRow = (i64, String, String, Vec<String>, DateTime<Utc>, Option<DateTime<Utc>>, Option<DateTime<Utc>>); | |
| 88 | + | |
| 89 | +fn token_dto((id, name, prefix, scopes, created_at, last_used_at, expires_at): TokenRow) -> shared::Token { | |
| 90 | + shared::Token { id, name, prefix, scopes, created_at, last_used_at, expires_at } | |
| 91 | +} | |
| 92 | + | |
| 93 | +pub async fn list_tokens(State(state): State<AppState>, ApiViewer(viewer): ApiViewer) -> ApiResult<Json<Vec<shared::Token>>> { | |
| 94 | + need_scope(&viewer, Scope::User)?; | |
| 95 | + let rows: Vec<TokenRow> = sqlx::query_as( | |
| 96 | + "select id, name, token_prefix, scopes, created_at, last_used_at, expires_at | |
| 97 | + from access_tokens where user_id = $1 order by created_at desc", | |
| 98 | + ) | |
| 99 | + .bind(viewer.id) | |
| 100 | + .fetch_all(&state.db) | |
| 101 | + .await?; | |
| 102 | + Ok(Json(rows.into_iter().map(token_dto).collect())) | |
| 103 | +} | |
| 104 | + | |
| 105 | +/// Scopes a new token may carry: a subset of the caller's own, so a narrow | |
| 106 | +/// token can never mint a broader one. | |
| 107 | +fn allowed_scopes(viewer: &Viewer, requested: Option<&[String]>) -> Result<Vec<&'static str>, String> { | |
| 108 | + let requested: Vec<String> = match requested { | |
| 109 | + Some(list) if !list.is_empty() => list.iter().map(|s| s.trim().to_ascii_lowercase()).collect(), | |
| 110 | + _ => vec!["repo".into(), "packages".into(), "user".into()], | |
| 111 | + }; | |
| 112 | + let mut out = Vec::new(); | |
| 113 | + for scope in &requested { | |
| 114 | + let (name, held) = match scope.as_str() { | |
| 115 | + "repo" => ("repo", viewer.scopes.repo), | |
| 116 | + "packages" => ("packages", viewer.scopes.packages), | |
| 117 | + "user" => ("user", viewer.scopes.user), | |
| 118 | + "admin" => ("admin", viewer.scopes.admin && viewer.is_admin), | |
| 119 | + other => return Err(format!("Unknown scope '{other}'. Use repo, packages, user or admin.")), | |
| 120 | + }; | |
| 121 | + if !held { | |
| 122 | + return Err(format!("You cannot grant the '{name}' scope.")); | |
| 123 | + } | |
| 124 | + if !out.contains(&name) { | |
| 125 | + out.push(name); | |
| 126 | + } | |
| 127 | + } | |
| 128 | + Ok(out) | |
| 129 | +} | |
| 130 | + | |
| 131 | +pub async fn create_token(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, ApiJson(body): ApiJson<shared::CreateToken>) -> ApiResult<Response> { | |
| 132 | + need_scope(&viewer, Scope::User)?; | |
| 133 | + let name = body.name.trim(); | |
| 134 | + if name.is_empty() || name.chars().count() > 100 { | |
| 135 | + return Err(ApiError(AppError::bad("Token names must be 1 to 100 characters."))); | |
| 136 | + } | |
| 137 | + let scopes = allowed_scopes(&viewer, body.scopes.as_deref()).map_err(|m| ApiError(AppError::bad(m)))?; | |
| 138 | + let expires_at = match body.expires_in_days { | |
| 139 | + Some(0) | None => None, | |
| 140 | + Some(days) if days <= 3650 => Some(Utc::now() + Duration::days(days as i64)), | |
| 141 | + Some(_) => return Err(ApiError(AppError::bad("Tokens can last at most 3650 days."))), | |
| 142 | + }; | |
| 143 | + let new = auth::create_token(&state.db, viewer.id, name, &scopes, expires_at).await?; | |
| 144 | + let row: TokenRow = sqlx::query_as( | |
| 145 | + "select id, name, token_prefix, scopes, created_at, last_used_at, expires_at from access_tokens where id = $1", | |
| 146 | + ) | |
| 147 | + .bind(new.id) | |
| 148 | + .fetch_one(&state.db) | |
| 149 | + .await?; | |
| 150 | + audit(&state.db, Some(viewer.id), "token.create", name, json!({ "scopes": scopes }), None).await; | |
| 151 | + analytics::track(&state, "token_created", Some(&viewer.name), "/api/v1/user/tokens", json!({ "scopes": scopes.join(","), "via": "api" })); | |
| 152 | + Ok(created(shared::CreatedToken { token: token_dto(row), secret: new.secret })) | |
| 153 | +} | |
| 154 | + | |
| 155 | +pub async fn delete_token(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, Path(id): Path<i64>) -> ApiResult<StatusCode> { | |
| 156 | + need_scope(&viewer, Scope::User)?; | |
| 157 | + let name: Option<String> = sqlx::query_scalar("delete from access_tokens where id = $1 and user_id = $2 returning name") | |
| 158 | + .bind(id) | |
| 159 | + .bind(viewer.id) | |
| 160 | + .fetch_optional(&state.db) | |
| 161 | + .await?; | |
| 162 | + let name = name.ok_or(ApiError(AppError::NotFound))?; | |
| 163 | + audit(&state.db, Some(viewer.id), "token.revoke", &name, json!({ "id": id }), None).await; | |
| 164 | + analytics::track(&state, "token_revoked", Some(&viewer.name), "/api/v1/user/tokens", json!({ "via": "api" })); | |
| 165 | + Ok(StatusCode::NO_CONTENT) | |
| 166 | +} | |
| 167 | + | |
| 168 | +#[cfg(test)] | |
| 169 | +mod tests { | |
| 170 | + use super::*; | |
| 171 | + use crate::auth::{AuthVia, Scopes}; | |
| 172 | + | |
| 173 | + fn viewer(scopes: Scopes, is_admin: bool) -> Viewer { | |
| 174 | + Viewer { id: 1, name: "a".into(), is_admin, avatar_key: None, scopes, via: AuthVia::Token(1) } | |
| 175 | + } | |
| 176 | + | |
| 177 | + #[test] | |
| 178 | + fn tokens_cannot_escalate() { | |
| 179 | + let narrow = viewer(Scopes { repo: true, packages: false, user: true, admin: false }, false); | |
| 180 | + assert!(allowed_scopes(&narrow, Some(&["repo".into()])).is_ok()); | |
| 181 | + assert!(allowed_scopes(&narrow, Some(&["packages".into()])).is_err()); | |
| 182 | + assert!(allowed_scopes(&narrow, None).is_err(), "default set includes packages"); | |
| 183 | + let full = viewer(Scopes::ALL, false); | |
| 184 | + assert!(allowed_scopes(&full, Some(&["admin".into()])).is_err(), "admin scope needs a site admin"); | |
| 185 | + assert_eq!(allowed_scopes(&full, Some(&["repo".into(), "REPO".into()])).unwrap(), vec!["repo"]); | |
| 186 | + } | |
| 187 | +} |
+245-3backend/src/api/mod.rs
| @@ -1,11 +1,253 @@ | ||
| 1 | -//! JSON API for the `ig` CLI at /api/v1. Stub; filled in by the API work. | |
| 1 | +//! JSON API for the `ig` CLI and scripts, at /api/v1. | |
| 2 | +//! | |
| 3 | +//! Callers authenticate with a personal access token (`Authorization: Bearer | |
| 4 | +//! igp_...`) or a browser session. Every handler checks the token scope its | |
| 5 | +//! area needs, then defers to `perm` for the access decision, so the API can | |
| 6 | +//! never see more than the web UI would show the same person. | |
| 2 | 7 | |
| 8 | +mod device; | |
| 9 | +mod keys; | |
| 10 | +mod orgs; | |
| 11 | +mod packages; | |
| 3 | 12 | pub mod release; |
| 13 | +mod repos; | |
| 14 | +mod users; | |
| 4 | 15 | |
| 5 | -use axum::Router; | |
| 16 | +use axum::{ | |
| 17 | + Json, Router, | |
| 18 | + extract::{FromRequest, Request, rejection::JsonRejection}, | |
| 19 | + http::StatusCode, | |
| 20 | + response::{IntoResponse, Response}, | |
| 21 | + routing::{any, delete, get, post, put}, | |
| 22 | +}; | |
| 23 | +use irongit_shared as shared; | |
| 24 | +use serde::de::DeserializeOwned; | |
| 6 | 25 | |
| 7 | -use crate::state::AppState; | |
| 26 | +use crate::{ | |
| 27 | + auth::Viewer, | |
| 28 | + config::Config, | |
| 29 | + error::{ApiError, ApiResult, AppError}, | |
| 30 | + models::{Account, Package, Repo}, | |
| 31 | + perm::Access, | |
| 32 | + state::AppState, | |
| 33 | + web::ui, | |
| 34 | +}; | |
| 8 | 35 | |
| 9 | 36 | pub fn router() -> Router<AppState> { |
| 10 | 37 | Router::new() |
| 38 | + .route("/api/v1/user", get(users::me)) | |
| 39 | + .route("/api/v1/users/{name}", get(users::show)) | |
| 40 | + .route("/api/v1/users/{name}/contributions", get(users::contributions)) | |
| 41 | + .route("/api/v1/accounts/{owner}/repos", get(repos::list)) | |
| 42 | + .route("/api/v1/accounts/{owner}/packages", get(packages::list)) | |
| 43 | + .route("/api/v1/repos", post(repos::create)) | |
| 44 | + .route("/api/v1/repos/{owner}/{repo}", get(repos::show).patch(repos::update).delete(repos::delete)) | |
| 45 | + .route("/api/v1/repos/{owner}/{repo}/collaborators", get(repos::collaborators)) | |
| 46 | + .route( | |
| 47 | + "/api/v1/repos/{owner}/{repo}/collaborators/{user}", | |
| 48 | + put(repos::set_collaborator).delete(repos::remove_collaborator), | |
| 49 | + ) | |
| 50 | + .route("/api/v1/packages/{owner}/{name}", get(packages::show).patch(packages::update).delete(packages::delete)) | |
| 51 | + .route("/api/v1/packages/{owner}/{name}/tags", get(packages::tags)) | |
| 52 | + .route("/api/v1/packages/{owner}/{name}/tags/{tag}", delete(packages::delete_tag)) | |
| 53 | + .route("/api/v1/user/keys", get(keys::list_keys).post(keys::create_key)) | |
| 54 | + .route("/api/v1/user/keys/{id}", delete(keys::delete_key)) | |
| 55 | + .route("/api/v1/user/tokens", get(keys::list_tokens).post(keys::create_token)) | |
| 56 | + .route("/api/v1/user/tokens/{id}", delete(keys::delete_token)) | |
| 57 | + .route("/api/v1/user/orgs", get(orgs::mine)) | |
| 58 | + .route("/api/v1/orgs", post(orgs::create)) | |
| 59 | + .route("/api/v1/orgs/{org}/members", get(orgs::members)) | |
| 60 | + .route("/api/v1/orgs/{org}/members/{user}", put(orgs::set_member).delete(orgs::remove_member)) | |
| 61 | + .route("/api/v1/device/code", post(device::code)) | |
| 62 | + .route("/api/v1/device/token", post(device::token)) | |
| 63 | + .route("/login/device", get(device::page).post(device::submit)) | |
| 64 | + .route("/api/v1/cli/latest", get(release::latest)) | |
| 65 | + .route("/download/ig", get(release::download)) | |
| 66 | + .route("/install.sh", get(release::install_script)) | |
| 67 | + .route("/api/v1/{*rest}", any(not_found)) | |
| 68 | +} | |
| 69 | + | |
| 70 | +/// Unknown API paths answer in JSON rather than with the HTML 404 page. | |
| 71 | +async fn not_found() -> ApiError { | |
| 72 | + ApiError(AppError::NotFound) | |
| 73 | +} | |
| 74 | + | |
| 75 | +/// `Json<T>` whose rejections are JSON errors like everything else here. | |
| 76 | +pub struct ApiJson<T>(pub T); | |
| 77 | + | |
| 78 | +impl<T: DeserializeOwned, S: Send + Sync> FromRequest<S> for ApiJson<T> { | |
| 79 | + type Rejection = ApiError; | |
| 80 | + | |
| 81 | + async fn from_request(request: Request, state: &S) -> Result<Self, Self::Rejection> { | |
| 82 | + match Json::<T>::from_request(request, state).await { | |
| 83 | + Ok(Json(value)) => Ok(ApiJson(value)), | |
| 84 | + Err(rejection) => Err(ApiError(AppError::bad(json_problem(&rejection)))), | |
| 85 | + } | |
| 86 | + } | |
| 87 | +} | |
| 88 | + | |
| 89 | +fn json_problem(rejection: &JsonRejection) -> String { | |
| 90 | + match rejection { | |
| 91 | + JsonRejection::MissingJsonContentType(_) => "Send a JSON body with Content-Type: application/json.".into(), | |
| 92 | + other => format!("Invalid JSON body: {}", other.body_text()), | |
| 93 | + } | |
| 94 | +} | |
| 95 | + | |
| 96 | +/// 201 Created with a JSON body. | |
| 97 | +pub fn created<T: serde::Serialize>(value: T) -> Response { | |
| 98 | + (StatusCode::CREATED, Json(value)).into_response() | |
| 99 | +} | |
| 100 | + | |
| 101 | +/// Which token scope an endpoint needs. | |
| 102 | +#[derive(Clone, Copy)] | |
| 103 | +pub enum Scope { | |
| 104 | + Repo, | |
| 105 | + Packages, | |
| 106 | + User, | |
| 107 | +} | |
| 108 | + | |
| 109 | +impl Scope { | |
| 110 | + fn name(self) -> &'static str { | |
| 111 | + match self { | |
| 112 | + Scope::Repo => "repo", | |
| 113 | + Scope::Packages => "packages", | |
| 114 | + Scope::User => "user", | |
| 115 | + } | |
| 116 | + } | |
| 117 | +} | |
| 118 | + | |
| 119 | +pub fn need_scope(viewer: &Viewer, scope: Scope) -> ApiResult<()> { | |
| 120 | + let granted = match scope { | |
| 121 | + Scope::Repo => viewer.scopes.repo, | |
| 122 | + Scope::Packages => viewer.scopes.packages, | |
| 123 | + Scope::User => viewer.scopes.user, | |
| 124 | + }; | |
| 125 | + if granted { | |
| 126 | + Ok(()) | |
| 127 | + } else { | |
| 128 | + Err(ApiError(AppError::forbidden(format!("This token lacks the '{}' scope.", scope.name())))) | |
| 129 | + } | |
| 130 | +} | |
| 131 | + | |
| 132 | +pub fn scope_names(viewer: &Viewer) -> Vec<String> { | |
| 133 | + let s = viewer.scopes; | |
| 134 | + [("repo", s.repo), ("packages", s.packages), ("user", s.user), ("admin", s.admin)] | |
| 135 | + .into_iter() | |
| 136 | + .filter(|(_, on)| *on) | |
| 137 | + .map(|(name, _)| name.to_string()) | |
| 138 | + .collect() | |
| 139 | +} | |
| 140 | + | |
| 141 | +pub fn permission_name(access: Access) -> Option<String> { | |
| 142 | + match access { | |
| 143 | + Access::None => None, | |
| 144 | + Access::Read => Some("read".into()), | |
| 145 | + Access::Write => Some("write".into()), | |
| 146 | + Access::Admin => Some("admin".into()), | |
| 147 | + } | |
| 148 | +} | |
| 149 | + | |
| 150 | +pub fn avatar_url(config: &Config, name: &str, key: Option<&str>) -> String { | |
| 151 | + format!("{}{}", config.base_url(), ui::avatar_url(name, key)) | |
| 152 | +} | |
| 153 | + | |
| 154 | +pub fn clone_urls(config: &Config, owner: &str, name: &str) -> (String, String) { | |
| 155 | + let https = format!("{}/{owner}/{name}.git", config.base_url()); | |
| 156 | + // A non-default port needs the ssh:// form; scp-style has no port syntax. | |
| 157 | + let ssh = if config.ssh_host.contains(':') { | |
| 158 | + format!("ssh://git@{}/{owner}/{name}.git", config.ssh_host) | |
| 159 | + } else { | |
| 160 | + format!("git@{}:{owner}/{name}.git", config.ssh_host) | |
| 161 | + }; | |
| 162 | + (https, ssh) | |
| 163 | +} | |
| 164 | + | |
| 165 | +pub fn repo_dto(config: &Config, repo: &Repo, permission: Option<Access>) -> shared::Repo { | |
| 166 | + let (clone_https, clone_ssh) = clone_urls(config, &repo.owner_name, &repo.name); | |
| 167 | + shared::Repo { | |
| 168 | + id: repo.id, | |
| 169 | + owner: repo.owner_name.clone(), | |
| 170 | + name: repo.name.clone(), | |
| 171 | + full_name: repo.full_name(), | |
| 172 | + description: repo.description.clone(), | |
| 173 | + visibility: repo.visibility.clone(), | |
| 174 | + default_branch: repo.default_branch.clone(), | |
| 175 | + size_bytes: repo.size_bytes, | |
| 176 | + is_empty: repo.is_empty, | |
| 177 | + archived: repo.archived, | |
| 178 | + clone_https, | |
| 179 | + clone_ssh, | |
| 180 | + web_url: format!("{}{}", config.base_url(), repo.url()), | |
| 181 | + permission: permission.and_then(permission_name), | |
| 182 | + created_at: repo.created_at, | |
| 183 | + updated_at: repo.updated_at, | |
| 184 | + pushed_at: repo.pushed_at, | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +pub fn account_dto(config: &Config, account: &Account) -> shared::Account { | |
| 189 | + shared::Account { | |
| 190 | + id: account.id, | |
| 191 | + name: account.name.clone(), | |
| 192 | + kind: account.kind.clone(), | |
| 193 | + display_name: account.display_name.clone(), | |
| 194 | + bio: account.bio.clone(), | |
| 195 | + location: account.location.clone(), | |
| 196 | + website: account.website.clone(), | |
| 197 | + avatar_url: avatar_url(config, &account.name, account.avatar_key.as_deref()), | |
| 198 | + web_url: format!("{}/{}", config.base_url(), account.name), | |
| 199 | + created_at: account.created_at, | |
| 200 | + } | |
| 201 | +} | |
| 202 | + | |
| 203 | +pub fn package_dto( | |
| 204 | + config: &Config, | |
| 205 | + package: &Package, | |
| 206 | + tag_count: i64, | |
| 207 | + latest_tag: Option<&str>, | |
| 208 | + linked_repo: Option<String>, | |
| 209 | + permission: Option<Access>, | |
| 210 | +) -> shared::Package { | |
| 211 | + let reference = match latest_tag { | |
| 212 | + Some(tag) => format!("{}/{}:{tag}", config.registry_host(), package.full_name()), | |
| 213 | + None => format!("{}/{}", config.registry_host(), package.full_name()), | |
| 214 | + }; | |
| 215 | + shared::Package { | |
| 216 | + id: package.id, | |
| 217 | + owner: package.owner_name.clone(), | |
| 218 | + name: package.name.clone(), | |
| 219 | + full_name: package.full_name(), | |
| 220 | + visibility: package.visibility.clone(), | |
| 221 | + description: package.description.clone(), | |
| 222 | + repo: linked_repo, | |
| 223 | + pull_count: package.pull_count, | |
| 224 | + tag_count, | |
| 225 | + pull_command: format!("docker pull {reference}"), | |
| 226 | + web_url: format!("{}{}", config.base_url(), package.url()), | |
| 227 | + permission: permission.and_then(permission_name), | |
| 228 | + created_at: package.created_at, | |
| 229 | + updated_at: package.updated_at, | |
| 230 | + } | |
| 231 | +} | |
| 232 | + | |
| 233 | +/// Looks up an account by name or answers 404. | |
| 234 | +pub async fn account_or_404(state: &AppState, name: &str) -> ApiResult<Account> { | |
| 235 | + Account::by_name(&state.db, name).await?.ok_or(ApiError(AppError::NotFound)) | |
| 236 | +} | |
| 237 | + | |
| 238 | +/// Pagination: `?page=1&per_page=100`, capped at 100 per page. | |
| 239 | +#[derive(serde::Deserialize, Default)] | |
| 240 | +pub struct Paging { | |
| 241 | + pub page: Option<i64>, | |
| 242 | + pub per_page: Option<i64>, | |
| 243 | +} | |
| 244 | + | |
| 245 | +impl Paging { | |
| 246 | + pub fn limit(&self) -> i64 { | |
| 247 | + self.per_page.unwrap_or(100).clamp(1, 100) | |
| 248 | + } | |
| 249 | + | |
| 250 | + pub fn offset(&self) -> i64 { | |
| 251 | + (self.page.unwrap_or(1).max(1) - 1) * self.limit() | |
| 252 | + } | |
| 11 | 253 | } |
+171-0backend/src/api/orgs.rs
| @@ -0,0 +1,171 @@ | ||
| 1 | +//! Organizations and their members. | |
| 2 | + | |
| 3 | +use axum::{ | |
| 4 | + Json, | |
| 5 | + extract::{Path, State}, | |
| 6 | + http::StatusCode, | |
| 7 | + response::Response, | |
| 8 | +}; | |
| 9 | +use irongit_shared as shared; | |
| 10 | +use serde_json::json; | |
| 11 | + | |
| 12 | +use super::{ApiJson, Scope, account_dto, account_or_404, avatar_url, created, need_scope}; | |
| 13 | +use crate::{ | |
| 14 | + analytics, | |
| 15 | + auth::{ApiViewer, Viewer}, | |
| 16 | + error::{ApiError, ApiResult, AppError}, | |
| 17 | + models::{Account, audit}, | |
| 18 | + ops, | |
| 19 | + perm::{self, Access}, | |
| 20 | + state::AppState, | |
| 21 | +}; | |
| 22 | + | |
| 23 | +pub async fn create(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, ApiJson(body): ApiJson<shared::CreateOrg>) -> ApiResult<Response> { | |
| 24 | + need_scope(&viewer, Scope::User)?; | |
| 25 | + let org = ops::create_org(&state, &viewer, &body.name, body.display_name.as_deref().unwrap_or("")).await?; | |
| 26 | + tracing::info!(org = %org.name, owner = %viewer.name, via = "api", "organization created"); | |
| 27 | + Ok(created(account_dto(&state.config, &org))) | |
| 28 | +} | |
| 29 | + | |
| 30 | +pub async fn mine(State(state): State<AppState>, ApiViewer(viewer): ApiViewer) -> ApiResult<Json<Vec<shared::OrgMembership>>> { | |
| 31 | + let rows: Vec<(String, String, Option<String>, String)> = sqlx::query_as( | |
| 32 | + "select a.name::text, a.display_name, a.avatar_key, m.role | |
| 33 | + from org_members m join accounts a on a.id = m.org_id | |
| 34 | + where m.user_id = $1 order by a.name", | |
| 35 | + ) | |
| 36 | + .bind(viewer.id) | |
| 37 | + .fetch_all(&state.db) | |
| 38 | + .await?; | |
| 39 | + Ok(Json( | |
| 40 | + rows.into_iter() | |
| 41 | + .map(|(org, display_name, key, role)| shared::OrgMembership { | |
| 42 | + avatar_url: avatar_url(&state.config, &org, key.as_deref()), | |
| 43 | + org, | |
| 44 | + display_name, | |
| 45 | + role, | |
| 46 | + }) | |
| 47 | + .collect(), | |
| 48 | + )) | |
| 49 | +} | |
| 50 | + | |
| 51 | +async fn org_or_404(state: &AppState, name: &str) -> ApiResult<Account> { | |
| 52 | + let account = account_or_404(state, name).await?; | |
| 53 | + if !account.is_org() { | |
| 54 | + return Err(ApiError(AppError::NotFound)); | |
| 55 | + } | |
| 56 | + Ok(account) | |
| 57 | +} | |
| 58 | + | |
| 59 | +/// Membership is public, like the members list on the org's page. | |
| 60 | +pub async fn members(State(state): State<AppState>, Path(org): Path<String>) -> ApiResult<Json<Vec<shared::OrgMember>>> { | |
| 61 | + let org = org_or_404(&state, &org).await?; | |
| 62 | + let rows: Vec<(String, Option<String>, String, chrono::DateTime<chrono::Utc>)> = sqlx::query_as( | |
| 63 | + "select a.name::text, a.avatar_key, m.role, m.created_at | |
| 64 | + from org_members m join accounts a on a.id = m.user_id | |
| 65 | + where m.org_id = $1 order by m.role desc, a.name", | |
| 66 | + ) | |
| 67 | + .bind(org.id) | |
| 68 | + .fetch_all(&state.db) | |
| 69 | + .await?; | |
| 70 | + Ok(Json( | |
| 71 | + rows.into_iter() | |
| 72 | + .map(|(username, key, role, joined_at)| shared::OrgMember { | |
| 73 | + avatar_url: avatar_url(&state.config, &username, key.as_deref()), | |
| 74 | + username, | |
| 75 | + role, | |
| 76 | + joined_at, | |
| 77 | + }) | |
| 78 | + .collect(), | |
| 79 | + )) | |
| 80 | +} | |
| 81 | + | |
| 82 | +async fn owner_count(state: &AppState, org_id: i64) -> ApiResult<i64> { | |
| 83 | + Ok(sqlx::query_scalar("select count(*) from org_members where org_id = $1 and role = 'owner'") | |
| 84 | + .bind(org_id) | |
| 85 | + .fetch_one(&state.db) | |
| 86 | + .await?) | |
| 87 | +} | |
| 88 | + | |
| 89 | +async fn require_org_owner(state: &AppState, viewer: &Viewer, org: &Account) -> ApiResult<()> { | |
| 90 | + need_scope(viewer, Scope::User)?; | |
| 91 | + if perm::owner_access(&state.db, Some(viewer), org.id).await? != Access::Admin { | |
| 92 | + return Err(ApiError(AppError::forbidden("Only organization owners can manage members."))); | |
| 93 | + } | |
| 94 | + Ok(()) | |
| 95 | +} | |
| 96 | + | |
| 97 | +pub async fn set_member( | |
| 98 | + State(state): State<AppState>, | |
| 99 | + ApiViewer(viewer): ApiViewer, | |
| 100 | + Path((org, user)): Path<(String, String)>, | |
| 101 | + ApiJson(body): ApiJson<shared::SetRole>, | |
| 102 | +) -> ApiResult<Json<shared::OrgMember>> { | |
| 103 | + let org = org_or_404(&state, &org).await?; | |
| 104 | + require_org_owner(&state, &viewer, &org).await?; | |
| 105 | + if !matches!(body.role.as_str(), "owner" | "member") { | |
| 106 | + return Err(ApiError(AppError::bad("Role must be owner or member."))); | |
| 107 | + } | |
| 108 | + let target = account_or_404(&state, &user).await?; | |
| 109 | + if target.is_org() { | |
| 110 | + return Err(ApiError(AppError::bad("Members must be users."))); | |
| 111 | + } | |
| 112 | + | |
| 113 | + let current: Option<String> = sqlx::query_scalar("select role from org_members where org_id = $1 and user_id = $2") | |
| 114 | + .bind(org.id) | |
| 115 | + .bind(target.id) | |
| 116 | + .fetch_optional(&state.db) | |
| 117 | + .await?; | |
| 118 | + if current.as_deref() == Some("owner") && body.role == "member" && owner_count(&state, org.id).await? <= 1 { | |
| 119 | + return Err(ApiError(AppError::conflict("An organization needs at least one owner. Promote someone else first."))); | |
| 120 | + } | |
| 121 | + let joined_at: chrono::DateTime<chrono::Utc> = sqlx::query_scalar( | |
| 122 | + "insert into org_members (org_id, user_id, role) values ($1, $2, $3) | |
| 123 | + on conflict (org_id, user_id) do update set role = excluded.role | |
| 124 | + returning created_at", | |
| 125 | + ) | |
| 126 | + .bind(org.id) | |
| 127 | + .bind(target.id) | |
| 128 | + .bind(&body.role) | |
| 129 | + .fetch_one(&state.db) | |
| 130 | + .await?; | |
| 131 | + audit(&state.db, Some(viewer.id), "org.member.set", &org.name, json!({ "user": target.name, "role": body.role }), None).await; | |
| 132 | + analytics::track(&state, if current.is_some() { "org_member_role_changed" } else { "org_member_added" }, Some(&viewer.name), &format!("/{}", org.name), json!({ "role": body.role, "via": "api" })); | |
| 133 | + Ok(Json(shared::OrgMember { | |
| 134 | + avatar_url: avatar_url(&state.config, &target.name, target.avatar_key.as_deref()), | |
| 135 | + username: target.name, | |
| 136 | + role: body.role, | |
| 137 | + joined_at, | |
| 138 | + })) | |
| 139 | +} | |
| 140 | + | |
| 141 | +pub async fn remove_member( | |
| 142 | + State(state): State<AppState>, | |
| 143 | + ApiViewer(viewer): ApiViewer, | |
| 144 | + Path((org, user)): Path<(String, String)>, | |
| 145 | +) -> ApiResult<StatusCode> { | |
| 146 | + let org = org_or_404(&state, &org).await?; | |
| 147 | + let target = account_or_404(&state, &user).await?; | |
| 148 | + // Owners remove anyone; members may leave on their own. | |
| 149 | + if target.id != viewer.id { | |
| 150 | + require_org_owner(&state, &viewer, &org).await?; | |
| 151 | + } else { | |
| 152 | + need_scope(&viewer, Scope::User)?; | |
| 153 | + } | |
| 154 | + let role: Option<String> = sqlx::query_scalar("select role from org_members where org_id = $1 and user_id = $2") | |
| 155 | + .bind(org.id) | |
| 156 | + .bind(target.id) | |
| 157 | + .fetch_optional(&state.db) | |
| 158 | + .await?; | |
| 159 | + let role = role.ok_or(ApiError(AppError::NotFound))?; | |
| 160 | + if role == "owner" && owner_count(&state, org.id).await? <= 1 { | |
| 161 | + return Err(ApiError(AppError::conflict("An organization needs at least one owner. Promote someone else first."))); | |
| 162 | + } | |
| 163 | + sqlx::query("delete from org_members where org_id = $1 and user_id = $2") | |
| 164 | + .bind(org.id) | |
| 165 | + .bind(target.id) | |
| 166 | + .execute(&state.db) | |
| 167 | + .await?; | |
| 168 | + audit(&state.db, Some(viewer.id), "org.member.remove", &org.name, json!({ "user": target.name }), None).await; | |
| 169 | + analytics::track(&state, "org_member_removed", Some(&viewer.name), &format!("/{}", org.name), json!({ "via": "api" })); | |
| 170 | + Ok(StatusCode::NO_CONTENT) | |
| 171 | +} |
+209-0backend/src/api/packages.rs
| @@ -0,0 +1,209 @@ | ||
| 1 | +//! Container images (packages): listing, settings, tags. Pushing and pulling | |
| 2 | +//! happen through the registry at /v2/; this is the management side. | |
| 3 | + | |
| 4 | +use std::collections::HashMap; | |
| 5 | + | |
| 6 | +use axum::{ | |
| 7 | + Json, | |
| 8 | + extract::{Path, Query, State}, | |
| 9 | + http::StatusCode, | |
| 10 | +}; | |
| 11 | +use irongit_shared as shared; | |
| 12 | +use serde_json::json; | |
| 13 | + | |
| 14 | +use super::{ApiJson, Paging, Scope, account_or_404, need_scope, package_dto}; | |
| 15 | +use crate::{ | |
| 16 | + analytics, | |
| 17 | + auth::{ApiViewer, Viewer}, | |
| 18 | + error::{ApiError, ApiResult, AppError}, | |
| 19 | + models::{Package, audit}, | |
| 20 | + package_select, | |
| 21 | + perm::{self, Access, Area}, | |
| 22 | + state::AppState, | |
| 23 | + visible_packages, | |
| 24 | +}; | |
| 25 | + | |
| 26 | +async fn readable(state: &AppState, viewer: Option<&Viewer>, owner: &str, name: &str) -> ApiResult<(Package, Access)> { | |
| 27 | + let package = Package::by_path(&state.db, owner, name).await?.ok_or(ApiError(AppError::NotFound))?; | |
| 28 | + let access = perm::package_access(&state.db, viewer, &package).await?; | |
| 29 | + if !access.can_read() { | |
| 30 | + return Err(ApiError(AppError::NotFound)); | |
| 31 | + } | |
| 32 | + Ok((package, access)) | |
| 33 | +} | |
| 34 | + | |
| 35 | +/// Tag counts, newest tag and linked repo name for a set of packages. | |
| 36 | +async fn extras(state: &AppState, packages: &[Package]) -> ApiResult<(HashMap<i64, i64>, HashMap<i64, String>, HashMap<i64, String>)> { | |
| 37 | + let ids: Vec<i64> = packages.iter().map(|p| p.id).collect(); | |
| 38 | + let counts: HashMap<i64, i64> = | |
| 39 | + sqlx::query_as::<_, (i64, i64)>("select package_id, count(*)::bigint from tags where package_id = any($1) group by package_id") | |
| 40 | + .bind(&ids) | |
| 41 | + .fetch_all(&state.db) | |
| 42 | + .await? | |
| 43 | + .into_iter() | |
| 44 | + .collect(); | |
| 45 | + let latest: HashMap<i64, String> = sqlx::query_as::<_, (i64, String)>( | |
| 46 | + "select distinct on (package_id) package_id, name from tags where package_id = any($1) order by package_id, updated_at desc", | |
| 47 | + ) | |
| 48 | + .bind(&ids) | |
| 49 | + .fetch_all(&state.db) | |
| 50 | + .await? | |
| 51 | + .into_iter() | |
| 52 | + .collect(); | |
| 53 | + let repo_ids: Vec<i64> = packages.iter().filter_map(|p| p.repo_id).collect(); | |
| 54 | + let repos: HashMap<i64, String> = sqlx::query_as::<_, (i64, String)>( | |
| 55 | + "select r.id, a.name::text || '/' || r.name::text from repos r join accounts a on a.id = r.owner_id where r.id = any($1)", | |
| 56 | + ) | |
| 57 | + .bind(&repo_ids) | |
| 58 | + .fetch_all(&state.db) | |
| 59 | + .await? | |
| 60 | + .into_iter() | |
| 61 | + .collect(); | |
| 62 | + Ok((counts, latest, repos)) | |
| 63 | +} | |
| 64 | + | |
| 65 | +fn to_dto(state: &AppState, package: &Package, extras: &(HashMap<i64, i64>, HashMap<i64, String>, HashMap<i64, String>), access: Option<Access>) -> shared::Package { | |
| 66 | + let (counts, latest, repos) = extras; | |
| 67 | + package_dto( | |
| 68 | + &state.config, | |
| 69 | + package, | |
| 70 | + counts.get(&package.id).copied().unwrap_or(0), | |
| 71 | + latest.get(&package.id).map(String::as_str), | |
| 72 | + package.repo_id.and_then(|id| repos.get(&id).cloned()), | |
| 73 | + access, | |
| 74 | + ) | |
| 75 | +} | |
| 76 | + | |
| 77 | +pub async fn list( | |
| 78 | + State(state): State<AppState>, | |
| 79 | + viewer: Option<ApiViewer>, | |
| 80 | + Path(owner): Path<String>, | |
| 81 | + Query(paging): Query<Paging>, | |
| 82 | +) -> ApiResult<Json<Vec<shared::Package>>> { | |
| 83 | + let viewer = viewer.map(|ApiViewer(v)| v); | |
| 84 | + let account = account_or_404(&state, &owner).await?; | |
| 85 | + let (viewer_id, site_admin) = perm::visibility_binds(viewer.as_ref(), Area::Packages); | |
| 86 | + let packages: Vec<Package> = sqlx::query_as(concat!( | |
| 87 | + package_select!(), | |
| 88 | + " where p.owner_id = $3 and ", | |
| 89 | + visible_packages!(), | |
| 90 | + " order by p.updated_at desc limit $4 offset $5" | |
| 91 | + )) | |
| 92 | + .bind(viewer_id) | |
| 93 | + .bind(site_admin) | |
| 94 | + .bind(account.id) | |
| 95 | + .bind(paging.limit()) | |
| 96 | + .bind(paging.offset()) | |
| 97 | + .fetch_all(&state.db) | |
| 98 | + .await?; | |
| 99 | + let extras = extras(&state, &packages).await?; | |
| 100 | + Ok(Json(packages.iter().map(|p| to_dto(&state, p, &extras, None)).collect())) | |
| 101 | +} | |
| 102 | + | |
| 103 | +pub async fn show( | |
| 104 | + State(state): State<AppState>, | |
| 105 | + viewer: Option<ApiViewer>, | |
| 106 | + Path((owner, name)): Path<(String, String)>, | |
| 107 | +) -> ApiResult<Json<shared::Package>> { | |
| 108 | + let viewer = viewer.map(|ApiViewer(v)| v); | |
| 109 | + let (package, access) = readable(&state, viewer.as_ref(), &owner, &name).await?; | |
| 110 | + let extras = extras(&state, std::slice::from_ref(&package)).await?; | |
| 111 | + Ok(Json(to_dto(&state, &package, &extras, Some(access)))) | |
| 112 | +} | |
| 113 | + | |
| 114 | +pub async fn update( | |
| 115 | + State(state): State<AppState>, | |
| 116 | + ApiViewer(viewer): ApiViewer, | |
| 117 | + Path((owner, name)): Path<(String, String)>, | |
| 118 | + ApiJson(body): ApiJson<shared::UpdatePackage>, | |
| 119 | +) -> ApiResult<Json<shared::Package>> { | |
| 120 | + need_scope(&viewer, Scope::Packages)?; | |
| 121 | + let (package, access) = readable(&state, Some(&viewer), &owner, &name).await?; | |
| 122 | + if !access.is_admin() { | |
| 123 | + return Err(ApiError(AppError::forbidden("Only image admins can change its settings."))); | |
| 124 | + } | |
| 125 | + if let Some(visibility) = &body.visibility { | |
| 126 | + if !matches!(visibility.as_str(), "public" | "private") { | |
| 127 | + return Err(ApiError(AppError::bad("Visibility must be public or private."))); | |
| 128 | + } | |
| 129 | + sqlx::query("update packages set visibility = $2, updated_at = now() where id = $1") | |
| 130 | + .bind(package.id) | |
| 131 | + .bind(visibility) | |
| 132 | + .execute(&state.db) | |
| 133 | + .await?; | |
| 134 | + audit(&state.db, Some(viewer.id), "package.visibility", &package.full_name(), json!({ "visibility": visibility }), None).await; | |
| 135 | + analytics::track(&state, "package_visibility_changed", Some(&viewer.name), &package.url(), json!({ "visibility": visibility, "via": "api" })); | |
| 136 | + } | |
| 137 | + if let Some(description) = &body.description { | |
| 138 | + let description: String = description.trim().chars().take(350).collect(); | |
| 139 | + sqlx::query("update packages set description = $2, updated_at = now() where id = $1") | |
| 140 | + .bind(package.id) | |
| 141 | + .bind(description) | |
| 142 | + .execute(&state.db) | |
| 143 | + .await?; | |
| 144 | + } | |
| 145 | + let package = Package::by_id(&state.db, package.id).await?.ok_or(ApiError(AppError::NotFound))?; | |
| 146 | + let extras = extras(&state, std::slice::from_ref(&package)).await?; | |
| 147 | + Ok(Json(to_dto(&state, &package, &extras, Some(access)))) | |
| 148 | +} | |
| 149 | + | |
| 150 | +/// Deletes the image and all its tags. Layers are reclaimed by registry GC. | |
| 151 | +pub async fn delete(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, Path((owner, name)): Path<(String, String)>) -> ApiResult<StatusCode> { | |
| 152 | + need_scope(&viewer, Scope::Packages)?; | |
| 153 | + let (package, access) = readable(&state, Some(&viewer), &owner, &name).await?; | |
| 154 | + if !access.is_admin() { | |
| 155 | + return Err(ApiError(AppError::forbidden("Only image admins can delete it."))); | |
| 156 | + } | |
| 157 | + sqlx::query("delete from packages where id = $1").bind(package.id).execute(&state.db).await?; | |
| 158 | + audit(&state.db, Some(viewer.id), "package.delete", &package.full_name(), json!({}), None).await; | |
| 159 | + analytics::track(&state, "package_deleted", Some(&viewer.name), &package.url(), json!({ "via": "api" })); | |
| 160 | + tracing::info!(package = %package.full_name(), actor = %viewer.name, "package deleted"); | |
| 161 | + Ok(StatusCode::NO_CONTENT) | |
| 162 | +} | |
| 163 | + | |
| 164 | +pub async fn tags( | |
| 165 | + State(state): State<AppState>, | |
| 166 | + viewer: Option<ApiViewer>, | |
| 167 | + Path((owner, name)): Path<(String, String)>, | |
| 168 | +) -> ApiResult<Json<Vec<shared::Tag>>> { | |
| 169 | + let viewer = viewer.map(|ApiViewer(v)| v); | |
| 170 | + let (package, _) = readable(&state, viewer.as_ref(), &owner, &name).await?; | |
| 171 | + let rows: Vec<(String, String, String, i64, chrono::DateTime<chrono::Utc>)> = sqlx::query_as( | |
| 172 | + "select t.name, m.digest, m.media_type, m.total_size, t.updated_at | |
| 173 | + from tags t join manifests m on m.id = t.manifest_id | |
| 174 | + where t.package_id = $1 order by t.updated_at desc, t.name", | |
| 175 | + ) | |
| 176 | + .bind(package.id) | |
| 177 | + .fetch_all(&state.db) | |
| 178 | + .await?; | |
| 179 | + Ok(Json( | |
| 180 | + rows.into_iter() | |
| 181 | + .map(|(name, digest, media_type, size, updated_at)| shared::Tag { name, digest, media_type, size, updated_at }) | |
| 182 | + .collect(), | |
| 183 | + )) | |
| 184 | +} | |
| 185 | + | |
| 186 | +pub async fn delete_tag( | |
| 187 | + State(state): State<AppState>, | |
| 188 | + ApiViewer(viewer): ApiViewer, | |
| 189 | + Path((owner, name, tag)): Path<(String, String, String)>, | |
| 190 | +) -> ApiResult<StatusCode> { | |
| 191 | + need_scope(&viewer, Scope::Packages)?; | |
| 192 | + let (package, access) = readable(&state, Some(&viewer), &owner, &name).await?; | |
| 193 | + if !access.can_write() { | |
| 194 | + return Err(ApiError(AppError::forbidden("Deleting tags needs write access to the image."))); | |
| 195 | + } | |
| 196 | + let removed = sqlx::query("delete from tags where package_id = $1 and name = $2") | |
| 197 | + .bind(package.id) | |
| 198 | + .bind(&tag) | |
| 199 | + .execute(&state.db) | |
| 200 | + .await? | |
| 201 | + .rows_affected(); | |
| 202 | + if removed == 0 { | |
| 203 | + return Err(ApiError(AppError::NotFound)); | |
| 204 | + } | |
| 205 | + sqlx::query("update packages set updated_at = now() where id = $1").bind(package.id).execute(&state.db).await?; | |
| 206 | + audit(&state.db, Some(viewer.id), "package.tag.delete", &format!("{}:{tag}", package.full_name()), json!({}), None).await; | |
| 207 | + analytics::track(&state, "package_tag_deleted", Some(&viewer.name), &package.url(), json!({ "via": "api" })); | |
| 208 | + Ok(StatusCode::NO_CONTENT) | |
| 209 | +} |
+241-5backend/src/api/release.rs
| @@ -1,9 +1,245 @@ | ||
| 1 | -//! CLI releases stored in R2. Stub. | |
| 1 | +//! `ig` releases. Builds live in R2; this server only records them and hands | |
| 2 | +//! out short-lived download links, so downloads never pass through it. | |
| 3 | +//! | |
| 4 | +//! irongit admin publish-cli <path> --version X upload a build | |
| 5 | +//! GET /api/v1/cli/latest newest release metadata | |
| 6 | +//! GET /download/ig[?version=X] 302 to the R2 object | |
| 7 | +//! GET /install.sh curl | sh installer | |
| 2 | 8 | |
| 3 | -use std::path::Path; | |
| 9 | +use std::{path::Path, time::Duration}; | |
| 4 | 10 | |
| 5 | -use crate::storage::Storage; | |
| 11 | +use anyhow::Context; | |
| 12 | +use axum::{ | |
| 13 | + Json, | |
| 14 | + extract::{Query, State}, | |
| 15 | + http::{HeaderMap, StatusCode, header}, | |
| 16 | + response::{IntoResponse, Response}, | |
| 17 | +}; | |
| 18 | +use chrono::{DateTime, Utc}; | |
| 19 | +use irongit_shared as shared; | |
| 20 | +use serde::Deserialize; | |
| 21 | +use serde_json::json; | |
| 22 | +use sha2::{Digest, Sha256}; | |
| 23 | +use tokio::io::AsyncReadExt; | |
| 6 | 24 | |
| 7 | -pub async fn publish(_db: &sqlx::PgPool, _storage: &Storage, _path: &Path, _version: &str, _target: &str) -> anyhow::Result<()> { | |
| 8 | - anyhow::bail!("not implemented yet") | |
| 25 | +use crate::{ | |
| 26 | + analytics, auth, | |
| 27 | + error::{ApiError, ApiResult, AppError}, | |
| 28 | + state::AppState, | |
| 29 | + storage::{Storage, keys}, | |
| 30 | +}; | |
| 31 | + | |
| 32 | +#[derive(sqlx::FromRow)] | |
| 33 | +struct ReleaseRow { | |
| 34 | + version: String, | |
| 35 | + target: String, | |
| 36 | + r2_key: String, | |
| 37 | + sha256: String, | |
| 38 | + size: i64, | |
| 39 | + created_at: DateTime<Utc>, | |
| 40 | +} | |
| 41 | + | |
| 42 | +/// Uploads a build and records it as the newest release of `version`. | |
| 43 | +pub async fn publish(db: &sqlx::PgPool, storage: &Storage, path: &Path, version: &str, target: &str) -> anyhow::Result<()> { | |
| 44 | + let version = version.trim().trim_start_matches('v'); | |
| 45 | + anyhow::ensure!( | |
| 46 | + !version.is_empty() && version.len() <= 32 && version.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')), | |
| 47 | + "version must look like 1.2.3" | |
| 48 | + ); | |
| 49 | + anyhow::ensure!(target.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')), "invalid target"); | |
| 50 | + | |
| 51 | + let mut file = tokio::fs::File::open(path).await.with_context(|| format!("opening {}", path.display()))?; | |
| 52 | + let mut hasher = Sha256::new(); | |
| 53 | + let mut buffer = vec![0u8; 1 << 20]; | |
| 54 | + let mut size = 0u64; | |
| 55 | + loop { | |
| 56 | + let read = file.read(&mut buffer).await?; | |
| 57 | + if read == 0 { | |
| 58 | + break; | |
| 59 | + } | |
| 60 | + hasher.update(&buffer[..read]); | |
| 61 | + size += read as u64; | |
| 62 | + } | |
| 63 | + anyhow::ensure!(size > 0, "{} is empty", path.display()); | |
| 64 | + let sha256 = hex::encode(hasher.finalize()); | |
| 65 | + | |
| 66 | + let key = keys::cli_release(version, target); | |
| 67 | + storage.put_file(&key, path).await.context("uploading to R2")?; | |
| 68 | + sqlx::query( | |
| 69 | + "insert into cli_releases (version, target, r2_key, sha256, size) values ($1, $2, $3, $4, $5) | |
| 70 | + on conflict (version) do update set target = excluded.target, r2_key = excluded.r2_key, | |
| 71 | + sha256 = excluded.sha256, size = excluded.size, created_at = now()", | |
| 72 | + ) | |
| 73 | + .bind(version) | |
| 74 | + .bind(target) | |
| 75 | + .bind(&key) | |
| 76 | + .bind(&sha256) | |
| 77 | + .bind(size as i64) | |
| 78 | + .execute(db) | |
| 79 | + .await?; | |
| 80 | + println!("published ig {version} ({target}, {size} bytes)\n sha256 {sha256}\n r2 {}/{key}", storage.bucket_name()); | |
| 81 | + Ok(()) | |
| 82 | +} | |
| 83 | + | |
| 84 | +async fn latest_row(state: &AppState) -> Result<Option<ReleaseRow>, sqlx::Error> { | |
| 85 | + sqlx::query_as("select version, target, r2_key, sha256, size, created_at from cli_releases order by created_at desc, id desc limit 1") | |
| 86 | + .fetch_optional(&state.db) | |
| 87 | + .await | |
| 88 | +} | |
| 89 | + | |
| 90 | +fn release_dto(state: &AppState, row: &ReleaseRow) -> shared::CliRelease { | |
| 91 | + shared::CliRelease { | |
| 92 | + version: row.version.clone(), | |
| 93 | + target: row.target.clone(), | |
| 94 | + sha256: row.sha256.clone(), | |
| 95 | + size: row.size, | |
| 96 | + url: format!("{}/download/ig?version={}", state.config.base_url(), row.version), | |
| 97 | + created_at: row.created_at, | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +pub async fn latest(State(state): State<AppState>) -> ApiResult<Json<shared::CliRelease>> { | |
| 102 | + let row = latest_row(&state).await?.ok_or(ApiError(AppError::NotFound))?; | |
| 103 | + Ok(Json(release_dto(&state, &row))) | |
| 104 | +} | |
| 105 | + | |
| 106 | +#[derive(Deserialize)] | |
| 107 | +pub struct DownloadQuery { | |
| 108 | + version: Option<String>, | |
| 109 | +} | |
| 110 | + | |
| 111 | +pub async fn download(State(state): State<AppState>, headers: HeaderMap, Query(query): Query<DownloadQuery>) -> Response { | |
| 112 | + let row = match &query.version { | |
| 113 | + Some(version) => { | |
| 114 | + sqlx::query_as::<_, ReleaseRow>("select version, target, r2_key, sha256, size, created_at from cli_releases where version = $1") | |
| 115 | + .bind(version.trim_start_matches('v')) | |
| 116 | + .fetch_optional(&state.db) | |
| 117 | + .await | |
| 118 | + } | |
| 119 | + None => latest_row(&state).await, | |
| 120 | + }; | |
| 121 | + match row { | |
| 122 | + Ok(Some(row)) => { | |
| 123 | + let url = state.storage.presign_get(&row.r2_key, Duration::from_secs(600), Some("ig")); | |
| 124 | + let agent = auth::user_agent(&headers).unwrap_or("").chars().take(40).collect::<String>(); | |
| 125 | + analytics::track(&state, "cli_downloaded", None, "/download/ig", json!({ "version": row.version, "agent": agent })); | |
| 126 | + tracing::info!(version = %row.version, "cli download"); | |
| 127 | + (StatusCode::FOUND, [(header::LOCATION, url.to_string())]).into_response() | |
| 128 | + } | |
| 129 | + Ok(None) => (StatusCode::NOT_FOUND, "No ig release has been published yet.\n").into_response(), | |
| 130 | + Err(error) => { | |
| 131 | + tracing::error!(%error, "cli release lookup failed"); | |
| 132 | + (StatusCode::INTERNAL_SERVER_ERROR, "internal error\n").into_response() | |
| 133 | + } | |
| 134 | + } | |
| 135 | +} | |
| 136 | + | |
| 137 | +/// Single-quote a value for POSIX sh. | |
| 138 | +fn sh_quote(value: &str) -> String { | |
| 139 | + format!("'{}'", value.replace('\'', "'\\''")) | |
| 140 | +} | |
| 141 | + | |
| 142 | +pub async fn install_script(State(state): State<AppState>) -> Response { | |
| 143 | + let release = match latest_row(&state).await { | |
| 144 | + Ok(release) => release, | |
| 145 | + Err(error) => { | |
| 146 | + tracing::error!(%error, "cli release lookup failed"); | |
| 147 | + return (StatusCode::INTERNAL_SERVER_ERROR, "echo 'ig: the server could not look up releases' >&2; exit 1\n").into_response(); | |
| 148 | + } | |
| 149 | + }; | |
| 150 | + let host = state.config.base_url(); | |
| 151 | + let script = match release { | |
| 152 | + Some(release) => INSTALL_SCRIPT | |
| 153 | + .replace("__HOST__", &sh_quote(&host)) | |
| 154 | + .replace("__VERSION__", &sh_quote(&release.version)) | |
| 155 | + .replace("__SHA256__", &sh_quote(&release.sha256)), | |
| 156 | + None => format!("#!/bin/sh\necho \"ig: no release has been published on {host} yet\" >&2\nexit 1\n"), | |
| 157 | + }; | |
| 158 | + analytics::track(&state, "cli_install_script_fetched", None, "/install.sh", json!({})); | |
| 159 | + ( | |
| 160 | + StatusCode::OK, | |
| 161 | + [(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8"), (header::CACHE_CONTROL, "no-store")], | |
| 162 | + script, | |
| 163 | + ) | |
| 164 | + .into_response() | |
| 165 | +} | |
| 166 | + | |
| 167 | +const INSTALL_SCRIPT: &str = r#"#!/bin/sh | |
| 168 | +# Installs ig, the irongit command line tool, for x86_64 Linux. | |
| 169 | +# | |
| 170 | +# curl -fsSL <host>/install.sh | sh | |
| 171 | +# | |
| 172 | +# Set IG_INSTALL_DIR to choose where it goes (default ~/.local/bin). | |
| 173 | +set -eu | |
| 174 | + | |
| 175 | +HOST=__HOST__ | |
| 176 | +VERSION=__VERSION__ | |
| 177 | +SHA256=__SHA256__ | |
| 178 | +DIR="${IG_INSTALL_DIR:-$HOME/.local/bin}" | |
| 179 | + | |
| 180 | +say() { printf '%s\n' "$*"; } | |
| 181 | +fail() { printf 'ig install: %s\n' "$*" >&2; exit 1; } | |
| 182 | + | |
| 183 | +case "$(uname -s)/$(uname -m)" in | |
| 184 | + Linux/x86_64|Linux/amd64) ;; | |
| 185 | + *) fail "only x86_64 Linux builds are published right now (this is $(uname -s) $(uname -m))" ;; | |
| 186 | +esac | |
| 187 | + | |
| 188 | +if command -v curl >/dev/null 2>&1; then | |
| 189 | + fetch() { curl -fsSL "$1" -o "$2"; } | |
| 190 | +elif command -v wget >/dev/null 2>&1; then | |
| 191 | + fetch() { wget -qO "$2" "$1"; } | |
| 192 | +else | |
| 193 | + fail "curl or wget is required" | |
| 194 | +fi | |
| 195 | + | |
| 196 | +if command -v sha256sum >/dev/null 2>&1; then | |
| 197 | + digest() { sha256sum "$1" | cut -d' ' -f1; } | |
| 198 | +elif command -v shasum >/dev/null 2>&1; then | |
| 199 | + digest() { shasum -a 256 "$1" | cut -d' ' -f1; } | |
| 200 | +else | |
| 201 | + fail "sha256sum or shasum is required to verify the download" | |
| 202 | +fi | |
| 203 | + | |
| 204 | +mkdir -p "$DIR" | |
| 205 | +TMP="$(mktemp "$DIR/.ig-download.XXXXXX")" | |
| 206 | +trap 'rm -f "$TMP"' EXIT INT TERM | |
| 207 | + | |
| 208 | +say "Downloading ig $VERSION from $HOST" | |
| 209 | +fetch "$HOST/download/ig?version=$VERSION" "$TMP" || fail "download failed" | |
| 210 | + | |
| 211 | +ACTUAL="$(digest "$TMP")" | |
| 212 | +[ "$ACTUAL" = "$SHA256" ] || fail "checksum mismatch (expected $SHA256, got $ACTUAL)" | |
| 213 | + | |
| 214 | +chmod 755 "$TMP" | |
| 215 | +mv -f "$TMP" "$DIR/ig" | |
| 216 | +trap - EXIT INT TERM | |
| 217 | +# Docker finds credential helpers by name: docker-credential-<name>. | |
| 218 | +ln -sf ig "$DIR/docker-credential-ig" | |
| 219 | + | |
| 220 | +say "Installed ig $VERSION to $DIR/ig" | |
| 221 | +case ":$PATH:" in | |
| 222 | + *":$DIR:"*) ;; | |
| 223 | + *) say "Note: $DIR is not on your PATH. Add it, for example:" | |
| 224 | + say " echo 'export PATH=\"$DIR:\$PATH\"' >> ~/.profile" ;; | |
| 225 | +esac | |
| 226 | +say "" | |
| 227 | +say "Next: ig login --host $HOST" | |
| 228 | +"#; | |
| 229 | + | |
| 230 | +#[cfg(test)] | |
| 231 | +mod tests { | |
| 232 | + use super::*; | |
| 233 | + | |
| 234 | + #[test] | |
| 235 | + fn quoting_survives_single_quotes() { | |
| 236 | + assert_eq!(sh_quote("a'b"), r"'a'\''b'"); | |
| 237 | + assert_eq!(sh_quote("http://x:1"), "'http://x:1'"); | |
| 238 | + } | |
| 239 | + | |
| 240 | + #[test] | |
| 241 | + fn script_has_no_unfilled_placeholders() { | |
| 242 | + let filled = INSTALL_SCRIPT.replace("__HOST__", "'h'").replace("__VERSION__", "'1'").replace("__SHA256__", "'s'"); | |
| 243 | + assert!(!filled.contains("__")); | |
| 244 | + } | |
| 9 | 245 | } |
+233-0backend/src/api/repos.rs
| @@ -0,0 +1,233 @@ | ||
| 1 | +//! Repositories and their collaborators. | |
| 2 | + | |
| 3 | +use axum::{ | |
| 4 | + Json, | |
| 5 | + extract::{Path, Query, State}, | |
| 6 | + http::StatusCode, | |
| 7 | + response::Response, | |
| 8 | +}; | |
| 9 | +use irongit_shared as shared; | |
| 10 | +use serde_json::json; | |
| 11 | + | |
| 12 | +use super::{ApiJson, Paging, Scope, account_or_404, avatar_url, created, need_scope, repo_dto}; | |
| 13 | +use crate::{repo_select, visible_repos}; | |
| 14 | +use crate::{ | |
| 15 | + analytics, | |
| 16 | + auth::{ApiViewer, Viewer}, | |
| 17 | + error::{ApiError, ApiResult, AppError}, | |
| 18 | + git::{Git, plausible_ref}, | |
| 19 | + models::{Repo, audit}, | |
| 20 | + ops, | |
| 21 | + perm::{self, Access, Area}, | |
| 22 | + state::AppState, | |
| 23 | +}; | |
| 24 | + | |
| 25 | +/// Finds a repo the caller can at least read; everything else is a 404 so | |
| 26 | +/// private repos are indistinguishable from missing ones. | |
| 27 | +async fn readable(state: &AppState, viewer: Option<&Viewer>, owner: &str, name: &str) -> ApiResult<(Repo, Access)> { | |
| 28 | + let repo = Repo::by_path(&state.db, owner, name).await?.ok_or(ApiError(AppError::NotFound))?; | |
| 29 | + let access = perm::repo_access(&state.db, viewer, &repo, Area::Repo).await?; | |
| 30 | + if !access.can_read() { | |
| 31 | + return Err(ApiError(AppError::NotFound)); | |
| 32 | + } | |
| 33 | + Ok((repo, access)) | |
| 34 | +} | |
| 35 | + | |
| 36 | +pub async fn list( | |
| 37 | + State(state): State<AppState>, | |
| 38 | + viewer: Option<ApiViewer>, | |
| 39 | + Path(owner): Path<String>, | |
| 40 | + Query(paging): Query<Paging>, | |
| 41 | +) -> ApiResult<Json<Vec<shared::Repo>>> { | |
| 42 | + let viewer = viewer.map(|ApiViewer(v)| v); | |
| 43 | + let account = account_or_404(&state, &owner).await?; | |
| 44 | + let (viewer_id, site_admin) = perm::visibility_binds(viewer.as_ref(), Area::Repo); | |
| 45 | + let repos: Vec<Repo> = sqlx::query_as(concat!( | |
| 46 | + repo_select!(), | |
| 47 | + " where r.owner_id = $3 and ", | |
| 48 | + visible_repos!(), | |
| 49 | + " order by r.pushed_at desc nulls last, r.name limit $4 offset $5" | |
| 50 | + )) | |
| 51 | + .bind(viewer_id) | |
| 52 | + .bind(site_admin) | |
| 53 | + .bind(account.id) | |
| 54 | + .bind(paging.limit()) | |
| 55 | + .bind(paging.offset()) | |
| 56 | + .fetch_all(&state.db) | |
| 57 | + .await?; | |
| 58 | + Ok(Json(repos.iter().map(|r| repo_dto(&state.config, r, None)).collect())) | |
| 59 | +} | |
| 60 | + | |
| 61 | +pub async fn create(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, ApiJson(body): ApiJson<shared::CreateRepo>) -> ApiResult<Response> { | |
| 62 | + need_scope(&viewer, Scope::Repo)?; | |
| 63 | + let owner_name = body.owner.clone().unwrap_or_else(|| viewer.name.clone()); | |
| 64 | + let owner = account_or_404(&state, &owner_name).await?; | |
| 65 | + let visibility = body.visibility.as_deref().unwrap_or("private"); | |
| 66 | + let repo = ops::create_repo(&state, &viewer, &owner, &body.name, body.description.as_deref().unwrap_or(""), visibility).await?; | |
| 67 | + tracing::info!(repo = %repo.full_name(), via = "api", "repository created"); | |
| 68 | + Ok(created(repo_dto(&state.config, &repo, Some(Access::Admin)))) | |
| 69 | +} | |
| 70 | + | |
| 71 | +pub async fn show( | |
| 72 | + State(state): State<AppState>, | |
| 73 | + viewer: Option<ApiViewer>, | |
| 74 | + Path((owner, name)): Path<(String, String)>, | |
| 75 | +) -> ApiResult<Json<shared::Repo>> { | |
| 76 | + let viewer = viewer.map(|ApiViewer(v)| v); | |
| 77 | + let (repo, access) = readable(&state, viewer.as_ref(), &owner, &name).await?; | |
| 78 | + Ok(Json(repo_dto(&state.config, &repo, Some(access)))) | |
| 79 | +} | |
| 80 | + | |
| 81 | +pub async fn update( | |
| 82 | + State(state): State<AppState>, | |
| 83 | + ApiViewer(viewer): ApiViewer, | |
| 84 | + Path((owner, name)): Path<(String, String)>, | |
| 85 | + ApiJson(body): ApiJson<shared::UpdateRepo>, | |
| 86 | +) -> ApiResult<Json<shared::Repo>> { | |
| 87 | + need_scope(&viewer, Scope::Repo)?; | |
| 88 | + let (repo, access) = readable(&state, Some(&viewer), &owner, &name).await?; | |
| 89 | + if !access.is_admin() { | |
| 90 | + return Err(ApiError(AppError::forbidden("Only repository admins can change settings."))); | |
| 91 | + } | |
| 92 | + | |
| 93 | + if let Some(description) = &body.description { | |
| 94 | + let description: String = description.trim().chars().take(350).collect(); | |
| 95 | + sqlx::query("update repos set description = $2, updated_at = now() where id = $1") | |
| 96 | + .bind(repo.id) | |
| 97 | + .bind(description) | |
| 98 | + .execute(&state.db) | |
| 99 | + .await?; | |
| 100 | + } | |
| 101 | + if let Some(branch) = &body.default_branch { | |
| 102 | + let branch = branch.trim(); | |
| 103 | + if !plausible_ref(branch) { | |
| 104 | + return Err(ApiError(AppError::bad("That is not a valid branch name."))); | |
| 105 | + } | |
| 106 | + let git = Git::new(repo.disk_path(&state.config)); | |
| 107 | + let refs = git.ref_snapshot().await?; | |
| 108 | + if !refs.contains_key(&format!("refs/heads/{branch}")) { | |
| 109 | + return Err(ApiError(AppError::bad(format!("There is no branch named {branch}.")))); | |
| 110 | + } | |
| 111 | + git.set_head(branch).await?; | |
| 112 | + sqlx::query("update repos set default_branch = $2, updated_at = now() where id = $1") | |
| 113 | + .bind(repo.id) | |
| 114 | + .bind(branch) | |
| 115 | + .execute(&state.db) | |
| 116 | + .await?; | |
| 117 | + } | |
| 118 | + if let Some(archived) = body.archived { | |
| 119 | + sqlx::query("update repos set archived = $2, updated_at = now() where id = $1") | |
| 120 | + .bind(repo.id) | |
| 121 | + .bind(archived) | |
| 122 | + .execute(&state.db) | |
| 123 | + .await?; | |
| 124 | + audit(&state.db, Some(viewer.id), if archived { "repo.archive" } else { "repo.unarchive" }, &repo.full_name(), json!({}), None).await; | |
| 125 | + } | |
| 126 | + if let Some(visibility) = &body.visibility { | |
| 127 | + ops::set_repo_visibility(&state, &viewer, &repo, visibility).await?; | |
| 128 | + } | |
| 129 | + | |
| 130 | + let repo = Repo::by_id(&state.db, repo.id).await?.ok_or(ApiError(AppError::NotFound))?; | |
| 131 | + tracing::info!(repo = %repo.full_name(), actor = %viewer.name, "repository settings updated via api"); | |
| 132 | + Ok(Json(repo_dto(&state.config, &repo, Some(access)))) | |
| 133 | +} | |
| 134 | + | |
| 135 | +pub async fn delete(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, Path((owner, name)): Path<(String, String)>) -> ApiResult<StatusCode> { | |
| 136 | + need_scope(&viewer, Scope::Repo)?; | |
| 137 | + let (repo, _) = readable(&state, Some(&viewer), &owner, &name).await?; | |
| 138 | + ops::delete_repo(&state, &viewer, &repo).await?; | |
| 139 | + Ok(StatusCode::NO_CONTENT) | |
| 140 | +} | |
| 141 | + | |
| 142 | +pub async fn collaborators( | |
| 143 | + State(state): State<AppState>, | |
| 144 | + viewer: Option<ApiViewer>, | |
| 145 | + Path((owner, name)): Path<(String, String)>, | |
| 146 | +) -> ApiResult<Json<Vec<shared::Collaborator>>> { | |
| 147 | + let viewer = viewer.map(|ApiViewer(v)| v); | |
| 148 | + let (repo, _) = readable(&state, viewer.as_ref(), &owner, &name).await?; | |
| 149 | + let rows: Vec<(String, Option<String>, String, chrono::DateTime<chrono::Utc>)> = sqlx::query_as( | |
| 150 | + "select a.name::text, a.avatar_key, c.permission, c.created_at | |
| 151 | + from repo_collaborators c join accounts a on a.id = c.user_id | |
| 152 | + where c.repo_id = $1 order by a.name", | |
| 153 | + ) | |
| 154 | + .bind(repo.id) | |
| 155 | + .fetch_all(&state.db) | |
| 156 | + .await?; | |
| 157 | + Ok(Json( | |
| 158 | + rows.into_iter() | |
| 159 | + .map(|(username, key, permission, created_at)| shared::Collaborator { | |
| 160 | + avatar_url: avatar_url(&state.config, &username, key.as_deref()), | |
| 161 | + username, | |
| 162 | + permission, | |
| 163 | + created_at, | |
| 164 | + }) | |
| 165 | + .collect(), | |
| 166 | + )) | |
| 167 | +} | |
| 168 | + | |
| 169 | +pub async fn set_collaborator( | |
| 170 | + State(state): State<AppState>, | |
| 171 | + ApiViewer(viewer): ApiViewer, | |
| 172 | + Path((owner, name, user)): Path<(String, String, String)>, | |
| 173 | + ApiJson(body): ApiJson<shared::SetPermission>, | |
| 174 | +) -> ApiResult<Json<shared::Collaborator>> { | |
| 175 | + need_scope(&viewer, Scope::Repo)?; | |
| 176 | + let (repo, access) = readable(&state, Some(&viewer), &owner, &name).await?; | |
| 177 | + if !access.is_admin() { | |
| 178 | + return Err(ApiError(AppError::forbidden("Only repository admins can manage collaborators."))); | |
| 179 | + } | |
| 180 | + if !matches!(body.permission.as_str(), "read" | "write" | "admin") { | |
| 181 | + return Err(ApiError(AppError::bad("Permission must be read, write or admin."))); | |
| 182 | + } | |
| 183 | + let target = account_or_404(&state, &user).await?; | |
| 184 | + if target.is_org() { | |
| 185 | + return Err(ApiError(AppError::bad("Collaborators must be users, not organizations."))); | |
| 186 | + } | |
| 187 | + if target.id == repo.owner_id { | |
| 188 | + return Err(ApiError(AppError::bad("The owner already has full access."))); | |
| 189 | + } | |
| 190 | + let created_at: chrono::DateTime<chrono::Utc> = sqlx::query_scalar( | |
| 191 | + "insert into repo_collaborators (repo_id, user_id, permission) values ($1, $2, $3) | |
| 192 | + on conflict (repo_id, user_id) do update set permission = excluded.permission | |
| 193 | + returning created_at", | |
| 194 | + ) | |
| 195 | + .bind(repo.id) | |
| 196 | + .bind(target.id) | |
| 197 | + .bind(&body.permission) | |
| 198 | + .fetch_one(&state.db) | |
| 199 | + .await?; | |
| 200 | + audit(&state.db, Some(viewer.id), "repo.collaborator.set", &repo.full_name(), json!({ "user": target.name, "permission": body.permission }), None).await; | |
| 201 | + analytics::track(&state, "collaborator_added", Some(&viewer.name), &repo.url(), json!({ "permission": body.permission, "via": "api" })); | |
| 202 | + Ok(Json(shared::Collaborator { | |
| 203 | + avatar_url: avatar_url(&state.config, &target.name, target.avatar_key.as_deref()), | |
| 204 | + username: target.name, | |
| 205 | + permission: body.permission, | |
| 206 | + created_at, | |
| 207 | + })) | |
| 208 | +} | |
| 209 | + | |
| 210 | +pub async fn remove_collaborator( | |
| 211 | + State(state): State<AppState>, | |
| 212 | + ApiViewer(viewer): ApiViewer, | |
| 213 | + Path((owner, name, user)): Path<(String, String, String)>, | |
| 214 | +) -> ApiResult<StatusCode> { | |
| 215 | + need_scope(&viewer, Scope::Repo)?; | |
| 216 | + let (repo, access) = readable(&state, Some(&viewer), &owner, &name).await?; | |
| 217 | + let target = account_or_404(&state, &user).await?; | |
| 218 | + // Admins remove anyone; collaborators may remove themselves. | |
| 219 | + if !access.is_admin() && target.id != viewer.id { | |
| 220 | + return Err(ApiError(AppError::forbidden("Only repository admins can manage collaborators."))); | |
| 221 | + } | |
| 222 | + let removed = sqlx::query("delete from repo_collaborators where repo_id = $1 and user_id = $2") | |
| 223 | + .bind(repo.id) | |
| 224 | + .bind(target.id) | |
| 225 | + .execute(&state.db) | |
| 226 | + .await? | |
| 227 | + .rows_affected(); | |
| 228 | + if removed == 0 { | |
| 229 | + return Err(ApiError(AppError::NotFound)); | |
| 230 | + } | |
| 231 | + audit(&state.db, Some(viewer.id), "repo.collaborator.remove", &repo.full_name(), json!({ "user": target.name }), None).await; | |
| 232 | + Ok(StatusCode::NO_CONTENT) | |
| 233 | +} |
+94-0backend/src/api/users.rs
| @@ -0,0 +1,94 @@ | ||
| 1 | +//! /api/v1/user, /api/v1/users/{name}, and contribution counts. | |
| 2 | + | |
| 3 | +use axum::{ | |
| 4 | + Json, | |
| 5 | + extract::{Path, State}, | |
| 6 | +}; | |
| 7 | +use chrono::{Duration, NaiveDate, Utc}; | |
| 8 | +use irongit_shared as shared; | |
| 9 | + | |
| 10 | +use super::{account_dto, account_or_404, avatar_url, scope_names}; | |
| 11 | +use crate::visible_repos; | |
| 12 | +use crate::{ | |
| 13 | + auth::ApiViewer, | |
| 14 | + error::{ApiError, ApiResult, AppError}, | |
| 15 | + perm::{self, Area}, | |
| 16 | + state::AppState, | |
| 17 | +}; | |
| 18 | + | |
| 19 | +pub async fn me(State(state): State<AppState>, ApiViewer(viewer): ApiViewer) -> ApiResult<Json<shared::User>> { | |
| 20 | + let row: (String, String, String, String, Option<String>, chrono::DateTime<Utc>) = sqlx::query_as( | |
| 21 | + "select display_name, bio, location, website, avatar_key, created_at from accounts where id = $1", | |
| 22 | + ) | |
| 23 | + .bind(viewer.id) | |
| 24 | + .fetch_one(&state.db) | |
| 25 | + .await?; | |
| 26 | + let email = if viewer.scopes.user { | |
| 27 | + sqlx::query_scalar::<_, String>("select email::text from emails where user_id = $1 and is_primary") | |
| 28 | + .bind(viewer.id) | |
| 29 | + .fetch_optional(&state.db) | |
| 30 | + .await? | |
| 31 | + } else { | |
| 32 | + None | |
| 33 | + }; | |
| 34 | + let (display_name, bio, location, website, avatar_key, created_at) = row; | |
| 35 | + Ok(Json(shared::User { | |
| 36 | + id: viewer.id, | |
| 37 | + avatar_url: avatar_url(&state.config, &viewer.name, avatar_key.as_deref()), | |
| 38 | + username: viewer.name.clone(), | |
| 39 | + display_name, | |
| 40 | + bio, | |
| 41 | + location, | |
| 42 | + website, | |
| 43 | + is_admin: viewer.is_admin, | |
| 44 | + email, | |
| 45 | + scopes: scope_names(&viewer), | |
| 46 | + created_at, | |
| 47 | + })) | |
| 48 | +} | |
| 49 | + | |
| 50 | +pub async fn show(State(state): State<AppState>, Path(name): Path<String>) -> ApiResult<Json<shared::Account>> { | |
| 51 | + let account = account_or_404(&state, &name).await?; | |
| 52 | + Ok(Json(account_dto(&state.config, &account))) | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// Commits per day over the last year. Public repos always count; private | |
| 56 | +/// ones count when the user opted in, when the viewer can read the repo, or | |
| 57 | +/// when the viewer is the user. | |
| 58 | +pub async fn contributions( | |
| 59 | + State(state): State<AppState>, | |
| 60 | + viewer: Option<ApiViewer>, | |
| 61 | + Path(name): Path<String>, | |
| 62 | +) -> ApiResult<Json<shared::Contributions>> { | |
| 63 | + let viewer = viewer.map(|ApiViewer(v)| v); | |
| 64 | + let account = account_or_404(&state, &name).await?; | |
| 65 | + if account.is_org() { | |
| 66 | + return Err(ApiError(AppError::bad("Contributions are tracked for users, not organizations."))); | |
| 67 | + } | |
| 68 | + let show_private: bool = sqlx::query_scalar("select show_private_contributions from users where account_id = $1") | |
| 69 | + .bind(account.id) | |
| 70 | + .fetch_one(&state.db) | |
| 71 | + .await?; | |
| 72 | + let is_self = viewer.as_ref().is_some_and(|v| v.id == account.id || v.site_admin()); | |
| 73 | + let (viewer_id, site_admin) = perm::visibility_binds(viewer.as_ref(), Area::Repo); | |
| 74 | + | |
| 75 | + let to = Utc::now().date_naive(); | |
| 76 | + let from = to - Duration::days(364); | |
| 77 | + let rows: Vec<(NaiveDate, i64)> = sqlx::query_as(concat!( | |
| 78 | + "select c.day, count(*)::bigint from contribution_commits c join repos r on r.id = c.repo_id ", | |
| 79 | + "where c.user_id = $3 and c.day between $5 and $6 and ($4 or ", | |
| 80 | + visible_repos!(), | |
| 81 | + ") group by c.day order by c.day" | |
| 82 | + )) | |
| 83 | + .bind(viewer_id) | |
| 84 | + .bind(site_admin) | |
| 85 | + .bind(account.id) | |
| 86 | + .bind(show_private || is_self) | |
| 87 | + .bind(from) | |
| 88 | + .bind(to) | |
| 89 | + .fetch_all(&state.db) | |
| 90 | + .await?; | |
| 91 | + | |
| 92 | + let days: Vec<shared::ContributionDay> = rows.into_iter().map(|(date, count)| shared::ContributionDay { date, count }).collect(); | |
| 93 | + Ok(Json(shared::Contributions { username: account.name, total: days.iter().map(|d| d.count).sum(), from, to, days })) | |
| 94 | +} |
+1-0cli/Cargo.toml
| @@ -5,6 +5,7 @@ edition = "2024" | ||
| 5 | 5 | |
| 6 | 6 | [dependencies] |
| 7 | 7 | anyhow = "1.0.104" |
| 8 | +chrono = "0.4.45" | |
| 8 | 9 | clap = { version = "4.6.7", features = ["derive", "env"] } |
| 9 | 10 | dirs = "7.0.0" |
| 10 | 11 | hex = "0.4.3" |
+374-0cli/src/auth.rs
| @@ -0,0 +1,374 @@ | ||
| 1 | +//! Signing in, and making git and docker use the saved token. | |
| 2 | + | |
| 3 | +use std::{ | |
| 4 | + io::{BufRead, Read, Write}, | |
| 5 | + path::{Path, PathBuf}, | |
| 6 | + process::{Command, Stdio}, | |
| 7 | + time::{Duration, Instant}, | |
| 8 | +}; | |
| 9 | + | |
| 10 | +use anyhow::{Context, bail}; | |
| 11 | +use irongit_shared::{self as shared, device_errors}; | |
| 12 | +use serde_json::{Value, json}; | |
| 13 | + | |
| 14 | +use crate::{ | |
| 15 | + client::{ApiFailure, Client}, | |
| 16 | + config::{self, Config, HostEntry}, | |
| 17 | + output::{bold, dim, green, yellow}, | |
| 18 | +}; | |
| 19 | + | |
| 20 | +pub struct LoginOptions { | |
| 21 | + pub host: Option<String>, | |
| 22 | + pub with_token: bool, | |
| 23 | + pub skip_setup: bool, | |
| 24 | +} | |
| 25 | + | |
| 26 | +pub fn login(options: LoginOptions) -> anyhow::Result<()> { | |
| 27 | + let mut config = Config::load()?; | |
| 28 | + let host = config.resolve_host(options.host.as_deref())?; | |
| 29 | + | |
| 30 | + let (username, token) = if options.with_token { | |
| 31 | + let mut token = String::new(); | |
| 32 | + std::io::stdin().read_to_string(&mut token)?; | |
| 33 | + let token = token.trim().to_string(); | |
| 34 | + if token.is_empty() { | |
| 35 | + bail!("no token on stdin. Example: echo igp_... | ig login --with-token --host {host}"); | |
| 36 | + } | |
| 37 | + let user: shared::User = Client::new(&host, Some(token.clone()))?.get("/api/v1/user").context("the token was not accepted")?; | |
| 38 | + (user.username, token) | |
| 39 | + } else { | |
| 40 | + device_login(&host)? | |
| 41 | + }; | |
| 42 | + | |
| 43 | + config.hosts.insert(host.clone(), HostEntry { username: username.clone(), token }); | |
| 44 | + config.default_host = Some(host.clone()); | |
| 45 | + config.save()?; | |
| 46 | + println!("{} Logged in to {host} as {}", green("✓"), bold(&username)); | |
| 47 | + println!("{}", dim(&format!(" token saved in {}", config::path()?.display()))); | |
| 48 | + | |
| 49 | + if !options.skip_setup { | |
| 50 | + match setup_git(&host) { | |
| 51 | + Ok(()) => println!("{} git uses ig for {host} credentials", green("✓")), | |
| 52 | + Err(error) => println!("{} could not configure git: {error:#}", yellow("!")), | |
| 53 | + } | |
| 54 | + match setup_docker(&host) { | |
| 55 | + Ok(note) => println!("{} docker uses ig for {}{note}", green("✓"), config::authority(&host)), | |
| 56 | + Err(error) => println!("{} could not configure docker: {error:#}", yellow("!")), | |
| 57 | + } | |
| 58 | + } | |
| 59 | + Ok(()) | |
| 60 | +} | |
| 61 | + | |
| 62 | +fn machine_name() -> String { | |
| 63 | + std::fs::read_to_string("/proc/sys/kernel/hostname") | |
| 64 | + .or_else(|_| std::fs::read_to_string("/etc/hostname")) | |
| 65 | + .map(|s| s.trim().to_string()) | |
| 66 | + .ok() | |
| 67 | + .filter(|s| !s.is_empty()) | |
| 68 | + .unwrap_or_else(|| "this computer".into()) | |
| 69 | +} | |
| 70 | + | |
| 71 | +fn device_login(host: &str) -> anyhow::Result<(String, String)> { | |
| 72 | + let client = Client::new(host, None)?; | |
| 73 | + let code: shared::DeviceCodeResponse = | |
| 74 | + client.post("/api/v1/device/code", &shared::DeviceCodeRequest { client_name: machine_name() }).context("starting device login")?; | |
| 75 | + | |
| 76 | + println!("First copy your one-time code: {}", bold(&code.user_code)); | |
| 77 | + println!("Then open {} and approve it.", bold(&code.verification_uri_complete)); | |
| 78 | + open_browser(&code.verification_uri_complete); | |
| 79 | + println!("{}", dim("Waiting for approval...")); | |
| 80 | + | |
| 81 | + let deadline = Instant::now() + Duration::from_secs(code.expires_in); | |
| 82 | + let mut interval = Duration::from_secs(code.interval.max(1)); | |
| 83 | + let request = shared::DeviceTokenRequest { device_code: code.device_code.clone() }; | |
| 84 | + loop { | |
| 85 | + if Instant::now() > deadline { | |
| 86 | + bail!("the code expired before it was approved. Run ig login again."); | |
| 87 | + } | |
| 88 | + std::thread::sleep(interval); | |
| 89 | + match client.post::<_, shared::DeviceTokenResponse>("/api/v1/device/token", &request) { | |
| 90 | + Ok(done) => return Ok((done.username, done.token)), | |
| 91 | + Err(error) => match error.downcast_ref::<ApiFailure>() { | |
| 92 | + Some(f) if f.code == device_errors::PENDING => continue, | |
| 93 | + Some(f) if f.code == device_errors::SLOW_DOWN => interval += Duration::from_secs(5), | |
| 94 | + Some(f) if f.code == device_errors::DENIED => bail!("the login was denied in the browser"), | |
| 95 | + Some(f) if f.code == device_errors::EXPIRED => bail!("the code expired or was already used. Run ig login again."), | |
| 96 | + _ => return Err(error.context("waiting for approval")), | |
| 97 | + }, | |
| 98 | + } | |
| 99 | + } | |
| 100 | +} | |
| 101 | + | |
| 102 | +/// Best effort: a browser if there is a display, never blocking or failing. | |
| 103 | +fn open_browser(url: &str) { | |
| 104 | + if std::env::var_os("IG_NO_BROWSER").is_some() { | |
| 105 | + return; | |
| 106 | + } | |
| 107 | + if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() { | |
| 108 | + return; | |
| 109 | + } | |
| 110 | + let _ = Command::new("xdg-open").arg(url).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn(); | |
| 111 | +} | |
| 112 | + | |
| 113 | +pub fn logout(host_flag: Option<&str>) -> anyhow::Result<()> { | |
| 114 | + let mut config = Config::load()?; | |
| 115 | + let host = config.resolve_host(host_flag)?; | |
| 116 | + let Some(entry) = config.hosts.remove(&host) else { | |
| 117 | + bail!("not logged in to {host}"); | |
| 118 | + }; | |
| 119 | + // Revoke the token server-side too, matched by its visible prefix. | |
| 120 | + let client = Client::new(&host, Some(entry.token.clone()))?; | |
| 121 | + let revoked = client | |
| 122 | + .get::<Vec<shared::Token>>("/api/v1/user/tokens") | |
| 123 | + .ok() | |
| 124 | + .and_then(|tokens| tokens.into_iter().find(|t| entry.token.starts_with(&t.prefix))) | |
| 125 | + .map(|t| client.delete(&format!("/api/v1/user/tokens/{}", t.id)).is_ok()) | |
| 126 | + .unwrap_or(false); | |
| 127 | + if config.default_host.as_deref() == Some(host.as_str()) { | |
| 128 | + config.default_host = config.hosts.keys().next().cloned(); | |
| 129 | + } | |
| 130 | + config.save()?; | |
| 131 | + println!("{} Logged out of {host}{}", green("✓"), if revoked { " and revoked the token" } else { "" }); | |
| 132 | + Ok(()) | |
| 133 | +} | |
| 134 | + | |
| 135 | +pub fn status(host_flag: Option<&str>, json_output: bool) -> anyhow::Result<()> { | |
| 136 | + let config = Config::load()?; | |
| 137 | + let hosts: Vec<String> = match host_flag { | |
| 138 | + Some(h) => vec![config::normalize_host(h)?], | |
| 139 | + None => config.hosts.keys().cloned().collect(), | |
| 140 | + }; | |
| 141 | + if hosts.is_empty() { | |
| 142 | + bail!("not logged in anywhere. Run: ig login --host https://your-irongit-server"); | |
| 143 | + } | |
| 144 | + let mut report = Vec::new(); | |
| 145 | + for host in hosts { | |
| 146 | + let default = config.default_host.as_deref() == Some(host.as_str()); | |
| 147 | + let result = match config.token_for(&host) { | |
| 148 | + Some(token) => Client::new(&host, Some(token))?.get::<shared::User>("/api/v1/user"), | |
| 149 | + None => Err(anyhow::anyhow!("no token saved")), | |
| 150 | + }; | |
| 151 | + match result { | |
| 152 | + Ok(user) => { | |
| 153 | + if !json_output { | |
| 154 | + println!("{host}{}", if default { dim(" (default)") } else { String::new() }); | |
| 155 | + println!(" {} Logged in as {} (scopes: {})", green("✓"), bold(&user.username), user.scopes.join(", ")); | |
| 156 | + println!(" git helper: {}", if git_helper_configured(&host) { "configured" } else { "not configured (ig auth setup-git)" }); | |
| 157 | + println!(" docker helper: {}", if docker_helper_configured(&host) { "configured" } else { "not configured (ig auth setup-docker)" }); | |
| 158 | + } | |
| 159 | + report.push(json!({ "host": host, "default": default, "username": user.username, "scopes": user.scopes, "ok": true })); | |
| 160 | + } | |
| 161 | + Err(error) => { | |
| 162 | + if !json_output { | |
| 163 | + println!("{host}{}", if default { dim(" (default)") } else { String::new() }); | |
| 164 | + println!(" {} {error:#}", yellow("!")); | |
| 165 | + } | |
| 166 | + report.push(json!({ "host": host, "default": default, "ok": false, "error": format!("{error:#}") })); | |
| 167 | + } | |
| 168 | + } | |
| 169 | + } | |
| 170 | + if json_output { | |
| 171 | + crate::output::json(&report)?; | |
| 172 | + } | |
| 173 | + Ok(()) | |
| 174 | +} | |
| 175 | + | |
| 176 | +pub fn print_token(host_flag: Option<&str>) -> anyhow::Result<()> { | |
| 177 | + let config = Config::load()?; | |
| 178 | + let host = config.resolve_host(host_flag)?; | |
| 179 | + match config.token_for(&host) { | |
| 180 | + Some(token) => { | |
| 181 | + println!("{token}"); | |
| 182 | + Ok(()) | |
| 183 | + } | |
| 184 | + None => bail!("not logged in to {host}"), | |
| 185 | + } | |
| 186 | +} | |
| 187 | + | |
| 188 | +// --------------------------------------------------------------------------- | |
| 189 | +// git | |
| 190 | + | |
| 191 | +fn current_exe() -> anyhow::Result<PathBuf> { | |
| 192 | + std::env::current_exe().context("cannot locate the ig executable") | |
| 193 | +} | |
| 194 | + | |
| 195 | +fn sh_quote(path: &Path) -> String { | |
| 196 | + format!("'{}'", path.display().to_string().replace('\'', "'\\''")) | |
| 197 | +} | |
| 198 | + | |
| 199 | +fn git_helper_value() -> anyhow::Result<String> { | |
| 200 | + Ok(format!("!{} auth git-credential", sh_quote(¤t_exe()?))) | |
| 201 | +} | |
| 202 | + | |
| 203 | +/// `credential.<host>.helper`: reset to empty first so helpers from other | |
| 204 | +/// config (a keychain) are not consulted for this host, then add ig. | |
| 205 | +pub fn setup_git(host: &str) -> anyhow::Result<()> { | |
| 206 | + let key = format!("credential.{host}.helper"); | |
| 207 | + let run = |args: &[&str]| -> anyhow::Result<()> { | |
| 208 | + let status = Command::new("git").args(args).stdin(Stdio::null()).status().context("running git (is it installed?)")?; | |
| 209 | + if !status.success() { | |
| 210 | + bail!("git {} failed", args.join(" ")); | |
| 211 | + } | |
| 212 | + Ok(()) | |
| 213 | + }; | |
| 214 | + run(&["config", "--global", "--replace-all", &key, ""])?; | |
| 215 | + run(&["config", "--global", "--add", &key, &git_helper_value()?])?; | |
| 216 | + Ok(()) | |
| 217 | +} | |
| 218 | + | |
| 219 | +fn git_helper_configured(host: &str) -> bool { | |
| 220 | + Command::new("git") | |
| 221 | + .args(["config", "--global", "--get-all", &format!("credential.{host}.helper")]) | |
| 222 | + .stderr(Stdio::null()) | |
| 223 | + .output() | |
| 224 | + .map(|o| String::from_utf8_lossy(&o.stdout).contains("auth git-credential")) | |
| 225 | + .unwrap_or(false) | |
| 226 | +} | |
| 227 | + | |
| 228 | +/// The git credential protocol: key=value lines on stdin, answer on stdout. | |
| 229 | +pub fn git_credential(operation: &str) -> anyhow::Result<()> { | |
| 230 | + let mut fields = std::collections::HashMap::new(); | |
| 231 | + for line in std::io::stdin().lock().lines() { | |
| 232 | + let line = line?; | |
| 233 | + if line.is_empty() { | |
| 234 | + break; | |
| 235 | + } | |
| 236 | + if let Some((key, value)) = line.split_once('=') { | |
| 237 | + fields.insert(key.to_string(), value.to_string()); | |
| 238 | + } | |
| 239 | + } | |
| 240 | + if operation != "get" { | |
| 241 | + return Ok(()); // store/erase: tokens are managed by ig login/logout | |
| 242 | + } | |
| 243 | + let (Some(protocol), Some(authority)) = (fields.get("protocol"), fields.get("host")) else { | |
| 244 | + return Ok(()); | |
| 245 | + }; | |
| 246 | + let config = Config::load()?; | |
| 247 | + let host = format!("{protocol}://{}", authority.to_ascii_lowercase()); | |
| 248 | + let Some(entry) = config.hosts.get(&host) else { | |
| 249 | + return Ok(()); // not ours: say nothing so git tries other helpers | |
| 250 | + }; | |
| 251 | + let token = config.token_for(&host).unwrap_or_else(|| entry.token.clone()); | |
| 252 | + let mut out = std::io::stdout().lock(); | |
| 253 | + writeln!(out, "protocol={protocol}")?; | |
| 254 | + writeln!(out, "host={authority}")?; | |
| 255 | + writeln!(out, "username={}", entry.username)?; | |
| 256 | + writeln!(out, "password={token}")?; | |
| 257 | + Ok(()) | |
| 258 | +} | |
| 259 | + | |
| 260 | +// --------------------------------------------------------------------------- | |
| 261 | +// docker | |
| 262 | + | |
| 263 | +fn docker_config_path() -> anyhow::Result<PathBuf> { | |
| 264 | + if let Some(dir) = std::env::var_os("DOCKER_CONFIG") { | |
| 265 | + return Ok(PathBuf::from(dir).join("config.json")); | |
| 266 | + } | |
| 267 | + Ok(dirs::home_dir().context("HOME is not set")?.join(".docker").join("config.json")) | |
| 268 | +} | |
| 269 | + | |
| 270 | +fn read_docker_config() -> anyhow::Result<Value> { | |
| 271 | + let path = docker_config_path()?; | |
| 272 | + match std::fs::read_to_string(&path) { | |
| 273 | + Ok(text) if !text.trim().is_empty() => serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display())), | |
| 274 | + Ok(_) => Ok(json!({})), | |
| 275 | + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(json!({})), | |
| 276 | + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), | |
| 277 | + } | |
| 278 | +} | |
| 279 | + | |
| 280 | +fn on_path(name: &str) -> Option<PathBuf> { | |
| 281 | + std::env::var_os("PATH").and_then(|paths| std::env::split_paths(&paths).map(|dir| dir.join(name)).find(|p| p.is_file())) | |
| 282 | +} | |
| 283 | + | |
| 284 | +/// Points docker at `docker-credential-ig` for this registry and makes sure | |
| 285 | +/// that name exists next to ig. Returns a note for the user, if any. | |
| 286 | +pub fn setup_docker(host: &str) -> anyhow::Result<String> { | |
| 287 | + let path = docker_config_path()?; | |
| 288 | + let mut doc = read_docker_config()?; | |
| 289 | + let registry = config::authority(host); | |
| 290 | + let object = doc.as_object_mut().context("docker config is not a JSON object")?; | |
| 291 | + let helpers = object.entry("credHelpers").or_insert_with(|| json!({})); | |
| 292 | + helpers.as_object_mut().context("credHelpers is not an object")?.insert(registry, json!("ig")); | |
| 293 | + if let Some(dir) = path.parent() { | |
| 294 | + std::fs::create_dir_all(dir)?; | |
| 295 | + } | |
| 296 | + std::fs::write(&path, serde_json::to_string_pretty(&doc)? + "\n").with_context(|| format!("writing {}", path.display()))?; | |
| 297 | + | |
| 298 | + if on_path("docker-credential-ig").is_some() { | |
| 299 | + return Ok(String::new()); | |
| 300 | + } | |
| 301 | + let exe = current_exe()?; | |
| 302 | + let link = exe.with_file_name("docker-credential-ig"); | |
| 303 | + if !link.exists() { | |
| 304 | + #[cfg(unix)] | |
| 305 | + std::os::unix::fs::symlink(&exe, &link).with_context(|| format!("creating {}", link.display()))?; | |
| 306 | + } | |
| 307 | + if on_path("docker-credential-ig").is_some() { | |
| 308 | + Ok(String::new()) | |
| 309 | + } else { | |
| 310 | + Ok(format!(" (add {} to PATH so docker can find docker-credential-ig)", link.parent().map(|p| p.display().to_string()).unwrap_or_default())) | |
| 311 | + } | |
| 312 | +} | |
| 313 | + | |
| 314 | +fn docker_helper_configured(host: &str) -> bool { | |
| 315 | + read_docker_config() | |
| 316 | + .ok() | |
| 317 | + .and_then(|doc| doc.get("credHelpers")?.get(config::authority(host))?.as_str().map(|s| s == "ig")) | |
| 318 | + .unwrap_or(false) | |
| 319 | +} | |
| 320 | + | |
| 321 | +/// "https://localhost:7878/v2/" -> "localhost:7878" | |
| 322 | +fn registry_authority(server_url: &str) -> String { | |
| 323 | + config::authority(server_url.trim()).trim_end_matches('/').to_string() | |
| 324 | +} | |
| 325 | + | |
| 326 | +/// Entry point when invoked as `docker-credential-ig <get|store|erase|list>`. | |
| 327 | +pub fn docker_credential_helper(args: &[String]) -> anyhow::Result<()> { | |
| 328 | + let operation = args.get(1).map(String::as_str).unwrap_or(""); | |
| 329 | + let mut input = String::new(); | |
| 330 | + if operation != "list" { | |
| 331 | + std::io::stdin().read_to_string(&mut input)?; | |
| 332 | + } | |
| 333 | + let config = Config::load()?; | |
| 334 | + match operation { | |
| 335 | + "get" => { | |
| 336 | + let server = input.trim(); | |
| 337 | + let Some((host, entry)) = config.find_by_authority(®istry_authority(server)) else { | |
| 338 | + // Docker recognizes exactly this message as "no credentials". | |
| 339 | + println!("credentials not found in native keychain"); | |
| 340 | + std::process::exit(1); | |
| 341 | + }; | |
| 342 | + let token = config.token_for(host).unwrap_or_else(|| entry.token.clone()); | |
| 343 | + println!("{}", json!({ "ServerURL": server, "Username": entry.username, "Secret": token })); | |
| 344 | + Ok(()) | |
| 345 | + } | |
| 346 | + "list" => { | |
| 347 | + let map: serde_json::Map<String, Value> = | |
| 348 | + config.hosts.iter().map(|(host, entry)| (config::authority(host), json!(entry.username))).collect(); | |
| 349 | + println!("{}", Value::Object(map)); | |
| 350 | + Ok(()) | |
| 351 | + } | |
| 352 | + // Tokens come from `ig login`; `docker login` cannot replace them. | |
| 353 | + "store" | "erase" => Ok(()), | |
| 354 | + other => bail!("docker-credential-ig: unknown operation '{other}' (expected get, store, erase or list)"), | |
| 355 | + } | |
| 356 | +} | |
| 357 | + | |
| 358 | +#[cfg(test)] | |
| 359 | +mod tests { | |
| 360 | + use super::*; | |
| 361 | + | |
| 362 | + #[test] | |
| 363 | + fn registry_authority_strips_scheme_and_path() { | |
| 364 | + assert_eq!(registry_authority("localhost:7878"), "localhost:7878"); | |
| 365 | + assert_eq!(registry_authority("https://git.example.com/v2/"), "git.example.com"); | |
| 366 | + assert_eq!(registry_authority("http://localhost:7878\n"), "localhost:7878"); | |
| 367 | + } | |
| 368 | + | |
| 369 | + #[test] | |
| 370 | + fn helper_paths_are_shell_quoted() { | |
| 371 | + assert_eq!(sh_quote(Path::new("/opt/my tools/ig")), "'/opt/my tools/ig'"); | |
| 372 | + assert_eq!(sh_quote(Path::new("/a'b/ig")), r"'/a'\''b/ig'"); | |
| 373 | + } | |
| 374 | +} |
+135-0cli/src/client.rs
| @@ -0,0 +1,135 @@ | ||
| 1 | +//! Thin blocking client for /api/v1 that turns error bodies into readable | |
| 2 | +//! messages. | |
| 3 | + | |
| 4 | +use std::time::Duration; | |
| 5 | + | |
| 6 | +use anyhow::{Context, anyhow}; | |
| 7 | +use irongit_shared::ErrorBody; | |
| 8 | +use reqwest::{Method, StatusCode, blocking::Response}; | |
| 9 | +use serde::{Serialize, de::DeserializeOwned}; | |
| 10 | + | |
| 11 | +use crate::VERSION; | |
| 12 | + | |
| 13 | +pub struct Client { | |
| 14 | + http: reqwest::blocking::Client, | |
| 15 | + pub host: String, | |
| 16 | + token: Option<String>, | |
| 17 | +} | |
| 18 | + | |
| 19 | +/// An HTTP error from the server, kept typed so callers can branch on it. | |
| 20 | +#[derive(Debug)] | |
| 21 | +pub struct ApiFailure { | |
| 22 | + pub status: StatusCode, | |
| 23 | + pub code: String, | |
| 24 | +} | |
| 25 | + | |
| 26 | +impl std::fmt::Display for ApiFailure { | |
| 27 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 28 | + write!(f, "{} (HTTP {})", self.code, self.status.as_u16()) | |
| 29 | + } | |
| 30 | +} | |
| 31 | + | |
| 32 | +impl std::error::Error for ApiFailure {} | |
| 33 | + | |
| 34 | +pub fn http_client() -> anyhow::Result<reqwest::blocking::Client> { | |
| 35 | + Ok(reqwest::blocking::Client::builder() | |
| 36 | + .user_agent(format!("ig/{VERSION}")) | |
| 37 | + .connect_timeout(Duration::from_secs(10)) | |
| 38 | + .timeout(Duration::from_secs(300)) | |
| 39 | + .build()?) | |
| 40 | +} | |
| 41 | + | |
| 42 | +impl Client { | |
| 43 | + pub fn new(host: &str, token: Option<String>) -> anyhow::Result<Self> { | |
| 44 | + Ok(Self { http: http_client()?, host: host.to_string(), token }) | |
| 45 | + } | |
| 46 | + | |
| 47 | + pub fn url(&self, path: &str) -> String { | |
| 48 | + format!("{}{}", self.host, path) | |
| 49 | + } | |
| 50 | + | |
| 51 | + fn send(&self, method: Method, path: &str, body: Option<&serde_json::Value>) -> anyhow::Result<Response> { | |
| 52 | + let mut request = self.http.request(method.clone(), self.url(path)).header("accept", "application/json"); | |
| 53 | + if let Some(token) = &self.token { | |
| 54 | + request = request.bearer_auth(token); | |
| 55 | + } | |
| 56 | + if let Some(body) = body { | |
| 57 | + request = request.json(body); | |
| 58 | + } | |
| 59 | + request.send().with_context(|| format!("could not reach {} ({method} {path})", self.host)) | |
| 60 | + } | |
| 61 | + | |
| 62 | + fn check(&self, response: Response) -> anyhow::Result<Response> { | |
| 63 | + let status = response.status(); | |
| 64 | + if status.is_success() { | |
| 65 | + return Ok(response); | |
| 66 | + } | |
| 67 | + let text = response.text().unwrap_or_default(); | |
| 68 | + let code = serde_json::from_str::<ErrorBody>(&text).map(|b| b.error).unwrap_or_else(|_| text.trim().chars().take(300).collect()); | |
| 69 | + let failure = ApiFailure { status, code }; | |
| 70 | + if status == StatusCode::UNAUTHORIZED { | |
| 71 | + return Err(anyhow!(failure).context(format!("not signed in to {} or the token was revoked. Run: ig login --host {}", self.host, self.host))); | |
| 72 | + } | |
| 73 | + Err(anyhow!(failure)) | |
| 74 | + } | |
| 75 | + | |
| 76 | + pub fn get<T: DeserializeOwned>(&self, path: &str) -> anyhow::Result<T> { | |
| 77 | + let response = self.check(self.send(Method::GET, path, None)?)?; | |
| 78 | + Ok(response.json().with_context(|| format!("unexpected response from GET {path}"))?) | |
| 79 | + } | |
| 80 | + | |
| 81 | + pub fn post<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> anyhow::Result<T> { | |
| 82 | + let body = serde_json::to_value(body)?; | |
| 83 | + let response = self.check(self.send(Method::POST, path, Some(&body))?)?; | |
| 84 | + Ok(response.json().with_context(|| format!("unexpected response from POST {path}"))?) | |
| 85 | + } | |
| 86 | + | |
| 87 | + pub fn patch<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> anyhow::Result<T> { | |
| 88 | + let body = serde_json::to_value(body)?; | |
| 89 | + let response = self.check(self.send(Method::PATCH, path, Some(&body))?)?; | |
| 90 | + Ok(response.json().with_context(|| format!("unexpected response from PATCH {path}"))?) | |
| 91 | + } | |
| 92 | + | |
| 93 | + pub fn put<B: Serialize, T: DeserializeOwned>(&self, path: &str, body: &B) -> anyhow::Result<T> { | |
| 94 | + let body = serde_json::to_value(body)?; | |
| 95 | + let response = self.check(self.send(Method::PUT, path, Some(&body))?)?; | |
| 96 | + Ok(response.json().with_context(|| format!("unexpected response from PUT {path}"))?) | |
| 97 | + } | |
| 98 | + | |
| 99 | + pub fn delete(&self, path: &str) -> anyhow::Result<()> { | |
| 100 | + self.check(self.send(Method::DELETE, path, None)?)?; | |
| 101 | + Ok(()) | |
| 102 | + } | |
| 103 | + | |
| 104 | + /// Any request, returning status and body without judging them. | |
| 105 | + pub fn raw(&self, method: Method, path: &str, body: Option<&serde_json::Value>) -> anyhow::Result<(StatusCode, String)> { | |
| 106 | + let response = self.send(method, path, body)?; | |
| 107 | + let status = response.status(); | |
| 108 | + Ok((status, response.text().unwrap_or_default())) | |
| 109 | + } | |
| 110 | +} | |
| 111 | + | |
| 112 | +/// Percent-encodes one path segment ("tools/builder" -> "tools%2Fbuilder"). | |
| 113 | +pub fn segment(value: &str) -> String { | |
| 114 | + let mut out = String::with_capacity(value.len()); | |
| 115 | + for byte in value.bytes() { | |
| 116 | + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { | |
| 117 | + out.push(byte as char); | |
| 118 | + } else { | |
| 119 | + out.push_str(&format!("%{byte:02X}")); | |
| 120 | + } | |
| 121 | + } | |
| 122 | + out | |
| 123 | +} | |
| 124 | + | |
| 125 | +#[cfg(test)] | |
| 126 | +mod tests { | |
| 127 | + use super::*; | |
| 128 | + | |
| 129 | + #[test] | |
| 130 | + fn segments_encode_slashes() { | |
| 131 | + assert_eq!(segment("tools/builder"), "tools%2Fbuilder"); | |
| 132 | + assert_eq!(segment("api"), "api"); | |
| 133 | + assert_eq!(segment("a b"), "a%20b"); | |
| 134 | + } | |
| 135 | +} |
+506-0cli/src/cmds.rs
| @@ -0,0 +1,506 @@ | ||
| 1 | +//! Repository, image, key, token, organization and raw API commands. | |
| 2 | + | |
| 3 | +use std::process::Command; | |
| 4 | + | |
| 5 | +use anyhow::{Context, bail}; | |
| 6 | +use irongit_shared as shared; | |
| 7 | +use reqwest::Method; | |
| 8 | +use serde_json::Value; | |
| 9 | + | |
| 10 | +use crate::{ | |
| 11 | + client::{Client, segment}, | |
| 12 | + config::Config, | |
| 13 | + output::{self, ago, bold, bytes, dim, green, table}, | |
| 14 | +}; | |
| 15 | + | |
| 16 | +/// A signed-in client plus who it is signed in as. | |
| 17 | +pub struct Session { | |
| 18 | + pub client: Client, | |
| 19 | + pub username: Option<String>, | |
| 20 | + pub json: bool, | |
| 21 | +} | |
| 22 | + | |
| 23 | +impl Session { | |
| 24 | + pub fn open(host_flag: Option<&str>, json: bool) -> anyhow::Result<Self> { | |
| 25 | + let config = Config::load()?; | |
| 26 | + let host = config.resolve_host(host_flag)?; | |
| 27 | + let token = config.token_for(&host); | |
| 28 | + let username = config.hosts.get(&host).map(|h| h.username.clone()); | |
| 29 | + Ok(Self { client: Client::new(&host, token)?, username, json }) | |
| 30 | + } | |
| 31 | + | |
| 32 | + /// The signed-in username, asking the server if it is not saved. | |
| 33 | + pub fn me(&self) -> anyhow::Result<String> { | |
| 34 | + if let Some(name) = &self.username { | |
| 35 | + return Ok(name.clone()); | |
| 36 | + } | |
| 37 | + Ok(self.client.get::<shared::User>("/api/v1/user")?.username) | |
| 38 | + } | |
| 39 | + | |
| 40 | + /// "owner/name" or just "name" (owned by the signed-in user). | |
| 41 | + pub fn split_repo(&self, spec: &str) -> anyhow::Result<(String, String)> { | |
| 42 | + let spec = spec.trim().trim_end_matches(".git").trim_matches('/'); | |
| 43 | + match spec.split_once('/') { | |
| 44 | + Some((owner, name)) if !owner.is_empty() && !name.is_empty() && !name.contains('/') => Ok((owner.to_string(), name.to_string())), | |
| 45 | + None if !spec.is_empty() => Ok((self.me()?, spec.to_string())), | |
| 46 | + _ => bail!("expected owner/name, got '{spec}'"), | |
| 47 | + } | |
| 48 | + } | |
| 49 | +} | |
| 50 | + | |
| 51 | +// --------------------------------------------------------------------------- | |
| 52 | +// repos | |
| 53 | + | |
| 54 | +pub fn repo_create(s: &Session, name: &str, org: Option<&str>, public: bool, description: Option<&str>) -> anyhow::Result<()> { | |
| 55 | + let body = shared::CreateRepo { | |
| 56 | + owner: org.map(str::to_string), | |
| 57 | + name: name.to_string(), | |
| 58 | + description: description.map(str::to_string), | |
| 59 | + visibility: Some(if public { "public" } else { "private" }.into()), | |
| 60 | + }; | |
| 61 | + let repo: shared::Repo = s.client.post("/api/v1/repos", &body)?; | |
| 62 | + if s.json { | |
| 63 | + return output::json(&repo); | |
| 64 | + } | |
| 65 | + println!("{} Created {} ({})", green("✓"), bold(&repo.full_name), repo.visibility); | |
| 66 | + println!(" {}", repo.web_url); | |
| 67 | + println!(); | |
| 68 | + println!("Push an existing repository:"); | |
| 69 | + println!(" git remote add origin {}", repo.clone_https); | |
| 70 | + println!(" git push -u origin main"); | |
| 71 | + println!("Or clone it: ig repo clone {}", repo.full_name); | |
| 72 | + Ok(()) | |
| 73 | +} | |
| 74 | + | |
| 75 | +pub fn repo_list(s: &Session, owner: Option<&str>) -> anyhow::Result<()> { | |
| 76 | + let owner = match owner { | |
| 77 | + Some(o) => o.to_string(), | |
| 78 | + None => s.me()?, | |
| 79 | + }; | |
| 80 | + let repos: Vec<shared::Repo> = s.client.get(&format!("/api/v1/accounts/{}/repos", segment(&owner)))?; | |
| 81 | + if s.json { | |
| 82 | + return output::json(&repos); | |
| 83 | + } | |
| 84 | + if repos.is_empty() { | |
| 85 | + println!("{}", dim(&format!("{owner} has no repositories you can see."))); | |
| 86 | + return Ok(()); | |
| 87 | + } | |
| 88 | + let rows: Vec<Vec<String>> = repos | |
| 89 | + .iter() | |
| 90 | + .map(|r| { | |
| 91 | + vec![ | |
| 92 | + r.full_name.clone(), | |
| 93 | + format!("{}{}", r.visibility, if r.archived { ", archived" } else { "" }), | |
| 94 | + if r.is_empty { "-".into() } else { r.default_branch.clone() }, | |
| 95 | + bytes(r.size_bytes), | |
| 96 | + ago(r.pushed_at), | |
| 97 | + r.description.chars().take(60).collect(), | |
| 98 | + ] | |
| 99 | + }) | |
| 100 | + .collect(); | |
| 101 | + table(&["NAME", "VISIBILITY", "BRANCH", "SIZE", "PUSHED", "DESCRIPTION"], &rows); | |
| 102 | + Ok(()) | |
| 103 | +} | |
| 104 | + | |
| 105 | +pub fn repo_view(s: &Session, spec: &str) -> anyhow::Result<()> { | |
| 106 | + let (owner, name) = s.split_repo(spec)?; | |
| 107 | + let repo: shared::Repo = s.client.get(&format!("/api/v1/repos/{}/{}", segment(&owner), segment(&name)))?; | |
| 108 | + if s.json { | |
| 109 | + return output::json(&repo); | |
| 110 | + } | |
| 111 | + println!("{} {}", bold(&repo.full_name), dim(&repo.visibility)); | |
| 112 | + if !repo.description.is_empty() { | |
| 113 | + println!("{}", repo.description); | |
| 114 | + } | |
| 115 | + println!(); | |
| 116 | + let rows = vec![ | |
| 117 | + vec!["web".into(), repo.web_url.clone()], | |
| 118 | + vec!["https".into(), repo.clone_https.clone()], | |
| 119 | + vec!["ssh".into(), repo.clone_ssh.clone()], | |
| 120 | + vec!["default branch".into(), if repo.is_empty { "(empty repository)".into() } else { repo.default_branch.clone() }], | |
| 121 | + vec!["size".into(), bytes(repo.size_bytes)], | |
| 122 | + vec!["last push".into(), ago(repo.pushed_at)], | |
| 123 | + vec!["your access".into(), repo.permission.clone().unwrap_or_else(|| "none".into())], | |
| 124 | + ]; | |
| 125 | + for row in rows { | |
| 126 | + println!(" {:<15} {}", dim(&row[0]), row[1]); | |
| 127 | + } | |
| 128 | + if repo.archived { | |
| 129 | + println!("\n This repository is archived (read-only)."); | |
| 130 | + } | |
| 131 | + Ok(()) | |
| 132 | +} | |
| 133 | + | |
| 134 | +pub fn repo_clone(s: &Session, spec: &str, dir: Option<&str>, ssh: bool) -> anyhow::Result<()> { | |
| 135 | + let (owner, name) = s.split_repo(spec)?; | |
| 136 | + let repo: shared::Repo = s.client.get(&format!("/api/v1/repos/{}/{}", segment(&owner), segment(&name)))?; | |
| 137 | + let url = if ssh { &repo.clone_ssh } else { &repo.clone_https }; | |
| 138 | + let mut command = Command::new("git"); | |
| 139 | + if !ssh { | |
| 140 | + // Works even if `ig auth setup-git` was skipped. | |
| 141 | + let exe = std::env::current_exe()?; | |
| 142 | + command.arg("-c").arg(format!("credential.{}.helper=!'{}' auth git-credential", s.client.host, exe.display())); | |
| 143 | + } | |
| 144 | + command.arg("clone").arg(url); | |
| 145 | + if let Some(dir) = dir { | |
| 146 | + command.arg(dir); | |
| 147 | + } | |
| 148 | + let status = command.status().context("running git clone (is git installed?)")?; | |
| 149 | + if !status.success() { | |
| 150 | + std::process::exit(status.code().unwrap_or(1)); | |
| 151 | + } | |
| 152 | + Ok(()) | |
| 153 | +} | |
| 154 | + | |
| 155 | +pub fn repo_delete(s: &Session, spec: &str, yes: bool) -> anyhow::Result<()> { | |
| 156 | + let (owner, name) = s.split_repo(spec)?; | |
| 157 | + if !yes { | |
| 158 | + bail!("deleting {owner}/{name} removes all of its history. Re-run with --yes to confirm."); | |
| 159 | + } | |
| 160 | + s.client.delete(&format!("/api/v1/repos/{}/{}", segment(&owner), segment(&name)))?; | |
| 161 | + println!("{} Deleted {owner}/{name}", green("✓")); | |
| 162 | + Ok(()) | |
| 163 | +} | |
| 164 | + | |
| 165 | +pub fn repo_update(s: &Session, spec: &str, update: shared::UpdateRepo, what: &str) -> anyhow::Result<()> { | |
| 166 | + let (owner, name) = s.split_repo(spec)?; | |
| 167 | + let repo: shared::Repo = s.client.patch(&format!("/api/v1/repos/{}/{}", segment(&owner), segment(&name)), &update)?; | |
| 168 | + if s.json { | |
| 169 | + return output::json(&repo); | |
| 170 | + } | |
| 171 | + println!("{} {} {what}", green("✓"), repo.full_name); | |
| 172 | + Ok(()) | |
| 173 | +} | |
| 174 | + | |
| 175 | +pub fn collab_list(s: &Session, spec: &str) -> anyhow::Result<()> { | |
| 176 | + let (owner, name) = s.split_repo(spec)?; | |
| 177 | + let people: Vec<shared::Collaborator> = s.client.get(&format!("/api/v1/repos/{}/{}/collaborators", segment(&owner), segment(&name)))?; | |
| 178 | + if s.json { | |
| 179 | + return output::json(&people); | |
| 180 | + } | |
| 181 | + if people.is_empty() { | |
| 182 | + println!("{}", dim("No collaborators.")); | |
| 183 | + } | |
| 184 | + table(&["USER", "PERMISSION", "ADDED"], &people.iter().map(|c| vec![c.username.clone(), c.permission.clone(), ago(Some(c.created_at))]).collect::<Vec<_>>()); | |
| 185 | + Ok(()) | |
| 186 | +} | |
| 187 | + | |
| 188 | +pub fn collab_add(s: &Session, spec: &str, user: &str, permission: &str) -> anyhow::Result<()> { | |
| 189 | + let (owner, name) = s.split_repo(spec)?; | |
| 190 | + let c: shared::Collaborator = s.client.put( | |
| 191 | + &format!("/api/v1/repos/{}/{}/collaborators/{}", segment(&owner), segment(&name), segment(user)), | |
| 192 | + &shared::SetPermission { permission: permission.to_string() }, | |
| 193 | + )?; | |
| 194 | + println!("{} {} has {} access to {owner}/{name}", green("✓"), c.username, c.permission); | |
| 195 | + Ok(()) | |
| 196 | +} | |
| 197 | + | |
| 198 | +pub fn collab_remove(s: &Session, spec: &str, user: &str) -> anyhow::Result<()> { | |
| 199 | + let (owner, name) = s.split_repo(spec)?; | |
| 200 | + s.client.delete(&format!("/api/v1/repos/{}/{}/collaborators/{}", segment(&owner), segment(&name), segment(user)))?; | |
| 201 | + println!("{} Removed {user} from {owner}/{name}", green("✓")); | |
| 202 | + Ok(()) | |
| 203 | +} | |
| 204 | + | |
| 205 | +// --------------------------------------------------------------------------- | |
| 206 | +// images | |
| 207 | + | |
| 208 | +/// Parsed image reference: owner, path below the owner, optional tag. | |
| 209 | +#[derive(Debug, PartialEq)] | |
| 210 | +pub struct ImageRef { | |
| 211 | + pub owner: String, | |
| 212 | + pub name: String, | |
| 213 | + pub tag: Option<String>, | |
| 214 | +} | |
| 215 | + | |
| 216 | +/// Accepts "owner/name", "owner/a/b:tag" and "host:port/owner/name:tag". | |
| 217 | +pub fn parse_image(spec: &str) -> anyhow::Result<ImageRef> { | |
| 218 | + let mut parts: Vec<&str> = spec.trim().split('/').filter(|p| !p.is_empty()).collect(); | |
| 219 | + if parts.len() >= 3 && (parts[0].contains('.') || parts[0].contains(':') || parts[0] == "localhost") { | |
| 220 | + parts.remove(0); | |
| 221 | + } | |
| 222 | + if parts.len() < 2 { | |
| 223 | + bail!("expected owner/name[:tag], got '{spec}'"); | |
| 224 | + } | |
| 225 | + let owner = parts[0].to_string(); | |
| 226 | + let mut rest = parts[1..].join("/"); | |
| 227 | + let mut tag = None; | |
| 228 | + if let Some(last) = rest.rsplit('/').next() { | |
| 229 | + if let Some((_, t)) = last.split_once(':') { | |
| 230 | + tag = Some(t.to_string()); | |
| 231 | + rest = rest[..rest.len() - t.len() - 1].to_string(); | |
| 232 | + } | |
| 233 | + } | |
| 234 | + Ok(ImageRef { owner, name: rest.to_ascii_lowercase(), tag }) | |
| 235 | +} | |
| 236 | + | |
| 237 | +fn package_path(image: &ImageRef) -> String { | |
| 238 | + format!("/api/v1/packages/{}/{}", segment(&image.owner), segment(&image.name)) | |
| 239 | +} | |
| 240 | + | |
| 241 | +pub fn image_list(s: &Session, owner: Option<&str>) -> anyhow::Result<()> { | |
| 242 | + let owner = match owner { | |
| 243 | + Some(o) => o.to_string(), | |
| 244 | + None => s.me()?, | |
| 245 | + }; | |
| 246 | + let packages: Vec<shared::Package> = s.client.get(&format!("/api/v1/accounts/{}/packages", segment(&owner)))?; | |
| 247 | + if s.json { | |
| 248 | + return output::json(&packages); | |
| 249 | + } | |
| 250 | + if packages.is_empty() { | |
| 251 | + println!("{}", dim(&format!("{owner} has no images you can see."))); | |
| 252 | + return Ok(()); | |
| 253 | + } | |
| 254 | + let rows: Vec<Vec<String>> = packages | |
| 255 | + .iter() | |
| 256 | + .map(|p| vec![p.full_name.clone(), p.visibility.clone(), p.tag_count.to_string(), p.pull_count.to_string(), ago(Some(p.updated_at)), p.repo.clone().unwrap_or_default()]) | |
| 257 | + .collect(); | |
| 258 | + table(&["IMAGE", "VISIBILITY", "TAGS", "PULLS", "UPDATED", "REPO"], &rows); | |
| 259 | + Ok(()) | |
| 260 | +} | |
| 261 | + | |
| 262 | +pub fn image_tags(s: &Session, spec: &str) -> anyhow::Result<()> { | |
| 263 | + let image = parse_image(spec)?; | |
| 264 | + let tags: Vec<shared::Tag> = s.client.get(&format!("{}/tags", package_path(&image)))?; | |
| 265 | + if s.json { | |
| 266 | + return output::json(&tags); | |
| 267 | + } | |
| 268 | + if tags.is_empty() { | |
| 269 | + println!("{}", dim("No tags.")); | |
| 270 | + return Ok(()); | |
| 271 | + } | |
| 272 | + let rows: Vec<Vec<String>> = tags | |
| 273 | + .iter() | |
| 274 | + .map(|t| vec![t.name.clone(), t.digest.chars().take(19).collect(), bytes(t.size), ago(Some(t.updated_at))]) | |
| 275 | + .collect(); | |
| 276 | + table(&["TAG", "DIGEST", "SIZE", "UPDATED"], &rows); | |
| 277 | + Ok(()) | |
| 278 | +} | |
| 279 | + | |
| 280 | +pub fn image_visibility(s: &Session, spec: &str, visibility: &str) -> anyhow::Result<()> { | |
| 281 | + let image = parse_image(spec)?; | |
| 282 | + let package: shared::Package = | |
| 283 | + s.client.patch(&package_path(&image), &shared::UpdatePackage { visibility: Some(visibility.to_string()), description: None })?; | |
| 284 | + if s.json { | |
| 285 | + return output::json(&package); | |
| 286 | + } | |
| 287 | + println!("{} {} is now {}", green("✓"), package.full_name, package.visibility); | |
| 288 | + if package.visibility == "public" { | |
| 289 | + println!(" Anyone can now run: {}", package.pull_command); | |
| 290 | + } | |
| 291 | + Ok(()) | |
| 292 | +} | |
| 293 | + | |
| 294 | +pub fn image_delete(s: &Session, spec: &str, yes: bool) -> anyhow::Result<()> { | |
| 295 | + let image = parse_image(spec)?; | |
| 296 | + let full = format!("{}/{}", image.owner, image.name); | |
| 297 | + if !yes { | |
| 298 | + match &image.tag { | |
| 299 | + Some(tag) => bail!("re-run with --yes to delete tag {full}:{tag}"), | |
| 300 | + None => bail!("deleting {full} removes every tag. Re-run with --yes to confirm."), | |
| 301 | + } | |
| 302 | + } | |
| 303 | + match &image.tag { | |
| 304 | + Some(tag) => { | |
| 305 | + s.client.delete(&format!("{}/tags/{}", package_path(&image), segment(tag)))?; | |
| 306 | + println!("{} Deleted tag {full}:{tag}", green("✓")); | |
| 307 | + } | |
| 308 | + None => { | |
| 309 | + s.client.delete(&package_path(&image))?; | |
| 310 | + println!("{} Deleted image {full}", green("✓")); | |
| 311 | + } | |
| 312 | + } | |
| 313 | + Ok(()) | |
| 314 | +} | |
| 315 | + | |
| 316 | +// --------------------------------------------------------------------------- | |
| 317 | +// ssh keys | |
| 318 | + | |
| 319 | +pub fn key_add(s: &Session, file: Option<&str>, title: Option<&str>) -> anyhow::Result<()> { | |
| 320 | + let path = match file { | |
| 321 | + Some(f) => std::path::PathBuf::from(f), | |
| 322 | + None => { | |
| 323 | + let ssh = dirs::home_dir().context("HOME is not set")?.join(".ssh"); | |
| 324 | + ["id_ed25519.pub", "id_ecdsa.pub", "id_rsa.pub"] | |
| 325 | + .iter() | |
| 326 | + .map(|n| ssh.join(n)) | |
| 327 | + .find(|p| p.is_file()) | |
| 328 | + .context("no public key in ~/.ssh. Create one with: ssh-keygen -t ed25519")? | |
| 329 | + } | |
| 330 | + }; | |
| 331 | + if path.extension().is_none_or(|e| e != "pub") && file.is_some() { | |
| 332 | + let text = std::fs::read_to_string(&path).unwrap_or_default(); | |
| 333 | + if text.contains("PRIVATE KEY") { | |
| 334 | + bail!("{} is a private key. Pass the .pub file.", path.display()); | |
| 335 | + } | |
| 336 | + } | |
| 337 | + let key = std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; | |
| 338 | + let added: shared::SshKey = s.client.post("/api/v1/user/keys", &shared::CreateSshKey { title: title.map(str::to_string), key })?; | |
| 339 | + if s.json { | |
| 340 | + return output::json(&added); | |
| 341 | + } | |
| 342 | + println!("{} Added SSH key \"{}\" ({})", green("✓"), added.title, added.fingerprint); | |
| 343 | + Ok(()) | |
| 344 | +} | |
| 345 | + | |
| 346 | +pub fn key_list(s: &Session) -> anyhow::Result<()> { | |
| 347 | + let keys: Vec<shared::SshKey> = s.client.get("/api/v1/user/keys")?; | |
| 348 | + if s.json { | |
| 349 | + return output::json(&keys); | |
| 350 | + } | |
| 351 | + if keys.is_empty() { | |
| 352 | + println!("{}", dim("No SSH keys. Add one with: ig ssh-key add")); | |
| 353 | + return Ok(()); | |
| 354 | + } | |
| 355 | + table( | |
| 356 | + &["ID", "TITLE", "FINGERPRINT", "ADDED", "LAST USED"], | |
| 357 | + &keys.iter().map(|k| vec![k.id.to_string(), k.title.clone(), k.fingerprint.clone(), ago(Some(k.created_at)), ago(k.last_used_at)]).collect::<Vec<_>>(), | |
| 358 | + ); | |
| 359 | + Ok(()) | |
| 360 | +} | |
| 361 | + | |
| 362 | +pub fn key_remove(s: &Session, id: i64) -> anyhow::Result<()> { | |
| 363 | + s.client.delete(&format!("/api/v1/user/keys/{id}"))?; | |
| 364 | + println!("{} Removed SSH key {id}", green("✓")); | |
| 365 | + Ok(()) | |
| 366 | +} | |
| 367 | + | |
| 368 | +// --------------------------------------------------------------------------- | |
| 369 | +// tokens | |
| 370 | + | |
| 371 | +pub fn token_create(s: &Session, name: &str, scopes: Option<&str>, expires_days: Option<u32>) -> anyhow::Result<()> { | |
| 372 | + let scopes = scopes.map(|list| list.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()); | |
| 373 | + let created: shared::CreatedToken = | |
| 374 | + s.client.post("/api/v1/user/tokens", &shared::CreateToken { name: name.to_string(), scopes, expires_in_days: expires_days })?; | |
| 375 | + if s.json { | |
| 376 | + return output::json(&created); | |
| 377 | + } | |
| 378 | + println!("{} Created token \"{}\" (scopes: {})", green("✓"), created.token.name, created.token.scopes.join(", ")); | |
| 379 | + println!("{}", created.secret); | |
| 380 | + println!("{}", dim("Copy it now; it cannot be shown again.")); | |
| 381 | + Ok(()) | |
| 382 | +} | |
| 383 | + | |
| 384 | +pub fn token_list(s: &Session) -> anyhow::Result<()> { | |
| 385 | + let tokens: Vec<shared::Token> = s.client.get("/api/v1/user/tokens")?; | |
| 386 | + if s.json { | |
| 387 | + return output::json(&tokens); | |
| 388 | + } | |
| 389 | + table( | |
| 390 | + &["ID", "NAME", "PREFIX", "SCOPES", "LAST USED", "EXPIRES"], | |
| 391 | + &tokens | |
| 392 | + .iter() | |
| 393 | + .map(|t| { | |
| 394 | + vec![ | |
| 395 | + t.id.to_string(), | |
| 396 | + t.name.clone(), | |
| 397 | + format!("{}...", t.prefix), | |
| 398 | + t.scopes.join(","), | |
| 399 | + ago(t.last_used_at), | |
| 400 | + t.expires_at.map(|e| e.format("%Y-%m-%d").to_string()).unwrap_or_else(|| "never".into()), | |
| 401 | + ] | |
| 402 | + }) | |
| 403 | + .collect::<Vec<_>>(), | |
| 404 | + ); | |
| 405 | + Ok(()) | |
| 406 | +} | |
| 407 | + | |
| 408 | +pub fn token_revoke(s: &Session, id: i64) -> anyhow::Result<()> { | |
| 409 | + s.client.delete(&format!("/api/v1/user/tokens/{id}"))?; | |
| 410 | + println!("{} Revoked token {id}", green("✓")); | |
| 411 | + Ok(()) | |
| 412 | +} | |
| 413 | + | |
| 414 | +// --------------------------------------------------------------------------- | |
| 415 | +// orgs | |
| 416 | + | |
| 417 | +pub fn org_create(s: &Session, name: &str, display_name: Option<&str>) -> anyhow::Result<()> { | |
| 418 | + let org: shared::Account = s.client.post("/api/v1/orgs", &shared::CreateOrg { name: name.to_string(), display_name: display_name.map(str::to_string) })?; | |
| 419 | + if s.json { | |
| 420 | + return output::json(&org); | |
| 421 | + } | |
| 422 | + println!("{} Created organization {} ({})", green("✓"), bold(&org.name), org.web_url); | |
| 423 | + println!(" Create repos in it with: ig repo create <name> --org {}", org.name); | |
| 424 | + Ok(()) | |
| 425 | +} | |
| 426 | + | |
| 427 | +pub fn org_list(s: &Session) -> anyhow::Result<()> { | |
| 428 | + let orgs: Vec<shared::OrgMembership> = s.client.get("/api/v1/user/orgs")?; | |
| 429 | + if s.json { | |
| 430 | + return output::json(&orgs); | |
| 431 | + } | |
| 432 | + if orgs.is_empty() { | |
| 433 | + println!("{}", dim("You are not in any organizations.")); | |
| 434 | + return Ok(()); | |
| 435 | + } | |
| 436 | + table(&["ORG", "ROLE", "NAME"], &orgs.iter().map(|o| vec![o.org.clone(), o.role.clone(), o.display_name.clone()]).collect::<Vec<_>>()); | |
| 437 | + Ok(()) | |
| 438 | +} | |
| 439 | + | |
| 440 | +pub fn org_members(s: &Session, org: &str) -> anyhow::Result<()> { | |
| 441 | + let members: Vec<shared::OrgMember> = s.client.get(&format!("/api/v1/orgs/{}/members", segment(org)))?; | |
| 442 | + if s.json { | |
| 443 | + return output::json(&members); | |
| 444 | + } | |
| 445 | + table(&["USER", "ROLE", "JOINED"], &members.iter().map(|m| vec![m.username.clone(), m.role.clone(), ago(Some(m.joined_at))]).collect::<Vec<_>>()); | |
| 446 | + Ok(()) | |
| 447 | +} | |
| 448 | + | |
| 449 | +pub fn org_add(s: &Session, org: &str, user: &str, role: &str) -> anyhow::Result<()> { | |
| 450 | + let member: shared::OrgMember = | |
| 451 | + s.client.put(&format!("/api/v1/orgs/{}/members/{}", segment(org), segment(user)), &shared::SetRole { role: role.to_string() })?; | |
| 452 | + println!("{} {} is now {} of {org}", green("✓"), member.username, if member.role == "owner" { "an owner" } else { "a member" }); | |
| 453 | + Ok(()) | |
| 454 | +} | |
| 455 | + | |
| 456 | +pub fn org_remove(s: &Session, org: &str, user: &str) -> anyhow::Result<()> { | |
| 457 | + s.client.delete(&format!("/api/v1/orgs/{}/members/{}", segment(org), segment(user)))?; | |
| 458 | + println!("{} Removed {user} from {org}", green("✓")); | |
| 459 | + Ok(()) | |
| 460 | +} | |
| 461 | + | |
| 462 | +// --------------------------------------------------------------------------- | |
| 463 | +// raw API | |
| 464 | + | |
| 465 | +pub fn api(s: &Session, method: &str, path: &str, data: Option<&str>) -> anyhow::Result<()> { | |
| 466 | + let method: Method = method.to_ascii_uppercase().parse().context("invalid HTTP method")?; | |
| 467 | + let path = if path.starts_with("/api/") || path.starts_with("/v2/") { | |
| 468 | + path.to_string() | |
| 469 | + } else { | |
| 470 | + format!("/api/v1/{}", path.trim_start_matches('/')) | |
| 471 | + }; | |
| 472 | + let body: Option<Value> = match data { | |
| 473 | + Some("-") => { | |
| 474 | + let mut text = String::new(); | |
| 475 | + std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)?; | |
| 476 | + Some(serde_json::from_str(&text).context("stdin is not JSON")?) | |
| 477 | + } | |
| 478 | + Some(text) => Some(serde_json::from_str(text).context("-d is not JSON")?), | |
| 479 | + None => None, | |
| 480 | + }; | |
| 481 | + let (status, text) = s.client.raw(method, &path, body.as_ref())?; | |
| 482 | + match serde_json::from_str::<Value>(&text) { | |
| 483 | + Ok(value) => println!("{}", serde_json::to_string_pretty(&value)?), | |
| 484 | + Err(_) if !text.is_empty() => println!("{text}"), | |
| 485 | + Err(_) => {} | |
| 486 | + } | |
| 487 | + if !status.is_success() { | |
| 488 | + eprintln!("HTTP {}", status.as_u16()); | |
| 489 | + std::process::exit(1); | |
| 490 | + } | |
| 491 | + Ok(()) | |
| 492 | +} | |
| 493 | + | |
| 494 | +#[cfg(test)] | |
| 495 | +mod tests { | |
| 496 | + use super::*; | |
| 497 | + | |
| 498 | + #[test] | |
| 499 | + fn image_references_parse() { | |
| 500 | + assert_eq!(parse_image("alice/api").unwrap(), ImageRef { owner: "alice".into(), name: "api".into(), tag: None }); | |
| 501 | + assert_eq!(parse_image("alice/tools/builder:1.2").unwrap(), ImageRef { owner: "alice".into(), name: "tools/builder".into(), tag: Some("1.2".into()) }); | |
| 502 | + assert_eq!(parse_image("localhost:7878/alice/api:latest").unwrap(), ImageRef { owner: "alice".into(), name: "api".into(), tag: Some("latest".into()) }); | |
| 503 | + assert_eq!(parse_image("git.example.com/org/svc").unwrap(), ImageRef { owner: "org".into(), name: "svc".into(), tag: None }); | |
| 504 | + assert!(parse_image("justone").is_err()); | |
| 505 | + } | |
| 506 | +} |
+183-0cli/src/config.rs
| @@ -0,0 +1,183 @@ | ||
| 1 | +//! ~/.config/irongit/config.toml: which server to talk to and the token for | |
| 2 | +//! each. Written with mode 0600 because it holds credentials. | |
| 3 | +//! | |
| 4 | +//! default_host = "https://git.example.com" | |
| 5 | +//! | |
| 6 | +//! [hosts."https://git.example.com"] | |
| 7 | +//! username = "alice" | |
| 8 | +//! token = "igp_..." | |
| 9 | + | |
| 10 | +use std::{collections::BTreeMap, path::PathBuf}; | |
| 11 | + | |
| 12 | +use anyhow::{Context, bail}; | |
| 13 | +use serde::{Deserialize, Serialize}; | |
| 14 | + | |
| 15 | +#[derive(Debug, Default, Serialize, Deserialize, PartialEq)] | |
| 16 | +pub struct Config { | |
| 17 | + pub default_host: Option<String>, | |
| 18 | + #[serde(default)] | |
| 19 | + pub hosts: BTreeMap<String, HostEntry>, | |
| 20 | +} | |
| 21 | + | |
| 22 | +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] | |
| 23 | +pub struct HostEntry { | |
| 24 | + pub username: String, | |
| 25 | + pub token: String, | |
| 26 | +} | |
| 27 | + | |
| 28 | +pub fn path() -> anyhow::Result<PathBuf> { | |
| 29 | + if let Some(dir) = std::env::var_os("IG_CONFIG_DIR") { | |
| 30 | + return Ok(PathBuf::from(dir).join("config.toml")); | |
| 31 | + } | |
| 32 | + let base = dirs::config_dir().context("cannot find a config directory (is HOME set?)")?; | |
| 33 | + Ok(base.join("irongit").join("config.toml")) | |
| 34 | +} | |
| 35 | + | |
| 36 | +impl Config { | |
| 37 | + pub fn load() -> anyhow::Result<Self> { | |
| 38 | + let path = path()?; | |
| 39 | + match std::fs::read_to_string(&path) { | |
| 40 | + Ok(text) => Self::parse(&text).with_context(|| format!("reading {}", path.display())), | |
| 41 | + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), | |
| 42 | + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), | |
| 43 | + } | |
| 44 | + } | |
| 45 | + | |
| 46 | + pub fn parse(text: &str) -> anyhow::Result<Self> { | |
| 47 | + Ok(toml::from_str(text)?) | |
| 48 | + } | |
| 49 | + | |
| 50 | + pub fn save(&self) -> anyhow::Result<()> { | |
| 51 | + let path = path()?; | |
| 52 | + if let Some(dir) = path.parent() { | |
| 53 | + std::fs::create_dir_all(dir)?; | |
| 54 | + } | |
| 55 | + let text = toml::to_string_pretty(self)?; | |
| 56 | + let tmp = path.with_extension("toml.tmp"); | |
| 57 | + { | |
| 58 | + use std::io::Write; | |
| 59 | + let mut options = std::fs::OpenOptions::new(); | |
| 60 | + options.write(true).create(true).truncate(true); | |
| 61 | + #[cfg(unix)] | |
| 62 | + { | |
| 63 | + use std::os::unix::fs::OpenOptionsExt; | |
| 64 | + options.mode(0o600); | |
| 65 | + } | |
| 66 | + let mut file = options.open(&tmp).with_context(|| format!("writing {}", tmp.display()))?; | |
| 67 | + file.write_all(text.as_bytes())?; | |
| 68 | + } | |
| 69 | + std::fs::rename(&tmp, &path)?; | |
| 70 | + Ok(()) | |
| 71 | + } | |
| 72 | + | |
| 73 | + /// The host to use: --host / IG_HOST first, then the saved default. | |
| 74 | + pub fn resolve_host(&self, flag: Option<&str>) -> anyhow::Result<String> { | |
| 75 | + match flag.filter(|h| !h.trim().is_empty()) { | |
| 76 | + Some(host) => normalize_host(host), | |
| 77 | + None => match &self.default_host { | |
| 78 | + Some(host) => Ok(host.clone()), | |
| 79 | + None => bail!("no server configured. Run: ig login --host https://your-irongit-server"), | |
| 80 | + }, | |
| 81 | + } | |
| 82 | + } | |
| 83 | + | |
| 84 | + /// Token for `host`: IG_TOKEN wins, then the saved one. | |
| 85 | + pub fn token_for(&self, host: &str) -> Option<String> { | |
| 86 | + if let Some(token) = std::env::var("IG_TOKEN").ok().filter(|t| !t.trim().is_empty()) { | |
| 87 | + return Some(token.trim().to_string()); | |
| 88 | + } | |
| 89 | + self.hosts.get(host).map(|h| h.token.clone()) | |
| 90 | + } | |
| 91 | + | |
| 92 | + /// Finds the saved host whose "host[:port]" matches, for credential | |
| 93 | + /// helpers that only know the authority. | |
| 94 | + pub fn find_by_authority(&self, authority: &str) -> Option<(&String, &HostEntry)> { | |
| 95 | + let wanted = authority.trim().trim_end_matches('/').to_ascii_lowercase(); | |
| 96 | + self.hosts.iter().find(|(host, _)| self::authority(host).eq_ignore_ascii_case(&wanted)) | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +/// "git.example.com" -> "https://git.example.com"; localhost defaults to | |
| 101 | +/// http. Paths and trailing slashes are dropped. | |
| 102 | +pub fn normalize_host(input: &str) -> anyhow::Result<String> { | |
| 103 | + let input = input.trim().trim_end_matches('/'); | |
| 104 | + if input.is_empty() { | |
| 105 | + bail!("empty host"); | |
| 106 | + } | |
| 107 | + let with_scheme = if input.contains("://") { | |
| 108 | + input.to_string() | |
| 109 | + } else { | |
| 110 | + let bare = input.split('/').next().unwrap_or(input); | |
| 111 | + let local = bare.starts_with("localhost") || bare.starts_with("127.") || bare.starts_with("[::1]"); | |
| 112 | + format!("{}://{input}", if local { "http" } else { "https" }) | |
| 113 | + }; | |
| 114 | + let (scheme, rest) = with_scheme.split_once("://").expect("has scheme"); | |
| 115 | + let scheme = scheme.to_ascii_lowercase(); | |
| 116 | + if scheme != "http" && scheme != "https" { | |
| 117 | + bail!("host must use http or https, not {scheme}"); | |
| 118 | + } | |
| 119 | + let authority = rest.split(['/', '?', '#']).next().unwrap_or("").to_ascii_lowercase(); | |
| 120 | + if authority.is_empty() || authority.contains('@') || authority.contains(char::is_whitespace) { | |
| 121 | + bail!("not a valid host: {input}"); | |
| 122 | + } | |
| 123 | + Ok(format!("{scheme}://{authority}")) | |
| 124 | +} | |
| 125 | + | |
| 126 | +/// "https://git.example.com:8443" -> "git.example.com:8443". | |
| 127 | +pub fn authority(host: &str) -> String { | |
| 128 | + host.split_once("://").map(|(_, rest)| rest).unwrap_or(host).split('/').next().unwrap_or("").to_string() | |
| 129 | +} | |
| 130 | + | |
| 131 | +#[cfg(test)] | |
| 132 | +mod tests { | |
| 133 | + use super::*; | |
| 134 | + | |
| 135 | + #[test] | |
| 136 | + fn hosts_normalize() { | |
| 137 | + assert_eq!(normalize_host("git.example.com").unwrap(), "https://git.example.com"); | |
| 138 | + assert_eq!(normalize_host("https://Git.Example.com/").unwrap(), "https://git.example.com"); | |
| 139 | + assert_eq!(normalize_host("localhost:7878").unwrap(), "http://localhost:7878"); | |
| 140 | + assert_eq!(normalize_host("http://localhost:7878/alice/repo").unwrap(), "http://localhost:7878"); | |
| 141 | + assert!(normalize_host("ftp://x").is_err()); | |
| 142 | + assert!(normalize_host("https://user@host").is_err()); | |
| 143 | + assert!(normalize_host("").is_err()); | |
| 144 | + } | |
| 145 | + | |
| 146 | + #[test] | |
| 147 | + fn config_roundtrips_and_parses() { | |
| 148 | + let text = r#" | |
| 149 | +default_host = "https://git.example.com" | |
| 150 | + | |
| 151 | +[hosts."https://git.example.com"] | |
| 152 | +username = "alice" | |
| 153 | +token = "igp_abc" | |
| 154 | + | |
| 155 | +[hosts."http://localhost:7878"] | |
| 156 | +username = "bob" | |
| 157 | +token = "igp_def" | |
| 158 | +"#; | |
| 159 | + let config = Config::parse(text).unwrap(); | |
| 160 | + assert_eq!(config.default_host.as_deref(), Some("https://git.example.com")); | |
| 161 | + assert_eq!(config.hosts["http://localhost:7878"].username, "bob"); | |
| 162 | + let again = Config::parse(&toml::to_string_pretty(&config).unwrap()).unwrap(); | |
| 163 | + assert_eq!(config, again); | |
| 164 | + assert!(Config::parse("").unwrap().hosts.is_empty()); | |
| 165 | + } | |
| 166 | + | |
| 167 | + #[test] | |
| 168 | + fn authority_lookup_matches_credential_helper_input() { | |
| 169 | + let config = Config::parse("[hosts.\"http://localhost:7878\"]\nusername = \"bob\"\ntoken = \"t\"\n").unwrap(); | |
| 170 | + assert_eq!(config.find_by_authority("localhost:7878").unwrap().1.username, "bob"); | |
| 171 | + assert_eq!(config.find_by_authority("LOCALHOST:7878/").unwrap().1.username, "bob"); | |
| 172 | + assert!(config.find_by_authority("localhost:9999").is_none()); | |
| 173 | + assert_eq!(authority("https://a.b:8443"), "a.b:8443"); | |
| 174 | + } | |
| 175 | + | |
| 176 | + #[test] | |
| 177 | + fn resolve_prefers_flag() { | |
| 178 | + let config = Config { default_host: Some("https://a.example".into()), hosts: Default::default() }; | |
| 179 | + assert_eq!(config.resolve_host(None).unwrap(), "https://a.example"); | |
| 180 | + assert_eq!(config.resolve_host(Some("b.example")).unwrap(), "https://b.example"); | |
| 181 | + assert!(Config::default().resolve_host(None).is_err()); | |
| 182 | + } | |
| 183 | +} |
+347-1cli/src/main.rs
| @@ -1 +1,347 @@ | ||
| 1 | -fn main() {} | |
| 1 | +//! ig: the irongit command line tool. | |
| 2 | +//! | |
| 3 | +//! Also acts as `docker-credential-ig` when invoked under that name (a | |
| 4 | +//! symlink install.sh creates), so `docker push`/`pull` use the token from | |
| 5 | +//! `ig login` without `docker login`. | |
| 6 | + | |
| 7 | +mod auth; | |
| 8 | +mod client; | |
| 9 | +mod cmds; | |
| 10 | +mod config; | |
| 11 | +mod output; | |
| 12 | +mod upgrade; | |
| 13 | + | |
| 14 | +use clap::{Args, Parser, Subcommand}; | |
| 15 | +use irongit_shared::UpdateRepo; | |
| 16 | + | |
| 17 | +use crate::cmds::Session; | |
| 18 | + | |
| 19 | +/// Release builds set IG_BUILD_VERSION; otherwise the crate version. | |
| 20 | +pub const VERSION: &str = match option_env!("IG_BUILD_VERSION") { | |
| 21 | + Some(v) => v, | |
| 22 | + None => env!("CARGO_PKG_VERSION"), | |
| 23 | +}; | |
| 24 | + | |
| 25 | +#[derive(Parser)] | |
| 26 | +#[command(name = "ig", version = VERSION, about = "Work with irongit repositories, images and accounts from the terminal")] | |
| 27 | +struct Cli { | |
| 28 | + /// Server to use, e.g. https://git.example.com (default: the one you logged in to). | |
| 29 | + #[arg(long, global = true, env = "IG_HOST")] | |
| 30 | + host: Option<String>, | |
| 31 | + /// Print JSON instead of tables. | |
| 32 | + #[arg(long, global = true)] | |
| 33 | + json: bool, | |
| 34 | + #[command(subcommand)] | |
| 35 | + command: Cmd, | |
| 36 | +} | |
| 37 | + | |
| 38 | +#[derive(Subcommand)] | |
| 39 | +enum Cmd { | |
| 40 | + /// Sign in through the browser (or --with-token) and set up git and docker. | |
| 41 | + Login { | |
| 42 | + /// Read a personal access token from stdin instead of using the browser. | |
| 43 | + #[arg(long)] | |
| 44 | + with_token: bool, | |
| 45 | + /// Do not configure the git and docker credential helpers. | |
| 46 | + #[arg(long)] | |
| 47 | + skip_setup: bool, | |
| 48 | + }, | |
| 49 | + /// Forget the saved token for a host and revoke it on the server. | |
| 50 | + Logout, | |
| 51 | + /// Authentication status, tokens and credential helpers. | |
| 52 | + #[command(subcommand)] | |
| 53 | + Auth(AuthCmd), | |
| 54 | + /// Repositories. | |
| 55 | + #[command(subcommand)] | |
| 56 | + Repo(RepoCmd), | |
| 57 | + /// Container images in the registry. | |
| 58 | + #[command(subcommand)] | |
| 59 | + Image(ImageCmd), | |
| 60 | + /// SSH keys for git over SSH. | |
| 61 | + #[command(subcommand, name = "ssh-key")] | |
| 62 | + SshKey(KeyCmd), | |
| 63 | + /// Personal access tokens. | |
| 64 | + #[command(subcommand)] | |
| 65 | + Token(TokenCmd), | |
| 66 | + /// Organizations. | |
| 67 | + #[command(subcommand)] | |
| 68 | + Org(OrgCmd), | |
| 69 | + /// Call the API directly: ig api GET user | |
| 70 | + Api { | |
| 71 | + method: String, | |
| 72 | + path: String, | |
| 73 | + /// JSON body, or - to read it from stdin. | |
| 74 | + #[arg(short = 'd', long = "data")] | |
| 75 | + data: Option<String>, | |
| 76 | + }, | |
| 77 | + /// Replace ig with the newest release from the server. | |
| 78 | + Upgrade { | |
| 79 | + /// Reinstall even if this version is current. | |
| 80 | + #[arg(long)] | |
| 81 | + force: bool, | |
| 82 | + /// Only report whether a newer version exists. | |
| 83 | + #[arg(long)] | |
| 84 | + check: bool, | |
| 85 | + }, | |
| 86 | +} | |
| 87 | + | |
| 88 | +#[derive(Subcommand)] | |
| 89 | +enum AuthCmd { | |
| 90 | + /// Show which hosts you are logged in to. | |
| 91 | + Status, | |
| 92 | + /// Print the token for the current host. | |
| 93 | + Token, | |
| 94 | + /// Make git use ig for this host's HTTPS credentials. | |
| 95 | + SetupGit, | |
| 96 | + /// Make docker use ig for this host's registry credentials. | |
| 97 | + SetupDocker, | |
| 98 | + /// git credential helper protocol (called by git). | |
| 99 | + #[command(hide = true)] | |
| 100 | + GitCredential { operation: String }, | |
| 101 | +} | |
| 102 | + | |
| 103 | +#[derive(Subcommand)] | |
| 104 | +enum RepoCmd { | |
| 105 | + /// Create a repository. | |
| 106 | + Create { | |
| 107 | + name: String, | |
| 108 | + /// Create it in this organization instead of your account. | |
| 109 | + #[arg(long)] | |
| 110 | + org: Option<String>, | |
| 111 | + #[command(flatten)] | |
| 112 | + visibility: VisibilityFlags, | |
| 113 | + #[arg(short = 'd', long)] | |
| 114 | + description: Option<String>, | |
| 115 | + }, | |
| 116 | + /// List repositories of a user or organization (default: you). | |
| 117 | + List { owner: Option<String> }, | |
| 118 | + /// Show a repository. | |
| 119 | + View { repo: String }, | |
| 120 | + /// Clone a repository with git. | |
| 121 | + Clone { | |
| 122 | + repo: String, | |
| 123 | + dir: Option<String>, | |
| 124 | + /// Clone over SSH instead of HTTPS. | |
| 125 | + #[arg(long)] | |
| 126 | + ssh: bool, | |
| 127 | + }, | |
| 128 | + /// Delete a repository and all of its history. | |
| 129 | + Delete { | |
| 130 | + repo: String, | |
| 131 | + #[arg(long)] | |
| 132 | + yes: bool, | |
| 133 | + }, | |
| 134 | + /// Make a repository public or private. | |
| 135 | + Visibility { | |
| 136 | + repo: String, | |
| 137 | + #[arg(value_parser = ["public", "private"])] | |
| 138 | + visibility: String, | |
| 139 | + }, | |
| 140 | + /// Change the description, default branch or archived state. | |
| 141 | + Edit { | |
| 142 | + repo: String, | |
| 143 | + #[arg(short = 'd', long)] | |
| 144 | + description: Option<String>, | |
| 145 | + #[arg(long)] | |
| 146 | + default_branch: Option<String>, | |
| 147 | + #[arg(long, conflicts_with = "unarchive")] | |
| 148 | + archive: bool, | |
| 149 | + #[arg(long)] | |
| 150 | + unarchive: bool, | |
| 151 | + }, | |
| 152 | + /// Manage collaborators. | |
| 153 | + #[command(subcommand)] | |
| 154 | + Collab(CollabCmd), | |
| 155 | +} | |
| 156 | + | |
| 157 | +#[derive(Args)] | |
| 158 | +struct VisibilityFlags { | |
| 159 | + /// Anyone can see and clone it. | |
| 160 | + #[arg(long, conflicts_with = "private")] | |
| 161 | + public: bool, | |
| 162 | + /// Only you and people you add (the default). | |
| 163 | + #[arg(long)] | |
| 164 | + private: bool, | |
| 165 | +} | |
| 166 | + | |
| 167 | +#[derive(Subcommand)] | |
| 168 | +enum CollabCmd { | |
| 169 | + List { repo: String }, | |
| 170 | + Add { | |
| 171 | + repo: String, | |
| 172 | + user: String, | |
| 173 | + #[arg(long, default_value = "write", value_parser = ["read", "write", "admin"])] | |
| 174 | + permission: String, | |
| 175 | + }, | |
| 176 | + Remove { repo: String, user: String }, | |
| 177 | +} | |
| 178 | + | |
| 179 | +#[derive(Subcommand)] | |
| 180 | +enum ImageCmd { | |
| 181 | + /// List images of a user or organization (default: you). | |
| 182 | + List { owner: Option<String> }, | |
| 183 | + /// List an image's tags. | |
| 184 | + Tags { image: String }, | |
| 185 | + /// Make an image public or private (independent of any repository). | |
| 186 | + Visibility { | |
| 187 | + image: String, | |
| 188 | + #[arg(value_parser = ["public", "private"])] | |
| 189 | + visibility: String, | |
| 190 | + }, | |
| 191 | + /// Delete an image, or one tag with owner/name:tag. | |
| 192 | + Delete { | |
| 193 | + image: String, | |
| 194 | + #[arg(long)] | |
| 195 | + yes: bool, | |
| 196 | + }, | |
| 197 | +} | |
| 198 | + | |
| 199 | +#[derive(Subcommand)] | |
| 200 | +enum KeyCmd { | |
| 201 | + /// Add a public key (default ~/.ssh/id_ed25519.pub). | |
| 202 | + Add { | |
| 203 | + file: Option<String>, | |
| 204 | + #[arg(long)] | |
| 205 | + title: Option<String>, | |
| 206 | + }, | |
| 207 | + List, | |
| 208 | + Remove { id: i64 }, | |
| 209 | +} | |
| 210 | + | |
| 211 | +#[derive(Subcommand)] | |
| 212 | +enum TokenCmd { | |
| 213 | + /// Create a token; the secret is printed once. | |
| 214 | + Create { | |
| 215 | + name: String, | |
| 216 | + /// Comma-separated: repo,packages,user,admin (default repo,packages,user). | |
| 217 | + #[arg(long)] | |
| 218 | + scopes: Option<String>, | |
| 219 | + #[arg(long)] | |
| 220 | + expires_days: Option<u32>, | |
| 221 | + }, | |
| 222 | + List, | |
| 223 | + Revoke { id: i64 }, | |
| 224 | +} | |
| 225 | + | |
| 226 | +#[derive(Subcommand)] | |
| 227 | +enum OrgCmd { | |
| 228 | + Create { | |
| 229 | + name: String, | |
| 230 | + #[arg(long)] | |
| 231 | + display_name: Option<String>, | |
| 232 | + }, | |
| 233 | + /// Organizations you belong to. | |
| 234 | + List, | |
| 235 | + Members { org: String }, | |
| 236 | + /// Add a member or change their role. | |
| 237 | + Add { | |
| 238 | + org: String, | |
| 239 | + user: String, | |
| 240 | + #[arg(long, default_value = "member", value_parser = ["member", "owner"])] | |
| 241 | + role: String, | |
| 242 | + }, | |
| 243 | + Remove { org: String, user: String }, | |
| 244 | +} | |
| 245 | + | |
| 246 | +fn main() { | |
| 247 | + let args: Vec<String> = std::env::args().collect(); | |
| 248 | + let invoked_as = args.first().and_then(|a| std::path::Path::new(a).file_name()).and_then(|n| n.to_str()).unwrap_or("ig"); | |
| 249 | + let result = if invoked_as.starts_with("docker-credential-") { | |
| 250 | + auth::docker_credential_helper(&args) | |
| 251 | + } else { | |
| 252 | + run(Cli::parse()) | |
| 253 | + }; | |
| 254 | + if let Err(error) = result { | |
| 255 | + eprintln!("ig: {error:#}"); | |
| 256 | + std::process::exit(1); | |
| 257 | + } | |
| 258 | +} | |
| 259 | + | |
| 260 | +fn run(cli: Cli) -> anyhow::Result<()> { | |
| 261 | + let host = cli.host.as_deref(); | |
| 262 | + let session = || Session::open(host, cli.json); | |
| 263 | + match cli.command { | |
| 264 | + Cmd::Login { with_token, skip_setup } => auth::login(auth::LoginOptions { host: cli.host.clone(), with_token, skip_setup }), | |
| 265 | + Cmd::Logout => auth::logout(host), | |
| 266 | + Cmd::Auth(cmd) => match cmd { | |
| 267 | + AuthCmd::Status => auth::status(host, cli.json), | |
| 268 | + AuthCmd::Token => auth::print_token(host), | |
| 269 | + AuthCmd::SetupGit => { | |
| 270 | + let h = config::Config::load()?.resolve_host(host)?; | |
| 271 | + auth::setup_git(&h)?; | |
| 272 | + println!("git uses ig for {h} credentials"); | |
| 273 | + Ok(()) | |
| 274 | + } | |
| 275 | + AuthCmd::SetupDocker => { | |
| 276 | + let h = config::Config::load()?.resolve_host(host)?; | |
| 277 | + let note = auth::setup_docker(&h)?; | |
| 278 | + println!("docker uses ig for {}{note}", config::authority(&h)); | |
| 279 | + Ok(()) | |
| 280 | + } | |
| 281 | + AuthCmd::GitCredential { operation } => auth::git_credential(&operation), | |
| 282 | + }, | |
| 283 | + Cmd::Repo(cmd) => { | |
| 284 | + let s = session()?; | |
| 285 | + match cmd { | |
| 286 | + RepoCmd::Create { name, org, visibility, description } => cmds::repo_create(&s, &name, org.as_deref(), visibility.public, description.as_deref()), | |
| 287 | + RepoCmd::List { owner } => cmds::repo_list(&s, owner.as_deref()), | |
| 288 | + RepoCmd::View { repo } => cmds::repo_view(&s, &repo), | |
| 289 | + RepoCmd::Clone { repo, dir, ssh } => cmds::repo_clone(&s, &repo, dir.as_deref(), ssh), | |
| 290 | + RepoCmd::Delete { repo, yes } => cmds::repo_delete(&s, &repo, yes), | |
| 291 | + RepoCmd::Visibility { repo, visibility } => { | |
| 292 | + let what = format!("is now {visibility}"); | |
| 293 | + cmds::repo_update(&s, &repo, UpdateRepo { visibility: Some(visibility), ..Default::default() }, &what) | |
| 294 | + } | |
| 295 | + RepoCmd::Edit { repo, description, default_branch, archive, unarchive } => { | |
| 296 | + let archived = if archive { Some(true) } else if unarchive { Some(false) } else { None }; | |
| 297 | + if description.is_none() && default_branch.is_none() && archived.is_none() { | |
| 298 | + anyhow::bail!("nothing to change: pass --description, --default-branch, --archive or --unarchive"); | |
| 299 | + } | |
| 300 | + cmds::repo_update(&s, &repo, UpdateRepo { description, default_branch, archived, visibility: None }, "updated") | |
| 301 | + } | |
| 302 | + RepoCmd::Collab(c) => match c { | |
| 303 | + CollabCmd::List { repo } => cmds::collab_list(&s, &repo), | |
| 304 | + CollabCmd::Add { repo, user, permission } => cmds::collab_add(&s, &repo, &user, &permission), | |
| 305 | + CollabCmd::Remove { repo, user } => cmds::collab_remove(&s, &repo, &user), | |
| 306 | + }, | |
| 307 | + } | |
| 308 | + } | |
| 309 | + Cmd::Image(cmd) => { | |
| 310 | + let s = session()?; | |
| 311 | + match cmd { | |
| 312 | + ImageCmd::List { owner } => cmds::image_list(&s, owner.as_deref()), | |
| 313 | + ImageCmd::Tags { image } => cmds::image_tags(&s, &image), | |
| 314 | + ImageCmd::Visibility { image, visibility } => cmds::image_visibility(&s, &image, &visibility), | |
| 315 | + ImageCmd::Delete { image, yes } => cmds::image_delete(&s, &image, yes), | |
| 316 | + } | |
| 317 | + } | |
| 318 | + Cmd::SshKey(cmd) => { | |
| 319 | + let s = session()?; | |
| 320 | + match cmd { | |
| 321 | + KeyCmd::Add { file, title } => cmds::key_add(&s, file.as_deref(), title.as_deref()), | |
| 322 | + KeyCmd::List => cmds::key_list(&s), | |
| 323 | + KeyCmd::Remove { id } => cmds::key_remove(&s, id), | |
| 324 | + } | |
| 325 | + } | |
| 326 | + Cmd::Token(cmd) => { | |
| 327 | + let s = session()?; | |
| 328 | + match cmd { | |
| 329 | + TokenCmd::Create { name, scopes, expires_days } => cmds::token_create(&s, &name, scopes.as_deref(), expires_days), | |
| 330 | + TokenCmd::List => cmds::token_list(&s), | |
| 331 | + TokenCmd::Revoke { id } => cmds::token_revoke(&s, id), | |
| 332 | + } | |
| 333 | + } | |
| 334 | + Cmd::Org(cmd) => { | |
| 335 | + let s = session()?; | |
| 336 | + match cmd { | |
| 337 | + OrgCmd::Create { name, display_name } => cmds::org_create(&s, &name, display_name.as_deref()), | |
| 338 | + OrgCmd::List => cmds::org_list(&s), | |
| 339 | + OrgCmd::Members { org } => cmds::org_members(&s, &org), | |
| 340 | + OrgCmd::Add { org, user, role } => cmds::org_add(&s, &org, &user, &role), | |
| 341 | + OrgCmd::Remove { org, user } => cmds::org_remove(&s, &org, &user), | |
| 342 | + } | |
| 343 | + } | |
| 344 | + Cmd::Api { method, path, data } => cmds::api(&session()?, &method, &path, data.as_deref()), | |
| 345 | + Cmd::Upgrade { force, check } => upgrade::upgrade(host, force, check), | |
| 346 | + } | |
| 347 | +} |
+106-0cli/src/output.rs
| @@ -0,0 +1,106 @@ | ||
| 1 | +//! Terminal output: aligned tables, sizes, relative times. | |
| 2 | + | |
| 3 | +use std::io::IsTerminal; | |
| 4 | + | |
| 5 | +use chrono::{DateTime, Utc}; | |
| 6 | + | |
| 7 | +fn color() -> bool { | |
| 8 | + std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() | |
| 9 | +} | |
| 10 | + | |
| 11 | +pub fn bold(s: &str) -> String { | |
| 12 | + if color() { format!("\x1b[1m{s}\x1b[0m") } else { s.to_string() } | |
| 13 | +} | |
| 14 | + | |
| 15 | +pub fn dim(s: &str) -> String { | |
| 16 | + if color() { format!("\x1b[2m{s}\x1b[0m") } else { s.to_string() } | |
| 17 | +} | |
| 18 | + | |
| 19 | +pub fn green(s: &str) -> String { | |
| 20 | + if color() { format!("\x1b[32m{s}\x1b[0m") } else { s.to_string() } | |
| 21 | +} | |
| 22 | + | |
| 23 | +pub fn yellow(s: &str) -> String { | |
| 24 | + if color() { format!("\x1b[33m{s}\x1b[0m") } else { s.to_string() } | |
| 25 | +} | |
| 26 | + | |
| 27 | +/// Prints rows under headers, columns padded to the widest cell. | |
| 28 | +pub fn table(headers: &[&str], rows: &[Vec<String>]) { | |
| 29 | + if rows.is_empty() { | |
| 30 | + return; | |
| 31 | + } | |
| 32 | + let mut widths: Vec<usize> = headers.iter().map(|h| h.chars().count()).collect(); | |
| 33 | + for row in rows { | |
| 34 | + for (i, cell) in row.iter().enumerate() { | |
| 35 | + if i < widths.len() { | |
| 36 | + widths[i] = widths[i].max(cell.chars().count()); | |
| 37 | + } | |
| 38 | + } | |
| 39 | + } | |
| 40 | + let line = |cells: Vec<String>, style: fn(&str) -> String| { | |
| 41 | + let last = cells.len().saturating_sub(1); | |
| 42 | + let text: Vec<String> = cells | |
| 43 | + .iter() | |
| 44 | + .enumerate() | |
| 45 | + .map(|(i, c)| { | |
| 46 | + let padded = if i == last { c.clone() } else { format!("{c:<width$}", width = widths[i]) }; | |
| 47 | + style(&padded) | |
| 48 | + }) | |
| 49 | + .collect(); | |
| 50 | + println!("{}", text.join(" ").trim_end()); | |
| 51 | + }; | |
| 52 | + line(headers.iter().map(|h| h.to_string()).collect(), dim); | |
| 53 | + for row in rows { | |
| 54 | + line(row.clone(), |s| s.to_string()); | |
| 55 | + } | |
| 56 | +} | |
| 57 | + | |
| 58 | +pub fn bytes(n: i64) -> String { | |
| 59 | + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; | |
| 60 | + let mut value = n.max(0) as f64; | |
| 61 | + let mut unit = 0; | |
| 62 | + while value >= 1024.0 && unit < UNITS.len() - 1 { | |
| 63 | + value /= 1024.0; | |
| 64 | + unit += 1; | |
| 65 | + } | |
| 66 | + if unit == 0 { format!("{n} B") } else if value < 10.0 { format!("{value:.1} {}", UNITS[unit]) } else { format!("{value:.0} {}", UNITS[unit]) } | |
| 67 | +} | |
| 68 | + | |
| 69 | +pub fn ago(at: Option<DateTime<Utc>>) -> String { | |
| 70 | + let Some(at) = at else { return "never".into() }; | |
| 71 | + let seconds = (Utc::now() - at).num_seconds(); | |
| 72 | + let (n, unit) = match seconds { | |
| 73 | + s if s < 45 => return "just now".into(), | |
| 74 | + s if s < 3600 => ((s + 30) / 60, "minute"), | |
| 75 | + s if s < 86_400 => ((s + 1800) / 3600, "hour"), | |
| 76 | + s if s < 30 * 86_400 => ((s + 43_200) / 86_400, "day"), | |
| 77 | + s if s < 365 * 86_400 => (s / (30 * 86_400), "month"), | |
| 78 | + s => (s / (365 * 86_400), "year"), | |
| 79 | + }; | |
| 80 | + let n = n.max(1); | |
| 81 | + format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }) | |
| 82 | +} | |
| 83 | + | |
| 84 | +pub fn json<T: serde::Serialize>(value: &T) -> anyhow::Result<()> { | |
| 85 | + println!("{}", serde_json::to_string_pretty(value)?); | |
| 86 | + Ok(()) | |
| 87 | +} | |
| 88 | + | |
| 89 | +#[cfg(test)] | |
| 90 | +mod tests { | |
| 91 | + use super::*; | |
| 92 | + | |
| 93 | + #[test] | |
| 94 | + fn sizes() { | |
| 95 | + assert_eq!(bytes(0), "0 B"); | |
| 96 | + assert_eq!(bytes(2048), "2.0 KB"); | |
| 97 | + assert_eq!(bytes(5 * 1024 * 1024 * 1024), "5.0 GB"); | |
| 98 | + } | |
| 99 | + | |
| 100 | + #[test] | |
| 101 | + fn relative_times() { | |
| 102 | + assert_eq!(ago(None), "never"); | |
| 103 | + assert_eq!(ago(Some(Utc::now())), "just now"); | |
| 104 | + assert_eq!(ago(Some(Utc::now() - chrono::Duration::days(3))), "3 days ago"); | |
| 105 | + } | |
| 106 | +} |
+62-0cli/src/upgrade.rs
| @@ -0,0 +1,62 @@ | ||
| 1 | +//! `ig upgrade`: replace this executable with the server's newest release. | |
| 2 | + | |
| 3 | +use std::io::Write; | |
| 4 | + | |
| 5 | +use anyhow::{Context, bail}; | |
| 6 | +use irongit_shared::{self as shared, version_newer}; | |
| 7 | +use sha2::{Digest, Sha256}; | |
| 8 | + | |
| 9 | +use crate::{ | |
| 10 | + VERSION, | |
| 11 | + client::{Client, http_client}, | |
| 12 | + config::Config, | |
| 13 | + output::{bold, green}, | |
| 14 | +}; | |
| 15 | + | |
| 16 | +pub fn upgrade(host_flag: Option<&str>, force: bool, check_only: bool) -> anyhow::Result<()> { | |
| 17 | + let config = Config::load()?; | |
| 18 | + let host = config.resolve_host(host_flag)?; | |
| 19 | + let latest: shared::CliRelease = Client::new(&host, None)?.get("/api/v1/cli/latest").context("asking the server for the latest ig")?; | |
| 20 | + | |
| 21 | + let newer = version_newer(&latest.version, VERSION); | |
| 22 | + if !newer && !force { | |
| 23 | + println!("{} ig {VERSION} is up to date", green("✓")); | |
| 24 | + return Ok(()); | |
| 25 | + } | |
| 26 | + if check_only { | |
| 27 | + println!("ig {} is available (you have {VERSION}). Run: ig upgrade", bold(&latest.version)); | |
| 28 | + return Ok(()); | |
| 29 | + } | |
| 30 | + | |
| 31 | + let exe = std::env::current_exe().context("cannot locate the running ig")?; | |
| 32 | + println!("Downloading ig {} ({} bytes)...", latest.version, latest.size); | |
| 33 | + // A fresh client: the download redirects to storage and must not carry a token. | |
| 34 | + let mut response = http_client()?.get(&latest.url).send()?.error_for_status().context("downloading the release")?; | |
| 35 | + let mut bytes = Vec::with_capacity(latest.size.max(0) as usize); | |
| 36 | + std::io::copy(&mut response, &mut bytes)?; | |
| 37 | + let actual = hex::encode(Sha256::digest(&bytes)); | |
| 38 | + if actual != latest.sha256 { | |
| 39 | + bail!("checksum mismatch: expected {}, got {actual}. Nothing was changed.", latest.sha256); | |
| 40 | + } | |
| 41 | + | |
| 42 | + // Write next to the executable and rename over it: atomic on one filesystem, | |
| 43 | + // and safe while the old binary is still running. | |
| 44 | + let dir = exe.parent().context("executable has no directory")?; | |
| 45 | + let tmp = dir.join(format!(".ig-upgrade-{}", std::process::id())); | |
| 46 | + { | |
| 47 | + let mut file = std::fs::File::create(&tmp).with_context(|| format!("cannot write to {} (try with sudo?)", dir.display()))?; | |
| 48 | + file.write_all(&bytes)?; | |
| 49 | + file.sync_all()?; | |
| 50 | + } | |
| 51 | + #[cfg(unix)] | |
| 52 | + { | |
| 53 | + use std::os::unix::fs::PermissionsExt; | |
| 54 | + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?; | |
| 55 | + } | |
| 56 | + if let Err(error) = std::fs::rename(&tmp, &exe) { | |
| 57 | + let _ = std::fs::remove_file(&tmp); | |
| 58 | + return Err(error).with_context(|| format!("replacing {}", exe.display())); | |
| 59 | + } | |
| 60 | + println!("{} Upgraded ig {VERSION} -> {} ({})", green("✓"), bold(&latest.version), exe.display()); | |
| 61 | + Ok(()) | |
| 62 | +} |
+193-0frontend/src/pages/docs/cli.astro
| @@ -0,0 +1,193 @@ | ||
| 1 | +--- | |
| 2 | +import Layout from "../../layouts/Layout.astro"; | |
| 3 | + | |
| 4 | +type Command = [usage: string, description: string]; | |
| 5 | +type Group = { id: string; title: string; intro?: string; commands: Command[] }; | |
| 6 | + | |
| 7 | +const groups: Group[] = [ | |
| 8 | + { | |
| 9 | + id: "auth", | |
| 10 | + title: "Signing in", | |
| 11 | + intro: "ig keeps one token per server in ~/.config/irongit/config.toml (mode 0600).", | |
| 12 | + commands: [ | |
| 13 | + ["ig login --host URL", "Sign in through the browser with a one-time code, save the token, and set up the git and docker credential helpers."], | |
| 14 | + ["ig login --with-token", "Read an existing personal access token from stdin instead (for CI and servers without a browser)."], | |
| 15 | + ["ig login --skip-setup", "Sign in without touching ~/.gitconfig or ~/.docker/config.json."], | |
| 16 | + ["ig logout", "Forget the token for the current server and revoke it there."], | |
| 17 | + ["ig auth status", "Which servers you are signed in to, as whom, with which scopes, and whether the helpers are set up."], | |
| 18 | + ["ig auth token", "Print the current token, e.g. for curl."], | |
| 19 | + ["ig auth setup-git", "Make git ask ig for credentials for this server (HTTPS clone, fetch, push)."], | |
| 20 | + ["ig auth setup-docker", "Point docker at docker-credential-ig for this server's registry."], | |
| 21 | + ], | |
| 22 | + }, | |
| 23 | + { | |
| 24 | + id: "repo", | |
| 25 | + title: "Repositories", | |
| 26 | + intro: "Wherever a repository is expected you can write owner/name, or just name for your own.", | |
| 27 | + commands: [ | |
| 28 | + ["ig repo create NAME [--org ORG] [--public] [-d TEXT]", "Create a repository. Private unless you pass --public."], | |
| 29 | + ["ig repo list [OWNER]", "Repositories of a user or organization that you can see (default: yours)."], | |
| 30 | + ["ig repo view REPO", "Clone URLs, default branch, size, last push and your access level."], | |
| 31 | + ["ig repo clone REPO [DIR] [--ssh]", "Clone over HTTPS with ig's credentials, or over SSH with your key."], | |
| 32 | + ["ig repo visibility REPO public|private", "Change who can see and clone the code. Images keep their own setting."], | |
| 33 | + ["ig repo edit REPO [-d TEXT] [--default-branch B] [--archive|--unarchive]", "Change the description or default branch, or make the repository read-only."], | |
| 34 | + ["ig repo delete REPO --yes", "Delete the repository and its history."], | |
| 35 | + ["ig repo collab list REPO", "People with access besides the owner."], | |
| 36 | + ["ig repo collab add REPO USER [--permission read|write|admin]", "Give someone access (default write)."], | |
| 37 | + ["ig repo collab remove REPO USER", "Take access away. Collaborators can also remove themselves."], | |
| 38 | + ], | |
| 39 | + }, | |
| 40 | + { | |
| 41 | + id: "image", | |
| 42 | + title: "Container images", | |
| 43 | + intro: "Images are named owner/name or owner/path/name. A registry host prefix and :tag are accepted and ignored where they don't apply.", | |
| 44 | + commands: [ | |
| 45 | + ["ig image list [OWNER]", "Images of a user or organization (default: yours) with tag and pull counts."], | |
| 46 | + ["ig image tags IMAGE", "Tags with digests and sizes."], | |
| 47 | + ["ig image visibility IMAGE public|private", "Allow anonymous pulls, or require a token. Independent of any linked repository."], | |
| 48 | + ["ig image delete IMAGE:TAG --yes", "Delete one tag."], | |
| 49 | + ["ig image delete IMAGE --yes", "Delete the image and every tag. Unused layers are cleaned up afterwards."], | |
| 50 | + ], | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + id: "ssh", | |
| 54 | + title: "SSH keys", | |
| 55 | + commands: [ | |
| 56 | + ["ig ssh-key add [FILE] [--title T]", "Upload a public key (default ~/.ssh/id_ed25519.pub, then id_ecdsa.pub, id_rsa.pub)."], | |
| 57 | + ["ig ssh-key list", "Your keys with fingerprints and when each was last used."], | |
| 58 | + ["ig ssh-key remove ID", "Delete a key."], | |
| 59 | + ], | |
| 60 | + }, | |
| 61 | + { | |
| 62 | + id: "token", | |
| 63 | + title: "Access tokens", | |
| 64 | + intro: "Scopes: repo (git and repositories), packages (images), user (profile, keys, tokens, organizations), admin (site admins only). A token can only create tokens with scopes it has itself.", | |
| 65 | + commands: [ | |
| 66 | + ["ig token create NAME [--scopes repo,packages] [--expires-days N]", "Create a token. The secret is printed once."], | |
| 67 | + ["ig token list", "Your tokens, their scopes and last use."], | |
| 68 | + ["ig token revoke ID", "Revoke a token immediately."], | |
| 69 | + ], | |
| 70 | + }, | |
| 71 | + { | |
| 72 | + id: "org", | |
| 73 | + title: "Organizations", | |
| 74 | + intro: "Owners manage members and everything the organization owns; members can create and push to its repositories and images.", | |
| 75 | + commands: [ | |
| 76 | + ["ig org create NAME [--display-name TEXT]", "Create an organization with you as its owner."], | |
| 77 | + ["ig org list", "Organizations you belong to and your role."], | |
| 78 | + ["ig org members ORG", "Who is in it."], | |
| 79 | + ["ig org add ORG USER [--role member|owner]", "Add someone or change their role."], | |
| 80 | + ["ig org remove ORG USER", "Remove someone. You can always leave; the last owner cannot."], | |
| 81 | + ], | |
| 82 | + }, | |
| 83 | + { | |
| 84 | + id: "other", | |
| 85 | + title: "Everything else", | |
| 86 | + commands: [ | |
| 87 | + ["ig api METHOD PATH [-d JSON|-]", "Call the JSON API directly, e.g. ig api GET user or ig api PATCH repos/you/app -d '{\"description\":\"x\"}'."], | |
| 88 | + ["ig upgrade [--check] [--force]", "Replace ig with the newest release from the server, verified by SHA-256."], | |
| 89 | + ["ig --json ...", "Machine-readable output for list and view commands."], | |
| 90 | + ["ig --version", "The installed version."], | |
| 91 | + ], | |
| 92 | + }, | |
| 93 | +]; | |
| 94 | + | |
| 95 | +const env: Command[] = [ | |
| 96 | + ["IG_HOST", "Server to use instead of the saved default (same as --host)."], | |
| 97 | + ["IG_TOKEN", "Token to use instead of the saved one, handy in CI."], | |
| 98 | + ["IG_CONFIG_DIR", "Directory holding config.toml (default ~/.config/irongit)."], | |
| 99 | + ["IG_INSTALL_DIR", "Where install.sh puts ig (default ~/.local/bin)."], | |
| 100 | + ["NO_COLOR", "Disable colored output."], | |
| 101 | +]; | |
| 102 | +--- | |
| 103 | + | |
| 104 | +<Layout title="ig CLI · irongit" description="Install and use ig, the irongit command line tool: login, repositories, images, SSH keys, tokens and organizations."> | |
| 105 | + <div class="grid gap-8 md:grid-cols-[170px_1fr]"> | |
| 106 | + <nav class="hidden text-[13px] md:block"> | |
| 107 | + <div class="sticky top-4 space-y-0.5"> | |
| 108 | + <a href="/docs" class="block py-0.5 text-ink-dim">Docs</a> | |
| 109 | + <a href="#install" class="block py-0.5 text-ink-dim">Install</a> | |
| 110 | + <a href="#login" class="block py-0.5 text-ink-dim">First login</a> | |
| 111 | + {groups.map((g) => <a href={`#${g.id}`} class="block py-0.5 text-ink-dim">{g.title}</a>)} | |
| 112 | + <a href="#env" class="block py-0.5 text-ink-dim">Environment</a> | |
| 113 | + </div> | |
| 114 | + </nav> | |
| 115 | + | |
| 116 | + <div class="min-w-0"> | |
| 117 | + <p class="font-mono text-xs tracking-widest text-ember uppercase">Command line</p> | |
| 118 | + <h1 class="mt-2 text-2xl font-semibold">ig</h1> | |
| 119 | + <p class="mt-2 max-w-prose text-ink-dim"> | |
| 120 | + One static binary for x86_64 Linux. It signs you in, creates and manages repositories and images, and acts as the | |
| 121 | + credential helper for both git and docker, so you never paste a token into either. | |
| 122 | + </p> | |
| 123 | + | |
| 124 | + <h2 id="install" class="mt-8 text-[15px] font-semibold">Install</h2> | |
| 125 | + <div class="box mt-2 overflow-hidden"> | |
| 126 | + <pre class="overflow-x-auto bg-surface-sunken p-3 font-mono text-[12.5px] leading-6">curl -fsSL <span class="host-url">https://this-site</span>/install.sh | sh</pre> | |
| 127 | + </div> | |
| 128 | + <p class="mt-2 text-[13px] text-ink-dim"> | |
| 129 | + The script downloads the newest release, checks its SHA-256, installs it to <code>~/.local/bin/ig</code> and adds the | |
| 130 | + {" "}<code>docker-credential-ig</code> link docker needs. Prefer to do it by hand? Download | |
| 131 | + {" "}<a href="/download/ig" data-track="docs_cli_download_clicked"><code>/download/ig</code></a>, make it executable and put it on | |
| 132 | + your PATH. Later, <code>ig upgrade</code> keeps it current. | |
| 133 | + </p> | |
| 134 | + | |
| 135 | + <h2 id="login" class="mt-8 text-[15px] font-semibold">First login</h2> | |
| 136 | + <div class="box mt-2 overflow-hidden"> | |
| 137 | + <pre class="overflow-x-auto bg-surface-sunken p-3 font-mono text-[12.5px] leading-6"><span class="text-ink-faint">$</span> ig login --host <span class="host-url">https://this-site</span> | |
| 138 | +First copy your one-time code: BCDF-GHJK | |
| 139 | +Then open <span class="host-url">https://this-site</span>/login/device?code=BCDF-GHJK and approve it. | |
| 140 | +Waiting for approval... | |
| 141 | +✓ Logged in to <span class="host-url">https://this-site</span> as you | |
| 142 | +✓ git uses ig for <span class="host-url">https://this-site</span> credentials | |
| 143 | +✓ docker uses ig for <span class="host">this-site</span></pre> | |
| 144 | + </div> | |
| 145 | + <p class="mt-2 text-[13px] text-ink-dim"> | |
| 146 | + The browser page shows which machine is asking and what the token can do. After you approve, <code>git clone</code>, | |
| 147 | + {" "}<code>git push</code>, <code>docker push</code> and <code>docker pull</code> against this server just work. On a machine without a | |
| 148 | + browser, create a token in Settings and run <code>ig login --with-token < token.txt</code>. | |
| 149 | + </p> | |
| 150 | + | |
| 151 | + { | |
| 152 | + groups.map((g) => ( | |
| 153 | + <section> | |
| 154 | + <h2 id={g.id} class="mt-8 text-[15px] font-semibold"> | |
| 155 | + {g.title} | |
| 156 | + </h2> | |
| 157 | + {g.intro && <p class="mt-1 text-[13px] text-ink-dim">{g.intro}</p>} | |
| 158 | + <div class="box mt-2 divide-y divide-edge"> | |
| 159 | + {g.commands.map(([usage, description]) => ( | |
| 160 | + <div class="grid gap-1 px-3 py-2 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] md:gap-4"> | |
| 161 | + <code class="text-[12.5px] break-words text-ink">{usage}</code> | |
| 162 | + <span class="text-[13px] text-ink-dim">{description}</span> | |
| 163 | + </div> | |
| 164 | + ))} | |
| 165 | + </div> | |
| 166 | + </section> | |
| 167 | + )) | |
| 168 | + } | |
| 169 | + | |
| 170 | + <h2 id="env" class="mt-8 text-[15px] font-semibold">Environment</h2> | |
| 171 | + <div class="box mt-2 divide-y divide-edge"> | |
| 172 | + { | |
| 173 | + env.map(([name, description]) => ( | |
| 174 | + <div class="grid gap-1 px-3 py-2 md:grid-cols-[180px_1fr] md:gap-4"> | |
| 175 | + <code class="text-[12.5px] text-ink">{name}</code> | |
| 176 | + <span class="text-[13px] text-ink-dim">{description}</span> | |
| 177 | + </div> | |
| 178 | + )) | |
| 179 | + } | |
| 180 | + </div> | |
| 181 | + <p class="mt-4 text-[13px] text-ink-dim"> | |
| 182 | + Every command exits non-zero on failure and prints the server's reason, so ig is safe to use in scripts. Add | |
| 183 | + {" "}<code>--json</code> to get output you can pipe to <code>jq</code>. | |
| 184 | + </p> | |
| 185 | + </div> | |
| 186 | + </div> | |
| 187 | +</Layout> | |
| 188 | + | |
| 189 | +<script> | |
| 190 | + const origin = `${location.protocol}//${location.host}`; | |
| 191 | + document.querySelectorAll(".host-url").forEach((el) => (el.textContent = origin)); | |
| 192 | + document.querySelectorAll(".host").forEach((el) => (el.textContent = location.host)); | |
| 193 | +</script> |
+91-0frontend/src/pages/docs/index.astro
| @@ -0,0 +1,91 @@ | ||
| 1 | +--- | |
| 2 | +import Layout from "../../layouts/Layout.astro"; | |
| 3 | + | |
| 4 | +const sections = [ | |
| 5 | + { | |
| 6 | + href: "/docs/cli", | |
| 7 | + title: "The ig command line tool", | |
| 8 | + body: "Install ig, sign in from the terminal, and manage repositories, images, SSH keys, tokens and organizations.", | |
| 9 | + }, | |
| 10 | + { | |
| 11 | + href: "/docs/git", | |
| 12 | + title: "Git: HTTPS, SSH and LFS", | |
| 13 | + body: "Clone URLs, SSH keys, file size limits, storage quotas and storing large files with Git LFS.", | |
| 14 | + }, | |
| 15 | + { | |
| 16 | + href: "/docs/registry", | |
| 17 | + title: "Container registry", | |
| 18 | + body: "Push and pull Docker images, make images public or private independently of code, and clean up tags.", | |
| 19 | + }, | |
| 20 | +]; | |
| 21 | +--- | |
| 22 | + | |
| 23 | +<Layout title="Docs · irongit" description="How to use irongit: the ig CLI, git over HTTPS and SSH, Git LFS and the container registry."> | |
| 24 | + <div class="grid gap-8 md:grid-cols-[1fr_300px]"> | |
| 25 | + <div> | |
| 26 | + <p class="font-mono text-xs tracking-widest text-ember uppercase">Documentation</p> | |
| 27 | + <h1 class="mt-2 text-2xl font-semibold">Using irongit</h1> | |
| 28 | + <p class="mt-2 max-w-prose text-ink-dim"> | |
| 29 | + irongit hosts git repositories and container images. Everything in the web UI is also available from the | |
| 30 | + {" "}<a href="/docs/cli"><code>ig</code></a> command line tool. | |
| 31 | + </p> | |
| 32 | + | |
| 33 | + <h2 class="mt-8 text-[15px] font-semibold">Quick start</h2> | |
| 34 | + <div class="box mt-2 overflow-hidden"> | |
| 35 | + <div class="box-head text-ink-dim">terminal</div> | |
| 36 | + <pre class="overflow-x-auto bg-surface-sunken p-3 font-mono text-[12.5px] leading-6"><span class="text-ink-faint"># 1. install ig (x86_64 Linux)</span> | |
| 37 | +curl -fsSL <span class="host-url">https://this-site</span>/install.sh | sh | |
| 38 | + | |
| 39 | +<span class="text-ink-faint"># 2. sign in; this also sets up git and docker credentials</span> | |
| 40 | +ig login --host <span class="host-url">https://this-site</span> | |
| 41 | + | |
| 42 | +<span class="text-ink-faint"># 3. create a repository and push to it</span> | |
| 43 | +ig repo create hello --private | |
| 44 | +git remote add origin <span class="host-url">https://this-site</span>/you/hello.git | |
| 45 | +git push -u origin main | |
| 46 | + | |
| 47 | +<span class="text-ink-faint"># 4. push an image; its visibility is separate from the repo's</span> | |
| 48 | +docker push <span class="host">this-site</span>/you/hello:1.0 | |
| 49 | +ig image visibility you/hello public</pre> | |
| 50 | + </div> | |
| 51 | + | |
| 52 | + <h2 class="mt-8 text-[15px] font-semibold">Guides</h2> | |
| 53 | + <div class="mt-2 grid gap-px sm:grid-cols-2"> | |
| 54 | + { | |
| 55 | + sections.map((s) => ( | |
| 56 | + <a href={s.href} class="block border border-edge p-3 text-ink no-underline hover:bg-surface-hover hover:no-underline"> | |
| 57 | + <div class="font-semibold text-accent">{s.title}</div> | |
| 58 | + <div class="mt-1 text-[13px] text-ink-dim">{s.body}</div> | |
| 59 | + </a> | |
| 60 | + )) | |
| 61 | + } | |
| 62 | + </div> | |
| 63 | + </div> | |
| 64 | + | |
| 65 | + <aside class="space-y-3 text-[13px]"> | |
| 66 | + <div class="box"> | |
| 67 | + <div class="box-head font-semibold">Credentials</div> | |
| 68 | + <div class="space-y-2 p-3 text-ink-dim"> | |
| 69 | + <p> | |
| 70 | + Git, docker and the API take a <strong class="text-ink">personal access token</strong>, never your password. | |
| 71 | + {" "}<code>ig login</code> creates one for you; create others under Settings or with <code>ig token create</code>. | |
| 72 | + </p> | |
| 73 | + <p>Tokens carry scopes: <code>repo</code>, <code>packages</code>, <code>user</code> and, for admins, <code>admin</code>.</p> | |
| 74 | + </div> | |
| 75 | + </div> | |
| 76 | + <div class="box"> | |
| 77 | + <div class="box-head font-semibold">API</div> | |
| 78 | + <div class="space-y-2 p-3 text-ink-dim"> | |
| 79 | + <p>JSON under <code>/api/v1</code> with <code>Authorization: Bearer igp_...</code>.</p> | |
| 80 | + <p>Try it with <code>ig api GET user</code>.</p> | |
| 81 | + </div> | |
| 82 | + </div> | |
| 83 | + </aside> | |
| 84 | + </div> | |
| 85 | +</Layout> | |
| 86 | + | |
| 87 | +<script> | |
| 88 | + const origin = `${location.protocol}//${location.host}`; | |
| 89 | + document.querySelectorAll(".host-url").forEach((el) => (el.textContent = origin)); | |
| 90 | + document.querySelectorAll(".host").forEach((el) => (el.textContent = location.host)); | |
| 91 | +</script> |