irongit

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

Import GitHub accounts (web, API, ig import) with LFS, profile and email linking; sign in with GitHub

huncholanehuncholaneauthored
parent 6823530commit e730fefe67d3431a95de5ddba9e286783c296f93Browse files

26 files changed, +2783 -179

+13-1README.md
@@ -66,11 +66,23 @@ ig repo clone you/api
6666 docker push localhost:7878/you/api:1.0
6767 ig image visibility you/api public
6868 ig ssh-key add # ~/.ssh/id_ed25519.pub by default
69+ig import github my-github-name # bring a GitHub account over (uses your gh login)
6970 ig upgrade
7071 ```
7172
7273 Full reference at `/docs/cli`; git, SSH and LFS at `/docs/git`; the
73-registry at `/docs/registry`.
74+registry at `/docs/registry`; GitHub imports at `/docs/import`.
75+
76+## Importing from GitHub
77+
78+`/new/import` (or `ig import github OWNER`) copies a GitHub user's or
79+organization's repositories: every branch and tag, Git LFS objects (into R2),
80+description, default branch, visibility and archived state; optionally the
81+profile; and, for your own account, your GitHub-verified emails so imported
82+commits count on your heatmap. It runs as a background job on the server
83+(`backend/src/importer/`). GitHub tokens are held in memory only and OAuth
84+import tokens are revoked when the job ends. Sign in with GitHub and "Connect
85+GitHub" need `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` (see secrets.md).
7486
7587 ## Tests
7688
+56-0backend/migrations/0002_imports.sql
@@ -0,0 +1,56 @@
1+-- Imports from GitHub. One row per run, one item per repository. The GitHub
2+-- token is never stored: it lives in the running job's memory only, so an
3+-- import interrupted by a restart is marked failed and can be started again.
4+
5+create table imports (
6+ id bigserial primary key,
7+ user_id bigint not null references users (account_id) on delete cascade,
8+ owner_id bigint not null references accounts (id) on delete cascade,
9+ source text not null default 'github' check (source in ('github')),
10+ source_owner text not null,
11+ status text not null default 'pending'
12+ check (status in ('pending', 'running', 'done', 'failed', 'cancelled')),
13+ visibility_mode text not null default 'keep' check (visibility_mode in ('keep', 'private', 'public')),
14+ import_profile boolean not null default false,
15+ link_emails boolean not null default false,
16+ total integer not null default 0,
17+ done integer not null default 0,
18+ failed integer not null default 0,
19+ notes text not null default '',
20+ error text,
21+ created_at timestamptz not null default now(),
22+ started_at timestamptz,
23+ finished_at timestamptz
24+);
25+create index imports_user on imports (user_id, created_at desc);
26+
27+create table import_items (
28+ id bigserial primary key,
29+ import_id bigint not null references imports (id) on delete cascade,
30+ position integer not null,
31+ source_name text not null,
32+ target_name text not null,
33+ visibility text not null check (visibility in ('public', 'private')),
34+ description text not null default '',
35+ default_branch text,
36+ archived boolean not null default false,
37+ clone_url text not null,
38+ size_kb bigint not null default 0,
39+ status text not null default 'pending'
40+ check (status in ('pending', 'running', 'done', 'failed', 'skipped')),
41+ repo_id bigint references repos (id) on delete set null,
42+ commits integer not null default 0,
43+ lfs_objects integer not null default 0,
44+ error text,
45+ started_at timestamptz,
46+ finished_at timestamptz
47+);
48+create index import_items_import on import_items (import_id, position);
49+
50+-- Imported repositories show up in activity feeds as one "imported" event.
51+alter table push_events drop constraint push_events_via_check;
52+alter table push_events add constraint push_events_via_check check (via in ('http', 'ssh', 'web', 'import'));
53+
54+-- GitHub joins Google as a sign-in provider.
55+alter table oauth_identities drop constraint oauth_identities_provider_check;
56+alter table oauth_identities add constraint oauth_identities_provider_check check (provider in ('google', 'github'));
+39-0backend/src/api/imports.rs
@@ -0,0 +1,39 @@
1+//! GitHub imports: preview, start, follow, cancel.
2+
3+use axum::{
4+ Json,
5+ extract::{Path, State},
6+ http::StatusCode,
7+ response::Response,
8+};
9+use irongit_shared as shared;
10+
11+use super::{ApiJson, Scope, created, need_scope};
12+use crate::{auth::ApiViewer, error::ApiResult, importer, state::AppState};
13+
14+pub async fn preview(ApiViewer(viewer): ApiViewer, ApiJson(request): ApiJson<shared::GithubPreviewRequest>) -> ApiResult<Json<shared::GithubPreview>> {
15+ need_scope(&viewer, Scope::Repo)?;
16+ Ok(Json(importer::preview(&request.source_owner, request.token).await?))
17+}
18+
19+pub async fn start(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, ApiJson(request): ApiJson<shared::StartImport>) -> ApiResult<Response> {
20+ need_scope(&viewer, Scope::Repo)?;
21+ let id = importer::start(&state, &viewer, request).await?;
22+ Ok(created(importer::load(&state, &viewer, id).await?))
23+}
24+
25+pub async fn list(State(state): State<AppState>, ApiViewer(viewer): ApiViewer) -> ApiResult<Json<Vec<shared::Import>>> {
26+ need_scope(&viewer, Scope::Repo)?;
27+ Ok(Json(importer::list(&state, &viewer).await?))
28+}
29+
30+pub async fn show(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, Path(id): Path<i64>) -> ApiResult<Json<shared::Import>> {
31+ need_scope(&viewer, Scope::Repo)?;
32+ Ok(Json(importer::load(&state, &viewer, id).await?))
33+}
34+
35+pub async fn cancel(State(state): State<AppState>, ApiViewer(viewer): ApiViewer, Path(id): Path<i64>) -> ApiResult<StatusCode> {
36+ need_scope(&viewer, Scope::Repo)?;
37+ importer::cancel(&state, &viewer, id).await?;
38+ Ok(StatusCode::NO_CONTENT)
39+}
+5-0backend/src/api/mod.rs
@@ -6,6 +6,7 @@
66 //! never see more than the web UI would show the same person.
77
88 mod device;
9+mod imports;
910 mod keys;
1011 mod orgs;
1112 mod packages;
@@ -58,6 +59,10 @@ pub fn router() -> Router<AppState> {
5859 .route("/api/v1/orgs", post(orgs::create))
5960 .route("/api/v1/orgs/{org}/members", get(orgs::members))
6061 .route("/api/v1/orgs/{org}/members/{user}", put(orgs::set_member).delete(orgs::remove_member))
62+ .route("/api/v1/imports/github/preview", post(imports::preview))
63+ .route("/api/v1/imports", get(imports::list).post(imports::start))
64+ .route("/api/v1/imports/{id}", get(imports::show))
65+ .route("/api/v1/imports/{id}/cancel", post(imports::cancel))
6166 .route("/api/v1/device/code", post(device::code))
6267 .route("/api/v1/device/token", post(device::token))
6368 .route("/login/device", get(device::page).post(device::submit))
+14-0backend/src/config.rs
@@ -26,9 +26,17 @@ pub struct Config {
2626 pub smtp: Option<SmtpConfig>,
2727 pub rybbit: Option<RybbitConfig>,
2828 pub google: Option<GoogleConfig>,
29+ pub github: Option<GithubConfig>,
2930 pub backup_interval_hours: u64,
3031 }
3132
33+/// GitHub sign-in (OAuth app). Callback: {PUBLIC_URL}/login/github/callback
34+#[derive(Clone, Debug)]
35+pub struct GithubConfig {
36+ pub client_id: String,
37+ pub client_secret: String,
38+}
39+
3240 /// Google sign-in (OpenID Connect). Redirect URI: {PUBLIC_URL}/login/google/callback
3341 #[derive(Clone, Debug)]
3442 pub struct GoogleConfig {
@@ -123,6 +131,11 @@ impl Config {
123131 _ => None,
124132 };
125133
134+ let github = match (var("GITHUB_CLIENT_ID"), var("GITHUB_CLIENT_SECRET")) {
135+ (Some(client_id), Some(client_secret)) => Some(GithubConfig { client_id, client_secret }),
136+ _ => None,
137+ };
138+
126139 Ok(Self {
127140 host: var("HOST").unwrap_or_else(|| "127.0.0.1".into()),
128141 port,
@@ -145,6 +158,7 @@ impl Config {
145158 smtp,
146159 rybbit,
147160 google,
161+ github,
148162 backup_interval_hours: var("BACKUP_INTERVAL_HOURS").map(|v| v.parse()).transpose()?.unwrap_or(24),
149163 })
150164 }
+235-0backend/src/importer/github.rs
@@ -0,0 +1,235 @@
1+//! The slice of the GitHub REST API the importer needs: an account, its
2+//! repositories, and (for the token's own account) its verified emails.
3+
4+use std::time::Duration;
5+
6+use anyhow::{Context, anyhow};
7+use base64::Engine;
8+use chrono::{DateTime, Utc};
9+use irongit_shared as shared;
10+use reqwest::{StatusCode, header};
11+use serde::{Deserialize, de::DeserializeOwned};
12+
13+const API: &str = "https://api.github.com";
14+const MAX_REPOS: usize = 2000;
15+
16+pub struct GitHub {
17+ http: reqwest::Client,
18+ token: Option<String>,
19+}
20+
21+#[derive(Debug, Clone, Deserialize)]
22+pub struct RawAccount {
23+ pub login: String,
24+ pub id: i64,
25+ #[serde(rename = "type")]
26+ pub kind: String,
27+ pub name: Option<String>,
28+ pub bio: Option<String>,
29+ pub location: Option<String>,
30+ pub blog: Option<String>,
31+ pub avatar_url: String,
32+}
33+
34+#[derive(Debug, Clone, Deserialize)]
35+pub struct RawRepo {
36+ pub name: String,
37+ pub description: Option<String>,
38+ pub private: bool,
39+ pub fork: bool,
40+ #[serde(default)]
41+ pub archived: bool,
42+ pub default_branch: Option<String>,
43+ #[serde(default)]
44+ pub size: i64,
45+ pub pushed_at: Option<DateTime<Utc>>,
46+ pub html_url: String,
47+ pub clone_url: String,
48+}
49+
50+#[derive(Debug, Deserialize)]
51+struct RawEmail {
52+ email: String,
53+ verified: bool,
54+}
55+
56+impl RawAccount {
57+ pub fn to_shared(&self) -> shared::GithubAccount {
58+ shared::GithubAccount {
59+ login: self.login.clone(),
60+ kind: self.kind.clone(),
61+ name: self.name.clone().unwrap_or_default(),
62+ bio: self.bio.clone().unwrap_or_default(),
63+ location: self.location.clone().unwrap_or_default(),
64+ website: self.blog.clone().unwrap_or_default(),
65+ avatar_url: self.avatar_url.clone(),
66+ }
67+ }
68+
69+ pub fn is_org(&self) -> bool {
70+ self.kind == "Organization"
71+ }
72+}
73+
74+impl RawRepo {
75+ pub fn to_shared(&self) -> shared::GithubRepo {
76+ shared::GithubRepo {
77+ name: self.name.clone(),
78+ description: self.description.clone().unwrap_or_default(),
79+ private: self.private,
80+ fork: self.fork,
81+ archived: self.archived,
82+ default_branch: self.default_branch.clone().unwrap_or_default(),
83+ size_kb: self.size,
84+ pushed_at: self.pushed_at,
85+ html_url: self.html_url.clone(),
86+ }
87+ }
88+}
89+
90+/// GitHub logins: alphanumerics and single hyphens, up to 39 characters.
91+pub fn valid_login(login: &str) -> bool {
92+ !login.is_empty()
93+ && login.len() <= 39
94+ && login.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
95+ && !login.starts_with('-')
96+ && !login.ends_with('-')
97+}
98+
99+impl GitHub {
100+ pub fn new(token: Option<String>) -> anyhow::Result<Self> {
101+ let http = reqwest::Client::builder()
102+ .user_agent("irongit-importer")
103+ .connect_timeout(Duration::from_secs(10))
104+ .timeout(Duration::from_secs(30))
105+ .build()?;
106+ Ok(Self { http, token: token.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) })
107+ }
108+
109+ pub fn has_token(&self) -> bool {
110+ self.token.is_some()
111+ }
112+
113+ /// `Authorization` value for git over HTTPS and the LFS API.
114+ pub fn basic_auth(&self) -> Option<String> {
115+ self.token.as_ref().map(|t| {
116+ format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(format!("x-access-token:{t}")))
117+ })
118+ }
119+
120+ async fn get<T: DeserializeOwned>(&self, path: &str) -> anyhow::Result<(T, Option<i64>)> {
121+ let mut request = self
122+ .http
123+ .get(format!("{API}{path}"))
124+ .header(header::ACCEPT, "application/vnd.github+json")
125+ .header("X-GitHub-Api-Version", "2022-11-28");
126+ if let Some(token) = &self.token {
127+ request = request.bearer_auth(token);
128+ }
129+ let response = request.send().await.context("could not reach the GitHub API")?;
130+ let remaining = response
131+ .headers()
132+ .get("x-ratelimit-remaining")
133+ .and_then(|v| v.to_str().ok()?.parse().ok());
134+ let status = response.status();
135+ if status.is_success() {
136+ return Ok((response.json().await.with_context(|| format!("unexpected GitHub response for {path}"))?, remaining));
137+ }
138+ let reset = response
139+ .headers()
140+ .get("x-ratelimit-reset")
141+ .and_then(|v| v.to_str().ok()?.parse::<i64>().ok())
142+ .and_then(|t| DateTime::from_timestamp(t, 0));
143+ let body = response.text().await.unwrap_or_default();
144+ tracing::warn!(path, %status, body = %body.chars().take(300).collect::<String>(), "GitHub API error");
145+ Err(match status {
146+ StatusCode::UNAUTHORIZED => anyhow!("GitHub rejected the token. Check that it is correct and has not expired."),
147+ StatusCode::FORBIDDEN | StatusCode::TOO_MANY_REQUESTS if remaining == Some(0) => anyhow!(
148+ "GitHub's API limit is used up until {} UTC. Add a GitHub token to get 5,000 calls an hour.",
149+ reset.map(|r| r.format("%H:%M").to_string()).unwrap_or_else(|| "later".into())
150+ ),
151+ StatusCode::NOT_FOUND => anyhow!("not found"),
152+ other => anyhow!("GitHub answered {other} for {path}"),
153+ })
154+ }
155+
156+ pub async fn account(&self, login: &str) -> anyhow::Result<(RawAccount, Option<i64>)> {
157+ if !valid_login(login) {
158+ return Err(anyhow!("'{login}' is not a valid GitHub username"));
159+ }
160+ self.get::<RawAccount>(&format!("/users/{login}"))
161+ .await
162+ .map_err(|e| if e.to_string() == "not found" { anyhow!("GitHub has no user or organization named '{login}'") } else { e })
163+ }
164+
165+ /// The account the token belongs to, if there is a token.
166+ pub async fn token_owner(&self) -> anyhow::Result<Option<RawAccount>> {
167+ if self.token.is_none() {
168+ return Ok(None);
169+ }
170+ Ok(Some(self.get::<RawAccount>("/user").await?.0))
171+ }
172+
173+ /// Every repository the account owns that the credential can see.
174+ pub async fn repos(&self, account: &RawAccount, token_is_owner: bool) -> anyhow::Result<(Vec<RawRepo>, Option<i64>)> {
175+ let base = if account.is_org() {
176+ format!("/orgs/{}/repos?type=all", account.login)
177+ } else if token_is_owner {
178+ // Only this endpoint includes the user's own private repos.
179+ "/user/repos?affiliation=owner&visibility=all".to_string()
180+ } else {
181+ format!("/users/{}/repos?type=owner", account.login)
182+ };
183+ let mut out = Vec::new();
184+ let mut remaining = None;
185+ for page in 1.. {
186+ let (batch, left): (Vec<RawRepo>, _) = self.get(&format!("{base}&per_page=100&page={page}&sort=full_name")).await?;
187+ remaining = left.or(remaining);
188+ let done = batch.len() < 100;
189+ out.extend(batch);
190+ if done || out.len() >= MAX_REPOS {
191+ break;
192+ }
193+ }
194+ out.truncate(MAX_REPOS);
195+ Ok((out, remaining))
196+ }
197+
198+ /// Verified emails of the token's own account (needs the `user:email`
199+ /// scope; returns an empty list without it).
200+ pub async fn verified_emails(&self) -> Vec<String> {
201+ match self.get::<Vec<RawEmail>>("/user/emails").await {
202+ Ok((emails, _)) => emails.into_iter().filter(|e| e.verified).map(|e| e.email).collect(),
203+ Err(error) => {
204+ tracing::info!(%error, "could not read GitHub emails (token may lack user:email)");
205+ Vec::new()
206+ }
207+ }
208+ }
209+
210+ pub fn http(&self) -> &reqwest::Client {
211+ &self.http
212+ }
213+}
214+
215+#[cfg(test)]
216+mod tests {
217+ use super::*;
218+
219+ #[test]
220+ fn logins() {
221+ assert!(valid_login("octocat"));
222+ assert!(valid_login("my-org-2"));
223+ assert!(!valid_login("-bad"));
224+ assert!(!valid_login("a/b"));
225+ assert!(!valid_login(""));
226+ }
227+
228+ #[test]
229+ fn basic_auth_uses_x_access_token() {
230+ let gh = GitHub::new(Some("abc".into())).unwrap();
231+ let decoded = base64::engine::general_purpose::STANDARD.decode(gh.basic_auth().unwrap().trim_start_matches("Basic ")).unwrap();
232+ assert_eq!(decoded, b"x-access-token:abc");
233+ assert!(GitHub::new(Some(" ".into())).unwrap().basic_auth().is_none());
234+ }
235+}
+963-0backend/src/importer/mod.rs
@@ -0,0 +1,963 @@
1+//! Importing a GitHub account: its repositories (all branches and tags, Git
2+//! LFS objects included), optionally its profile, and for your own account
3+//! its verified emails so imported commits count on your heatmap.
4+//!
5+//! An import runs as a background job on the server, one repository at a
6+//! time. The GitHub token is held in that job's memory only and never
7+//! written anywhere; a restart interrupts the import, which is then marked
8+//! failed and can be started again (finished repositories are skipped as
9+//! "already exists").
10+
11+pub mod github;
12+
13+use std::{
14+ collections::{HashMap, HashSet},
15+ path::{Path, PathBuf},
16+ process::Stdio,
17+ sync::{LazyLock, Mutex},
18+ time::{Duration, Instant},
19+};
20+
21+use anyhow::{Context, anyhow};
22+use chrono::{DateTime, Utc};
23+use irongit_shared as shared;
24+use serde::Deserialize;
25+use serde_json::json;
26+use sha2::{Digest, Sha256};
27+use sqlx::FromRow;
28+use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
29+
30+use crate::{
31+ analytics,
32+ auth::{AuthVia, Scopes, Viewer},
33+ error::{AppError, AppResult},
34+ git::{Git, ZERO_SHA},
35+ models::{self, Account, Repo, audit},
36+ ops, perm, push,
37+ state::AppState,
38+ storage::keys,
39+ web::avatars,
40+};
41+
42+use github::{GitHub, RawRepo};
43+
44+/// How long one repository's fetch may take before it is abandoned.
45+const FETCH_TIMEOUT: Duration = Duration::from_secs(60 * 60);
46+
47+// ---------------------------------------------------------------------------
48+// Tokens between the preview page and the start button (web flow only).
49+
50+struct Stashed {
51+ token: String,
52+ user_id: i64,
53+ at: Instant,
54+}
55+
56+static STASH: LazyLock<Mutex<HashMap<String, Stashed>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
57+const STASH_TTL: Duration = Duration::from_secs(30 * 60);
58+
59+/// Keeps a GitHub token in memory for 30 minutes and returns a reference
60+/// the page can carry instead of the token itself.
61+pub fn stash_token(user_id: i64, token: String) -> String {
62+ let key = crate::auth::random_token(18);
63+ let mut stash = STASH.lock().unwrap();
64+ stash.retain(|_, s| s.at.elapsed() < STASH_TTL);
65+ stash.insert(key.clone(), Stashed { token, user_id, at: Instant::now() });
66+ key
67+}
68+
69+/// The stashed token, if it exists, is fresh and belongs to `user_id`.
70+pub fn stashed_token(key: &str, user_id: i64) -> Option<String> {
71+ let stash = STASH.lock().unwrap();
72+ stash.get(key).filter(|s| s.user_id == user_id && s.at.elapsed() < STASH_TTL).map(|s| s.token.clone())
73+}
74+
75+pub fn forget_token(key: &str) {
76+ STASH.lock().unwrap().remove(key);
77+}
78+
79+// ---------------------------------------------------------------------------
80+// Preview
81+
82+pub async fn preview(source_owner: &str, token: Option<String>) -> AppResult<shared::GithubPreview> {
83+ let gh = GitHub::new(token)?;
84+ let login = source_owner.trim().trim_start_matches('@').trim_start_matches("https://github.com/").trim_end_matches('/');
85+ let (account, _) = gh.account(login).await.map_err(|e| AppError::bad(e.to_string()))?;
86+ let token_owner = gh.token_owner().await.map_err(|e| AppError::bad(e.to_string()))?;
87+ let token_is_owner = token_owner.as_ref().is_some_and(|o| o.id == account.id);
88+ let (repos, remaining) = gh.repos(&account, token_is_owner).await.map_err(|e| AppError::bad(e.to_string()))?;
89+ tracing::info!(login = %account.login, repos = repos.len(), token = gh.has_token(), token_is_owner, "github preview");
90+ Ok(shared::GithubPreview {
91+ account: account.to_shared(),
92+ token_owner: token_owner.map(|o| o.login),
93+ token_is_owner,
94+ repos: repos.iter().map(RawRepo::to_shared).collect(),
95+ rate_limit_remaining: remaining,
96+ })
97+}
98+
99+// ---------------------------------------------------------------------------
100+// Start
101+
102+/// The name a GitHub repo gets on irongit (they share the same rules).
103+fn target_name(name: &str) -> Result<String, &'static str> {
104+ models::valid_repo_name(name).map(|_| name.to_string())
105+}
106+
107+pub async fn start(state: &AppState, viewer: &Viewer, request: shared::StartImport) -> AppResult<i64> {
108+ let db = &state.db;
109+ let target = match request.target_owner.as_deref().filter(|t| !t.trim().is_empty()) {
110+ Some(name) => Account::by_name(db, name.trim()).await?.ok_or_else(|| AppError::bad(format!("No account named {name}.")))?,
111+ None => Account::by_id(db, viewer.id).await?.ok_or(AppError::NotFound)?,
112+ };
113+ if !perm::can_create_under(db, viewer, &target).await? {
114+ return Err(AppError::forbidden(format!("You cannot create repositories under {}.", target.name)));
115+ }
116+ let busy: bool = sqlx::query_scalar("select exists(select 1 from imports where user_id = $1 and status in ('pending', 'running'))")
117+ .bind(viewer.id)
118+ .fetch_one(db)
119+ .await?;
120+ if busy {
121+ return Err(AppError::conflict("You already have an import running. Wait for it to finish or cancel it."));
122+ }
123+ let visibility_mode = match request.visibility.as_deref().unwrap_or("keep") {
124+ mode @ ("keep" | "private" | "public") => mode.to_string(),
125+ _ => return Err(AppError::bad("visibility must be keep, private or public.")),
126+ };
127+
128+ let gh = GitHub::new(request.token.clone())?;
129+ let (account, _) = gh.account(&request.source_owner).await.map_err(|e| AppError::bad(e.to_string()))?;
130+ let token_owner = gh.token_owner().await.map_err(|e| AppError::bad(e.to_string()))?;
131+ let token_is_owner = token_owner.as_ref().is_some_and(|o| o.id == account.id);
132+ if request.link_emails && !(token_is_owner && target.id == viewer.id) {
133+ return Err(AppError::bad(
134+ "Linking emails needs a GitHub token belonging to the account being imported, imported into your own irongit account.",
135+ ));
136+ }
137+ let (repos, _) = gh.repos(&account, token_is_owner).await.map_err(|e| AppError::bad(e.to_string()))?;
138+ let wanted: HashSet<String> = request.repos.iter().map(|r| r.trim().to_lowercase()).filter(|r| !r.is_empty()).collect();
139+ let selected: Vec<&RawRepo> = repos
140+ .iter()
141+ .filter(|r| if wanted.is_empty() { request.include_forks || !r.fork } else { wanted.contains(&r.name.to_lowercase()) })
142+ .collect();
143+ if selected.is_empty() {
144+ return Err(AppError::bad("No repositories selected."));
145+ }
146+ if !wanted.is_empty() {
147+ let found: HashSet<String> = selected.iter().map(|r| r.name.to_lowercase()).collect();
148+ let missing: Vec<&String> = wanted.iter().filter(|w| !found.contains(*w)).collect();
149+ if !missing.is_empty() {
150+ return Err(AppError::bad(format!(
151+ "Not found on {} (or not visible with this token): {}",
152+ account.login,
153+ missing.iter().map(|m| m.as_str()).collect::<Vec<_>>().join(", ")
154+ )));
155+ }
156+ }
157+
158+ let mut tx = db.begin().await?;
159+ let import_id: i64 = sqlx::query_scalar(
160+ "insert into imports (user_id, owner_id, source_owner, visibility_mode, import_profile, link_emails, total)
161+ values ($1, $2, $3, $4, $5, $6, $7) returning id",
162+ )
163+ .bind(viewer.id)
164+ .bind(target.id)
165+ .bind(&account.login)
166+ .bind(&visibility_mode)
167+ .bind(request.import_profile)
168+ .bind(request.link_emails)
169+ .bind(selected.len() as i32)
170+ .fetch_one(&mut *tx)
171+ .await?;
172+ for (position, repo) in selected.iter().enumerate() {
173+ let visibility = match visibility_mode.as_str() {
174+ "keep" => if repo.private { "private" } else { "public" },
175+ other => other,
176+ };
177+ let (target, status, error) = match target_name(&repo.name) {
178+ Ok(name) => (name, "pending", None),
179+ Err(reason) => (repo.name.clone(), "skipped", Some(reason.to_string())),
180+ };
181+ sqlx::query(
182+ "insert into import_items (import_id, position, source_name, target_name, visibility, description, default_branch,
183+ archived, clone_url, size_kb, status, error)
184+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
185+ )
186+ .bind(import_id)
187+ .bind(position as i32)
188+ .bind(&repo.name)
189+ .bind(&target)
190+ .bind(visibility)
191+ .bind(repo.description.clone().unwrap_or_default().chars().take(350).collect::<String>())
192+ .bind(&repo.default_branch)
193+ .bind(repo.archived)
194+ .bind(&repo.clone_url)
195+ .bind(repo.size)
196+ .bind(status)
197+ .bind(error)
198+ .execute(&mut *tx)
199+ .await?;
200+ }
201+ tx.commit().await?;
202+
203+ audit(db, Some(viewer.id), "import.start", &format!("github:{} -> {}", account.login, target.name), json!({ "repos": selected.len() }), None).await;
204+ analytics::track(
205+ state,
206+ "import_started",
207+ Some(&viewer.name),
208+ "/new/import",
209+ json!({ "source": "github", "repos": selected.len(), "with_token": gh.has_token(), "profile": request.import_profile, "emails": request.link_emails }),
210+ );
211+ tracing::info!(import_id, source = %account.login, target = %target.name, repos = selected.len(), "import started");
212+
213+ let job_state = state.clone();
214+ let token = request.token.clone();
215+ tokio::spawn(async move {
216+ let result = run(&job_state, import_id, token.clone()).await;
217+ // Tokens from "Connect GitHub" (gho_) were granted for this import only.
218+ if let Some(token) = token.filter(|t| t.starts_with("gho_")) {
219+ crate::web::account::revoke_github_token(&job_state, &token).await;
220+ }
221+ if let Err(error) = result {
222+ tracing::error!(import_id, ?error, "import failed");
223+ let _ = sqlx::query("update imports set status = 'failed', error = $2, finished_at = now() where id = $1")
224+ .bind(import_id)
225+ .bind(format!("{error:#}"))
226+ .execute(&job_state.db)
227+ .await;
228+ }
229+ });
230+ Ok(import_id)
231+}
232+
233+/// Marks imports a restart interrupted. Called at startup.
234+pub async fn recover(state: &AppState) {
235+ let result = sqlx::query(
236+ "with stopped as (
237+ update imports set status = 'failed', finished_at = now(),
238+ error = 'Interrupted by a server restart. Start the import again; finished repositories are kept.'
239+ where status in ('pending', 'running') returning id)
240+ update import_items set status = 'skipped', error = 'interrupted', finished_at = now()
241+ where import_id in (select id from stopped) and status in ('pending', 'running')",
242+ )
243+ .execute(&state.db)
244+ .await;
245+ match result {
246+ Ok(done) if done.rows_affected() > 0 => tracing::warn!(items = done.rows_affected(), "marked interrupted import items"),
247+ Ok(_) => {}
248+ Err(error) => tracing::error!(%error, "could not recover imports"),
249+ }
250+}
251+
252+// ---------------------------------------------------------------------------
253+// The job
254+
255+#[derive(FromRow)]
256+struct ImportRow {
257+ user_id: i64,
258+ owner_id: i64,
259+ source_owner: String,
260+ import_profile: bool,
261+ link_emails: bool,
262+}
263+
264+#[derive(FromRow, Clone)]
265+struct ItemRow {
266+ id: i64,
267+ source_name: String,
268+ target_name: String,
269+ visibility: String,
270+ description: String,
271+ default_branch: Option<String>,
272+ archived: bool,
273+ clone_url: String,
274+ size_kb: i64,
275+}
276+
277+async fn importer_viewer(state: &AppState, user_id: i64) -> anyhow::Result<Viewer> {
278+ let (name, is_admin, avatar_key): (String, bool, Option<String>) = sqlx::query_as(
279+ "select a.name::text, u.is_admin, a.avatar_key from users u join accounts a on a.id = u.account_id where u.account_id = $1",
280+ )
281+ .bind(user_id)
282+ .fetch_one(&state.db)
283+ .await?;
284+ // Admin powers are deliberately not carried into the job.
285+ Ok(Viewer { id: user_id, name, is_admin, avatar_key, scopes: Scopes { admin: false, ..Scopes::ALL }, via: AuthVia::Session })
286+}
287+
288+async fn run(state: &AppState, import_id: i64, token: Option<String>) -> anyhow::Result<()> {
289+ let db = &state.db;
290+ let import: ImportRow = sqlx::query_as(
291+ "update imports set status = 'running', started_at = now() where id = $1
292+ returning id, user_id, owner_id, source_owner, import_profile, link_emails",
293+ )
294+ .bind(import_id)
295+ .fetch_one(db)
296+ .await?;
297+ let viewer = importer_viewer(state, import.user_id).await?;
298+ let owner = Account::by_id(db, import.owner_id).await?.context("target account is gone")?;
299+ let gh = GitHub::new(token)?;
300+ let mut notes = Vec::new();
301+
302+ if import.link_emails {
303+ match link_emails(state, &gh, &import).await {
304+ Ok(n) => notes.push(format!("Linked {n} GitHub email address{} to {}.", if n == 1 { "" } else { "es" }, viewer.name)),
305+ Err(error) => notes.push(format!("Could not link emails: {error}")),
306+ }
307+ }
308+ if import.import_profile {
309+ match import_profile(state, &gh, &import, &owner).await {
310+ Ok(note) => notes.push(note),
311+ Err(error) => notes.push(format!("Could not import the profile: {error}")),
312+ }
313+ }
314+ save_notes(state, import_id, &notes).await;
315+
316+ let items: Vec<ItemRow> = sqlx::query_as(
317+ "select id, source_name, target_name, visibility, description, default_branch, archived, clone_url, size_kb
318+ from import_items where import_id = $1 and status = 'pending' order by position",
319+ )
320+ .bind(import_id)
321+ .fetch_all(db)
322+ .await?;
323+
324+ for item in items {
325+ let cancelled: bool = sqlx::query_scalar("select status = 'cancelled' from imports where id = $1").bind(import_id).fetch_one(db).await?;
326+ if cancelled {
327+ sqlx::query("update import_items set status = 'skipped', error = 'cancelled' where import_id = $1 and status = 'pending'")
328+ .bind(import_id)
329+ .execute(db)
330+ .await?;
331+ break;
332+ }
333+ sqlx::query("update import_items set status = 'running', started_at = now() where id = $1").bind(item.id).execute(db).await?;
334+ let started = Instant::now();
335+ let outcome = import_repo(state, &gh, &import, &viewer, &owner, &item).await;
336+ let seconds = started.elapsed().as_secs();
337+ match outcome {
338+ Ok(done) => {
339+ sqlx::query(
340+ "update import_items set status = $2, repo_id = $3, commits = $4, lfs_objects = $5, error = $6, finished_at = now()
341+ where id = $1",
342+ )
343+ .bind(item.id)
344+ .bind(if done.skipped { "skipped" } else { "done" })
345+ .bind(done.repo_id)
346+ .bind(done.commits as i32)
347+ .bind(done.lfs as i32)
348+ .bind(done.note)
349+ .execute(db)
350+ .await?;
351+ sqlx::query(if done.skipped {
352+ "update imports set failed = failed + 1 where id = $1"
353+ } else {
354+ "update imports set done = done + 1 where id = $1"
355+ })
356+ .bind(import_id)
357+ .execute(db)
358+ .await?;
359+ tracing::info!(import_id, repo = %item.target_name, commits = done.commits, lfs = done.lfs, seconds, skipped = done.skipped, "repository imported");
360+ }
361+ Err(error) => {
362+ tracing::warn!(import_id, repo = %item.target_name, error = %error, seconds, "repository import failed");
363+ sqlx::query("update import_items set status = 'failed', error = $2, finished_at = now() where id = $1")
364+ .bind(item.id)
365+ .bind(error.to_string().chars().take(2000).collect::<String>())
366+ .execute(db)
367+ .await?;
368+ sqlx::query("update imports set failed = failed + 1 where id = $1").bind(import_id).execute(db).await?;
369+ }
370+ }
371+ }
372+
373+ let (done, failed, total): (i32, i32, i32) = sqlx::query_as(
374+ "update imports set status = case
375+ when status = 'cancelled' then 'cancelled'
376+ -- Skips (already exists, invalid name) are not failures.
377+ when done = 0 and exists (select 1 from import_items where import_id = $1 and status = 'failed') then 'failed'
378+ else 'done' end,
379+ finished_at = now()
380+ where id = $1 returning done, failed, total",
381+ )
382+ .bind(import_id)
383+ .fetch_one(db)
384+ .await?;
385+ analytics::track(state, "import_finished", Some(&viewer.name), "/imports", json!({ "done": done, "failed": failed, "total": total }));
386+ audit(db, Some(viewer.id), "import.finish", &format!("github:{} -> {}", import.source_owner, owner.name), json!({ "done": done, "failed": failed }), None).await;
387+ tracing::info!(import_id, done, failed, total, "import finished");
388+ Ok(())
389+}
390+
391+async fn save_notes(state: &AppState, import_id: i64, notes: &[String]) {
392+ if notes.is_empty() {
393+ return;
394+ }
395+ let _ = sqlx::query("update imports set notes = $2 where id = $1").bind(import_id).bind(notes.join("\n")).execute(&state.db).await;
396+}
397+
398+/// Adds the GitHub account's verified emails (and its noreply addresses) to
399+/// the importing user, so commits authored with them count on the heatmap.
400+/// Only reachable when the token belongs to that GitHub account.
401+async fn link_emails(state: &AppState, gh: &GitHub, import: &ImportRow) -> anyhow::Result<u64> {
402+ let owner = gh.token_owner().await?.context("no token")?;
403+ let mut emails = gh.verified_emails().await;
404+ emails.push(format!("{}+{}@users.noreply.github.com", owner.id, owner.login));
405+ emails.push(format!("{}@users.noreply.github.com", owner.login));
406+ let mut added = 0;
407+ for email in emails {
408+ let inserted = sqlx::query("insert into emails (user_id, email, verified_at) values ($1, $2, now()) on conflict (email) do nothing")
409+ .bind(import.user_id)
410+ .bind(email.trim())
411+ .execute(&state.db)
412+ .await?
413+ .rows_affected();
414+ added += inserted;
415+ }
416+ audit(&state.db, Some(import.user_id), "email.link_github", &owner.login, json!({ "added": added }), None).await;
417+ Ok(added)
418+}
419+
420+/// Fills empty profile fields (and a missing avatar) from GitHub. Anything
421+/// already set on irongit is left alone.
422+async fn import_profile(state: &AppState, gh: &GitHub, import: &ImportRow, owner: &Account) -> anyhow::Result<String> {
423+ let (account, _) = gh.account(&import.source_owner).await?;
424+ let website = account.blog.clone().unwrap_or_default();
425+ let website = if website.is_empty() || website.starts_with("http") { website } else { format!("https://{website}") };
426+ sqlx::query(
427+ "update accounts set display_name = case when display_name = '' then $2 else display_name end,
428+ bio = case when bio = '' then $3 else bio end,
429+ location = case when location = '' then $4 else location end,
430+ website = case when website = '' then $5 else website end,
431+ updated_at = now()
432+ where id = $1",
433+ )
434+ .bind(owner.id)
435+ .bind(account.name.clone().unwrap_or_default().chars().take(80).collect::<String>())
436+ .bind(account.bio.clone().unwrap_or_default().chars().take(300).collect::<String>())
437+ .bind(account.location.clone().unwrap_or_default().chars().take(80).collect::<String>())
438+ .bind(website.chars().take(200).collect::<String>())
439+ .execute(&state.db)
440+ .await?;
441+ let mut note = format!("Profile details copied from github.com/{} where {} had none.", account.login, owner.name);
442+ if owner.avatar_key.is_none() {
443+ match avatars::import_from_url(state, owner.id, &account.avatar_url).await {
444+ Ok(()) => note.push_str(" Avatar imported."),
445+ Err(error) => tracing::warn!(?error, "github avatar import failed"),
446+ }
447+ }
448+ Ok(note)
449+}
450+
451+struct RepoOutcome {
452+ repo_id: Option<i64>,
453+ commits: u64,
454+ lfs: u64,
455+ skipped: bool,
456+ note: Option<String>,
457+}
458+
459+async fn import_repo(state: &AppState, gh: &GitHub, import: &ImportRow, viewer: &Viewer, owner: &Account, item: &ItemRow) -> anyhow::Result<RepoOutcome> {
460+ let config = &state.config;
461+ // Quota before anything is written: GitHub's size is a good estimate.
462+ let used = ops::account_usage(state, owner.id).await.map_err(|e| anyhow!("{e}"))?;
463+ let remaining = owner.quota(config).saturating_sub(used);
464+ let estimate = (item.size_kb.max(0) as u64) * 1024;
465+ if estimate > remaining {
466+ return Err(anyhow!(
467+ "needs about {} but {} has {} of storage left",
468+ crate::web::ui::bytes(estimate),
469+ owner.name,
470+ crate::web::ui::bytes(remaining)
471+ ));
472+ }
473+
474+ let repo = match ops::create_repo(state, viewer, owner, &item.target_name, &item.description, &item.visibility).await {
475+ Ok(repo) => repo,
476+ Err(AppError::Conflict(_)) => {
477+ return Ok(RepoOutcome {
478+ repo_id: None,
479+ commits: 0,
480+ lfs: 0,
481+ skipped: true,
482+ note: Some(format!("{}/{} already exists; left untouched", owner.name, item.target_name)),
483+ });
484+ }
485+ Err(other) => return Err(anyhow!("{other}")),
486+ };
487+
488+ match fill_repo(state, gh, import, &repo, item).await {
489+ Ok(outcome) => Ok(outcome),
490+ Err(error) => {
491+ // Leave nothing half-imported behind.
492+ if let Err(cleanup) = ops::delete_repo(state, viewer, &repo).await {
493+ tracing::error!(repo = %repo.full_name(), error = %cleanup, "could not remove a failed import");
494+ }
495+ Err(error)
496+ }
497+ }
498+}
499+
500+async fn fill_repo(state: &AppState, gh: &GitHub, import: &ImportRow, repo: &Repo, item: &ItemRow) -> anyhow::Result<RepoOutcome> {
501+ let config = &state.config;
502+ let git = Git::new(repo.disk_path(config));
503+ fetch(&git, &item.clone_url, gh.basic_auth().as_deref()).await?;
504+ let refs = git.ref_snapshot().await?;
505+
506+ let oversized = oversized_blobs(&git, config.limits.max_file_bytes).await?;
507+ if !oversized.is_empty() {
508+ let list: Vec<String> = oversized.iter().take(10).map(|(path, size)| format!("{path} ({})", crate::web::ui::bytes(*size))).collect();
509+ return Err(anyhow!(
510+ "has files larger than the {} limit: {}. Move them to Git LFS on GitHub (git lfs migrate import) and import again.",
511+ crate::web::ui::bytes(config.limits.max_file_bytes),
512+ list.join(", ")
513+ ));
514+ }
515+
516+ let (lfs, lfs_missing) = transfer_lfs(state, gh, &import.source_owner, &item.source_name, &git, repo.id).await?;
517+
518+ // GitHub's default branch, or the usual fallbacks.
519+ let branches: Vec<&str> = refs.keys().filter_map(|k| k.strip_prefix("refs/heads/")).collect();
520+ let default_branch = item
521+ .default_branch
522+ .clone()
523+ .filter(|b| branches.contains(&b.as_str()))
524+ .or_else(|| ["main", "master"].into_iter().find(|b| branches.contains(b)).map(String::from))
525+ .or_else(|| branches.first().map(|b| b.to_string()));
526+ let mut commits = 0;
527+ if let Some(branch) = &default_branch {
528+ git.set_head(branch).await?;
529+ let head = refs.get(&format!("refs/heads/{branch}")).cloned().unwrap_or_default();
530+ commits = git.count_commits(&head).await.unwrap_or(0);
531+ sqlx::query(
532+ "insert into push_events (repo_id, pusher_id, ref_name, before_sha, after_sha, commit_count, head_message, via)
533+ values ($1, $2, $3, $4, $5, $6, $7, 'import')",
534+ )
535+ .bind(repo.id)
536+ .bind(import.user_id)
537+ .bind(format!("refs/heads/{branch}"))
538+ .bind(ZERO_SHA)
539+ .bind(&head)
540+ .bind(commits.min(i32::MAX as u64) as i32)
541+ .bind(format!("Imported from github.com/{}/{}", import.source_owner, item.source_name))
542+ .execute(&state.db)
543+ .await?;
544+ push::record_contributions(state, repo.id, &git, ZERO_SHA, &head).await?;
545+ }
546+
547+ let size = git.disk_size().await.unwrap_or(0);
548+ sqlx::query(
549+ "update repos set default_branch = coalesce($2, default_branch), is_empty = $3, size_bytes = $4, archived = $5,
550+ pushed_at = case when $3 then pushed_at else now() end, updated_at = now()
551+ where id = $1",
552+ )
553+ .bind(repo.id)
554+ .bind(&default_branch)
555+ .bind(branches.is_empty())
556+ .bind(size as i64)
557+ .bind(item.archived)
558+ .execute(&state.db)
559+ .await?;
560+
561+ // The estimate can be off; enforce the quota on what actually landed.
562+ let owner = Account::by_id(&state.db, repo.owner_id).await?.context("owner gone")?;
563+ let used = ops::account_usage(state, owner.id).await.map_err(|e| anyhow!("{e}"))?;
564+ if used > owner.quota(&state.config) {
565+ return Err(anyhow!("took {} and put {} over its storage quota", crate::web::ui::bytes(size), owner.name));
566+ }
567+
568+ let note = (lfs_missing > 0).then(|| format!("{lfs_missing} Git LFS object(s) were missing on GitHub and were not copied"));
569+ Ok(RepoOutcome { repo_id: Some(repo.id), commits, lfs, skipped: false, note })
570+}
571+
572+/// Fetches every branch and tag from GitHub into the bare repository. The
573+/// token travels in an environment-provided header, never in the URL or
574+/// on the command line.
575+async fn fetch(git: &Git, url: &str, auth: Option<&str>) -> anyhow::Result<()> {
576+ let mut command = git.command();
577+ command
578+ .args(["fetch", "--quiet", "--prune", "--no-tags", "--end-of-options", url, "+refs/heads/*:refs/heads/*", "+refs/tags/*:refs/tags/*"])
579+ .stdout(Stdio::null())
580+ .stderr(Stdio::piped());
581+ if let Some(auth) = auth {
582+ command
583+ .env("GIT_CONFIG_COUNT", "1")
584+ .env("GIT_CONFIG_KEY_0", "http.https://github.com/.extraheader")
585+ .env("GIT_CONFIG_VALUE_0", format!("Authorization: {auth}"));
586+ }
587+ let child = command.spawn().context("could not run git fetch")?;
588+ let output = tokio::time::timeout(FETCH_TIMEOUT, child.wait_with_output())
589+ .await
590+ .map_err(|_| anyhow!("fetching from GitHub took longer than an hour"))??;
591+ if !output.status.success() {
592+ let stderr = String::from_utf8_lossy(&output.stderr);
593+ let reason = stderr.lines().find(|l| l.contains("fatal") || l.contains("error")).unwrap_or(stderr.trim()).to_string();
594+ let hint = if reason.contains("could not read Username") || reason.contains("Authentication failed") || reason.contains("not found") {
595+ " (private repositories need a GitHub token with repository access)"
596+ } else {
597+ ""
598+ };
599+ return Err(anyhow!("git fetch failed: {}{hint}", reason.trim()));
600+ }
601+ Ok(())
602+}
603+
604+/// Blobs anywhere in history larger than `max`, with a path for each.
605+async fn oversized_blobs(git: &Git, max: u64) -> anyhow::Result<Vec<(String, u64)>> {
606+ let output = git
607+ .command()
608+ .args(["cat-file", "--batch-all-objects", "--batch-check=%(objecttype) %(objectname) %(objectsize)"])
609+ .stderr(Stdio::null())
610+ .output()
611+ .await?;
612+ let big: HashMap<String, u64> = String::from_utf8_lossy(&output.stdout)
613+ .lines()
614+ .filter_map(|l| {
615+ let mut f = l.split_whitespace();
616+ let (kind, sha, size) = (f.next()?, f.next()?, f.next()?.parse::<u64>().ok()?);
617+ (kind == "blob" && size > max).then(|| (sha.to_string(), size))
618+ })
619+ .collect();
620+ if big.is_empty() {
621+ return Ok(Vec::new());
622+ }
623+ let listed = git.command().args(["rev-list", "--objects", "--all"]).stderr(Stdio::null()).output().await?;
624+ let mut out: Vec<(String, u64)> = String::from_utf8_lossy(&listed.stdout)
625+ .lines()
626+ .filter_map(|l| {
627+ let (sha, path) = l.split_once(' ')?;
628+ big.get(sha).map(|size| (path.to_string(), *size))
629+ })
630+ .collect();
631+ out.sort();
632+ out.dedup_by(|a, b| a.0 == b.0);
633+ Ok(out)
634+}
635+
636+// ---------------------------------------------------------------------------
637+// Git LFS: copy objects from GitHub's LFS store into R2.
638+
639+struct Pointer {
640+ oid: String,
641+ size: u64,
642+}
643+
644+fn parse_pointer(content: &[u8]) -> Option<Pointer> {
645+ let text = std::str::from_utf8(content).ok()?;
646+ if !text.starts_with("version https://git-lfs.github.com/spec/") {
647+ return None;
648+ }
649+ let oid = text.lines().find_map(|l| l.strip_prefix("oid sha256:"))?.trim().to_string();
650+ let size = text.lines().find_map(|l| l.strip_prefix("size "))?.trim().parse().ok()?;
651+ (oid.len() == 64 && oid.bytes().all(|b| b.is_ascii_hexdigit())).then(|| Pointer { oid: oid.to_ascii_lowercase(), size })
652+}
653+
654+/// LFS pointer files anywhere in the repository's history.
655+async fn lfs_pointers(git: &Git) -> anyhow::Result<Vec<Pointer>> {
656+ let output = git
657+ .command()
658+ .args(["cat-file", "--batch-all-objects", "--batch-check=%(objecttype) %(objectname) %(objectsize)"])
659+ .stderr(Stdio::null())
660+ .output()
661+ .await?;
662+ let candidates: Vec<String> = String::from_utf8_lossy(&output.stdout)
663+ .lines()
664+ .filter_map(|l| {
665+ let mut f = l.split_whitespace();
666+ let (kind, sha, size) = (f.next()?, f.next()?, f.next()?.parse::<u64>().ok()?);
667+ (kind == "blob" && (100..=1024).contains(&size)).then(|| sha.to_string())
668+ })
669+ .collect();
670+ if candidates.is_empty() {
671+ return Ok(Vec::new());
672+ }
673+
674+ let mut child = git.command().args(["cat-file", "--batch"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn()?;
675+ let mut stdin = child.stdin.take().context("stdin")?;
676+ let writer = tokio::spawn(async move {
677+ for sha in candidates {
678+ if stdin.write_all(format!("{sha}\n").as_bytes()).await.is_err() {
679+ break;
680+ }
681+ }
682+ drop(stdin);
683+ });
684+ let mut reader = BufReader::new(child.stdout.take().context("stdout")?);
685+ let mut pointers = HashMap::new();
686+ let mut header = String::new();
687+ loop {
688+ header.clear();
689+ if reader.read_line(&mut header).await? == 0 {
690+ break;
691+ }
692+ let size: usize = header.split_whitespace().nth(2).and_then(|s| s.parse().ok()).unwrap_or(0);
693+ let mut content = vec![0u8; size + 1]; // plus the trailing newline
694+ reader.read_exact(&mut content).await?;
695+ if let Some(pointer) = parse_pointer(&content[..size]) {
696+ pointers.entry(pointer.oid.clone()).or_insert(pointer);
697+ }
698+ }
699+ let _ = writer.await;
700+ let _ = child.wait().await;
701+ Ok(pointers.into_values().collect())
702+}
703+
704+#[derive(Deserialize)]
705+struct BatchResponse {
706+ #[serde(default)]
707+ objects: Vec<BatchObject>,
708+}
709+
710+#[derive(Deserialize)]
711+struct BatchObject {
712+ oid: String,
713+ size: u64,
714+ actions: Option<BatchActions>,
715+ error: Option<serde_json::Value>,
716+}
717+
718+#[derive(Deserialize)]
719+struct BatchActions {
720+ download: Option<BatchAction>,
721+}
722+
723+#[derive(Deserialize)]
724+struct BatchAction {
725+ href: String,
726+ #[serde(default)]
727+ header: HashMap<String, String>,
728+}
729+
730+/// Returns (objects stored, objects GitHub did not have).
731+async fn transfer_lfs(state: &AppState, gh: &GitHub, owner: &str, name: &str, git: &Git, repo_id: i64) -> anyhow::Result<(u64, u64)> {
732+ let pointers = lfs_pointers(git).await?;
733+ if pointers.is_empty() {
734+ return Ok((0, 0));
735+ }
736+ let max = state.config.limits.max_lfs_object_bytes;
737+ if let Some(big) = pointers.iter().find(|p| p.size > max) {
738+ return Err(anyhow!("has a Git LFS object of {}, over the {} limit", crate::web::ui::bytes(big.size), crate::web::ui::bytes(max)));
739+ }
740+ tracing::info!(repo_id, objects = pointers.len(), "copying Git LFS objects from GitHub");
741+ let downloads = reqwest::Client::builder().user_agent("irongit-importer").connect_timeout(Duration::from_secs(10)).build()?;
742+ let staging = state.config.uploads_dir().join(format!("lfs-import-{repo_id}"));
743+ tokio::fs::create_dir_all(&staging).await?;
744+ let result = async {
745+ let (mut stored, mut missing) = (0u64, 0u64);
746+ for chunk in pointers.chunks(100) {
747+ let mut request = gh
748+ .http()
749+ .post(format!("https://github.com/{owner}/{name}.git/info/lfs/objects/batch"))
750+ .header("Accept", "application/vnd.git-lfs+json")
751+ .header("Content-Type", "application/vnd.git-lfs+json")
752+ .json(&json!({
753+ "operation": "download",
754+ "transfers": ["basic"],
755+ "objects": chunk.iter().map(|p| json!({ "oid": p.oid, "size": p.size })).collect::<Vec<_>>(),
756+ }));
757+ if let Some(auth) = gh.basic_auth() {
758+ request = request.header("Authorization", auth);
759+ }
760+ let response = request.send().await.context("GitHub LFS batch request failed")?;
761+ if !response.status().is_success() {
762+ return Err(anyhow!("GitHub's LFS API answered {}", response.status()));
763+ }
764+ let batch: BatchResponse = response.json().await.context("unexpected GitHub LFS response")?;
765+ for object in batch.objects {
766+ let Some(download) = object.actions.and_then(|a| a.download).filter(|_| object.error.is_none()) else {
767+ missing += 1;
768+ continue;
769+ };
770+ copy_lfs_object(state, &downloads, &staging, &object.oid, object.size, &download).await?;
771+ sqlx::query("insert into repo_lfs_objects (repo_id, oid) values ($1, $2) on conflict do nothing")
772+ .bind(repo_id)
773+ .bind(&object.oid)
774+ .execute(&state.db)
775+ .await?;
776+ stored += 1;
777+ }
778+ }
779+ Ok((stored, missing))
780+ }
781+ .await;
782+ let _ = tokio::fs::remove_dir_all(&staging).await;
783+ result
784+}
785+
786+/// Downloads one object, checks it really hashes to its oid (which is the
787+/// proof of possession that lets it be linked), and stores it if new.
788+async fn copy_lfs_object(state: &AppState, http: &reqwest::Client, staging: &Path, oid: &str, size: u64, action: &BatchAction) -> anyhow::Result<()> {
789+ let path: PathBuf = staging.join(oid);
790+ // Only GitHub's own storage: a download link must never steer the server
791+ // at an internal address.
792+ let href = url::Url::parse(&action.href).context("bad LFS download link")?;
793+ let host = href.host_str().unwrap_or("");
794+ if href.scheme() != "https" || !(host == "github.com" || host.ends_with(".github.com") || host.ends_with(".githubusercontent.com")) {
795+ return Err(anyhow!("refusing a Git LFS download from {host}"));
796+ }
797+ let mut request = http.get(href);
798+ for (key, value) in &action.header {
799+ request = request.header(key, value);
800+ }
801+ let mut response = request.send().await?.error_for_status().context("downloading a Git LFS object from GitHub")?;
802+ let mut file = tokio::fs::File::create(&path).await?;
803+ let mut hasher = Sha256::new();
804+ let mut written = 0u64;
805+ while let Some(chunk) = response.chunk().await? {
806+ written += chunk.len() as u64;
807+ if written > size {
808+ return Err(anyhow!("Git LFS object {oid} is larger than its pointer says"));
809+ }
810+ hasher.update(&chunk);
811+ file.write_all(&chunk).await?;
812+ }
813+ file.flush().await?;
814+ drop(file);
815+ if hex::encode(hasher.finalize()) != oid || written != size {
816+ return Err(anyhow!("Git LFS object {oid} did not match its checksum"));
817+ }
818+ let known: bool = sqlx::query_scalar("select exists(select 1 from lfs_objects where oid = $1)").bind(oid).fetch_one(&state.db).await?;
819+ if !known {
820+ state.storage.put_file(&keys::lfs(oid), &path).await?;
821+ sqlx::query("insert into lfs_objects (oid, size) values ($1, $2) on conflict do nothing").bind(oid).bind(size as i64).execute(&state.db).await?;
822+ }
823+ let _ = tokio::fs::remove_file(&path).await;
824+ Ok(())
825+}
826+
827+// ---------------------------------------------------------------------------
828+// Reading imports back
829+
830+#[derive(FromRow)]
831+struct ImportView {
832+ id: i64,
833+ user_id: i64,
834+ source_owner: String,
835+ owner_name: String,
836+ status: String,
837+ total: i32,
838+ done: i32,
839+ failed: i32,
840+ notes: String,
841+ error: Option<String>,
842+ created_at: DateTime<Utc>,
843+ finished_at: Option<DateTime<Utc>>,
844+}
845+
846+#[derive(FromRow)]
847+struct ItemView {
848+ source_name: String,
849+ target_name: String,
850+ status: String,
851+ visibility: String,
852+ size_kb: i64,
853+ commits: i32,
854+ lfs_objects: i32,
855+ error: Option<String>,
856+ repo_exists: bool,
857+}
858+
859+/// An import with its items, if `viewer` started it (or is a site admin).
860+pub async fn load(state: &AppState, viewer: &Viewer, id: i64) -> AppResult<shared::Import> {
861+ let row: ImportView = sqlx::query_as(
862+ "select i.id, i.user_id, i.source_owner, a.name::text as owner_name, i.status, i.total, i.done, i.failed, i.notes, i.error,
863+ i.created_at, i.finished_at
864+ from imports i join accounts a on a.id = i.owner_id where i.id = $1",
865+ )
866+ .bind(id)
867+ .fetch_optional(&state.db)
868+ .await?
869+ .ok_or(AppError::NotFound)?;
870+ if row.user_id != viewer.id && !viewer.site_admin() {
871+ return Err(AppError::NotFound);
872+ }
873+ let items: Vec<ItemView> = sqlx::query_as(
874+ "select source_name, target_name, status, visibility, size_kb, commits, lfs_objects, error,
875+ (repo_id is not null and exists (select 1 from repos r where r.id = repo_id)) as repo_exists
876+ from import_items where import_id = $1 order by position",
877+ )
878+ .bind(id)
879+ .fetch_all(&state.db)
880+ .await?;
881+ let base = state.config.base_url();
882+ Ok(shared::Import {
883+ id: row.id,
884+ source: "github".into(),
885+ source_owner: row.source_owner,
886+ target_owner: row.owner_name.clone(),
887+ status: row.status,
888+ total: row.total,
889+ done: row.done,
890+ failed: row.failed,
891+ notes: row.notes,
892+ error: row.error,
893+ web_url: format!("{base}/imports/{}", row.id),
894+ created_at: row.created_at,
895+ finished_at: row.finished_at,
896+ items: items
897+ .into_iter()
898+ .map(|i| shared::ImportItem {
899+ repo_url: i.repo_exists.then(|| format!("{base}/{}/{}", row.owner_name, i.target_name)),
900+ name: i.source_name,
901+ target_name: i.target_name,
902+ status: i.status,
903+ visibility: i.visibility,
904+ size_kb: i.size_kb,
905+ commits: i.commits,
906+ lfs_objects: i.lfs_objects,
907+ error: i.error,
908+ })
909+ .collect(),
910+ })
911+}
912+
913+/// The viewer's imports, newest first.
914+pub async fn list(state: &AppState, viewer: &Viewer) -> AppResult<Vec<shared::Import>> {
915+ let ids: Vec<i64> = sqlx::query_scalar("select id from imports where user_id = $1 order by created_at desc limit 50")
916+ .bind(viewer.id)
917+ .fetch_all(&state.db)
918+ .await?;
919+ let mut out = Vec::with_capacity(ids.len());
920+ for id in ids {
921+ out.push(load(state, viewer, id).await?);
922+ }
923+ Ok(out)
924+}
925+
926+pub async fn cancel(state: &AppState, viewer: &Viewer, id: i64) -> AppResult<()> {
927+ let import = load(state, viewer, id).await?;
928+ if import.finished() {
929+ return Err(AppError::conflict("This import has already finished."));
930+ }
931+ sqlx::query("update imports set status = 'cancelled' where id = $1 and status in ('pending', 'running')").bind(id).execute(&state.db).await?;
932+ audit(&state.db, Some(viewer.id), "import.cancel", &id.to_string(), json!({}), None).await;
933+ Ok(())
934+}
935+
936+#[cfg(test)]
937+mod tests {
938+ use super::*;
939+
940+ #[test]
941+ fn pointers_parse() {
942+ let pointer = b"version https://git-lfs.github.com/spec/v1\noid sha256:4D7A214614AB2935C943F9E0FF69D22EADBB8F32B1258DAAA5E2CA24D17E2393\nsize 12345\n";
943+ let parsed = parse_pointer(pointer).unwrap();
944+ assert_eq!(parsed.size, 12345);
945+ assert_eq!(parsed.oid, "4d7a214614ab2935c943f9e0ff69d22eadbb8f32b1258daaa5e2ca24d17e2393");
946+ assert!(parse_pointer(b"just a small text file").is_none());
947+ }
948+
949+ #[test]
950+ fn dot_names_are_importable() {
951+ assert_eq!(target_name(".github").unwrap(), ".github");
952+ assert!(target_name("..").is_err());
953+ }
954+
955+ #[test]
956+ fn stash_is_per_user() {
957+ let key = stash_token(7, "ghp_x".into());
958+ assert_eq!(stashed_token(&key, 7).as_deref(), Some("ghp_x"));
959+ assert_eq!(stashed_token(&key, 8), None);
960+ forget_token(&key);
961+ assert_eq!(stashed_token(&key, 7), None);
962+ }
963+}
+3-0backend/src/jobs.rs
@@ -5,6 +5,9 @@ use std::time::Duration;
55 use crate::state::AppState;
66
77 pub fn spawn_all(state: AppState) {
8+ // Imports hold their GitHub token in memory only, so a restart ends them.
9+ let recover_state = state.clone();
10+ tokio::spawn(async move { crate::importer::recover(&recover_state).await });
811 crate::registry::spawn_gc(state.clone());
912 crate::backup::spawn(state.clone());
1013 tokio::spawn(prune_sessions(state));
+1-0backend/src/main.rs
@@ -19,6 +19,7 @@ mod frontend;
1919 mod git;
2020 mod git_http;
2121 mod hook;
22+mod importer;
2223 mod jobs;
2324 mod lfs;
2425 mod logging;
+4-2backend/src/models.rs
@@ -233,8 +233,10 @@ pub fn valid_repo_name(name: &str) -> Result<(), &'static str> {
233233 if !name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) {
234234 return Err("Repository names may only contain letters, digits, '.', '_' and '-'.");
235235 }
236- if name.starts_with('.') || name.ends_with(".git") || name == "-" {
237- return Err("Repository names cannot start with '.' or end with '.git'.");
236+ // Leading dots are fine (".github"); the bare dot names and a ".git"
237+ // suffix would be ambiguous in URLs and clone paths.
238+ if matches!(name, "." | ".." | "-") || name.ends_with(".git") {
239+ return Err("Repository names cannot be '.', '..' or end with '.git'.");
238240 }
239241 Ok(())
240242 }
+1-1backend/src/push.rs
@@ -131,7 +131,7 @@ pub async fn after_push(state: &AppState, repo: &Repo, pusher: Option<&Viewer>,
131131
132132 /// Credits commits that landed on the default branch to users who verified
133133 /// the author email. Keyed by (repo, sha), so re-pushes never double count.
134-async fn record_contributions(state: &AppState, repo_id: i64, git: &Git, old: &str, new: &str) -> anyhow::Result<()> {
134+pub async fn record_contributions(state: &AppState, repo_id: i64, git: &Git, old: &str, new: &str) -> anyhow::Result<()> {
135135 let commits = git.new_commits(old, new, MAX_CONTRIBUTION_COMMITS).await?;
136136 if commits.is_empty() {
137137 return Ok(());
+418-130backend/src/web/account.rs
@@ -6,6 +6,7 @@ use std::{
66 time::{Duration, Instant},
77 };
88
9+use anyhow::Context;
910 use axum::{
1011 Form, Router,
1112 extract::{Query, State},
@@ -40,7 +41,9 @@ pub fn router() -> Router<AppState> {
4041 .route("/logout", axum::routing::post(logout))
4142 .route("/login/google", get(google_start))
4243 .route("/login/google/callback", get(google_callback))
43- .route("/login/google/finish", get(google_finish_page).post(google_finish_submit))
44+ .route("/login/github", get(github_start))
45+ .route("/login/github/callback", get(github_callback))
46+ .route("/login/finish", get(finish_page).post(finish_submit))
4447 .route("/settings/emails/verify", get(verify_email))
4548 }
4649
@@ -82,17 +85,30 @@ fn auth_card(title: &str, body: Markup) -> Markup {
8285 }
8386 }
8487
85-fn google_button(state: &AppState, next: &str, label: &str) -> Markup {
88+/// "Continue with Google / GitHub" for whichever providers are configured.
89+fn provider_buttons(state: &AppState, next: &str, verb: &str) -> Markup {
90+ let google = state.config.google.is_some();
91+ let github = state.config.github.is_some();
8692 html! {
87- @if state.config.google.is_some() {
88- a href={ "/login/google?next=" (auth::urlencode(next)) } class="btn w-full py-1.5" data-track="google_signin_clicked" {
89- svg width="16" height="16" viewBox="0 0 48 48" aria-hidden="true" {
90- path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.7 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.4-.4-3.5z" {}
91- path fill="#FF3D00" d="m6.3 14.7 6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z" {}
92- path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-7.9l-6.5 5C9.5 39.6 16.2 44 24 44z" {}
93- path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.1-2.4-.4-3.5z" {}
93+ @if google || github {
94+ div class="space-y-2" {
95+ @if google {
96+ a href={ "/login/google?next=" (auth::urlencode(next)) } class="btn w-full py-1.5" data-track="google_signin_clicked" {
97+ svg width="16" height="16" viewBox="0 0 48 48" aria-hidden="true" {
98+ path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3C33.7 32.7 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.4-.4-3.5z" {}
99+ path fill="#FF3D00" d="m6.3 14.7 6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.8 1.2 7.9 3.1l5.7-5.7C34 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z" {}
100+ path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-7.9l-6.5 5C9.5 39.6 16.2 44 24 44z" {}
101+ path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.1-2.4-.4-3.5z" {}
102+ }
103+ (verb) " with Google"
104+ }
105+ }
106+ @if github {
107+ a href={ "/login/github?next=" (auth::urlencode(next)) } class="btn w-full py-1.5" data-track="github_signin_clicked" {
108+ (ui::icon_github(16))
109+ (verb) " with GitHub"
110+ }
94111 }
95- (label)
96112 }
97113 div class="my-3 flex items-center gap-2 text-xs text-ink-faint" {
98114 div class="h-px flex-1 bg-edge" {}
@@ -123,8 +139,10 @@ pub async fn login_page(ctx: Ctx, Query(query): Query<NextQuery>) -> Response {
123139 }
124140 let error = query.error.as_deref().map(|code| match code {
125141 "google" => "Google sign-in failed. Try again.",
126- "google_state" => "That Google sign-in link expired. Try again.",
142+ "github" => "GitHub sign-in failed. Try again.",
143+ "google_state" | "github_state" | "oauth_state" => "That sign-in link expired. Try again.",
127144 "google_unverified" => "Google did not confirm that email address.",
145+ "github_unverified" => "Your GitHub account has no verified email. Verify one on GitHub, or register with a password.",
128146 "email_in_use" => "An account already uses that email. Sign in with your password, then connect Google in Settings.",
129147 "closed" => "Registration is closed on this site.",
130148 "suspended" => "This account is suspended.",
@@ -140,7 +158,7 @@ fn login_view(ctx: &Ctx, next: &str, login: &str, error: Option<&str>) -> Page {
140158 "Sign in to irongit",
141159 html! {
142160 (ui::alert_error(error))
143- (google_button(&ctx.state, next, "Continue with Google"))
161+ (provider_buttons(&ctx.state, next, "Continue"))
144162 form method="post" action="/login" class="box space-y-3 p-4" data-track-submit="login_submitted" {
145163 input type="hidden" name="next" value=(next);
146164 div {
@@ -244,7 +262,7 @@ fn register_view(ctx: &Ctx, next: &str, form: &RegisterForm, error: Option<&str>
244262 } else {
245263 html! {
246264 (ui::alert_error(error))
247- (google_button(&ctx.state, next, "Sign up with Google"))
265+ (provider_buttons(&ctx.state, next, "Sign up"))
248266 form method="post" action="/register" class="box space-y-3 p-4" data-track-submit="register_submitted" {
249267 input type="hidden" name="next" value=(next);
250268 div {
@@ -333,31 +351,90 @@ pub async fn verify_email(ctx: Ctx, Query(query): Query<VerifyQuery>) -> AppResu
333351 }
334352
335353 // ---------------------------------------------------------------------------
336-// Google sign-in (OpenID Connect, authorization code + PKCE)
354+// Sign-in with Google (OpenID Connect) and GitHub (OAuth app). Both use the
355+// authorization code flow with a signed state cookie (and PKCE), then hand a
356+// provider-neutral profile to `complete_external`.
337357
338358 const OAUTH_COOKIE: &str = "ig_oauth";
339-const SIGNUP_COOKIE: &str = "ig_google_signup";
359+const SIGNUP_COOKIE: &str = "ig_oauth_signup";
360+
361+#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
362+#[serde(rename_all = "lowercase")]
363+pub enum Provider {
364+ Google,
365+ Github,
366+}
367+
368+impl Provider {
369+ pub fn slug(self) -> &'static str {
370+ match self {
371+ Provider::Google => "google",
372+ Provider::Github => "github",
373+ }
374+ }
375+
376+ pub fn label(self) -> &'static str {
377+ match self {
378+ Provider::Google => "Google",
379+ Provider::Github => "GitHub",
380+ }
381+ }
382+
383+ pub fn from_slug(slug: &str) -> Option<Self> {
384+ match slug {
385+ "google" => Some(Provider::Google),
386+ "github" => Some(Provider::Github),
387+ _ => None,
388+ }
389+ }
390+
391+ pub fn configured(self, state: &AppState) -> bool {
392+ match self {
393+ Provider::Google => state.config.google.is_some(),
394+ Provider::Github => state.config.github.is_some(),
395+ }
396+ }
397+}
340398
341399 #[derive(Serialize, Deserialize)]
342400 struct OAuthState {
401+ provider: Provider,
343402 state: String,
344403 verifier: String,
345404 next: String,
346- /// Set when a signed-in user is connecting Google from settings.
405+ /// Set when a signed-in user is connecting the provider from settings,
406+ /// or (with `import`) granting repository access for an import.
347407 link_user: Option<i64>,
408+ /// GitHub only: this authorization is for importing, not signing in.
409+ #[serde(default)]
410+ import: bool,
411+}
412+
413+/// What a provider told us about the person, in one shape.
414+struct ExternalProfile {
415+ provider: Provider,
416+ subject: String,
417+ /// A verified email, when the provider has one.
418+ email: Option<String>,
419+ name: String,
420+ picture: Option<String>,
421+ /// Username to suggest (GitHub login, or the email's local part).
422+ suggestion: String,
348423 }
349424
350425 #[derive(Serialize, Deserialize, Clone)]
351426 struct PendingSignup {
427+ provider: Provider,
352428 sub: String,
353429 email: String,
354430 name: String,
355431 picture: Option<String>,
432+ suggestion: String,
356433 next: String,
357434 }
358435
359-fn redirect_uri(state: &AppState) -> String {
360- format!("{}/login/google/callback", state.config.base_url())
436+fn redirect_uri(state: &AppState, provider: Provider) -> String {
437+ format!("{}/login/{}/callback", state.config.base_url(), provider.slug())
361438 }
362439
363440 fn short_cookie(name: &'static str, value: String, state: &AppState, minutes: i64) -> Cookie<'static> {
@@ -371,91 +448,158 @@ fn short_cookie(name: &'static str, value: String, state: &AppState, minutes: i6
371448 }
372449
373450 #[derive(Deserialize)]
374-pub struct GoogleStart {
451+pub struct OAuthStart {
375452 next: Option<String>,
376453 link: Option<String>,
454+ /// GitHub: ask for repository access to import (signed-in users only).
455+ import: Option<String>,
456+}
457+
458+pub async fn google_start(ctx: Ctx, jar: CookieJar, query: Query<OAuthStart>) -> AppResult<Response> {
459+ oauth_start(ctx, jar, query, Provider::Google).await
460+}
461+
462+pub async fn github_start(ctx: Ctx, jar: CookieJar, query: Query<OAuthStart>) -> AppResult<Response> {
463+ oauth_start(ctx, jar, query, Provider::Github).await
377464 }
378465
379-pub async fn google_start(ctx: Ctx, jar: CookieJar, Query(query): Query<GoogleStart>) -> AppResult<Response> {
380- let Some(google) = ctx.state.config.google.clone() else {
466+async fn oauth_start(ctx: Ctx, jar: CookieJar, Query(query): Query<OAuthStart>, provider: Provider) -> AppResult<Response> {
467+ if !provider.configured(&ctx.state) {
381468 return Err(AppError::NotFound);
382- };
383- let link_user = if query.link.is_some() { Some(ctx.viewer.as_ref().ok_or(AppError::Unauthorized)?.id) } else { None };
469+ }
470+ let import = provider == Provider::Github && query.import.is_some();
471+ let link_user = if query.link.is_some() || import { Some(ctx.viewer.as_ref().ok_or(AppError::Unauthorized)?.id) } else { None };
384472 let verifier = auth::random_token(48);
385473 let challenge = {
386474 use base64::Engine;
387475 use sha2::Digest;
388476 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sha2::Sha256::digest(verifier.as_bytes()))
389477 };
390- let oauth = OAuthState { state: auth::random_token(24), verifier, next: auth::safe_next(query.next.as_deref()), link_user };
391- let mut url = url::Url::parse("https://accounts.google.com/o/oauth2/v2/auth").expect("static url");
392- url.query_pairs_mut()
393- .append_pair("client_id", &google.client_id)
394- .append_pair("redirect_uri", &redirect_uri(&ctx.state))
395- .append_pair("response_type", "code")
396- .append_pair("scope", "openid email profile")
397- .append_pair("state", &oauth.state)
398- .append_pair("code_challenge", &challenge)
399- .append_pair("code_challenge_method", "S256")
400- .append_pair("prompt", "select_account");
401- let cookie = signed::sign(&ctx.state.config.secret_key, "oauth-google", &oauth, 600);
402- analytics::track(&ctx.state, "google_oauth_started", ctx.viewer.as_ref().map(|v| v.name.as_str()), "/login/google", json!({ "link": oauth.link_user.is_some() }));
478+ let oauth = OAuthState { provider, state: auth::random_token(24), verifier, next: auth::safe_next(query.next.as_deref()), link_user, import };
479+ let redirect = redirect_uri(&ctx.state, provider);
480+ let url = match provider {
481+ Provider::Google => {
482+ let google = ctx.state.config.google.as_ref().expect("checked above");
483+ let mut url = url::Url::parse("https://accounts.google.com/o/oauth2/v2/auth").expect("static url");
484+ url.query_pairs_mut()
485+ .append_pair("client_id", &google.client_id)
486+ .append_pair("redirect_uri", &redirect)
487+ .append_pair("response_type", "code")
488+ .append_pair("scope", "openid email profile")
489+ .append_pair("state", &oauth.state)
490+ .append_pair("code_challenge", &challenge)
491+ .append_pair("code_challenge_method", "S256")
492+ .append_pair("prompt", "select_account");
493+ url
494+ }
495+ Provider::Github => {
496+ let github = ctx.state.config.github.as_ref().expect("checked above");
497+ // Signing in needs the profile and emails only. Importing private
498+ // repositories needs `repo` (OAuth apps have no read-only scope);
499+ // that token is used for the import alone and revoked afterwards.
500+ let scope = if import { "repo read:org read:user user:email" } else { "read:user user:email" };
501+ let mut url = url::Url::parse("https://github.com/login/oauth/authorize").expect("static url");
502+ url.query_pairs_mut()
503+ .append_pair("client_id", &github.client_id)
504+ .append_pair("redirect_uri", &redirect)
505+ .append_pair("scope", scope)
506+ .append_pair("state", &oauth.state)
507+ .append_pair("code_challenge", &challenge)
508+ .append_pair("code_challenge_method", "S256")
509+ .append_pair("allow_signup", "true");
510+ url
511+ }
512+ };
513+ let cookie = signed::sign(&ctx.state.config.secret_key, "oauth", &oauth, 600);
514+ analytics::track(
515+ &ctx.state,
516+ "oauth_started",
517+ ctx.viewer.as_ref().map(|v| v.name.as_str()),
518+ &format!("/login/{}", provider.slug()),
519+ json!({ "provider": provider.slug(), "link": oauth.link_user.is_some(), "import": import }),
520+ );
403521 Ok((jar.add(short_cookie(OAUTH_COOKIE, cookie, &ctx.state, 10)), Redirect::to(url.as_str())).into_response())
404522 }
405523
406524 #[derive(Deserialize)]
407-pub struct GoogleCallback {
525+pub struct OAuthCallback {
408526 code: Option<String>,
409527 state: Option<String>,
410528 error: Option<String>,
411529 }
412530
413-#[derive(Deserialize)]
414-struct TokenResponse {
415- access_token: String,
531+pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, query: Query<OAuthCallback>) -> AppResult<Response> {
532+ oauth_callback(ctx, headers, jar, query, Provider::Google).await
416533 }
417534
418-#[derive(Deserialize)]
419-struct UserInfo {
420- sub: String,
421- email: Option<String>,
422- email_verified: Option<bool>,
423- name: Option<String>,
424- picture: Option<String>,
535+pub async fn github_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, query: Query<OAuthCallback>) -> AppResult<Response> {
536+ oauth_callback(ctx, headers, jar, query, Provider::Github).await
425537 }
426538
427-pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query(query): Query<GoogleCallback>) -> AppResult<Response> {
428- let Some(google) = ctx.state.config.google.clone() else { return Err(AppError::NotFound) };
539+async fn oauth_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query(query): Query<OAuthCallback>, provider: Provider) -> AppResult<Response> {
540+ if !provider.configured(&ctx.state) {
541+ return Err(AppError::NotFound);
542+ }
543+ let slug = provider.slug();
429544 let fail = |code: &str| Redirect::to(&format!("/login?error={code}")).into_response();
430- let oauth: Option<OAuthState> = jar.get(OAUTH_COOKIE).and_then(|c| signed::verify(&ctx.state.config.secret_key, "oauth-google", c.value()));
545+ let oauth: Option<OAuthState> = jar.get(OAUTH_COOKIE).and_then(|c| signed::verify(&ctx.state.config.secret_key, "oauth", c.value()));
431546 let jar = jar.remove(Cookie::build(OAUTH_COOKIE).path("/").build());
432- let Some(oauth) = oauth.filter(|o| query.state.as_deref() == Some(o.state.as_str())) else {
433- tracing::warn!("google callback with missing or mismatched state");
434- return Ok((jar, fail("google_state")).into_response());
547+ let Some(oauth) = oauth.filter(|o| o.provider == provider && query.state.as_deref() == Some(o.state.as_str())) else {
548+ tracing::warn!(provider = slug, "oauth callback with missing or mismatched state");
549+ return Ok((jar, fail(&format!("{slug}_state"))).into_response());
435550 };
436551 if let Some(error) = &query.error {
437- tracing::info!(error, "google sign-in cancelled or refused");
438- return Ok((jar, Redirect::to(&format!("/login?next={}", auth::urlencode(&oauth.next)))).into_response());
552+ tracing::info!(provider = slug, error, "sign-in cancelled or refused");
553+ let back = if oauth.import { "/new/import".to_string() } else { format!("/login?next={}", auth::urlencode(&oauth.next)) };
554+ return Ok((jar, Redirect::to(&back)).into_response());
439555 }
440- let Some(code) = query.code.as_deref() else { return Ok((jar, fail("google")).into_response()) };
556+ let Some(code) = query.code.as_deref() else { return Ok((jar, fail(slug)).into_response()) };
441557
442- let info = match exchange_code(&ctx.state, &google, code, &oauth.verifier).await {
443- Ok(info) => info,
558+ let exchanged = match provider {
559+ Provider::Google => google_profile(&ctx.state, code, &oauth.verifier).await.map(|p| (p, None)),
560+ Provider::Github => github_profile(&ctx.state, code, &oauth.verifier).await.map(|(p, token)| (p, Some(token))),
561+ };
562+ let (profile, access_token) = match exchanged {
563+ Ok(result) => result,
444564 Err(error) => {
445- tracing::error!(?error, "google token exchange failed");
446- return Ok((jar, fail("google")).into_response());
565+ tracing::error!(provider = slug, ?error, "oauth code exchange failed");
566+ return Ok((jar, fail(slug)).into_response());
447567 }
448568 };
449- let ip = auth::client_ip(&headers);
569+
570+ if oauth.import {
571+ // Repository access for the importer: keep the token in memory for
572+ // this user and go straight to the repository list.
573+ let Some(viewer) = ctx.viewer.as_ref().filter(|v| Some(v.id) == oauth.link_user) else {
574+ return Ok((jar, Redirect::to("/login?next=/new/import")).into_response());
575+ };
576+ let token = access_token.unwrap_or_default();
577+ let key = crate::importer::stash_token(viewer.id, token);
578+ analytics::track(&ctx.state, "github_import_authorized", Some(&viewer.name), "/new/import", json!({}));
579+ let login = profile.suggestion.clone();
580+ return Ok((jar, Redirect::to(&format!("/new/import?github_ref={key}&source={}", auth::urlencode(&login)))).into_response());
581+ }
582+ // A sign-in token has done its job once the profile is read.
583+ if let Some(token) = access_token {
584+ revoke_github_token(&ctx.state, &token).await;
585+ }
586+ complete_external(&ctx, &headers, jar, profile, &oauth).await
587+}
588+
589+/// The shared tail of every external sign-in.
590+async fn complete_external(ctx: &Ctx, headers: &HeaderMap, jar: CookieJar, profile: ExternalProfile, oauth: &OAuthState) -> AppResult<Response> {
450591 let db = &ctx.state.db;
592+ let slug = profile.provider.slug();
593+ let fail = |code: &str| Redirect::to(&format!("/login?error={code}")).into_response();
594+ let ip = auth::client_ip(headers);
451595
452- // Already linked: sign in.
453596 let linked: Option<(i64, String, bool)> = sqlx::query_as(
454597 "select a.id, a.name::text, u.suspended_at is not null from oauth_identities o
455598 join users u on u.account_id = o.user_id join accounts a on a.id = u.account_id
456- where o.provider = 'google' and o.subject = $1",
599+ where o.provider = $1 and o.subject = $2",
457600 )
458- .bind(&info.sub)
601+ .bind(slug)
602+ .bind(&profile.subject)
459603 .fetch_optional(db)
460604 .await?;
461605
@@ -466,31 +610,39 @@ pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query
466610 }
467611 if let Some((owner, _, _)) = linked {
468612 let message = if owner == link_user { "already" } else { "taken" };
469- return Ok((jar, Redirect::to(&format!("/settings/security?google={message}"))).into_response());
613+ return Ok((jar, Redirect::to(&format!("/settings/security?{slug}={message}"))).into_response());
470614 }
471- sqlx::query("insert into oauth_identities (provider, subject, user_id, email) values ('google', $1, $2, $3) on conflict (provider, user_id) do update set subject = excluded.subject, email = excluded.email")
472- .bind(&info.sub)
473- .bind(link_user)
474- .bind(&info.email)
475- .execute(db)
476- .await?;
477- audit(db, Some(link_user), "google.link", info.email.as_deref().unwrap_or(""), json!({}), ip.as_deref()).await;
478- adopt_google_picture(&ctx.state, link_user, info.picture.as_deref()).await;
479- analytics::track(&ctx.state, "google_linked", ctx.viewer.as_ref().map(|v| v.name.as_str()), "/settings/security", json!({}));
480- return Ok((jar, Redirect::to("/settings/security?google=linked")).into_response());
615+ sqlx::query(
616+ "insert into oauth_identities (provider, subject, user_id, email) values ($1, $2, $3, $4)
617+ on conflict (provider, user_id) do update set subject = excluded.subject, email = excluded.email",
618+ )
619+ .bind(slug)
620+ .bind(&profile.subject)
621+ .bind(link_user)
622+ .bind(&profile.email)
623+ .execute(db)
624+ .await?;
625+ audit(db, Some(link_user), &format!("{slug}.link"), profile.email.as_deref().unwrap_or(""), json!({}), ip.as_deref()).await;
626+ adopt_picture(&ctx.state, link_user, profile.picture.as_deref()).await;
627+ analytics::track(&ctx.state, "oauth_linked", ctx.viewer.as_ref().map(|v| v.name.as_str()), "/settings/security", json!({ "provider": slug }));
628+ return Ok((jar, Redirect::to(&format!("/settings/security?{slug}=linked"))).into_response());
481629 }
482630
483631 if let Some((user_id, username, suspended)) = linked {
484632 if suspended {
485633 return Ok((jar, fail("suspended")).into_response());
486634 }
487- sqlx::query("update oauth_identities set last_used_at = now() where provider = 'google' and subject = $1").bind(&info.sub).execute(db).await?;
488- adopt_google_picture(&ctx.state, user_id, info.picture.as_deref()).await;
489- return sign_in(&ctx.state, jar, &headers, user_id, &username, &oauth.next).await;
635+ sqlx::query("update oauth_identities set last_used_at = now() where provider = $1 and subject = $2")
636+ .bind(slug)
637+ .bind(&profile.subject)
638+ .execute(db)
639+ .await?;
640+ adopt_picture(&ctx.state, user_id, profile.picture.as_deref()).await;
641+ return sign_in(&ctx.state, jar, headers, user_id, &username, &oauth.next, profile.provider).await;
490642 }
491643
492- let Some(email) = info.email.clone().filter(|_| info.email_verified == Some(true)) else {
493- return Ok((jar, fail("google_unverified")).into_response());
644+ let Some(email) = profile.email.clone() else {
645+ return Ok((jar, fail(&format!("{slug}_unverified"))).into_response());
494646 };
495647
496648 // Same verified email on an existing account: link and sign in.
@@ -508,27 +660,53 @@ pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query
508660 if suspended {
509661 return Ok((jar, fail("suspended")).into_response());
510662 }
511- sqlx::query("insert into oauth_identities (provider, subject, user_id, email) values ('google', $1, $2, $3) on conflict do nothing")
512- .bind(&info.sub)
663+ sqlx::query("insert into oauth_identities (provider, subject, user_id, email) values ($1, $2, $3, $4) on conflict do nothing")
664+ .bind(slug)
665+ .bind(&profile.subject)
513666 .bind(user_id)
514667 .bind(&email)
515668 .execute(db)
516669 .await?;
517- audit(db, Some(user_id), "google.link", &email, json!({ "auto": true }), ip.as_deref()).await;
518- adopt_google_picture(&ctx.state, user_id, info.picture.as_deref()).await;
519- return sign_in(&ctx.state, jar, &headers, user_id, &username, &oauth.next).await;
670+ audit(db, Some(user_id), &format!("{slug}.link"), &email, json!({ "auto": true }), ip.as_deref()).await;
671+ adopt_picture(&ctx.state, user_id, profile.picture.as_deref()).await;
672+ return sign_in(&ctx.state, jar, headers, user_id, &username, &oauth.next, profile.provider).await;
520673 }
521674
522675 if !ctx.state.config.registration_open {
523676 return Ok((jar, fail("closed")).into_response());
524677 }
525678 // New person: pick a username first.
526- let pending = PendingSignup { sub: info.sub, email, name: info.name.unwrap_or_default(), picture: info.picture, next: oauth.next };
527- let cookie = signed::sign(&ctx.state.config.secret_key, "google-signup", &pending, 900);
528- Ok((jar.add(short_cookie(SIGNUP_COOKIE, cookie, &ctx.state, 15)), Redirect::to("/login/google/finish")).into_response())
679+ let pending = PendingSignup {
680+ provider: profile.provider,
681+ sub: profile.subject,
682+ email,
683+ name: profile.name,
684+ picture: profile.picture,
685+ suggestion: profile.suggestion,
686+ next: oauth.next.clone(),
687+ };
688+ let cookie = signed::sign(&ctx.state.config.secret_key, "oauth-signup", &pending, 900);
689+ Ok((jar.add(short_cookie(SIGNUP_COOKIE, cookie, &ctx.state, 15)), Redirect::to("/login/finish")).into_response())
690+}
691+
692+#[derive(Deserialize)]
693+struct TokenResponse {
694+ access_token: Option<String>,
695+ error: Option<String>,
696+ error_description: Option<String>,
697+}
698+
699+#[derive(Deserialize)]
700+struct GoogleUserInfo {
701+ sub: String,
702+ email: Option<String>,
703+ email_verified: Option<bool>,
704+ name: Option<String>,
705+ picture: Option<String>,
529706 }
530707
531-async fn exchange_code(state: &AppState, google: &crate::config::GoogleConfig, code: &str, verifier: &str) -> anyhow::Result<UserInfo> {
708+async fn google_profile(state: &AppState, code: &str, verifier: &str) -> anyhow::Result<ExternalProfile> {
709+ let google = state.config.google.as_ref().context("google not configured")?;
532710 let token: TokenResponse = state
533711 .http
534712 .post("https://oauth2.googleapis.com/token")
@@ -536,7 +714,7 @@ async fn exchange_code(state: &AppState, google: &crate::config::GoogleConfig, c
536714 ("code", code),
537715 ("client_id", google.client_id.as_str()),
538716 ("client_secret", google.client_secret.as_str()),
539- ("redirect_uri", redirect_uri(state).as_str()),
717+ ("redirect_uri", redirect_uri(state, Provider::Google).as_str()),
540718 ("grant_type", "authorization_code"),
541719 ("code_verifier", verifier),
542720 ])
@@ -545,37 +723,140 @@ async fn exchange_code(state: &AppState, google: &crate::config::GoogleConfig, c
545723 .error_for_status()?
546724 .json()
547725 .await?;
726+ let access_token = token.access_token.context("google returned no access token")?;
548727 // Fetched from Google over TLS with the fresh access token, so the
549728 // claims need no separate id_token signature check.
550- Ok(state
729+ let info: GoogleUserInfo = state
551730 .http
552731 .get("https://openidconnect.googleapis.com/v1/userinfo")
553- .bearer_auth(token.access_token)
732+ .bearer_auth(access_token)
733+ .send()
734+ .await?
735+ .error_for_status()?
736+ .json()
737+ .await?;
738+ let email = info.email.filter(|_| info.email_verified == Some(true));
739+ let name = info.name.unwrap_or_default();
740+ Ok(ExternalProfile {
741+ provider: Provider::Google,
742+ suggestion: suggest_username(email.as_deref().unwrap_or(""), &name),
743+ subject: info.sub,
744+ email,
745+ name,
746+ picture: info.picture,
747+ })
748+}
749+
750+#[derive(Deserialize)]
751+struct GithubUser {
752+ id: i64,
753+ login: String,
754+ name: Option<String>,
755+ avatar_url: Option<String>,
756+}
757+
758+#[derive(Deserialize)]
759+struct GithubEmail {
760+ email: String,
761+ primary: bool,
762+ verified: bool,
763+}
764+
765+/// Exchanges the code; returns the profile and the access token.
766+async fn github_profile(state: &AppState, code: &str, verifier: &str) -> anyhow::Result<(ExternalProfile, String)> {
767+ let github = state.config.github.as_ref().context("github not configured")?;
768+ let token: TokenResponse = state
769+ .http
770+ .post("https://github.com/login/oauth/access_token")
771+ .header("Accept", "application/json")
772+ .form(&[
773+ ("client_id", github.client_id.as_str()),
774+ ("client_secret", github.client_secret.as_str()),
775+ ("code", code),
776+ ("redirect_uri", redirect_uri(state, Provider::Github).as_str()),
777+ ("code_verifier", verifier),
778+ ])
554779 .send()
555780 .await?
556781 .error_for_status()?
557782 .json()
558- .await?)
783+ .await?;
784+ let Some(access_token) = token.access_token else {
785+ anyhow::bail!("github token exchange: {} {}", token.error.unwrap_or_default(), token.error_description.unwrap_or_default());
786+ };
787+ let api = |path: &str| {
788+ state
789+ .http
790+ .get(format!("https://api.github.com{path}"))
791+ .bearer_auth(&access_token)
792+ .header("Accept", "application/vnd.github+json")
793+ .header("User-Agent", "irongit")
794+ };
795+ let user: GithubUser = api("/user").send().await?.error_for_status()?.json().await?;
796+ let emails: Vec<GithubEmail> = match api("/user/emails").send().await?.error_for_status() {
797+ Ok(response) => response.json().await.unwrap_or_default(),
798+ Err(_) => Vec::new(),
799+ };
800+ let email = emails
801+ .iter()
802+ .find(|e| e.primary && e.verified)
803+ .or_else(|| emails.iter().find(|e| e.verified && !e.email.ends_with("@users.noreply.github.com")))
804+ .map(|e| e.email.clone());
805+ let suggestion = {
806+ let lowered = user.login.to_ascii_lowercase();
807+ if crate::models::valid_account_name(&lowered).is_ok() { lowered } else { suggest_username(&lowered, "") }
808+ };
809+ Ok((
810+ ExternalProfile {
811+ provider: Provider::Github,
812+ subject: user.id.to_string(),
813+ email,
814+ name: user.name.unwrap_or_default(),
815+ picture: user.avatar_url,
816+ suggestion,
817+ },
818+ access_token,
819+ ))
820+}
821+
822+/// Revokes one GitHub OAuth token (sign-in tokens right away, import tokens
823+/// once the import ends). Failures are logged; the token expires anyway.
824+pub async fn revoke_github_token(state: &AppState, token: &str) {
825+ let Some(github) = state.config.github.as_ref() else { return };
826+ let result = state
827+ .http
828+ .delete(format!("https://api.github.com/applications/{}/token", github.client_id))
829+ .basic_auth(&github.client_id, Some(&github.client_secret))
830+ .header("Accept", "application/vnd.github+json")
831+ .header("User-Agent", "irongit")
832+ .json(&json!({ "access_token": token }))
833+ .send()
834+ .await;
835+ match result {
836+ Ok(r) if r.status().is_success() => tracing::debug!("github token revoked"),
837+ Ok(r) => tracing::warn!(status = %r.status(), "github token revoke refused"),
838+ Err(error) => tracing::warn!(%error, "github token revoke failed"),
839+ }
559840 }
560841
561-async fn sign_in(state: &AppState, jar: CookieJar, headers: &HeaderMap, user_id: i64, username: &str, next: &str) -> AppResult<Response> {
842+async fn sign_in(state: &AppState, jar: CookieJar, headers: &HeaderMap, user_id: i64, username: &str, next: &str, provider: Provider) -> AppResult<Response> {
562843 let ip = auth::client_ip(headers);
563844 let token = auth::create_session(&state.db, user_id, ip.as_deref(), auth::user_agent(headers)).await?;
564- audit(&state.db, Some(user_id), "user.login", username, json!({ "method": "google" }), ip.as_deref()).await;
565- analytics::track(state, "login", Some(username), "/login/google/callback", json!({ "method": "google" }));
566- tracing::info!(user = %username, "signed in with google");
845+ audit(&state.db, Some(user_id), "user.login", username, json!({ "method": provider.slug() }), ip.as_deref()).await;
846+ analytics::track(state, "login", Some(username), &format!("/login/{}/callback", provider.slug()), json!({ "method": provider.slug() }));
847+ tracing::info!(user = %username, provider = provider.slug(), "signed in");
567848 Ok((set_session(jar, state, token), Redirect::to(next)).into_response())
568849 }
569850
570-/// Uses the Google profile photo as the avatar when the account has none.
571-/// A picture someone uploaded is never replaced. Bounded so a slow fetch
572-/// cannot hold up sign-in; failures are logged and otherwise ignored.
573-async fn adopt_google_picture(state: &AppState, user_id: i64, picture: Option<&str>) {
851+/// Uses the provider's profile photo as the avatar when the account has
852+/// none. A picture someone uploaded is never replaced. Bounded so a slow
853+/// fetch cannot hold up sign-in; failures are logged and otherwise ignored.
854+async fn adopt_picture(state: &AppState, user_id: i64, picture: Option<&str>) {
574855 let Some(picture) = picture.filter(|p| !p.is_empty()) else { return };
575856 let has_avatar: bool = match sqlx::query_scalar("select avatar_key is not null from accounts where id = $1").bind(user_id).fetch_one(&state.db).await {
576857 Ok(has) => has,
577858 Err(error) => {
578- tracing::warn!(%error, user_id, "could not check avatar before google import");
859+ tracing::warn!(%error, user_id, "could not check avatar before import");
579860 return;
580861 }
581862 };
@@ -584,20 +865,20 @@ async fn adopt_google_picture(state: &AppState, user_id: i64, picture: Option<&s
584865 }
585866 match tokio::time::timeout(std::time::Duration::from_secs(5), avatars::import_from_url(state, user_id, picture)).await {
586867 Ok(Ok(())) => {
587- tracing::info!(user_id, "google profile photo imported as avatar");
588- analytics::track(state, "avatar_imported_google", None, "/login/google", json!({}));
868+ tracing::info!(user_id, "provider profile photo imported as avatar");
869+ analytics::track(state, "avatar_imported_provider", None, "/login", json!({}));
589870 }
590- Ok(Err(error)) => tracing::warn!(?error, user_id, "could not import google avatar"),
591- Err(_) => tracing::warn!(user_id, "google avatar import timed out"),
871+ Ok(Err(error)) => tracing::warn!(?error, user_id, "could not import provider avatar"),
872+ Err(_) => tracing::warn!(user_id, "provider avatar import timed out"),
592873 }
593874 }
594875
595876 fn pending_signup(state: &AppState, jar: &CookieJar) -> Option<PendingSignup> {
596- jar.get(SIGNUP_COOKIE).and_then(|c| signed::verify(&state.config.secret_key, "google-signup", c.value()))
877+ jar.get(SIGNUP_COOKIE).and_then(|c| signed::verify(&state.config.secret_key, "oauth-signup", c.value()))
597878 }
598879
599880 fn suggest_username(email: &str, name: &str) -> String {
600- let base = email.split('@').next().unwrap_or(name);
881+ let base = email.split('@').next().filter(|b| !b.is_empty()).unwrap_or(name);
601882 let mut out: String = base
602883 .to_ascii_lowercase()
603884 .chars()
@@ -621,11 +902,11 @@ fn finish_view(pending: &PendingSignup, username: &str, error: Option<&str>) ->
621902 img src=(picture) width="40" height="40" class="rounded-[4px]" alt="" referrerpolicy="no-referrer";
622903 }
623904 div class="min-w-0" {
624- div class="truncate font-semibold" { (pending.name) }
625- div class="truncate text-xs text-ink-dim" { (pending.email) }
905+ div class="truncate font-semibold" { @if pending.name.is_empty() { (pending.suggestion) } @else { (pending.name) } }
906+ div class="truncate text-xs text-ink-dim" { (pending.email) " via " (pending.provider.label()) }
626907 }
627908 }
628- form method="post" action="/login/google/finish" class="box space-y-3 p-4" data-track-submit="google_signup_submitted" {
909+ form method="post" action="/login/finish" class="box space-y-3 p-4" data-track-submit="oauth_signup_submitted" {
629910 div {
630911 label class="label" for="username" { "Pick a username" }
631912 input class="input" id="username" name="username" value=(username) pattern="[a-z0-9-]{1,39}" maxlength="39" autofocus required;
@@ -639,13 +920,13 @@ fn finish_view(pending: &PendingSignup, username: &str, error: Option<&str>) ->
639920 .noindex()
640921 }
641922
642-pub async fn google_finish_page(ctx: Ctx, jar: CookieJar) -> Response {
923+pub async fn finish_page(ctx: Ctx, jar: CookieJar) -> Response {
643924 match pending_signup(&ctx.state, &jar) {
644925 Some(pending) => {
645- let suggestion = suggest_username(&pending.email, &pending.name);
926+ let suggestion = pending.suggestion.clone();
646927 ctx.render(finish_view(&pending, &suggestion, None))
647928 }
648- None => Redirect::to("/login?error=google_state").into_response(),
929+ None => Redirect::to("/login?error=oauth_state").into_response(),
649930 }
650931 }
651932
@@ -654,9 +935,9 @@ pub struct FinishForm {
654935 username: String,
655936 }
656937
657-pub async fn google_finish_submit(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Form(form): Form<FinishForm>) -> AppResult<Response> {
938+pub async fn finish_submit(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Form(form): Form<FinishForm>) -> AppResult<Response> {
658939 let Some(pending) = pending_signup(&ctx.state, &jar) else {
659- return Ok(Redirect::to("/login?error=google_state").into_response());
940+ return Ok(Redirect::to("/login?error=oauth_state").into_response());
660941 };
661942 let created = ops::register_user(
662943 &ctx.state,
@@ -670,31 +951,38 @@ pub async fn google_finish_submit(ctx: Ctx, headers: HeaderMap, jar: CookieJar,
670951 }
671952 Err(other) => return Err(other),
672953 };
673- sqlx::query("insert into oauth_identities (provider, subject, user_id, email) values ('google', $1, $2, $3)")
954+ sqlx::query("insert into oauth_identities (provider, subject, user_id, email) values ($1, $2, $3, $4)")
955+ .bind(pending.provider.slug())
674956 .bind(&pending.sub)
675957 .bind(user_id)
676958 .bind(&pending.email)
677959 .execute(&ctx.state.db)
678960 .await?;
679- // Bring the Google profile photo along before the first page renders.
680- adopt_google_picture(&ctx.state, user_id, pending.picture.as_deref()).await;
961+ // Bring the profile photo along before the first page renders.
962+ adopt_picture(&ctx.state, user_id, pending.picture.as_deref()).await;
681963 let username = form.username.trim().to_ascii_lowercase();
682964 let jar = jar.remove(Cookie::build(SIGNUP_COOKIE).path("/").build());
683965 let next = if pending.next == "/" { format!("/{username}?welcome=1") } else { pending.next.clone() };
684- sign_in(&ctx.state, jar, &headers, user_id, &username, &next).await
966+ sign_in(&ctx.state, jar, &headers, user_id, &username, &next, pending.provider).await
685967 }
686968
687-/// Used by settings to unlink Google; only allowed when a password exists.
688-pub async fn unlink_google(state: &AppState, viewer: &RequireViewer) -> AppResult<()> {
689- let has_password: bool = sqlx::query_scalar("select password_hash is not null from users where account_id = $1")
690- .bind(viewer.0.id)
691- .fetch_one(&state.db)
692- .await?;
693- if !has_password {
694- return Err(AppError::bad("Set a password before disconnecting Google, or you would be locked out."));
969+/// Used by settings to disconnect a provider; refused when it is the only
970+/// way left to sign in.
971+pub async fn unlink_provider(state: &AppState, viewer: &RequireViewer, provider: Provider) -> AppResult<()> {
972+ let (has_password, other_providers): (bool, i64) = sqlx::query_as(
973+ "select u.password_hash is not null,
974+ (select count(*) from oauth_identities o where o.user_id = u.account_id and o.provider <> $2)
975+ from users u where u.account_id = $1",
976+ )
977+ .bind(viewer.0.id)
978+ .bind(provider.slug())
979+ .fetch_one(&state.db)
980+ .await?;
981+ if !has_password && other_providers == 0 {
982+ return Err(AppError::bad(format!("Set a password before disconnecting {}, or you would be locked out.", provider.label())));
695983 }
696- sqlx::query("delete from oauth_identities where provider = 'google' and user_id = $1").bind(viewer.0.id).execute(&state.db).await?;
697- audit(&state.db, Some(viewer.0.id), "google.unlink", "", json!({}), None).await;
984+ sqlx::query("delete from oauth_identities where provider = $1 and user_id = $2").bind(provider.slug()).bind(viewer.0.id).execute(&state.db).await?;
985+ audit(&state.db, Some(viewer.0.id), &format!("{}.unlink", provider.slug()), "", json!({}), None).await;
698986 Ok(())
699987 }
700988
+15-7backend/src/web/avatars.rs
@@ -115,15 +115,23 @@ pub async fn remove(state: &AppState, account_id: i64) -> crate::error::AppResul
115115 Ok(())
116116 }
117117
118-/// Copies a profile photo from a provider (Google) into R2.
118+/// Copies a profile photo from a sign-in provider (Google, GitHub) into R2.
119+/// Only the providers' own image hosts are fetched.
119120 pub async fn import_from_url(state: &AppState, account_id: i64, url: &str) -> anyhow::Result<()> {
120- let parsed = url::Url::parse(url)?;
121+ let mut parsed = url::Url::parse(url)?;
121122 anyhow::ensure!(parsed.scheme() == "https", "avatar url must be https");
122- anyhow::ensure!(parsed.host_str().is_some_and(|h| h.ends_with(".googleusercontent.com")), "unexpected avatar host");
123- // Google serves a larger rendition when the size suffix is changed.
124- let url = match url.rsplit_once("=s") {
125- Some((base, _)) => format!("{base}=s400-c"),
126- None => url.to_string(),
123+ let host = parsed.host_str().unwrap_or("").to_string();
124+ let url = if host.ends_with(".googleusercontent.com") {
125+ // Google serves a larger rendition when the size suffix is changed.
126+ match url.rsplit_once("=s") {
127+ Some((base, _)) => format!("{base}=s400-c"),
128+ None => url.to_string(),
129+ }
130+ } else if host == "avatars.githubusercontent.com" {
131+ parsed.query_pairs_mut().append_pair("s", "400");
132+ parsed.to_string()
133+ } else {
134+ anyhow::bail!("unexpected avatar host {host}");
127135 };
128136 let bytes = state.http.get(url).send().await?.error_for_status()?.bytes().await?;
129137 store(state, account_id, bytes).await.map_err(|e| anyhow::anyhow!("{e}"))
+5-1backend/src/web/home.rs
@@ -88,6 +88,7 @@ pub async fn index(ctx: Ctx, request: Request) -> AppResult<Response> {
8888 li { "Sign in from your terminal: " code class="text-ink" { "ig login" } }
8989 li { "Create a repo and push: " code class="text-ink" { "ig repo create hello --private" } }
9090 li { "Push an image: " code class="text-ink" { "docker push " (ctx.state.config.registry_host()) "/" (viewer.name) "/app:1.0" } }
91+ li { "Coming from GitHub? " a href="/new/import" { "Import your repositories" } }
9192 }
9293 }
9394 }
@@ -268,7 +269,10 @@ fn new_repo_view(owners: &[(String, Option<String>)], form: &NewRepoForm, error:
268269 let body = html! {
269270 div class="mx-auto max-w-[640px] px-4 py-6" {
270271 h1 class="text-lg font-semibold" { "New repository" }
271- p class="mb-4 text-[13px] text-ink-dim" { "A repository holds your code and its history. You can also run " code { "ig repo create <name>" } "." }
272+ p class="mb-4 text-[13px] text-ink-dim" {
273+ "A repository holds your code and its history. You can also run " code { "ig repo create <name>" } ", or "
274+ a href="/new/import" data-track="new_repo_import_link_clicked" { "import from GitHub" } "."
275+ }
272276 (ui::alert_error(error))
273277 form method="post" action="/new" class="space-y-4" data-track-submit="repo_create_submitted" {
274278 div class="flex flex-wrap items-end gap-2" {
+396-0backend/src/web/import.rs
@@ -0,0 +1,396 @@
1+//! Importing a GitHub account from the browser:
2+//!
3+//! /new/import pick the GitHub account (Connect GitHub, or a name + token)
4+//! /new/import?source=.. choose repositories and options
5+//! /imports/{id} live progress
6+//! /imports history
7+//!
8+//! A pasted or OAuth-granted GitHub token is held in server memory (keyed by
9+//! a random reference the page carries), never in a cookie, URL or database.
10+
11+use axum::{
12+ Form, Router,
13+ extract::{Path, Query},
14+ response::{IntoResponse, Redirect, Response},
15+ routing::{get, post},
16+};
17+use irongit_shared as shared;
18+use maud::{Markup, html};
19+use serde::Deserialize;
20+
21+use crate::{
22+ auth::{self, RequireViewer},
23+ error::{AppError, AppResult},
24+ importer,
25+ state::AppState,
26+ web::{
27+ layout::{Ctx, Page, fragment},
28+ ui,
29+ },
30+};
31+
32+pub fn router() -> Router<AppState> {
33+ Router::new()
34+ .route("/new/import", get(start_page))
35+ .route("/new/import/preview", post(preview_submit))
36+ .route("/new/import/start", post(start_submit))
37+ .route("/imports", get(list_page))
38+ .route("/imports/{id}", get(progress_page))
39+ .route("/imports/{id}/status", get(status_fragment))
40+ .route("/imports/{id}/cancel", post(cancel_submit))
41+}
42+
43+#[derive(Deserialize, Default)]
44+pub struct StartQuery {
45+ source: Option<String>,
46+ github_ref: Option<String>,
47+ error: Option<String>,
48+}
49+
50+async fn owner_choices(ctx: &Ctx, viewer_id: i64) -> AppResult<Vec<String>> {
51+ Ok(sqlx::query_scalar(
52+ "select name::text from accounts where id = $1
53+ union all
54+ select a.name::text from org_members m join accounts a on a.id = m.org_id where m.user_id = $1 order by 1",
55+ )
56+ .bind(viewer_id)
57+ .fetch_all(&ctx.state.db)
58+ .await?)
59+}
60+
61+fn shell(title: &str, body: Markup) -> Markup {
62+ html! {
63+ div class="mx-auto max-w-[1100px] px-4 py-5" {
64+ div class="mb-4 flex flex-wrap items-center gap-2" {
65+ (ui::icon_github(20))
66+ h1 class="text-lg font-semibold" { (title) }
67+ a href="/imports" class="ml-auto text-[13px]" { "Past imports" }
68+ }
69+ (body)
70+ }
71+ }
72+}
73+
74+pub async fn start_page(ctx: Ctx, RequireViewer(viewer): RequireViewer, Query(query): Query<StartQuery>) -> AppResult<Response> {
75+ let token = query.github_ref.as_deref().and_then(|key| importer::stashed_token(key, viewer.id));
76+ if let Some(source) = query.source.as_deref().filter(|s| !s.trim().is_empty()) {
77+ let preview = importer::preview(source, token.clone()).await;
78+ return match preview {
79+ Ok(preview) => {
80+ let owners = owner_choices(&ctx, viewer.id).await?;
81+ Ok(ctx.render(Page::new("Import from GitHub", shell("Import from GitHub", preview_view(&preview, &owners, &viewer.name, query.github_ref.as_deref()))).noindex()))
82+ }
83+ Err(AppError::BadRequest(message)) => Ok(ctx.render(start_view(&ctx, Some(source), Some(&message)))),
84+ Err(other) => Err(other),
85+ };
86+ }
87+ Ok(ctx.render(start_view(&ctx, None, query.error.as_deref())))
88+}
89+
90+fn start_view(ctx: &Ctx, source: Option<&str>, error: Option<&str>) -> Page {
91+ let oauth = ctx.state.config.github.is_some();
92+ let body = html! {
93+ (ui::alert_error(error))
94+ p class="mb-4 max-w-[70ch] text-[13px] text-ink-dim" {
95+ "Bring a GitHub user or organization over: every branch and tag of the repositories you pick, Git LFS files included, "
96+ "with their descriptions and visibility. The import runs on the server; you can close the page."
97+ }
98+ div class="grid gap-4 md:grid-cols-2" {
99+ @if oauth {
100+ div class="box p-4" {
101+ h2 class="font-semibold" { "Your GitHub account" }
102+ p class="mt-1 mb-3 text-[13px] text-ink-dim" {
103+ "Sign in to GitHub to include private repositories and link your GitHub emails so your commit history lights up your heatmap. "
104+ "GitHub asks for repository access; the token is used for this import only and revoked when it finishes."
105+ }
106+ a href="/login/github?import=1" class="btn btn-primary" data-track="import_connect_github_clicked" { (ui::icon_github(16)) "Connect GitHub" }
107+ }
108+ }
109+ form method="post" action="/new/import/preview" class="box space-y-3 p-4" data-track-submit="import_preview_submitted" {
110+ h2 class="font-semibold" { "Any account by name" }
111+ div {
112+ label class="label" for="source" { "GitHub user or organization" }
113+ input class="input" id="source" name="source" value=[source] placeholder="octocat" required autofocus;
114+ }
115+ div {
116+ label class="label" for="token" { "GitHub token " span class="font-normal text-ink-faint" { "(optional)" } }
117+ input class="input font-mono" id="token" name="token" type="password" autocomplete="off" placeholder="ghp_... or github_pat_...";
118+ p class="hint" {
119+ "Without a token only public repositories are imported. "
120+ a href="https://github.com/settings/tokens/new?scopes=repo,read:org,user:email&description=irongit%20import" target="_blank" rel="noopener" { "Create a token" }
121+ " with " code { "repo" } ", " code { "read:org" } " and " code { "user:email" } "."
122+ }
123+ }
124+ button type="submit" class="btn" { "Find repositories" }
125+ }
126+ }
127+ p class="mt-4 text-xs text-ink-faint" {
128+ "From a terminal: " code { "ig import github <user-or-org>" } " (uses your " code { "gh" } " login when there is one)."
129+ }
130+ };
131+ Page::new("Import from GitHub", shell("Import from GitHub", body)).noindex()
132+}
133+
134+fn preview_view(preview: &shared::GithubPreview, owners: &[String], me: &str, github_ref: Option<&str>) -> Markup {
135+ let account = &preview.account;
136+ let private = preview.repos.iter().filter(|r| r.private).count();
137+ html! {
138+ div class="box mb-4 flex flex-wrap items-center gap-3 p-3" {
139+ img src=(account.avatar_url) width="48" height="48" alt="" class="rounded-[4px]" referrerpolicy="no-referrer";
140+ div class="min-w-0 flex-1" {
141+ div class="font-semibold" { @if account.name.is_empty() { (account.login) } @else { (account.name) " " span class="font-normal text-ink-dim" { (account.login) } } }
142+ div class="text-xs text-ink-dim" {
143+ @if account.kind == "Organization" { "Organization" } @else { "User" } " on GitHub · "
144+ (ui::plural(preview.repos.len() as i64, "repository", "repositories"))
145+ @if private > 0 { " (" (private) " private)" }
146+ }
147+ }
148+ div class="text-xs text-ink-dim" {
149+ @match &preview.token_owner {
150+ Some(owner) => { "Using GitHub as " strong { (owner) } }
151+ None => { "Public repositories only" }
152+ }
153+ }
154+ a href="/new/import" class="btn btn-sm" { "Change" }
155+ }
156+ @if preview.repos.is_empty() {
157+ (ui::empty_state("Nothing to import", html! { "This account has no repositories visible to " @if preview.token_owner.is_some() { "this token" } @else { "anonymous visitors" } "." }))
158+ } @else {
159+ form method="post" action="/new/import/start" data-track-submit="import_start_submitted" {
160+ input type="hidden" name="source" value=(account.login);
161+ @if let Some(key) = github_ref { input type="hidden" name="github_ref" value=(key); }
162+ div class="mb-4 grid gap-3 rounded-[4px] border border-edge bg-surface-raised p-3 sm:grid-cols-2 lg:grid-cols-4" {
163+ div {
164+ label class="label" for="target_owner" { "Import into" }
165+ select class="input" id="target_owner" name="target_owner" {
166+ @for owner in owners { option value=(owner) selected[owner == me] { (owner) } }
167+ }
168+ }
169+ div {
170+ label class="label" for="visibility" { "Visibility" }
171+ select class="input" id="visibility" name="visibility" {
172+ option value="keep" selected { "Same as on GitHub" }
173+ option value="private" { "All private" }
174+ option value="public" { "All public" }
175+ }
176+ }
177+ label class="flex items-start gap-2 text-[13px]" {
178+ input type="checkbox" name="import_profile" value="1" class="mt-0.5";
179+ span { "Copy profile" span class="block text-xs text-ink-faint" { "Name, bio, location, website and avatar, where yours are empty" } }
180+ }
181+ @if preview.token_is_owner {
182+ label class="flex items-start gap-2 text-[13px]" {
183+ input type="checkbox" name="link_emails" value="1" checked class="mt-0.5";
184+ span { "Link my GitHub emails" span class="block text-xs text-ink-faint" { "So imported commits count on your heatmap (only when importing into your own account)" } }
185+ }
186+ }
187+ }
188+ div class="box overflow-x-auto" {
189+ table class="w-full text-[13px]" {
190+ thead class="bg-surface-raised text-left text-xs text-ink-dim" {
191+ tr {
192+ th class="w-8 px-3 py-1.5" { input type="checkbox" data-check-all="repos" checked aria-label="Select all"; }
193+ th class="px-2" { "Repository" }
194+ th class="px-2 text-right" { "Size" }
195+ th class="px-2 text-right" { "Last push" }
196+ }
197+ }
198+ tbody class="divide-y divide-edge" {
199+ @for repo in &preview.repos {
200+ tr class="hover:bg-surface-hover" {
201+ td class="px-3 py-1.5 align-top" { input type="checkbox" name="repos" value=(repo.name) checked[!repo.fork] aria-label=(repo.name); }
202+ td class="px-2 py-1.5" {
203+ div class="flex flex-wrap items-center gap-1.5" {
204+ a href=(repo.html_url) target="_blank" rel="noopener" class="font-semibold" { (repo.name) }
205+ (ui::visibility_tag(if repo.private { "private" } else { "public" }))
206+ @if repo.fork { span class="tag" { "Fork" } }
207+ @if repo.archived { span class="tag" { "Archived" } }
208+ }
209+ @if !repo.description.is_empty() { div class="mt-0.5 line-clamp-1 text-xs text-ink-dim" { (repo.description) } }
210+ }
211+ td class="px-2 text-right text-xs whitespace-nowrap text-ink-dim" { (ui::bytes((repo.size_kb.max(0) as u64) * 1024)) }
212+ td class="px-2 text-right text-xs whitespace-nowrap text-ink-faint" { @if let Some(at) = repo.pushed_at { (ui::time(at)) } }
213+ }
214+ }
215+ }
216+ }
217+ }
218+ div class="mt-3 flex flex-wrap items-center gap-3" {
219+ button type="submit" class="btn btn-primary" { "Start import" }
220+ span class="text-xs text-ink-faint" { "Forks start unchecked. Repositories whose name already exists are left untouched." }
221+ }
222+ }
223+ }
224+ }
225+}
226+
227+#[derive(Deserialize)]
228+pub struct PreviewForm {
229+ source: String,
230+ token: Option<String>,
231+}
232+
233+pub async fn preview_submit(RequireViewer(viewer): RequireViewer, Form(form): Form<PreviewForm>) -> Response {
234+ let source = form.source.trim().trim_start_matches('@').to_string();
235+ let mut url = format!("/new/import?source={}", auth::urlencode(&source));
236+ if let Some(token) = form.token.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()) {
237+ url.push_str(&format!("&github_ref={}", importer::stash_token(viewer.id, token)));
238+ }
239+ Redirect::to(&url).into_response()
240+}
241+
242+pub async fn start_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, body: String) -> AppResult<Response> {
243+ // Repeated checkbox names need the raw form: repos=a&repos=b.
244+ let pairs: Vec<(String, String)> = url::form_urlencoded::parse(body.as_bytes()).into_owned().collect();
245+ let field = |name: &str| pairs.iter().find(|(k, _)| k == name).map(|(_, v)| v.clone());
246+ let source = field("source").unwrap_or_default();
247+ let github_ref = field("github_ref");
248+ let token = github_ref.as_deref().and_then(|key| importer::stashed_token(key, viewer.id));
249+ if github_ref.is_some() && token.is_none() {
250+ return Ok(Redirect::to("/new/import?error=The+GitHub+authorization+expired.+Connect+again.").into_response());
251+ }
252+ let repos: Vec<String> = pairs.iter().filter(|(k, _)| k == "repos").map(|(_, v)| v.clone()).collect();
253+ if repos.is_empty() {
254+ let back = format!("/new/import?source={}{}", auth::urlencode(&source), github_ref.as_deref().map(|k| format!("&github_ref={k}")).unwrap_or_default());
255+ return Ok(Redirect::to(&back).into_response());
256+ }
257+ let request = shared::StartImport {
258+ source_owner: source.clone(),
259+ token,
260+ target_owner: field("target_owner"),
261+ repos,
262+ include_forks: true,
263+ visibility: field("visibility"),
264+ import_profile: field("import_profile").is_some(),
265+ link_emails: field("link_emails").is_some(),
266+ };
267+ match importer::start(&ctx.state, &viewer, request).await {
268+ Ok(id) => {
269+ if let Some(key) = &github_ref {
270+ importer::forget_token(key);
271+ }
272+ Ok(Redirect::to(&format!("/imports/{id}")).into_response())
273+ }
274+ Err(AppError::BadRequest(m) | AppError::Conflict(m) | AppError::Forbidden(m)) => {
275+ Ok(Redirect::to(&format!("/new/import?error={}", auth::urlencode(&m))).into_response())
276+ }
277+ Err(other) => Err(other),
278+ }
279+}
280+
281+fn status_tag(status: &str) -> Markup {
282+ html! {
283+ @match status {
284+ "done" => span class="tag border-ok/50 text-ok" { "Done" },
285+ "failed" => span class="tag border-danger/50 text-danger" { "Failed" },
286+ "running" => span class="tag border-accent/60 text-accent animate-pulse" { "Importing" },
287+ "skipped" => span class="tag" { "Skipped" },
288+ "cancelled" => span class="tag" { "Cancelled" },
289+ _ => span class="tag text-ink-faint" { "Waiting" },
290+ }
291+ }
292+}
293+
294+fn status_markup(import: &shared::Import) -> Markup {
295+ let finished = import.finished();
296+ let settled = import.done + import.failed;
297+ let percent = if import.total > 0 { (settled as f64 / import.total as f64 * 100.0).round() as i64 } else { 100 };
298+ html! {
299+ @if finished { span data-poll-done hidden {} }
300+ div class="mb-3 flex flex-wrap items-center gap-3 text-[13px]" {
301+ (status_tag(&import.status))
302+ span { (import.done) " imported" @if import.failed > 0 { ", " span class="text-danger" { (import.failed) " failed or skipped" } } " of " (import.total) }
303+ @if !finished {
304+ form method="post" action={ "/imports/" (import.id) "/cancel" } class="ml-auto" {
305+ button class="btn btn-sm" { "Cancel remaining" }
306+ }
307+ }
308+ }
309+ div class="mb-4 h-1.5 overflow-hidden rounded-[4px] bg-surface-hover" {
310+ div class={ "h-full " @if import.failed > 0 && finished && import.done == 0 { "bg-danger" } @else { "bg-ember" } } style={ "width:" (percent) "%" } {}
311+ }
312+ @if let Some(error) = &import.error { (ui::alert_error(Some(error))) }
313+ @if !import.notes.is_empty() {
314+ div class="alert alert-info mb-3 whitespace-pre-line" { (import.notes) }
315+ }
316+ div class="box divide-y divide-edge" {
317+ @for item in &import.items {
318+ div class="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-[13px]" {
319+ div class="min-w-0 flex-1" {
320+ div class="flex flex-wrap items-center gap-1.5" {
321+ @if let Some(url) = &item.repo_url {
322+ a href=(url) class="font-semibold" { (import.target_owner) "/" (item.target_name) }
323+ } @else {
324+ span class="font-semibold" { (item.name) }
325+ }
326+ (ui::visibility_tag(&item.visibility))
327+ }
328+ @if let Some(error) = &item.error {
329+ div class={ "mt-0.5 text-xs " @if item.status == "failed" { "text-danger" } @else { "text-ink-dim" } } { (error) }
330+ }
331+ }
332+ @if item.status == "done" {
333+ span class="text-xs text-ink-dim" {
334+ (ui::plural(item.commits as i64, "commit", "commits"))
335+ @if item.lfs_objects > 0 { ", " (item.lfs_objects) " LFS" }
336+ }
337+ } @else {
338+ span class="text-xs text-ink-faint" { "~" (ui::bytes((item.size_kb.max(0) as u64) * 1024)) }
339+ }
340+ (status_tag(&item.status))
341+ }
342+ }
343+ }
344+ @if finished && import.done > 0 {
345+ p class="mt-3 text-[13px]" { a href={ "/" (import.target_owner) "?tab=repositories" } { "See the repositories on " (import.target_owner) } }
346+ }
347+ }
348+}
349+
350+pub async fn progress_page(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path(id): Path<i64>) -> AppResult<Response> {
351+ let import = importer::load(&ctx.state, &viewer, id).await?;
352+ let body = html! {
353+ p class="mb-3 text-[13px] text-ink-dim" {
354+ "From " a href={ "https://github.com/" (import.source_owner) } target="_blank" rel="noopener" { "github.com/" (import.source_owner) }
355+ " into " a href={ "/" (import.target_owner) } { (import.target_owner) } ", started " (ui::time(import.created_at)) "."
356+ @if !import.finished() { " You can leave this page; the import keeps going." }
357+ }
358+ div data-lazy={ "/imports/" (id) "/status" } data-poll="2000" { (status_markup(&import)) }
359+ };
360+ Ok(ctx.render(Page::new(format!("Import of {}", import.source_owner), shell(&format!("Import of {}", import.source_owner), body)).noindex()))
361+}
362+
363+pub async fn status_fragment(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path(id): Path<i64>) -> AppResult<Response> {
364+ let import = importer::load(&ctx.state, &viewer, id).await?;
365+ Ok(fragment(status_markup(&import)))
366+}
367+
368+pub async fn cancel_submit(ctx: Ctx, RequireViewer(viewer): RequireViewer, Path(id): Path<i64>) -> AppResult<Response> {
369+ match importer::cancel(&ctx.state, &viewer, id).await {
370+ Ok(()) | Err(AppError::Conflict(_)) => Ok(Redirect::to(&format!("/imports/{id}")).into_response()),
371+ Err(other) => Err(other),
372+ }
373+}
374+
375+pub async fn list_page(ctx: Ctx, RequireViewer(viewer): RequireViewer) -> AppResult<Response> {
376+ let imports = importer::list(&ctx.state, &viewer).await?;
377+ let body = html! {
378+ div class="mb-3" { a href="/new/import" class="btn btn-primary" { "New import" } }
379+ @if imports.is_empty() {
380+ (ui::empty_state("No imports yet", html! { "Import a GitHub user or organization from " a href="/new/import" { "here" } "." }))
381+ } @else {
382+ div class="box divide-y divide-edge" {
383+ @for import in &imports {
384+ a href={ "/imports/" (import.id) } class="flex flex-wrap items-center gap-3 px-3 py-2 text-[13px] text-ink no-underline hover:bg-surface-hover hover:no-underline" {
385+ span class="font-semibold" { "github.com/" (import.source_owner) }
386+ span class="text-ink-dim" { "into " (import.target_owner) }
387+ span class="text-xs text-ink-faint" { (import.done) " of " (import.total) " imported" }
388+ span class="ml-auto text-xs text-ink-faint" { (ui::time(import.created_at)) }
389+ (status_tag(&import.status))
390+ }
391+ }
392+ }
393+ }
394+ };
395+ Ok(ctx.render(Page::new("Imports", shell("Imports", body)).noindex()))
396+}
+2-0backend/src/web/mod.rs
@@ -7,6 +7,7 @@ pub mod components;
77 pub mod heatmap;
88 pub mod highlight;
99 pub mod home;
10+pub mod import;
1011 pub mod layout;
1112 pub mod markdown;
1213 pub mod orgs;
@@ -36,6 +37,7 @@ pub fn router() -> Router<AppState> {
3637 .merge(settings::router())
3738 .merge(orgs::router())
3839 .merge(repo::router())
40+ .merge(import::router())
3941 .merge(admin::router())
4042 }
4143
+9-4backend/src/web/profile.rs
@@ -136,14 +136,16 @@ pub struct Activity {
136136 pub after_sha: String,
137137 pub created_at: DateTime<Utc>,
138138 pub pusher: Option<(String, Option<String>)>,
139+ pub via: String,
139140 }
140141
141142 /// Push events visible to the viewer. `pusher` filters to one person.
142143 pub async fn activity(ctx: &Ctx, pusher: Option<i64>, involved: Option<i64>, limit: i64) -> AppResult<Vec<Activity>> {
143144 let (viewer_id, admin) = perm::visibility_binds(ctx.viewer.as_ref(), Area::Repo);
144- let rows: Vec<(String, String, String, i32, String, String, String, DateTime<Utc>, Option<String>, Option<String>)> = sqlx::query_as(concat!(
145+ #[allow(clippy::type_complexity)]
146+ let rows: Vec<(String, String, String, i32, String, String, String, DateTime<Utc>, Option<String>, Option<String>, String)> = sqlx::query_as(concat!(
145147 "select a.name::text, r.name::text, e.ref_name, e.commit_count, e.head_message, e.before_sha, e.after_sha, e.created_at,
146- pa.name::text, pa.avatar_key
148+ pa.name::text, pa.avatar_key, e.via
147149 from push_events e join repos r on r.id = e.repo_id join accounts a on a.id = r.owner_id
148150 left join accounts pa on pa.id = e.pusher_id
149151 where ($3::bigint is null or e.pusher_id = $3)
@@ -163,7 +165,7 @@ pub async fn activity(ctx: &Ctx, pusher: Option<i64>, involved: Option<i64>, lim
163165 .await?;
164166 Ok(rows
165167 .into_iter()
166- .map(|(owner_name, repo_name, ref_name, commit_count, head_message, before_sha, after_sha, created_at, pusher, avatar)| Activity {
168+ .map(|(owner_name, repo_name, ref_name, commit_count, head_message, before_sha, after_sha, created_at, pusher, avatar, via)| Activity {
167169 owner_name,
168170 repo_name,
169171 ref_name,
@@ -173,6 +175,7 @@ pub async fn activity(ctx: &Ctx, pusher: Option<i64>, involved: Option<i64>, lim
173175 after_sha,
174176 created_at,
175177 pusher: pusher.map(|p| (p, avatar)),
178+ via,
176179 })
177180 .collect())
178181 }
@@ -194,7 +197,9 @@ pub fn activity_list(items: &[Activity], show_pusher: bool) -> Markup {
194197 div class="min-w-0 flex-1" {
195198 div {
196199 @if show_pusher { @if let Some((name, _)) = &item.pusher { a href={ "/" (name) } class="font-semibold text-ink" { (name) } " " } }
197- @if item.after_sha == ZERO_SHA {
200+ @if item.via == "import" {
201+ "imported " (ui::plural(item.commit_count as i64, "commit", "commits")) " into "
202+ } @else if item.after_sha == ZERO_SHA {
198203 "deleted " @if is_tag { "tag " } @else { "branch " } code class="text-xs" { (short_ref) } " in "
199204 } @else if item.before_sha == ZERO_SHA {
200205 @if is_tag { "tagged " } @else { "created branch " }
+49-32backend/src/web/settings.rs
@@ -1,4 +1,4 @@
1-//! /settings: profile and avatar, security (password, Google, sessions),
1+//! /settings: profile and avatar, security (password, Google, GitHub, sessions),
22 //! emails, SSH keys, access tokens and organizations.
33
44 use axum::{
@@ -21,7 +21,8 @@ use crate::{
2121 ops, sshkeys,
2222 state::AppState,
2323 web::{
24- account, avatars,
24+ account::{self, Provider},
25+ avatars,
2526 layout::{Ctx, Page},
2627 ui,
2728 },
@@ -35,7 +36,7 @@ pub fn router() -> Router<AppState> {
3536 .route("/settings/avatar/remove", post(avatar_remove))
3637 .route("/settings/security", get(security_page))
3738 .route("/settings/security/password", post(password_submit))
38- .route("/settings/security/google/unlink", post(google_unlink))
39+ .route("/settings/security/{provider}/unlink", post(provider_unlink))
3940 .route("/settings/security/sessions/revoke", post(sessions_revoke))
4041 .route("/settings/emails", get(emails_page).post(email_add))
4142 .route("/settings/emails/{id}/{action}", post(email_action))
@@ -90,6 +91,7 @@ pub struct Notice {
9091 saved: Option<String>,
9192 error: Option<String>,
9293 google: Option<String>,
94+ github: Option<String>,
9395 }
9496
9597 // ---------------------------------------------------------------------------
@@ -230,10 +232,10 @@ pub async fn avatar_remove(ctx: Ctx, RequireViewer(viewer): RequireViewer) -> Ap
230232 pub async fn security_page(ctx: Ctx, headers: HeaderMap, RequireViewer(viewer): RequireViewer, Query(notice): Query<Notice>) -> AppResult<Response> {
231233 let db = &ctx.state.db;
232234 let has_password: bool = sqlx::query_scalar("select password_hash is not null from users where account_id = $1").bind(viewer.id).fetch_one(db).await?;
233- let google: Option<(Option<String>, DateTime<Utc>)> =
234- sqlx::query_as("select email::text, created_at from oauth_identities where provider = 'google' and user_id = $1")
235+ let identities: Vec<(String, Option<String>, DateTime<Utc>)> =
236+ sqlx::query_as("select provider, email::text, created_at from oauth_identities where user_id = $1")
235237 .bind(viewer.id)
236- .fetch_optional(db)
238+ .fetch_all(db)
237239 .await?;
238240 let sessions: Vec<(String, Option<String>, Option<String>, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as(
239241 "select id_hash, ip, user_agent, created_at, last_seen_at from sessions where user_id = $1 and expires_at > now() order by last_seen_at desc limit 50",
@@ -242,23 +244,30 @@ pub async fn security_page(ctx: Ctx, headers: HeaderMap, RequireViewer(viewer):
242244 .fetch_all(db)
243245 .await?;
244246 let current = auth::session_token(&headers).map(auth::sha256_hex);
245- let google_notice = notice.google.as_deref().map(|g| match g {
246- "linked" => ("ok", "Google account connected. You can now sign in with Google."),
247- "already" => ("ok", "That Google account is already connected."),
248- "taken" => ("error", "That Google account is connected to a different irongit account."),
249- _ => ("error", "Google connection failed."),
250- });
247+ let provider_notice = [(Provider::Google, notice.google.as_deref()), (Provider::Github, notice.github.as_deref())]
248+ .into_iter()
249+ .find_map(|(provider, value)| value.map(|v| (provider, v)))
250+ .map(|(provider, value)| {
251+ let label = provider.label();
252+ match value {
253+ "linked" => ("ok", format!("{label} account connected. You can now sign in with {label}.")),
254+ "already" => ("ok", format!("That {label} account is already connected.")),
255+ "taken" => ("error", format!("That {label} account is connected to a different irongit account.")),
256+ _ => ("error", format!("{label} connection failed.")),
257+ }
258+ });
259+ let sign_in_methods = identities.len() + usize::from(has_password);
251260
252261 let body = html! {
253262 (ui::alert_ok(notice.saved.as_deref().map(|_| "Saved.")))
254263 (ui::alert_error(notice.error.as_deref()))
255- @if let Some((kind, message)) = google_notice {
256- @if kind == "ok" { (ui::alert_ok(Some(message))) } @else { (ui::alert_error(Some(message))) }
264+ @if let Some((kind, message)) = &provider_notice {
265+ @if *kind == "ok" { (ui::alert_ok(Some(message))) } @else { (ui::alert_error(Some(message))) }
257266 }
258267 section class="mb-6" {
259268 h2 class="mb-2 font-semibold" { @if has_password { "Change password" } @else { "Set a password" } }
260269 @if !has_password {
261- p class="mb-2 text-[13px] text-ink-dim" { "You sign in with Google. Setting a password lets you sign in without it." }
270+ p class="mb-2 text-[13px] text-ink-dim" { "You sign in with Google or GitHub. Setting a password lets you sign in without them." }
262271 }
263272 form method="post" action="/settings/security/password" class="max-w-[420px] space-y-3" data-track-submit="password_changed" {
264273 @if has_password {
@@ -275,21 +284,28 @@ pub async fn security_page(ctx: Ctx, headers: HeaderMap, RequireViewer(viewer):
275284 }
276285 }
277286 section class="mb-6" {
278- h2 class="mb-2 font-semibold" { "Google" }
279- div class="box flex flex-wrap items-center gap-3 p-3" {
280- @if let Some((email, since)) = &google {
281- div class="min-w-0 flex-1" {
282- div { "Connected" @if let Some(email) = email { " as " strong { (email) } } }
283- div class="text-xs text-ink-faint" { "Since " (ui::time(*since)) }
284- }
285- form method="post" action="/settings/security/google/unlink" {
286- button type="submit" class="btn btn-sm btn-danger" disabled[!has_password] title=[(!has_password).then_some("Set a password first")] { "Disconnect" }
287+ h2 class="mb-2 font-semibold" { "Connected accounts" }
288+ div class="box divide-y divide-edge" {
289+ @for provider in [Provider::Google, Provider::Github] {
290+ @let linked = identities.iter().find(|(p, _, _)| p == provider.slug());
291+ div class="flex flex-wrap items-center gap-3 p-3" {
292+ div class="w-16 font-semibold" { (provider.label()) }
293+ @if let Some((_, email, since)) = linked {
294+ div class="min-w-0 flex-1 text-[13px]" {
295+ div { "Connected" @if let Some(email) = email { " as " strong { (email) } } }
296+ div class="text-xs text-ink-faint" { "Since " (ui::time(*since)) }
297+ }
298+ form method="post" action={ "/settings/security/" (provider.slug()) "/unlink" } {
299+ button type="submit" class="btn btn-sm btn-danger" disabled[sign_in_methods < 2]
300+ title=[(sign_in_methods < 2).then_some("Set a password or connect another account first")] { "Disconnect" }
301+ }
302+ } @else if provider.configured(&ctx.state) {
303+ div class="min-w-0 flex-1 text-[13px] text-ink-dim" { "Sign in with your " (provider.label()) " account." }
304+ a href={ "/login/" (provider.slug()) "?link=1&next=/settings/security" } class="btn btn-sm" data-track={ (provider.slug()) "_connect_clicked" } { "Connect " (provider.label()) }
305+ } @else {
306+ div class="flex-1 text-[13px] text-ink-faint" { (provider.label()) " sign-in is not configured on this server." }
307+ }
287308 }
288- } @else if ctx.state.config.google.is_some() {
289- div class="flex-1 text-[13px] text-ink-dim" { "Sign in with your Google account as well as your password." }
290- a href="/login/google?link=1&next=/settings/security" class="btn btn-sm" data-track="google_connect_clicked" { "Connect Google" }
291- } @else {
292- div class="text-[13px] text-ink-dim" { "Google sign-in is not configured on this server." }
293309 }
294310 }
295311 }
@@ -351,10 +367,11 @@ pub async fn password_submit(ctx: Ctx, headers: HeaderMap, RequireViewer(viewer)
351367 Ok(Redirect::to("/settings/security?saved=1").into_response())
352368 }
353369
354-pub async fn google_unlink(ctx: Ctx, viewer: RequireViewer) -> AppResult<Response> {
355- match account::unlink_google(&ctx.state, &viewer).await {
370+pub async fn provider_unlink(ctx: Ctx, viewer: RequireViewer, Path(provider): Path<String>) -> AppResult<Response> {
371+ let provider = Provider::from_slug(&provider).ok_or(AppError::NotFound)?;
372+ match account::unlink_provider(&ctx.state, &viewer, provider).await {
356373 Ok(()) => {
357- analytics::track(&ctx.state, "google_unlinked", Some(&viewer.0.name), "/settings/security", json!({}));
374+ analytics::track(&ctx.state, "oauth_unlinked", Some(&viewer.0.name), "/settings/security", json!({ "provider": provider.slug() }));
358375 Ok(Redirect::to("/settings/security?saved=1").into_response())
359376 }
360377 Err(AppError::BadRequest(m)) => Ok(Redirect::to(&format!("/settings/security?error={}", auth::urlencode(&m))).into_response()),
+9-0backend/src/web/ui.rs
@@ -169,6 +169,15 @@ pub fn pager(base: &str, page: i64, has_next: bool) -> Markup {
169169 }
170170 }
171171
172+/// GitHub's mark (Octicons, MIT), in the current text color.
173+pub fn icon_github(size: u32) -> Markup {
174+ html! {
175+ svg width=(size) height=(size) viewBox="0 0 16 16" class="shrink-0 fill-current" aria-hidden="true" {
176+ path d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z" {}
177+ }
178+ }
179+}
180+
172181 pub fn icon_folder() -> Markup {
173182 PreEscaped(r#"<svg width="16" height="16" viewBox="0 0 16 16" class="shrink-0 fill-accent" aria-hidden="true"><path d="M1.75 2A1.75 1.75 0 0 0 0 3.75v8.5C0 13.216.784 14 1.75 14h12.5A1.75 1.75 0 0 0 16 12.25v-7A1.75 1.75 0 0 0 14.25 3.5H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.76 5.55 1.5 5 1.5H1.75Z"/></svg>"#.into())
174183 }
+243-0cli/src/import.rs
@@ -0,0 +1,243 @@
1+//! `ig import github OWNER`: bring a GitHub user or organization over.
2+
3+use std::{
4+ collections::HashMap,
5+ io::{IsTerminal, Write},
6+ process::Command,
7+ time::Duration,
8+};
9+
10+use anyhow::bail;
11+use irongit_shared as shared;
12+
13+use crate::{
14+ cmds::Session,
15+ output::{self, bold, bytes, dim, green, table, yellow},
16+};
17+
18+pub struct ImportOptions {
19+ pub owner: String,
20+ pub token: Option<String>,
21+ pub to: Option<String>,
22+ pub repos: Vec<String>,
23+ pub include_forks: bool,
24+ pub visibility: String,
25+ pub profile: bool,
26+ pub link_emails: bool,
27+ pub yes: bool,
28+ pub no_wait: bool,
29+ pub public_only: bool,
30+}
31+
32+/// The GitHub token to use: --token, then GITHUB_TOKEN / GH_TOKEN, then the
33+/// GitHub CLI's login (`gh auth token`). None means public repositories only.
34+fn github_token(explicit: Option<String>, public_only: bool) -> (Option<String>, &'static str) {
35+ if public_only {
36+ return (None, "none (--public-only)");
37+ }
38+ if let Some(token) = explicit.filter(|t| !t.trim().is_empty()) {
39+ return (Some(token.trim().to_string()), "--token");
40+ }
41+ for var in ["GITHUB_TOKEN", "GH_TOKEN"] {
42+ if let Ok(token) = std::env::var(var) {
43+ if !token.trim().is_empty() {
44+ return (Some(token.trim().to_string()), if var == "GITHUB_TOKEN" { "$GITHUB_TOKEN" } else { "$GH_TOKEN" });
45+ }
46+ }
47+ }
48+ if let Ok(output) = Command::new("gh").args(["auth", "token"]).output() {
49+ let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
50+ if output.status.success() && !token.is_empty() {
51+ return (Some(token), "gh auth token");
52+ }
53+ }
54+ (None, "none")
55+}
56+
57+fn confirm(question: &str) -> anyhow::Result<bool> {
58+ if !std::io::stdin().is_terminal() {
59+ bail!("{question} Re-run with --yes to confirm without a prompt.");
60+ }
61+ print!("{question} [y/N] ");
62+ std::io::stdout().flush()?;
63+ let mut answer = String::new();
64+ std::io::stdin().read_line(&mut answer)?;
65+ Ok(matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
66+}
67+
68+pub fn github(s: &Session, options: ImportOptions) -> anyhow::Result<()> {
69+ let (token, token_source) = github_token(options.token.clone(), options.public_only);
70+ let preview: shared::GithubPreview = s.client.post(
71+ "/api/v1/imports/github/preview",
72+ &shared::GithubPreviewRequest { source_owner: options.owner.clone(), token: token.clone() },
73+ )?;
74+
75+ let wanted: Vec<String> = options.repos.iter().map(|r| r.to_lowercase()).collect();
76+ let selected: Vec<&shared::GithubRepo> = preview
77+ .repos
78+ .iter()
79+ .filter(|r| if wanted.is_empty() { options.include_forks || !r.fork } else { wanted.contains(&r.name.to_lowercase()) })
80+ .collect();
81+ let missing: Vec<&String> = options.repos.iter().filter(|w| !preview.repos.iter().any(|r| r.name.eq_ignore_ascii_case(w))).collect();
82+ if !missing.is_empty() {
83+ bail!(
84+ "not on github.com/{} (or not visible{}): {}",
85+ preview.account.login,
86+ if token.is_some() { " with this token" } else { " without a token" },
87+ missing.iter().map(|m| m.as_str()).collect::<Vec<_>>().join(", ")
88+ );
89+ }
90+
91+ if !s.json {
92+ let account = &preview.account;
93+ println!(
94+ "{} {}{}",
95+ bold(&format!("github.com/{}", account.login)),
96+ if account.kind == "Organization" { "organization" } else { "user" },
97+ if account.name.is_empty() { String::new() } else { format!(", {}", account.name) }
98+ );
99+ match &preview.token_owner {
100+ Some(owner) => println!("{}", dim(&format!("token: {owner} (from {token_source}); private repositories included"))),
101+ None => println!("{}", dim("no GitHub token: public repositories only (set GITHUB_TOKEN or run `gh auth login`)")),
102+ }
103+ let skipped_forks = preview.repos.iter().filter(|r| r.fork).count();
104+ let rows: Vec<Vec<String>> = selected
105+ .iter()
106+ .map(|r| {
107+ let visibility = match options.visibility.as_str() {
108+ "keep" => if r.private { "private" } else { "public" },
109+ other => other,
110+ };
111+ let mut notes = Vec::new();
112+ if r.fork {
113+ notes.push("fork");
114+ }
115+ if r.archived {
116+ notes.push("archived");
117+ }
118+ vec![r.name.clone(), visibility.to_string(), bytes(r.size_kb * 1024), notes.join(", ")]
119+ })
120+ .collect();
121+ println!();
122+ table(&["REPOSITORY", "VISIBILITY", "SIZE", ""], &rows);
123+ if wanted.is_empty() && !options.include_forks && skipped_forks > 0 {
124+ println!("{}", dim(&format!("{skipped_forks} fork(s) left out; add --include-forks to bring them too")));
125+ }
126+ println!();
127+ }
128+ if selected.is_empty() {
129+ bail!("nothing to import");
130+ }
131+
132+ let target = match &options.to {
133+ Some(to) => to.clone(),
134+ None => s.me()?,
135+ };
136+ if options.link_emails && !preview.token_is_owner {
137+ bail!("--link-emails needs a GitHub token that belongs to {}", preview.account.login);
138+ }
139+ if !options.yes && !confirm(&format!("Import {} repositories from github.com/{} into {target}?", selected.len(), preview.account.login))? {
140+ bail!("cancelled");
141+ }
142+
143+ let request = shared::StartImport {
144+ source_owner: preview.account.login.clone(),
145+ token,
146+ target_owner: Some(target.clone()),
147+ repos: selected.iter().map(|r| r.name.clone()).collect(),
148+ include_forks: true,
149+ visibility: Some(options.visibility.clone()),
150+ import_profile: options.profile,
151+ // Default on when the token is the account's own and we import into ourselves.
152+ link_emails: options.link_emails || (preview.token_is_owner && options.to.is_none()),
153+ };
154+ let import: shared::Import = s.client.post("/api/v1/imports", &request)?;
155+ if options.no_wait {
156+ if s.json {
157+ return output::json(&import);
158+ }
159+ println!("{} import #{} started: {}", green("✓"), import.id, import.web_url);
160+ return Ok(());
161+ }
162+ if !s.json {
163+ println!("{}", dim(&format!("following {} (Ctrl-C stops watching; the import keeps running)", import.web_url)));
164+ }
165+ follow(s, import.id)
166+}
167+
168+/// Polls an import and prints each repository as it settles.
169+pub fn follow(s: &Session, id: i64) -> anyhow::Result<()> {
170+ let mut reported: HashMap<String, String> = HashMap::new();
171+ let mut notes_shown = false;
172+ loop {
173+ let import: shared::Import = s.client.get(&format!("/api/v1/imports/{id}"))?;
174+ if !s.json {
175+ if !notes_shown && !import.notes.is_empty() {
176+ for line in import.notes.lines() {
177+ println!("{} {line}", dim("·"));
178+ }
179+ notes_shown = true;
180+ }
181+ for item in &import.items {
182+ if reported.get(&item.name) == Some(&item.status) {
183+ continue;
184+ }
185+ match item.status.as_str() {
186+ "running" => println!("{} {}", dim("…"), item.name),
187+ "done" => {
188+ let lfs = if item.lfs_objects > 0 { format!(", {} LFS objects", item.lfs_objects) } else { String::new() };
189+ println!("{} {}/{} ({} commits{lfs})", green("✓"), import.target_owner, item.target_name, item.commits);
190+ if let Some(note) = &item.error {
191+ println!(" {}", yellow(note));
192+ }
193+ }
194+ "failed" => println!("{} {}: {}", yellow("✗"), item.name, item.error.as_deref().unwrap_or("failed")),
195+ "skipped" => println!("{} {}: {}", dim("-"), item.name, item.error.as_deref().unwrap_or("skipped")),
196+ _ => {}
197+ }
198+ reported.insert(item.name.clone(), item.status.clone());
199+ }
200+ }
201+ if import.finished() {
202+ if s.json {
203+ return output::json(&import);
204+ }
205+ println!();
206+ println!("{} of {} imported{}", import.done, import.total, if import.failed > 0 { format!(", {} failed or skipped", import.failed) } else { String::new() });
207+ if let Some(error) = &import.error {
208+ println!("{}", yellow(error));
209+ }
210+ if import.status == "failed" {
211+ bail!("import failed");
212+ }
213+ return Ok(());
214+ }
215+ std::thread::sleep(Duration::from_secs(2));
216+ }
217+}
218+
219+pub fn list(s: &Session) -> anyhow::Result<()> {
220+ let imports: Vec<shared::Import> = s.client.get("/api/v1/imports")?;
221+ if s.json {
222+ return output::json(&imports);
223+ }
224+ if imports.is_empty() {
225+ println!("no imports yet; try: ig import github <user-or-org>");
226+ return Ok(());
227+ }
228+ let rows: Vec<Vec<String>> = imports
229+ .iter()
230+ .map(|i| {
231+ vec![
232+ i.id.to_string(),
233+ format!("github.com/{}", i.source_owner),
234+ i.target_owner.clone(),
235+ format!("{}/{}", i.done, i.total),
236+ i.status.clone(),
237+ output::ago(Some(i.created_at)),
238+ ]
239+ })
240+ .collect();
241+ table(&["ID", "FROM", "INTO", "DONE", "STATUS", "STARTED"], &rows);
242+ Ok(())
243+}
+61-0cli/src/main.rs
@@ -8,6 +8,7 @@ mod auth;
88 mod client;
99 mod cmds;
1010 mod config;
11+mod import;
1112 mod output;
1213 mod upgrade;
1314
@@ -66,6 +67,9 @@ enum Cmd {
6667 /// Organizations.
6768 #[command(subcommand)]
6869 Org(OrgCmd),
70+ /// Import repositories (and your profile) from GitHub.
71+ #[command(subcommand)]
72+ Import(ImportCmd),
6973 /// Call the API directly: ig api GET user
7074 Api {
7175 method: String,
@@ -85,6 +89,50 @@ enum Cmd {
8589 },
8690 }
8791
92+#[derive(Subcommand)]
93+enum ImportCmd {
94+ /// Import a GitHub user's or organization's repositories.
95+ Github {
96+ /// GitHub user or organization, e.g. octocat.
97+ owner: String,
98+ /// Only this repository (repeat for more). Default: every repo except forks.
99+ #[arg(long = "repo", value_name = "NAME")]
100+ repos: Vec<String>,
101+ /// irongit user or organization to import into (default: you).
102+ #[arg(long)]
103+ to: Option<String>,
104+ /// GitHub token (default: $GITHUB_TOKEN, $GH_TOKEN or `gh auth token`).
105+ #[arg(long, env = "IG_GITHUB_TOKEN", hide_env_values = true)]
106+ token: Option<String>,
107+ /// Ignore any GitHub token and import public repositories only.
108+ #[arg(long)]
109+ public_only: bool,
110+ /// Include forks when importing everything.
111+ #[arg(long)]
112+ include_forks: bool,
113+ /// keep (same as GitHub), private or public.
114+ #[arg(long, default_value = "keep", value_parser = ["keep", "private", "public"])]
115+ visibility: String,
116+ /// Copy name, bio, location, website and avatar where yours are empty.
117+ #[arg(long)]
118+ profile: bool,
119+ /// Link the GitHub account's verified emails so imported commits count on your heatmap
120+ /// (automatic when the token is that account's and you import into yourself).
121+ #[arg(long)]
122+ link_emails: bool,
123+ /// Do not ask for confirmation.
124+ #[arg(long, short = 'y')]
125+ yes: bool,
126+ /// Start the import and return without following it.
127+ #[arg(long)]
128+ no_wait: bool,
129+ },
130+ /// Your imports.
131+ List,
132+ /// Follow an import until it finishes.
133+ Watch { id: i64 },
134+}
135+
88136 #[derive(Subcommand)]
89137 enum AuthCmd {
90138 /// Show which hosts you are logged in to.
@@ -341,6 +389,19 @@ fn run(cli: Cli) -> anyhow::Result<()> {
341389 OrgCmd::Remove { org, user } => cmds::org_remove(&s, &org, &user),
342390 }
343391 }
392+ Cmd::Import(cmd) => {
393+ let s = session()?;
394+ match cmd {
395+ ImportCmd::Github { owner, repos, to, token, public_only, include_forks, visibility, profile, link_emails, yes, no_wait } => {
396+ import::github(
397+ &s,
398+ import::ImportOptions { owner, token, to, repos, include_forks, visibility, profile, link_emails, yes, no_wait, public_only },
399+ )
400+ }
401+ ImportCmd::List => import::list(&s),
402+ ImportCmd::Watch { id } => import::follow(&s, id),
403+ }
404+ }
344405 Cmd::Api { method, path, data } => cmds::api(&session()?, &method, &path, data.as_deref()),
345406 Cmd::Upgrade { force, check } => upgrade::upgrade(host, force, check),
346407 }
+95-0frontend/src/pages/docs/import.astro
@@ -0,0 +1,95 @@
1+---
2+import Layout from "../../layouts/Layout.astro";
3+
4+const sections = [
5+ ["web", "From the browser"],
6+ ["cli", "From the terminal"],
7+ ["what", "What comes over"],
8+ ["heatmap", "Your commit history"],
9+ ["private", "Private repositories and tokens"],
10+ ["limits", "Limits and skips"],
11+];
12+---
13+
14+<Layout
15+ title="Import from GitHub · irongit docs"
16+ description="Import a GitHub user or organization into irongit: repositories with every branch and tag, Git LFS files, profile and commit history."
17+>
18+ <div class="grid gap-6 md:grid-cols-[180px_1fr]">
19+ <nav class="text-[13px] md:sticky md:top-4 md:self-start">
20+ <a href="/docs" class="text-ink-dim">Docs</a>
21+ <p class="mt-2 mb-1 text-xs font-semibold text-ink-faint uppercase">On this page</p>
22+ <ul class="space-y-1">
23+ {sections.map(([id, label]) => <li><a href={`#${id}`} class="text-ink-dim hover:text-ink">{label}</a></li>)}
24+ </ul>
25+ </nav>
26+
27+ <article class="markdown max-w-[760px]">
28+ <h1>Import from GitHub</h1>
29+ <p>
30+ Bring a GitHub user or organization over in one go. The import runs on the server, one repository at a time,
31+ so you can close the page and come back to watch the progress.
32+ </p>
33+
34+ <h2 id="web">From the browser</h2>
35+ <ol>
36+ <li>Open <a href="/new/import">New → Import from GitHub</a>.</li>
37+ <li>
38+ Choose <strong>Connect GitHub</strong> to import your own account, private repositories included, or type any
39+ GitHub user or organization (optionally with a token).
40+ </li>
41+ <li>Tick the repositories you want (forks start unticked), pick where they go and their visibility, and start.</li>
42+ </ol>
43+
44+ <h2 id="cli">From the terminal</h2>
45+ <pre><code>ig import github octocat # every repo except forks
46+ig import github my-org --to my-irongit-org # into an organization
47+ig import github me --repo api --repo web # just these two
48+ig import github me --visibility private --profile
49+ig import list
50+ig import watch 12</code></pre>
51+ <p>
52+ <code>ig</code> uses <code>--token</code>, then <code>$GITHUB_TOKEN</code> or <code>$GH_TOKEN</code>, then your
53+ GitHub CLI login (<code>gh auth token</code>). Add <code>--public-only</code> to ignore all of them.
54+ </p>
55+
56+ <h2 id="what">What comes over</h2>
57+ <ul>
58+ <li>Every branch and tag, with full history. Pull request refs and GitHub-only data (issues, wikis, releases) do not.</li>
59+ <li>Git LFS files, copied from GitHub's LFS storage and checked against their checksums.</li>
60+ <li>Description, default branch and archived state. Visibility follows GitHub unless you choose all private or all public.</li>
61+ <li>
62+ With <em>Copy profile</em>: name, bio, location, website and avatar, but only where yours are empty. Anything
63+ already set on irongit is left alone.
64+ </li>
65+ </ul>
66+
67+ <h2 id="heatmap">Your commit history</h2>
68+ <p>
69+ Commits count on your heatmap when they reach a default branch and their author email is one of your verified
70+ emails. When you import your own account with your own GitHub token (or <strong>Connect GitHub</strong>),
71+ irongit adds your GitHub-verified emails, including the <code>users.noreply.github.com</code> address GitHub
72+ commits through its web editor use, so your history shows up right away.
73+ </p>
74+
75+ <h2 id="private">Private repositories and tokens</h2>
76+ <p>
77+ Without a token only public repositories are visible. A token or <strong>Connect GitHub</strong> adds private
78+ ones. GitHub's OAuth apps can only ask for full <code>repo</code> access to read private code; irongit uses that
79+ token for the import alone, keeps it in memory only and revokes it when the import ends. A personal access
80+ token you paste is never stored either.
81+ </p>
82+
83+ <h2 id="limits">Limits and skips</h2>
84+ <ul>
85+ <li>A repository whose name already exists in the target is skipped and left untouched, so re-running an import is safe.</li>
86+ <li>
87+ Repositories with files over the size limit anywhere in their history are refused, with the file names. Move
88+ those files to Git LFS on GitHub (<code>git lfs migrate import</code>) and import again.
89+ </li>
90+ <li>Storage quotas apply; a repository that would not fit is refused before anything is written.</li>
91+ <li>One import runs at a time per person. A server restart interrupts it; start it again and finished repositories are skipped.</li>
92+ </ul>
93+ </article>
94+ </div>
95+</Layout>
+5-0frontend/src/pages/docs/index.astro
@@ -12,6 +12,11 @@ const sections = [
1212 title: "Git: HTTPS, SSH and LFS",
1313 body: "Clone URLs, SSH keys, file size limits, storage quotas and storing large files with Git LFS.",
1414 },
15+ {
16+ href: "/docs/import",
17+ title: "Import from GitHub",
18+ body: "Bring a GitHub user or organization over: every branch and tag, Git LFS files, your profile and your commit history.",
19+ },
1520 {
1621 href: "/docs/registry",
1722 title: "Container registry",
+22-1frontend/src/scripts/app.ts
@@ -154,6 +154,11 @@ async function loadLazy(el: HTMLElement) {
154154 initConfirmInputs(el);
155155 el.querySelectorAll<HTMLElement>("[data-scroll-end]").forEach((s) => (s.scrollLeft = s.scrollWidth));
156156 el.querySelectorAll<HTMLElement>("[data-lazy]").forEach(loadLazy);
157+ // <div data-lazy=... data-poll="2000"> refreshes until the fragment
158+ // contains [data-poll-done] (live import progress).
159+ if (el.dataset.poll && !el.querySelector("[data-poll-done]")) {
160+ setTimeout(() => loadLazy(el), Number(el.dataset.poll) || 2000);
161+ }
157162 } catch (error) {
158163 el.innerHTML = `<div class="alert alert-error">Could not load this section (${(error as Error).message}). <button class="btn btn-sm" data-retry>Retry</button></div>`;
159164 el.querySelector("[data-retry]")?.addEventListener("click", () => loadLazy(el));
@@ -165,7 +170,22 @@ async function loadLazy(el: HTMLElement) {
165170 }
166171
167172 function initLazy() {
168- document.querySelectorAll<HTMLElement>("[data-lazy]").forEach(loadLazy);
173+ document.querySelectorAll<HTMLElement>("[data-lazy]").forEach((el) => {
174+ // Server-rendered content is already current; start polling after a beat.
175+ if (el.dataset.poll && el.querySelector("[data-poll-done]")) return;
176+ if (el.dataset.poll) setTimeout(() => loadLazy(el), Number(el.dataset.poll) || 2000);
177+ else loadLazy(el);
178+ });
179+}
180+
181+// <input type=checkbox data-check-all="repos"> toggles every name="repos" box.
182+function initCheckAll() {
183+ document.addEventListener("change", (event) => {
184+ const box = event.target as HTMLInputElement;
185+ const name = box?.dataset?.checkAll;
186+ if (!name) return;
187+ box.form?.querySelectorAll<HTMLInputElement>(`input[type=checkbox][name="${name}"]`).forEach((b) => (b.checked = box.checked));
188+ });
169189 }
170190
171191 // --- Sidebar collapse (admin) ----------------------------------------------
@@ -233,6 +253,7 @@ function init() {
233253 initSidebar();
234254 initConfirmInputs();
235255 initAutoSubmit();
256+ initCheckAll();
236257 }
237258
238259 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
+2-0shared/Cargo.toml
@@ -6,4 +6,6 @@ edition = "2024"
66 [dependencies]
77 chrono = { version = "0.4.45", features = ["serde"] }
88 serde = { version = "1.0.229", features = ["derive"] }
9+
10+[dev-dependencies]
911 serde_json = "1.0.151"
+118-0shared/src/lib.rs
@@ -284,6 +284,124 @@ pub struct Contributions {
284284 pub days: Vec<ContributionDay>,
285285 }
286286
287+// ---------------------------------------------------------------------------
288+// GitHub import
289+
290+/// `POST /api/v1/imports/github/preview`: what would be imported.
291+#[derive(Debug, Clone, Serialize, Deserialize)]
292+pub struct GithubPreviewRequest {
293+ /// GitHub user or organization login.
294+ pub source_owner: String,
295+ /// A GitHub token. Optional; without one only public repos are visible.
296+ #[serde(default, skip_serializing_if = "Option::is_none")]
297+ pub token: Option<String>,
298+}
299+
300+#[derive(Debug, Clone, Serialize, Deserialize)]
301+pub struct GithubAccount {
302+ pub login: String,
303+ /// "User" or "Organization".
304+ pub kind: String,
305+ pub name: String,
306+ pub bio: String,
307+ pub location: String,
308+ pub website: String,
309+ pub avatar_url: String,
310+}
311+
312+#[derive(Debug, Clone, Serialize, Deserialize)]
313+pub struct GithubRepo {
314+ pub name: String,
315+ pub description: String,
316+ pub private: bool,
317+ pub fork: bool,
318+ pub archived: bool,
319+ pub default_branch: String,
320+ /// GitHub's estimate of the repository size.
321+ pub size_kb: i64,
322+ pub pushed_at: Option<DateTime<Utc>>,
323+ pub html_url: String,
324+}
325+
326+#[derive(Debug, Clone, Serialize, Deserialize)]
327+pub struct GithubPreview {
328+ pub account: GithubAccount,
329+ /// Login the token belongs to, when one was given.
330+ pub token_owner: Option<String>,
331+ /// True when the token belongs to the account being imported, which
332+ /// allows linking its verified emails.
333+ pub token_is_owner: bool,
334+ pub repos: Vec<GithubRepo>,
335+ /// GitHub API calls left this hour for the credential used.
336+ pub rate_limit_remaining: Option<i64>,
337+}
338+
339+/// `POST /api/v1/imports`: start an import.
340+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
341+pub struct StartImport {
342+ pub source_owner: String,
343+ #[serde(default, skip_serializing_if = "Option::is_none")]
344+ pub token: Option<String>,
345+ /// irongit user or organization to import into (default: you).
346+ #[serde(default, skip_serializing_if = "Option::is_none")]
347+ pub target_owner: Option<String>,
348+ /// Repository names to import. Empty means every repo that is not a fork
349+ /// (or every repo, with `include_forks`).
350+ #[serde(default)]
351+ pub repos: Vec<String>,
352+ #[serde(default)]
353+ pub include_forks: bool,
354+ /// "keep" (GitHub's), "private" or "public".
355+ #[serde(default, skip_serializing_if = "Option::is_none")]
356+ pub visibility: Option<String>,
357+ /// Copy name, bio, location, website and avatar where irongit's are empty.
358+ #[serde(default)]
359+ pub import_profile: bool,
360+ /// Add the GitHub account's verified emails to your irongit account so
361+ /// imported commits count on your heatmap (token must belong to it).
362+ #[serde(default)]
363+ pub link_emails: bool,
364+}
365+
366+#[derive(Debug, Clone, Serialize, Deserialize)]
367+pub struct ImportItem {
368+ pub name: String,
369+ pub target_name: String,
370+ /// pending, running, done, failed or skipped.
371+ pub status: String,
372+ pub visibility: String,
373+ pub size_kb: i64,
374+ pub commits: i32,
375+ pub lfs_objects: i32,
376+ pub error: Option<String>,
377+ pub repo_url: Option<String>,
378+}
379+
380+#[derive(Debug, Clone, Serialize, Deserialize)]
381+pub struct Import {
382+ pub id: i64,
383+ pub source: String,
384+ pub source_owner: String,
385+ pub target_owner: String,
386+ /// pending, running, done, failed or cancelled.
387+ pub status: String,
388+ pub total: i32,
389+ pub done: i32,
390+ pub failed: i32,
391+ pub notes: String,
392+ pub error: Option<String>,
393+ pub web_url: String,
394+ pub created_at: DateTime<Utc>,
395+ pub finished_at: Option<DateTime<Utc>>,
396+ pub items: Vec<ImportItem>,
397+}
398+
399+impl Import {
400+ pub fn finished(&self) -> bool {
401+ matches!(self.status.as_str(), "done" | "failed" | "cancelled")
402+ }
403+}
404+
287405 /// Compares dotted version strings numerically ("0.10.0" > "0.9.3").
288406 /// Non-numeric parts compare as 0, so "1.2.3-beta" orders like "1.2.3".
289407 pub fn version_newer(candidate: &str, current: &str) -> bool {