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