Skip to main content

flams_git/gl/
auth.rs

1use super::GitLab;
2use flams_utils::unwrap;
3use ftml_uris::ArchiveId;
4use gitlab::api::AsyncQuery;
5pub use oauth2::AccessToken;
6use oauth2::{url::Url, TokenResponse};
7use tracing::{instrument, Instrument};
8
9#[derive(Debug, Clone)]
10pub struct GitLabOAuth(
11    oauth2::basic::BasicClient<
12        oauth2::EndpointSet,
13        oauth2::EndpointNotSet,
14        oauth2::EndpointNotSet,
15        oauth2::EndpointNotSet,
16        oauth2::EndpointSet,
17    >,
18    GitLab,
19);
20
21#[derive(Debug, Clone, serde::Deserialize)]
22pub struct AuthRequest {
23    pub code: String,
24    //state: String
25}
26
27impl GitLabOAuth {
28    pub fn login_url(&self) -> Url {
29        let (url, _) = self
30            .0
31            .authorize_url(oauth2::CsrfToken::new_random)
32            .add_scope(oauth2::Scope::new("read_user".to_string()))
33            .add_scope(oauth2::Scope::new("api".to_string()))
34            .url();
35        url
36    }
37    /// #### Errors
38    pub async fn callback(
39        &self,
40        request: AuthRequest,
41    ) -> Result<AccessToken, impl std::error::Error> {
42        use oauth2::AuthorizationCode;
43        let code = AuthorizationCode::new(request.code);
44        let req = unwrap!(oauth2::reqwest::ClientBuilder::new().build().ok());
45        self.0
46            .exchange_code(code)
47            .request_async(&req)
48            .instrument(crate::REMOTE_SPAN.clone())
49            .await
50            .map(|token| token.access_token().clone())
51    }
52
53    /// #### Errors
54    pub async fn get_projects(
55        &self,
56        token: String,
57    ) -> Result<Vec<flams_backend_types::git::Project>, super::Err> {
58        self.get_projects_i(token)
59            .instrument(crate::REMOTE_SPAN.clone())
60            .await
61    }
62    #[instrument(
63        level = "debug",
64        target = "git",
65        name = "getting all projects for user",
66        skip_all
67    )]
68    async fn get_projects_i(
69        &self,
70        token: String,
71    ) -> Result<Vec<flams_backend_types::git::Project>, super::Err> {
72        let mut client = gitlab::ImpersonationClient::new(&self.1 .0.inner, token);
73        client.oauth2_token();
74        let r = gitlab::api::projects::Projects::builder()
75            .simple(true)
76            .min_access_level(gitlab::api::common::AccessLevel::Developer)
77            //.membership(false)
78            .build()
79            .unwrap_or_else(|_| unreachable!());
80        let r: Vec<flams_backend_types::git::Project> =
81            gitlab::api::paged(r, gitlab::api::Pagination::All)
82                .query_async(&client)
83                .await?;
84        let mut vs = self.1 .0.projects.lock();
85        vs.clear();
86        for p in &r {
87            if !vs.contains(&p.id) {
88                vs.insert(super::ProjectWithId {
89                    project: p.clone(),
90                    id: None,
91                });
92            }
93        }
94        drop(vs);
95        Ok(r)
96    }
97
98    /// #### Errors
99    pub async fn get_archive_id(
100        &self,
101        id: u64,
102        token: String,
103        branch: &str,
104    ) -> Result<Option<ArchiveId>, super::Err> {
105        self.get_archive_id_i(id, token, branch)
106            .instrument(crate::REMOTE_SPAN.clone())
107            .await
108    }
109
110    /// # Errors
111    #[instrument(
112        level = "debug",
113        target = "git",
114        name = "getting archive id",
115        skip(self, token)
116    )]
117    pub async fn get_archive_id_i(
118        &self,
119        id: u64,
120        token: String,
121        branch: &str,
122    ) -> Result<Option<ArchiveId>, super::Err> {
123        {
124            let vs = self.1 .0.projects.lock();
125            if let Some(super::ProjectWithId { id: Some(id), .. }) = vs.get(&id) {
126                return Ok(id.clone());
127            }
128        }
129
130        macro_rules! ret {
131            ($v:expr) => {{
132                tracing::info!("Found {:?}", $v);
133                let mut lock = self.1 .0.projects.lock();
134                if let Some(mut v) = lock.take(&id) {
135                    v.id = Some($v.clone());
136                    lock.insert(v);
137                }
138                drop(lock);
139                return Ok($v);
140            }};
141        }
142        let mut client = gitlab::ImpersonationClient::new(&self.1 .0.inner, token);
143        client.oauth2_token();
144        let r = gitlab::api::projects::repository::TreeBuilder::default()
145            .project(id)
146            .ref_(branch)
147            .recursive(false)
148            .build()
149            .unwrap_or_else(|_| unreachable!());
150        let r: Vec<flams_backend_types::git::TreeEntry> = r.query_async(&client).await?;
151        let Some(p) = r.into_iter().find_map(|e| {
152            if e.path.eq_ignore_ascii_case("meta-inf")
153                && matches!(e.tp, flams_backend_types::git::DirOrFile::Dir)
154            {
155                Some(e.path)
156            } else {
157                None
158            }
159        }) else {
160            ret!(None::<ArchiveId>)
161        };
162
163        let r = gitlab::api::projects::repository::TreeBuilder::default()
164            .project(id)
165            .ref_(branch)
166            .path(p)
167            .recursive(false)
168            .build()
169            .unwrap_or_else(|_| unreachable!());
170        let r: Vec<flams_backend_types::git::TreeEntry> = r.query_async(&client).await?;
171        let Some(p) = r.into_iter().find_map(|e| {
172            if e.name.eq_ignore_ascii_case("manifest.mf")
173                && matches!(e.tp, flams_backend_types::git::DirOrFile::File)
174            {
175                Some(e.path)
176            } else {
177                None
178            }
179        }) else {
180            ret!(None::<ArchiveId>)
181        };
182
183        let blob = gitlab::api::projects::repository::files::FileRaw::builder()
184            .project(id)
185            .file_path(p)
186            .ref_(branch)
187            .build()
188            .unwrap_or_else(|_| unreachable!());
189        let r = gitlab::api::raw(blob).query_async(&client).await?;
190        let r = std::str::from_utf8(&r)?;
191        let r = r.split('\n').find_map(|line| {
192            let line = line.trim();
193            line.strip_prefix("id:")
194                .and_then(|rest| ArchiveId::new(rest.trim()).ok())
195        });
196        ret!(r)
197    }
198
199    /// #### Errors
200    pub async fn get_branches(
201        &self,
202        id: u64,
203        token: String,
204    ) -> Result<Vec<flams_backend_types::git::Branch>, super::Err> {
205        self.get_branches_i(id, token)
206            .instrument(crate::REMOTE_SPAN.clone())
207            .await
208    }
209
210    /// # Errors
211    #[instrument(
212        level = "debug",
213        target = "git",
214        name = "getting branches for archive",
215        skip(self, token)
216    )]
217    pub async fn get_branches_i(
218        &self,
219        id: u64,
220        token: String,
221    ) -> Result<Vec<flams_backend_types::git::Branch>, super::Err> {
222        let mut client = gitlab::ImpersonationClient::new(&self.1 .0.inner, token);
223        client.oauth2_token();
224        let r = gitlab::api::projects::repository::branches::Branches::builder()
225            .project(id)
226            .build()
227            .map_err(|e| super::Err::Other(e.to_string()))?
228            .query_async(&client)
229            .await?;
230        Ok(r)
231    }
232}
233
234impl GitLab {
235    /// #### Errors
236    pub fn new_oauth(&self, redirect: &str) -> Result<GitLabOAuth, OAuthError> {
237        use oauth2::{AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
238        let url = &self.0.url;
239        let Some(app_id) = &self.0.id else {
240            return Err(OAuthError::MissingAppID);
241        };
242        let Some(app_secret) = &self.0.secret else {
243            return Err(OAuthError::MissingAppSecret);
244        };
245        let Ok(auth_url) = AuthUrl::new(format!("{url}/oauth/authorize")) else {
246            return Err(OAuthError::InvalidAuthURL(format!("{url}/oauth/authorize")));
247        };
248        let Ok(token_url) = TokenUrl::new(format!("{url}/oauth/token")) else {
249            return Err(OAuthError::InvalidTokenURL(format!("{url}/oauth/token")));
250        };
251        let Ok(redirect_url) = RedirectUrl::new(redirect.to_string()) else {
252            return Err(OAuthError::InvalidRedirectURL(redirect.to_string()));
253        };
254        let auth = oauth2::basic::BasicClient::new(ClientId::new(app_id.to_string()))
255            .set_client_secret(ClientSecret::new(app_secret.to_string()))
256            .set_auth_uri(auth_url)
257            .set_token_uri(token_url)
258            .set_redirect_uri(redirect_url);
259        Ok(GitLabOAuth(auth, self.clone()))
260    }
261
262    /// #### Errors
263    pub async fn get_oauth_user(
264        &self,
265        token: &oauth2::AccessToken,
266    ) -> Result<GitlabUser, reqwest::Error> {
267        let client = reqwest::Client::new();
268        let resp = client
269            .get(format!("{}/api/v4/user", self.0.url))
270            .bearer_auth(token.secret())
271            .send()
272            .instrument(crate::REMOTE_SPAN.clone())
273            .await?;
274        resp.json().instrument(crate::REMOTE_SPAN.clone()).await
275    }
276}
277
278#[derive(Debug)]
279pub enum OAuthError {
280    InvalidAuthURL(String),
281    InvalidTokenURL(String),
282    InvalidRedirectURL(String),
283    MissingAppID,
284    MissingAppSecret,
285}
286impl std::fmt::Display for OAuthError {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        match self {
289            Self::InvalidAuthURL(s) => write!(f, "Invalid auth url: {s}"),
290            Self::InvalidTokenURL(s) => write!(f, "Invalid token url: {s}"),
291            Self::InvalidRedirectURL(s) => write!(f, "Invalid redirect url: {s}"),
292            Self::MissingAppID => write!(f, "Missing app id"),
293            Self::MissingAppSecret => write!(f, "Missing secret"),
294        }
295    }
296}
297
298impl std::error::Error for OAuthError {}
299
300#[derive(Debug, serde::Deserialize, serde::Serialize)]
301pub struct GitlabUser {
302    pub id: i64,
303    pub username: String,
304    pub name: String,
305    //state:String,
306    pub avatar_url: String,
307    //pronouns:Option<String>,
308    pub email: Option<String>,
309    //commit_email:Option<String>,
310    pub can_create_group: bool,
311    pub can_create_project: bool,
312}
313
314/*
315{
316  "id":2,
317  "username":"jazzpirate",
318  "name":"Dennis Test",
319  "state":"active",
320  "locked":false,
321  "avatar_url":"https://www.gravatar.com/avatar/46a2a1db127c1d43862f7313d137b5ed3bdac8ffe356c7dcc868f25e413e83c0?s=80\\u0026d=identicon",
322  "web_url":"http://gitlab.example.com/jazzpirate",
323  "created_at":"2024-12-23T10:35:40.099Z",
324  "bio":"",
325  "location":"",
326  "public_email":null,
327  "skype":"",
328  "linkedin":"",
329  "twitter":"",
330  "discord":"",
331  "website_url":"",
332  "organization":"",
333  "job_title":"",
334  "pronouns":null,
335  "bot":false,
336  "work_information":null,
337  "local_time":null,
338  "last_sign_in_at":"2024-12-23T10:36:24.234Z",
339  "confirmed_at":"2024-12-23T10:35:39.994Z",
340  "last_activity_on":"2024-12-29",
341  "email":"d.mueller@kwarc.info",
342  "theme_id":3,
343  "color_scheme_id":1,
344  "projects_limit":100000,
345  "current_sign_in_at":
346  "2024-12-29T08:48:51.974Z",
347  "identities":[],
348  "can_create_group":true,
349  "can_create_project":true,
350  "two_factor_enabled":false,
351  "external":false,
352  "private_profile":false,
353  "commit_email":"d.mueller@kwarc.info"
354}
355 */