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 }
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 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 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 .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 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 #[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 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 #[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 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 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 pub avatar_url: String,
307 pub email: Option<String>,
309 pub can_create_group: bool,
311 pub can_create_project: bool,
312}
313
314