irongit

Git hosting and a container registry in one Rust binary (axum + Astro)

Merge branch 'worktree-agent-aba31e5de1ae4b7c3'

huncholanehuncholaneauthored
parent 179023bparent 9900f16commit fa99a770b3f651e8c84e1860ec34c955a23f77b6Browse files

20 files changed, +4065 -10

+1-0Cargo.lock
@@ -1876,6 +1876,7 @@ name = "ig"
18761876 version = "0.1.0"
18771877 dependencies = [
18781878 "anyhow",
1879+ "chrono",
18791880 "clap",
18801881 "dirs",
18811882 "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.
27
8+mod device;
9+mod keys;
10+mod orgs;
11+mod packages;
312 pub mod release;
13+mod repos;
14+mod users;
415
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;
625
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+};
835
936 pub fn router() -> Router<AppState> {
1037 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+ }
11253 }
+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
28
3-use std::path::Path;
9+use std::{path::Path, time::Duration};
410
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;
624
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+ }
9245 }
+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"
55
66 [dependencies]
77 anyhow = "1.0.104"
8+chrono = "0.4.45"
89 clap = { version = "4.6.7", features = ["derive", "env"] }
910 dirs = "7.0.0"
1011 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(&current_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(&registry_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 &lt; 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>
+325-1shared/src/lib.rs
@@ -1 +1,325 @@
1-pub fn placeholder() {}
1+//! Types exchanged between the irongit server's `/api/v1` and the `ig` CLI.
2+//! Both sides compile against this crate, so a field renamed here breaks
3+//! the build instead of a user's terminal.
4+
5+use chrono::{DateTime, NaiveDate, Utc};
6+use serde::{Deserialize, Serialize};
7+
8+/// Every API error: `{"error": "...", "status": 404}`. Device-flow polling
9+/// uses machine-readable codes in `error` (see `device_errors`).
10+#[derive(Debug, Clone, Serialize, Deserialize)]
11+pub struct ErrorBody {
12+ pub error: String,
13+ pub status: u16,
14+}
15+
16+/// The signed-in user (`GET /api/v1/user`).
17+#[derive(Debug, Clone, Serialize, Deserialize)]
18+pub struct User {
19+ pub id: i64,
20+ pub username: String,
21+ pub display_name: String,
22+ pub bio: String,
23+ pub location: String,
24+ pub website: String,
25+ pub avatar_url: String,
26+ pub is_admin: bool,
27+ /// Primary email; only present with the `user` scope.
28+ pub email: Option<String>,
29+ /// Scopes of the credential that made the request.
30+ pub scopes: Vec<String>,
31+ pub created_at: DateTime<Utc>,
32+}
33+
34+/// A user or organization (`GET /api/v1/users/{name}`).
35+#[derive(Debug, Clone, Serialize, Deserialize)]
36+pub struct Account {
37+ pub id: i64,
38+ pub name: String,
39+ /// "user" or "org".
40+ pub kind: String,
41+ pub display_name: String,
42+ pub bio: String,
43+ pub location: String,
44+ pub website: String,
45+ pub avatar_url: String,
46+ pub web_url: String,
47+ pub created_at: DateTime<Utc>,
48+}
49+
50+#[derive(Debug, Clone, Serialize, Deserialize)]
51+pub struct Repo {
52+ pub id: i64,
53+ pub owner: String,
54+ pub name: String,
55+ pub full_name: String,
56+ pub description: String,
57+ /// "public" or "private".
58+ pub visibility: String,
59+ pub default_branch: String,
60+ pub size_bytes: i64,
61+ pub is_empty: bool,
62+ pub archived: bool,
63+ pub clone_https: String,
64+ pub clone_ssh: String,
65+ pub web_url: String,
66+ /// The caller's access: "read", "write" or "admin". Absent in listings.
67+ pub permission: Option<String>,
68+ pub created_at: DateTime<Utc>,
69+ pub updated_at: DateTime<Utc>,
70+ pub pushed_at: Option<DateTime<Utc>>,
71+}
72+
73+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
74+pub struct CreateRepo {
75+ /// User or organization to create under; defaults to the caller.
76+ pub owner: Option<String>,
77+ pub name: String,
78+ pub description: Option<String>,
79+ /// "public" or "private" (default "private").
80+ pub visibility: Option<String>,
81+}
82+
83+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84+pub struct UpdateRepo {
85+ #[serde(skip_serializing_if = "Option::is_none")]
86+ pub description: Option<String>,
87+ #[serde(skip_serializing_if = "Option::is_none")]
88+ pub visibility: Option<String>,
89+ #[serde(skip_serializing_if = "Option::is_none")]
90+ pub default_branch: Option<String>,
91+ #[serde(skip_serializing_if = "Option::is_none")]
92+ pub archived: Option<bool>,
93+}
94+
95+#[derive(Debug, Clone, Serialize, Deserialize)]
96+pub struct Collaborator {
97+ pub username: String,
98+ /// "read", "write" or "admin".
99+ pub permission: String,
100+ pub avatar_url: String,
101+ pub created_at: DateTime<Utc>,
102+}
103+
104+#[derive(Debug, Clone, Serialize, Deserialize)]
105+pub struct SetPermission {
106+ pub permission: String,
107+}
108+
109+/// A container image repository, e.g. `alice/api`.
110+#[derive(Debug, Clone, Serialize, Deserialize)]
111+pub struct Package {
112+ pub id: i64,
113+ pub owner: String,
114+ pub name: String,
115+ pub full_name: String,
116+ pub visibility: String,
117+ pub description: String,
118+ /// Linked repository as "owner/name", if any.
119+ pub repo: Option<String>,
120+ pub pull_count: i64,
121+ pub tag_count: i64,
122+ /// `docker pull` reference for the newest tag (or the bare name).
123+ pub pull_command: String,
124+ pub web_url: String,
125+ pub permission: Option<String>,
126+ pub created_at: DateTime<Utc>,
127+ pub updated_at: DateTime<Utc>,
128+}
129+
130+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131+pub struct UpdatePackage {
132+ #[serde(skip_serializing_if = "Option::is_none")]
133+ pub visibility: Option<String>,
134+ #[serde(skip_serializing_if = "Option::is_none")]
135+ pub description: Option<String>,
136+}
137+
138+#[derive(Debug, Clone, Serialize, Deserialize)]
139+pub struct Tag {
140+ pub name: String,
141+ pub digest: String,
142+ pub media_type: String,
143+ /// Config plus layers, in bytes.
144+ pub size: i64,
145+ pub updated_at: DateTime<Utc>,
146+}
147+
148+#[derive(Debug, Clone, Serialize, Deserialize)]
149+pub struct SshKey {
150+ pub id: i64,
151+ pub title: String,
152+ pub fingerprint: String,
153+ pub public_key: String,
154+ pub created_at: DateTime<Utc>,
155+ pub last_used_at: Option<DateTime<Utc>>,
156+}
157+
158+#[derive(Debug, Clone, Serialize, Deserialize)]
159+pub struct CreateSshKey {
160+ pub title: Option<String>,
161+ /// OpenSSH public key line, e.g. "ssh-ed25519 AAAA... me@host".
162+ pub key: String,
163+}
164+
165+#[derive(Debug, Clone, Serialize, Deserialize)]
166+pub struct Token {
167+ pub id: i64,
168+ pub name: String,
169+ /// First characters of the secret, for recognizing it.
170+ pub prefix: String,
171+ pub scopes: Vec<String>,
172+ pub created_at: DateTime<Utc>,
173+ pub last_used_at: Option<DateTime<Utc>>,
174+ pub expires_at: Option<DateTime<Utc>>,
175+}
176+
177+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
178+pub struct CreateToken {
179+ pub name: String,
180+ /// Subset of repo, packages, user, admin. Defaults to repo, packages, user.
181+ pub scopes: Option<Vec<String>>,
182+ pub expires_in_days: Option<u32>,
183+}
184+
185+/// Returned once, at creation. The secret cannot be read back later.
186+#[derive(Debug, Clone, Serialize, Deserialize)]
187+pub struct CreatedToken {
188+ pub token: Token,
189+ pub secret: String,
190+}
191+
192+#[derive(Debug, Clone, Serialize, Deserialize)]
193+pub struct CreateOrg {
194+ pub name: String,
195+ pub display_name: Option<String>,
196+}
197+
198+/// An organization the caller belongs to.
199+#[derive(Debug, Clone, Serialize, Deserialize)]
200+pub struct OrgMembership {
201+ pub org: String,
202+ pub display_name: String,
203+ pub role: String,
204+ pub avatar_url: String,
205+}
206+
207+#[derive(Debug, Clone, Serialize, Deserialize)]
208+pub struct OrgMember {
209+ pub username: String,
210+ /// "owner" or "member".
211+ pub role: String,
212+ pub avatar_url: String,
213+ pub joined_at: DateTime<Utc>,
214+}
215+
216+#[derive(Debug, Clone, Serialize, Deserialize)]
217+pub struct SetRole {
218+ pub role: String,
219+}
220+
221+#[derive(Debug, Clone, Serialize, Deserialize)]
222+pub struct DeviceCodeRequest {
223+ pub client_name: String,
224+}
225+
226+#[derive(Debug, Clone, Serialize, Deserialize)]
227+pub struct DeviceCodeResponse {
228+ pub device_code: String,
229+ pub user_code: String,
230+ pub verification_uri: String,
231+ pub verification_uri_complete: String,
232+ /// Seconds to wait between polls.
233+ pub interval: u64,
234+ pub expires_in: u64,
235+}
236+
237+#[derive(Debug, Clone, Serialize, Deserialize)]
238+pub struct DeviceTokenRequest {
239+ pub device_code: String,
240+}
241+
242+#[derive(Debug, Clone, Serialize, Deserialize)]
243+pub struct DeviceTokenResponse {
244+ pub token: String,
245+ pub username: String,
246+}
247+
248+/// `error` values returned while polling `/api/v1/device/token`.
249+pub mod device_errors {
250+ /// 428: not approved yet; keep polling.
251+ pub const PENDING: &str = "authorization_pending";
252+ /// 429: polling too fast; add 5 seconds to the interval.
253+ pub const SLOW_DOWN: &str = "slow_down";
254+ /// 403: the person clicked Deny.
255+ pub const DENIED: &str = "access_denied";
256+ /// 410: the code expired or was already used.
257+ pub const EXPIRED: &str = "expired_token";
258+}
259+
260+#[derive(Debug, Clone, Serialize, Deserialize)]
261+pub struct CliRelease {
262+ pub version: String,
263+ pub target: String,
264+ pub sha256: String,
265+ pub size: i64,
266+ /// Download URL on this server (redirects to storage).
267+ pub url: String,
268+ pub created_at: DateTime<Utc>,
269+}
270+
271+#[derive(Debug, Clone, Serialize, Deserialize)]
272+pub struct ContributionDay {
273+ pub date: NaiveDate,
274+ pub count: i64,
275+}
276+
277+#[derive(Debug, Clone, Serialize, Deserialize)]
278+pub struct Contributions {
279+ pub username: String,
280+ pub total: i64,
281+ pub from: NaiveDate,
282+ pub to: NaiveDate,
283+ /// Only days with at least one contribution.
284+ pub days: Vec<ContributionDay>,
285+}
286+
287+/// Compares dotted version strings numerically ("0.10.0" > "0.9.3").
288+/// Non-numeric parts compare as 0, so "1.2.3-beta" orders like "1.2.3".
289+pub fn version_newer(candidate: &str, current: &str) -> bool {
290+ fn parts(v: &str) -> Vec<u64> {
291+ v.trim_start_matches('v')
292+ .split(['.', '-', '+'])
293+ .take(3)
294+ .map(|p| p.parse().unwrap_or(0))
295+ .collect()
296+ }
297+ let (a, b) = (parts(candidate), parts(current));
298+ for i in 0..3 {
299+ let (x, y) = (a.get(i).copied().unwrap_or(0), b.get(i).copied().unwrap_or(0));
300+ if x != y {
301+ return x > y;
302+ }
303+ }
304+ false
305+}
306+
307+#[cfg(test)]
308+mod tests {
309+ use super::*;
310+
311+ #[test]
312+ fn versions_compare_numerically() {
313+ assert!(version_newer("0.10.0", "0.9.3"));
314+ assert!(version_newer("1.0.0", "0.99.99"));
315+ assert!(version_newer("v0.1.1", "0.1.0"));
316+ assert!(!version_newer("0.1.0", "0.1.0"));
317+ assert!(!version_newer("0.1.0", "0.2.0"));
318+ }
319+
320+ #[test]
321+ fn update_repo_omits_unset_fields() {
322+ let body = serde_json::to_string(&UpdateRepo { visibility: Some("public".into()), ..Default::default() }).unwrap();
323+ assert_eq!(body, r#"{"visibility":"public"}"#);
324+ }
325+}