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
26 files changed, +2783 -179
+13-1README.md
| @@ -66,11 +66,23 @@ ig repo clone you/api | ||
| 66 | 66 | docker push localhost:7878/you/api:1.0 |
| 67 | 67 | ig image visibility you/api public |
| 68 | 68 | 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) | |
| 69 | 70 | ig upgrade |
| 70 | 71 | ``` |
| 71 | 72 | |
| 72 | 73 | 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). | |
| 74 | 86 | |
| 75 | 87 | ## Tests |
| 76 | 88 |
+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 @@ | ||
| 6 | 6 | //! never see more than the web UI would show the same person. |
| 7 | 7 | |
| 8 | 8 | mod device; |
| 9 | +mod imports; | |
| 9 | 10 | mod keys; |
| 10 | 11 | mod orgs; |
| 11 | 12 | mod packages; |
| @@ -58,6 +59,10 @@ pub fn router() -> Router<AppState> { | ||
| 58 | 59 | .route("/api/v1/orgs", post(orgs::create)) |
| 59 | 60 | .route("/api/v1/orgs/{org}/members", get(orgs::members)) |
| 60 | 61 | .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)) | |
| 61 | 66 | .route("/api/v1/device/code", post(device::code)) |
| 62 | 67 | .route("/api/v1/device/token", post(device::token)) |
| 63 | 68 | .route("/login/device", get(device::page).post(device::submit)) |
+14-0backend/src/config.rs
| @@ -26,9 +26,17 @@ pub struct Config { | ||
| 26 | 26 | pub smtp: Option<SmtpConfig>, |
| 27 | 27 | pub rybbit: Option<RybbitConfig>, |
| 28 | 28 | pub google: Option<GoogleConfig>, |
| 29 | + pub github: Option<GithubConfig>, | |
| 29 | 30 | pub backup_interval_hours: u64, |
| 30 | 31 | } |
| 31 | 32 | |
| 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 | + | |
| 32 | 40 | /// Google sign-in (OpenID Connect). Redirect URI: {PUBLIC_URL}/login/google/callback |
| 33 | 41 | #[derive(Clone, Debug)] |
| 34 | 42 | pub struct GoogleConfig { |
| @@ -123,6 +131,11 @@ impl Config { | ||
| 123 | 131 | _ => None, |
| 124 | 132 | }; |
| 125 | 133 | |
| 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 | + | |
| 126 | 139 | Ok(Self { |
| 127 | 140 | host: var("HOST").unwrap_or_else(|| "127.0.0.1".into()), |
| 128 | 141 | port, |
| @@ -145,6 +158,7 @@ impl Config { | ||
| 145 | 158 | smtp, |
| 146 | 159 | rybbit, |
| 147 | 160 | google, |
| 161 | + github, | |
| 148 | 162 | backup_interval_hours: var("BACKUP_INTERVAL_HOURS").map(|v| v.parse()).transpose()?.unwrap_or(24), |
| 149 | 163 | }) |
| 150 | 164 | } |
+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, ¬es).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; | ||
| 5 | 5 | use crate::state::AppState; |
| 6 | 6 | |
| 7 | 7 | 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 }); | |
| 8 | 11 | crate::registry::spawn_gc(state.clone()); |
| 9 | 12 | crate::backup::spawn(state.clone()); |
| 10 | 13 | tokio::spawn(prune_sessions(state)); |
+1-0backend/src/main.rs
| @@ -19,6 +19,7 @@ mod frontend; | ||
| 19 | 19 | mod git; |
| 20 | 20 | mod git_http; |
| 21 | 21 | mod hook; |
| 22 | +mod importer; | |
| 22 | 23 | mod jobs; |
| 23 | 24 | mod lfs; |
| 24 | 25 | mod logging; |
+4-2backend/src/models.rs
| @@ -233,8 +233,10 @@ pub fn valid_repo_name(name: &str) -> Result<(), &'static str> { | ||
| 233 | 233 | if !name.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) { |
| 234 | 234 | return Err("Repository names may only contain letters, digits, '.', '_' and '-'."); |
| 235 | 235 | } |
| 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'."); | |
| 238 | 240 | } |
| 239 | 241 | Ok(()) |
| 240 | 242 | } |
+1-1backend/src/push.rs
| @@ -131,7 +131,7 @@ pub async fn after_push(state: &AppState, repo: &Repo, pusher: Option<&Viewer>, | ||
| 131 | 131 | |
| 132 | 132 | /// Credits commits that landed on the default branch to users who verified |
| 133 | 133 | /// 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<()> { | |
| 135 | 135 | let commits = git.new_commits(old, new, MAX_CONTRIBUTION_COMMITS).await?; |
| 136 | 136 | if commits.is_empty() { |
| 137 | 137 | return Ok(()); |
+418-130backend/src/web/account.rs
| @@ -6,6 +6,7 @@ use std::{ | ||
| 6 | 6 | time::{Duration, Instant}, |
| 7 | 7 | }; |
| 8 | 8 | |
| 9 | +use anyhow::Context; | |
| 9 | 10 | use axum::{ |
| 10 | 11 | Form, Router, |
| 11 | 12 | extract::{Query, State}, |
| @@ -40,7 +41,9 @@ pub fn router() -> Router<AppState> { | ||
| 40 | 41 | .route("/logout", axum::routing::post(logout)) |
| 41 | 42 | .route("/login/google", get(google_start)) |
| 42 | 43 | .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)) | |
| 44 | 47 | .route("/settings/emails/verify", get(verify_email)) |
| 45 | 48 | } |
| 46 | 49 | |
| @@ -82,17 +85,30 @@ fn auth_card(title: &str, body: Markup) -> Markup { | ||
| 82 | 85 | } |
| 83 | 86 | } |
| 84 | 87 | |
| 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(); | |
| 86 | 92 | 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 | + } | |
| 94 | 111 | } |
| 95 | - (label) | |
| 96 | 112 | } |
| 97 | 113 | div class="my-3 flex items-center gap-2 text-xs text-ink-faint" { |
| 98 | 114 | div class="h-px flex-1 bg-edge" {} |
| @@ -123,8 +139,10 @@ pub async fn login_page(ctx: Ctx, Query(query): Query<NextQuery>) -> Response { | ||
| 123 | 139 | } |
| 124 | 140 | let error = query.error.as_deref().map(|code| match code { |
| 125 | 141 | "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.", | |
| 127 | 144 | "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.", | |
| 128 | 146 | "email_in_use" => "An account already uses that email. Sign in with your password, then connect Google in Settings.", |
| 129 | 147 | "closed" => "Registration is closed on this site.", |
| 130 | 148 | "suspended" => "This account is suspended.", |
| @@ -140,7 +158,7 @@ fn login_view(ctx: &Ctx, next: &str, login: &str, error: Option<&str>) -> Page { | ||
| 140 | 158 | "Sign in to irongit", |
| 141 | 159 | html! { |
| 142 | 160 | (ui::alert_error(error)) |
| 143 | - (google_button(&ctx.state, next, "Continue with Google")) | |
| 161 | + (provider_buttons(&ctx.state, next, "Continue")) | |
| 144 | 162 | form method="post" action="/login" class="box space-y-3 p-4" data-track-submit="login_submitted" { |
| 145 | 163 | input type="hidden" name="next" value=(next); |
| 146 | 164 | div { |
| @@ -244,7 +262,7 @@ fn register_view(ctx: &Ctx, next: &str, form: &RegisterForm, error: Option<&str> | ||
| 244 | 262 | } else { |
| 245 | 263 | html! { |
| 246 | 264 | (ui::alert_error(error)) |
| 247 | - (google_button(&ctx.state, next, "Sign up with Google")) | |
| 265 | + (provider_buttons(&ctx.state, next, "Sign up")) | |
| 248 | 266 | form method="post" action="/register" class="box space-y-3 p-4" data-track-submit="register_submitted" { |
| 249 | 267 | input type="hidden" name="next" value=(next); |
| 250 | 268 | div { |
| @@ -333,31 +351,90 @@ pub async fn verify_email(ctx: Ctx, Query(query): Query<VerifyQuery>) -> AppResu | ||
| 333 | 351 | } |
| 334 | 352 | |
| 335 | 353 | // --------------------------------------------------------------------------- |
| 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`. | |
| 337 | 357 | |
| 338 | 358 | 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 | +} | |
| 340 | 398 | |
| 341 | 399 | #[derive(Serialize, Deserialize)] |
| 342 | 400 | struct OAuthState { |
| 401 | + provider: Provider, | |
| 343 | 402 | state: String, |
| 344 | 403 | verifier: String, |
| 345 | 404 | 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. | |
| 347 | 407 | 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, | |
| 348 | 423 | } |
| 349 | 424 | |
| 350 | 425 | #[derive(Serialize, Deserialize, Clone)] |
| 351 | 426 | struct PendingSignup { |
| 427 | + provider: Provider, | |
| 352 | 428 | sub: String, |
| 353 | 429 | email: String, |
| 354 | 430 | name: String, |
| 355 | 431 | picture: Option<String>, |
| 432 | + suggestion: String, | |
| 356 | 433 | next: String, |
| 357 | 434 | } |
| 358 | 435 | |
| 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()) | |
| 361 | 438 | } |
| 362 | 439 | |
| 363 | 440 | 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 | ||
| 371 | 448 | } |
| 372 | 449 | |
| 373 | 450 | #[derive(Deserialize)] |
| 374 | -pub struct GoogleStart { | |
| 451 | +pub struct OAuthStart { | |
| 375 | 452 | next: Option<String>, |
| 376 | 453 | 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 | |
| 377 | 464 | } |
| 378 | 465 | |
| 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) { | |
| 381 | 468 | 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 }; | |
| 384 | 472 | let verifier = auth::random_token(48); |
| 385 | 473 | let challenge = { |
| 386 | 474 | use base64::Engine; |
| 387 | 475 | use sha2::Digest; |
| 388 | 476 | base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sha2::Sha256::digest(verifier.as_bytes())) |
| 389 | 477 | }; |
| 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 | + ); | |
| 403 | 521 | Ok((jar.add(short_cookie(OAUTH_COOKIE, cookie, &ctx.state, 10)), Redirect::to(url.as_str())).into_response()) |
| 404 | 522 | } |
| 405 | 523 | |
| 406 | 524 | #[derive(Deserialize)] |
| 407 | -pub struct GoogleCallback { | |
| 525 | +pub struct OAuthCallback { | |
| 408 | 526 | code: Option<String>, |
| 409 | 527 | state: Option<String>, |
| 410 | 528 | error: Option<String>, |
| 411 | 529 | } |
| 412 | 530 | |
| 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 | |
| 416 | 533 | } |
| 417 | 534 | |
| 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 | |
| 425 | 537 | } |
| 426 | 538 | |
| 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(); | |
| 429 | 544 | 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())); | |
| 431 | 546 | 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()); | |
| 435 | 550 | }; |
| 436 | 551 | 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()); | |
| 439 | 555 | } |
| 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()) }; | |
| 441 | 557 | |
| 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, | |
| 444 | 564 | 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()); | |
| 447 | 567 | } |
| 448 | 568 | }; |
| 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> { | |
| 450 | 591 | 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); | |
| 451 | 595 | |
| 452 | - // Already linked: sign in. | |
| 453 | 596 | let linked: Option<(i64, String, bool)> = sqlx::query_as( |
| 454 | 597 | "select a.id, a.name::text, u.suspended_at is not null from oauth_identities o |
| 455 | 598 | 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", | |
| 457 | 600 | ) |
| 458 | - .bind(&info.sub) | |
| 601 | + .bind(slug) | |
| 602 | + .bind(&profile.subject) | |
| 459 | 603 | .fetch_optional(db) |
| 460 | 604 | .await?; |
| 461 | 605 | |
| @@ -466,31 +610,39 @@ pub async fn google_callback(ctx: Ctx, headers: HeaderMap, jar: CookieJar, Query | ||
| 466 | 610 | } |
| 467 | 611 | if let Some((owner, _, _)) = linked { |
| 468 | 612 | 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()); | |
| 470 | 614 | } |
| 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()); | |
| 481 | 629 | } |
| 482 | 630 | |
| 483 | 631 | if let Some((user_id, username, suspended)) = linked { |
| 484 | 632 | if suspended { |
| 485 | 633 | return Ok((jar, fail("suspended")).into_response()); |
| 486 | 634 | } |
| 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; | |
| 490 | 642 | } |
| 491 | 643 | |
| 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()); | |
| 494 | 646 | }; |
| 495 | 647 | |
| 496 | 648 | // 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 | ||
| 508 | 660 | if suspended { |
| 509 | 661 | return Ok((jar, fail("suspended")).into_response()); |
| 510 | 662 | } |
| 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) | |
| 513 | 666 | .bind(user_id) |
| 514 | 667 | .bind(&email) |
| 515 | 668 | .execute(db) |
| 516 | 669 | .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; | |
| 520 | 673 | } |
| 521 | 674 | |
| 522 | 675 | if !ctx.state.config.registration_open { |
| 523 | 676 | return Ok((jar, fail("closed")).into_response()); |
| 524 | 677 | } |
| 525 | 678 | // 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>, | |
| 529 | 706 | } |
| 530 | 707 | |
| 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")?; | |
| 532 | 710 | let token: TokenResponse = state |
| 533 | 711 | .http |
| 534 | 712 | .post("https://oauth2.googleapis.com/token") |
| @@ -536,7 +714,7 @@ async fn exchange_code(state: &AppState, google: &crate::config::GoogleConfig, c | ||
| 536 | 714 | ("code", code), |
| 537 | 715 | ("client_id", google.client_id.as_str()), |
| 538 | 716 | ("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()), | |
| 540 | 718 | ("grant_type", "authorization_code"), |
| 541 | 719 | ("code_verifier", verifier), |
| 542 | 720 | ]) |
| @@ -545,37 +723,140 @@ async fn exchange_code(state: &AppState, google: &crate::config::GoogleConfig, c | ||
| 545 | 723 | .error_for_status()? |
| 546 | 724 | .json() |
| 547 | 725 | .await?; |
| 726 | + let access_token = token.access_token.context("google returned no access token")?; | |
| 548 | 727 | // Fetched from Google over TLS with the fresh access token, so the |
| 549 | 728 | // claims need no separate id_token signature check. |
| 550 | - Ok(state | |
| 729 | + let info: GoogleUserInfo = state | |
| 551 | 730 | .http |
| 552 | 731 | .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 | + ]) | |
| 554 | 779 | .send() |
| 555 | 780 | .await? |
| 556 | 781 | .error_for_status()? |
| 557 | 782 | .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 | + } | |
| 559 | 840 | } |
| 560 | 841 | |
| 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> { | |
| 562 | 843 | let ip = auth::client_ip(headers); |
| 563 | 844 | 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"); | |
| 567 | 848 | Ok((set_session(jar, state, token), Redirect::to(next)).into_response()) |
| 568 | 849 | } |
| 569 | 850 | |
| 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>) { | |
| 574 | 855 | let Some(picture) = picture.filter(|p| !p.is_empty()) else { return }; |
| 575 | 856 | 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 { |
| 576 | 857 | Ok(has) => has, |
| 577 | 858 | 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"); | |
| 579 | 860 | return; |
| 580 | 861 | } |
| 581 | 862 | }; |
| @@ -584,20 +865,20 @@ async fn adopt_google_picture(state: &AppState, user_id: i64, picture: Option<&s | ||
| 584 | 865 | } |
| 585 | 866 | match tokio::time::timeout(std::time::Duration::from_secs(5), avatars::import_from_url(state, user_id, picture)).await { |
| 586 | 867 | 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!({})); | |
| 589 | 870 | } |
| 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"), | |
| 592 | 873 | } |
| 593 | 874 | } |
| 594 | 875 | |
| 595 | 876 | 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())) | |
| 597 | 878 | } |
| 598 | 879 | |
| 599 | 880 | 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); | |
| 601 | 882 | let mut out: String = base |
| 602 | 883 | .to_ascii_lowercase() |
| 603 | 884 | .chars() |
| @@ -621,11 +902,11 @@ fn finish_view(pending: &PendingSignup, username: &str, error: Option<&str>) -> | ||
| 621 | 902 | img src=(picture) width="40" height="40" class="rounded-[4px]" alt="" referrerpolicy="no-referrer"; |
| 622 | 903 | } |
| 623 | 904 | 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()) } | |
| 626 | 907 | } |
| 627 | 908 | } |
| 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" { | |
| 629 | 910 | div { |
| 630 | 911 | label class="label" for="username" { "Pick a username" } |
| 631 | 912 | 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>) -> | ||
| 639 | 920 | .noindex() |
| 640 | 921 | } |
| 641 | 922 | |
| 642 | -pub async fn google_finish_page(ctx: Ctx, jar: CookieJar) -> Response { | |
| 923 | +pub async fn finish_page(ctx: Ctx, jar: CookieJar) -> Response { | |
| 643 | 924 | match pending_signup(&ctx.state, &jar) { |
| 644 | 925 | Some(pending) => { |
| 645 | - let suggestion = suggest_username(&pending.email, &pending.name); | |
| 926 | + let suggestion = pending.suggestion.clone(); | |
| 646 | 927 | ctx.render(finish_view(&pending, &suggestion, None)) |
| 647 | 928 | } |
| 648 | - None => Redirect::to("/login?error=google_state").into_response(), | |
| 929 | + None => Redirect::to("/login?error=oauth_state").into_response(), | |
| 649 | 930 | } |
| 650 | 931 | } |
| 651 | 932 | |
| @@ -654,9 +935,9 @@ pub struct FinishForm { | ||
| 654 | 935 | username: String, |
| 655 | 936 | } |
| 656 | 937 | |
| 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> { | |
| 658 | 939 | 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()); | |
| 660 | 941 | }; |
| 661 | 942 | let created = ops::register_user( |
| 662 | 943 | &ctx.state, |
| @@ -670,31 +951,38 @@ pub async fn google_finish_submit(ctx: Ctx, headers: HeaderMap, jar: CookieJar, | ||
| 670 | 951 | } |
| 671 | 952 | Err(other) => return Err(other), |
| 672 | 953 | }; |
| 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()) | |
| 674 | 956 | .bind(&pending.sub) |
| 675 | 957 | .bind(user_id) |
| 676 | 958 | .bind(&pending.email) |
| 677 | 959 | .execute(&ctx.state.db) |
| 678 | 960 | .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; | |
| 681 | 963 | let username = form.username.trim().to_ascii_lowercase(); |
| 682 | 964 | let jar = jar.remove(Cookie::build(SIGNUP_COOKIE).path("/").build()); |
| 683 | 965 | 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 | |
| 685 | 967 | } |
| 686 | 968 | |
| 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()))); | |
| 695 | 983 | } |
| 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; | |
| 698 | 986 | Ok(()) |
| 699 | 987 | } |
| 700 | 988 |
+15-7backend/src/web/avatars.rs
| @@ -115,15 +115,23 @@ pub async fn remove(state: &AppState, account_id: i64) -> crate::error::AppResul | ||
| 115 | 115 | Ok(()) |
| 116 | 116 | } |
| 117 | 117 | |
| 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. | |
| 119 | 120 | 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)?; | |
| 121 | 122 | 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}"); | |
| 127 | 135 | }; |
| 128 | 136 | let bytes = state.http.get(url).send().await?.error_for_status()?.bytes().await?; |
| 129 | 137 | 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> { | ||
| 88 | 88 | li { "Sign in from your terminal: " code class="text-ink" { "ig login" } } |
| 89 | 89 | li { "Create a repo and push: " code class="text-ink" { "ig repo create hello --private" } } |
| 90 | 90 | 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" } } | |
| 91 | 92 | } |
| 92 | 93 | } |
| 93 | 94 | } |
| @@ -268,7 +269,10 @@ fn new_repo_view(owners: &[(String, Option<String>)], form: &NewRepoForm, error: | ||
| 268 | 269 | let body = html! { |
| 269 | 270 | div class="mx-auto max-w-[640px] px-4 py-6" { |
| 270 | 271 | 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 | + } | |
| 272 | 276 | (ui::alert_error(error)) |
| 273 | 277 | form method="post" action="/new" class="space-y-4" data-track-submit="repo_create_submitted" { |
| 274 | 278 | 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; | ||
| 7 | 7 | pub mod heatmap; |
| 8 | 8 | pub mod highlight; |
| 9 | 9 | pub mod home; |
| 10 | +pub mod import; | |
| 10 | 11 | pub mod layout; |
| 11 | 12 | pub mod markdown; |
| 12 | 13 | pub mod orgs; |
| @@ -36,6 +37,7 @@ pub fn router() -> Router<AppState> { | ||
| 36 | 37 | .merge(settings::router()) |
| 37 | 38 | .merge(orgs::router()) |
| 38 | 39 | .merge(repo::router()) |
| 40 | + .merge(import::router()) | |
| 39 | 41 | .merge(admin::router()) |
| 40 | 42 | } |
| 41 | 43 |
+9-4backend/src/web/profile.rs
| @@ -136,14 +136,16 @@ pub struct Activity { | ||
| 136 | 136 | pub after_sha: String, |
| 137 | 137 | pub created_at: DateTime<Utc>, |
| 138 | 138 | pub pusher: Option<(String, Option<String>)>, |
| 139 | + pub via: String, | |
| 139 | 140 | } |
| 140 | 141 | |
| 141 | 142 | /// Push events visible to the viewer. `pusher` filters to one person. |
| 142 | 143 | pub async fn activity(ctx: &Ctx, pusher: Option<i64>, involved: Option<i64>, limit: i64) -> AppResult<Vec<Activity>> { |
| 143 | 144 | 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!( | |
| 145 | 147 | "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 | |
| 147 | 149 | from push_events e join repos r on r.id = e.repo_id join accounts a on a.id = r.owner_id |
| 148 | 150 | left join accounts pa on pa.id = e.pusher_id |
| 149 | 151 | 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 | ||
| 163 | 165 | .await?; |
| 164 | 166 | Ok(rows |
| 165 | 167 | .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 { | |
| 167 | 169 | owner_name, |
| 168 | 170 | repo_name, |
| 169 | 171 | ref_name, |
| @@ -173,6 +175,7 @@ pub async fn activity(ctx: &Ctx, pusher: Option<i64>, involved: Option<i64>, lim | ||
| 173 | 175 | after_sha, |
| 174 | 176 | created_at, |
| 175 | 177 | pusher: pusher.map(|p| (p, avatar)), |
| 178 | + via, | |
| 176 | 179 | }) |
| 177 | 180 | .collect()) |
| 178 | 181 | } |
| @@ -194,7 +197,9 @@ pub fn activity_list(items: &[Activity], show_pusher: bool) -> Markup { | ||
| 194 | 197 | div class="min-w-0 flex-1" { |
| 195 | 198 | div { |
| 196 | 199 | @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 { | |
| 198 | 203 | "deleted " @if is_tag { "tag " } @else { "branch " } code class="text-xs" { (short_ref) } " in " |
| 199 | 204 | } @else if item.before_sha == ZERO_SHA { |
| 200 | 205 | @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), | |
| 2 | 2 | //! emails, SSH keys, access tokens and organizations. |
| 3 | 3 | |
| 4 | 4 | use axum::{ |
| @@ -21,7 +21,8 @@ use crate::{ | ||
| 21 | 21 | ops, sshkeys, |
| 22 | 22 | state::AppState, |
| 23 | 23 | web::{ |
| 24 | - account, avatars, | |
| 24 | + account::{self, Provider}, | |
| 25 | + avatars, | |
| 25 | 26 | layout::{Ctx, Page}, |
| 26 | 27 | ui, |
| 27 | 28 | }, |
| @@ -35,7 +36,7 @@ pub fn router() -> Router<AppState> { | ||
| 35 | 36 | .route("/settings/avatar/remove", post(avatar_remove)) |
| 36 | 37 | .route("/settings/security", get(security_page)) |
| 37 | 38 | .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)) | |
| 39 | 40 | .route("/settings/security/sessions/revoke", post(sessions_revoke)) |
| 40 | 41 | .route("/settings/emails", get(emails_page).post(email_add)) |
| 41 | 42 | .route("/settings/emails/{id}/{action}", post(email_action)) |
| @@ -90,6 +91,7 @@ pub struct Notice { | ||
| 90 | 91 | saved: Option<String>, |
| 91 | 92 | error: Option<String>, |
| 92 | 93 | google: Option<String>, |
| 94 | + github: Option<String>, | |
| 93 | 95 | } |
| 94 | 96 | |
| 95 | 97 | // --------------------------------------------------------------------------- |
| @@ -230,10 +232,10 @@ pub async fn avatar_remove(ctx: Ctx, RequireViewer(viewer): RequireViewer) -> Ap | ||
| 230 | 232 | pub async fn security_page(ctx: Ctx, headers: HeaderMap, RequireViewer(viewer): RequireViewer, Query(notice): Query<Notice>) -> AppResult<Response> { |
| 231 | 233 | let db = &ctx.state.db; |
| 232 | 234 | 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") | |
| 235 | 237 | .bind(viewer.id) |
| 236 | - .fetch_optional(db) | |
| 238 | + .fetch_all(db) | |
| 237 | 239 | .await?; |
| 238 | 240 | let sessions: Vec<(String, Option<String>, Option<String>, DateTime<Utc>, DateTime<Utc>)> = sqlx::query_as( |
| 239 | 241 | "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): | ||
| 242 | 244 | .fetch_all(db) |
| 243 | 245 | .await?; |
| 244 | 246 | 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); | |
| 251 | 260 | |
| 252 | 261 | let body = html! { |
| 253 | 262 | (ui::alert_ok(notice.saved.as_deref().map(|_| "Saved."))) |
| 254 | 263 | (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))) } | |
| 257 | 266 | } |
| 258 | 267 | section class="mb-6" { |
| 259 | 268 | h2 class="mb-2 font-semibold" { @if has_password { "Change password" } @else { "Set a password" } } |
| 260 | 269 | @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." } | |
| 262 | 271 | } |
| 263 | 272 | form method="post" action="/settings/security/password" class="max-w-[420px] space-y-3" data-track-submit="password_changed" { |
| 264 | 273 | @if has_password { |
| @@ -275,21 +284,28 @@ pub async fn security_page(ctx: Ctx, headers: HeaderMap, RequireViewer(viewer): | ||
| 275 | 284 | } |
| 276 | 285 | } |
| 277 | 286 | 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 | + } | |
| 287 | 308 | } |
| 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." } | |
| 293 | 309 | } |
| 294 | 310 | } |
| 295 | 311 | } |
| @@ -351,10 +367,11 @@ pub async fn password_submit(ctx: Ctx, headers: HeaderMap, RequireViewer(viewer) | ||
| 351 | 367 | Ok(Redirect::to("/settings/security?saved=1").into_response()) |
| 352 | 368 | } |
| 353 | 369 | |
| 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 { | |
| 356 | 373 | 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() })); | |
| 358 | 375 | Ok(Redirect::to("/settings/security?saved=1").into_response()) |
| 359 | 376 | } |
| 360 | 377 | 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 { | ||
| 169 | 169 | } |
| 170 | 170 | } |
| 171 | 171 | |
| 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 | + | |
| 172 | 181 | pub fn icon_folder() -> Markup { |
| 173 | 182 | 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()) |
| 174 | 183 | } |
+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; | ||
| 8 | 8 | mod client; |
| 9 | 9 | mod cmds; |
| 10 | 10 | mod config; |
| 11 | +mod import; | |
| 11 | 12 | mod output; |
| 12 | 13 | mod upgrade; |
| 13 | 14 | |
| @@ -66,6 +67,9 @@ enum Cmd { | ||
| 66 | 67 | /// Organizations. |
| 67 | 68 | #[command(subcommand)] |
| 68 | 69 | Org(OrgCmd), |
| 70 | + /// Import repositories (and your profile) from GitHub. | |
| 71 | + #[command(subcommand)] | |
| 72 | + Import(ImportCmd), | |
| 69 | 73 | /// Call the API directly: ig api GET user |
| 70 | 74 | Api { |
| 71 | 75 | method: String, |
| @@ -85,6 +89,50 @@ enum Cmd { | ||
| 85 | 89 | }, |
| 86 | 90 | } |
| 87 | 91 | |
| 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 | + | |
| 88 | 136 | #[derive(Subcommand)] |
| 89 | 137 | enum AuthCmd { |
| 90 | 138 | /// Show which hosts you are logged in to. |
| @@ -341,6 +389,19 @@ fn run(cli: Cli) -> anyhow::Result<()> { | ||
| 341 | 389 | OrgCmd::Remove { org, user } => cmds::org_remove(&s, &org, &user), |
| 342 | 390 | } |
| 343 | 391 | } |
| 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 | + } | |
| 344 | 405 | Cmd::Api { method, path, data } => cmds::api(&session()?, &method, &path, data.as_deref()), |
| 345 | 406 | Cmd::Upgrade { force, check } => upgrade::upgrade(host, force, check), |
| 346 | 407 | } |
+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 = [ | ||
| 12 | 12 | title: "Git: HTTPS, SSH and LFS", |
| 13 | 13 | body: "Clone URLs, SSH keys, file size limits, storage quotas and storing large files with Git LFS.", |
| 14 | 14 | }, |
| 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 | + }, | |
| 15 | 20 | { |
| 16 | 21 | href: "/docs/registry", |
| 17 | 22 | title: "Container registry", |
+22-1frontend/src/scripts/app.ts
| @@ -154,6 +154,11 @@ async function loadLazy(el: HTMLElement) { | ||
| 154 | 154 | initConfirmInputs(el); |
| 155 | 155 | el.querySelectorAll<HTMLElement>("[data-scroll-end]").forEach((s) => (s.scrollLeft = s.scrollWidth)); |
| 156 | 156 | 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 | + } | |
| 157 | 162 | } catch (error) { |
| 158 | 163 | 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>`; |
| 159 | 164 | el.querySelector("[data-retry]")?.addEventListener("click", () => loadLazy(el)); |
| @@ -165,7 +170,22 @@ async function loadLazy(el: HTMLElement) { | ||
| 165 | 170 | } |
| 166 | 171 | |
| 167 | 172 | 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 | + }); | |
| 169 | 189 | } |
| 170 | 190 | |
| 171 | 191 | // --- Sidebar collapse (admin) ---------------------------------------------- |
| @@ -233,6 +253,7 @@ function init() { | ||
| 233 | 253 | initSidebar(); |
| 234 | 254 | initConfirmInputs(); |
| 235 | 255 | initAutoSubmit(); |
| 256 | + initCheckAll(); | |
| 236 | 257 | } |
| 237 | 258 | |
| 238 | 259 | if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); |