🏡 index : ~doyle/gitlab-cargo-shim.git

use crate::providers::{Release, User};
use async_trait::async_trait;
use futures::{stream::FuturesUnordered, StreamExt, TryStreamExt};
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use reqwest::header;
use serde::Deserialize;
use std::sync::Arc;

pub struct Gitlab {
    client: reqwest::Client,
    base_url: String,
}

impl Gitlab {
    pub fn new() -> anyhow::Result<Self> {
        let mut headers = header::HeaderMap::new();
        headers.insert(
            "PRIVATE-TOKEN",
            header::HeaderValue::from_static("token"),
        );

        Ok(Self {
            client: reqwest::ClientBuilder::new()
                .default_headers(headers)
                .build()?,
            base_url: "https://127.0.0.1/api/v4".to_string(),
        })
    }

    pub async fn get_impersonation_token_for(&self, user: &User) -> anyhow::Result<String> {
        let impersonation_token: GitlabImpersonationTokenResponse = self
            .client
            .get(format!(
                "{}/users/{}/impersonation_tokens",
                self.base_url, user.id
            ))
            .body(format!("name={};scopes=api", env!("CARGO_PKG_NAME")))
            .send()
            .await?
            .json()
            .await?;

        Ok(impersonation_token.token)
    }
}

#[async_trait]
impl super::UserProvider for Gitlab {
    async fn find_user_by_username_password_combo(
        &self,
        username_password: &str,
    ) -> anyhow::Result<Option<User>> {
        let mut splitter = username_password.splitn(2, ':');
        let (username, password) = (splitter.next().unwrap(), splitter.next().unwrap());

        if username == "gitlab-ci-token" {
            let res: GitlabJobResponse = self
                .client
                .get(format!("{}/job", self.base_url))
                .header("JOB-TOKEN", password)
                .send()
                .await?
                .json()
                .await?;

            Ok(Some(User {
                id: res.user.id,
                username: res.user.username,
            }))
        } else {
            Ok(None)
        }
    }

    async fn find_user_by_ssh_key(&self, fingerprint: &str) -> anyhow::Result<Option<User>> {
        let res: GitlabSshKeyLookupResponse = self
            .client
            .get(format!(
                "{}/keys?fingerprint={}",
                self.base_url, fingerprint
            ))
            .send()
            .await?
            .json()
            .await?;
        Ok(res.user.map(|u| User {
            id: u.id,
            username: u.username,
        }))
    }
}

#[async_trait]
impl super::PackageProvider for Gitlab {
    async fn fetch_releases_for_group(
        self: Arc<Self>,
        group: &str,
        do_as: User,
    ) -> anyhow::Result<Vec<Release>> {
        let impersonation_token = Arc::new(self.get_impersonation_token_for(&do_as).await?);

        let mut next_uri = Some(format!(
            "{}/groups/{}/packages?per_page=100&pagination=keyset&order_by=id&sort=asc&sudo={}",
            self.base_url,
            utf8_percent_encode(group, NON_ALPHANUMERIC),
            do_as.id
        ));

        let futures = FuturesUnordered::new();

        while let Some(uri) = next_uri.take() {
            let res = self.client.get(uri).send().await?;

            if let Some(link_header) = res.headers().get(reqwest::header::LINK) {
                let mut link_header = parse_link_header::parse_with_rel(link_header.to_str()?)?;

                if let Some(next) = link_header.remove("next") {
                    next_uri = Some(next.raw_uri);
                }
            }

            let res: Vec<GitlabPackageResponse> = res.json().await?;

            for release in res {
                let this = self.clone();
                let impersonation_token = impersonation_token.clone();

                futures.push(tokio::spawn(async move {
                    let (project, package) = {
                        let mut splitter = release.links.web_path.splitn(2, "/-/packages/");
                        match (splitter.next(), splitter.next()) {
                            (Some(project), Some(package)) => (&project[1..], package),
                            _ => return Ok(None),
                        }
                    };

                    let package_files: GitlabPackageFilesResponse = this
                        .client
                        .get(format!(
                            "{}/projects/{}/packages/{}/package_files",
                            this.base_url,
                            utf8_percent_encode(project, NON_ALPHANUMERIC),
                            utf8_percent_encode(package, NON_ALPHANUMERIC),
                        ))
                        .send()
                        .await?
                        .json()
                        .await?;

                    Ok::<_, anyhow::Error>(Some(Release {
                        uri: format!(
                            "{}/projects/{}/packages/generic/{}/{}/{}?private_token={}",
                            this.base_url,
                            utf8_percent_encode(project, NON_ALPHANUMERIC),
                            utf8_percent_encode(&release.name, NON_ALPHANUMERIC),
                            utf8_percent_encode(&release.version, NON_ALPHANUMERIC),
                            package_files.file_name,
                            impersonation_token,
                        ),
                        name: release.name,
                        version: release.version,
                        checksum: package_files.file_sha256,
                    }))
                }))
            }
        }

        futures
            .err_into()
            .filter_map(|v| async move { v.and_then(|v| v).transpose() })
            .try_collect()
            .await
    }
}

#[derive(Deserialize)]
pub struct GitlabImpersonationTokenResponse {
    pub token: String,
}

#[derive(Deserialize)]
pub struct GitlabPackageFilesResponse {
    pub file_name: String,
    pub file_sha256: String,
}

#[derive(Deserialize)]
pub struct GitlabPackageResponse {
    pub id: u64,
    pub name: String,
    pub version: String,
    #[serde(rename = "_links")]
    pub links: GitlabPackageLinksResponse,
}

#[derive(Deserialize)]
pub struct GitlabPackageLinksResponse {
    web_path: String,
}

#[derive(Deserialize)]
pub struct GitlabJobResponse {
    pub user: GitlabUserResponse,
}

#[derive(Deserialize)]
pub struct GitlabSshKeyLookupResponse {
    pub user: Option<GitlabUserResponse>,
}

#[derive(Deserialize)]
pub struct GitlabUserResponse {
    pub id: u64,
    pub username: String,
}