pack/src/api.rs

505 lines
18 KiB
Rust

use std::{fs, path, slice::Iter};
use bytes::Bytes;
use std::process::Command;
use rand::distr::{Alphanumeric, SampleString};
use thiserror::Error;
use axum::{
body::Body,
extract::{Path, Json, State, Multipart},
http::{StatusCode, header::{HeaderMap, AUTHORIZATION, CONTENT_TYPE}},
response::{IntoResponse, Response},
};
use reqwest::{
Client, Method, Request, RequestBuilder, Url
};
use serde::{Serialize, Deserialize};
use serde_json::Value;
use crate::{query, ServerState};
#[derive(Clone, Copy)]
#[repr(u8)]
enum RepoFeature {
Docs = 0,
Builds = 1,
Nightly = 2,
PreProd = 3,
}
type FeatureList = Vec<String>;
impl RepoFeature {
pub fn iter() -> Iter<'static, RepoFeature> {
static REPOFEATURE: [RepoFeature; 4] = [RepoFeature::Docs, RepoFeature::Builds, RepoFeature::Nightly, RepoFeature::PreProd];
REPOFEATURE.iter()
}
}
#[derive(Error, Debug)]
pub enum APIError {
#[error("Request error: {0}")]
RequestError(#[from] reqwest::Error),
#[error("SQL error: {0}")]
SQLError(#[from] sqlite::Error),
#[error("Filesystem error: {0}")]
IOError(#[from] std::io::Error),
#[error("Multipart extract error: {0}")]
MultipartError(#[from] axum::extract::multipart::MultipartError),
#[error("Unexpected input in JSON: {msg}")]
InvalidJson { msg: String },
#[error("No such feature '{feature}' exists")]
InvalidFeature { feature: String },
#[error("The feature '{feature}' is not enabled for this repository")]
DisabledFeature { feature: String },
#[error("The current user is not an owner of '{repo}'")]
Unauthorized { repo: String },
#[error("A token was not provided or is malformed")]
Tokenless,
#[error("{msg}")]
Other { msg: String },
}
#[repr(u16)]
pub enum ErrorCode {
InvalidToken = 0, // Invalid OAuth token.
External = 1, // Error originates from an external location/server.
Filesystem = 2, // Error originates from filesystem operation.
Sql = 3, // Error originates from SQL.
BadRequest = 4, // Error originates from client request.
}
#[derive(Serialize)]
pub struct ErrorResponse {
status: u16,
code: u16,
message: String,
}
type Result<T, E = APIError> = core::result::Result<T, E>;
impl IntoResponse for APIError {
fn into_response(self) -> Response<Body> {
let (status, code) = match self {
APIError::RequestError(ref err) => {
let status = err.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let action = match status {
StatusCode::FORBIDDEN => ErrorCode::InvalidToken,
_ => ErrorCode::External
};
(status, action)
},
APIError::SQLError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Sql),
APIError::IOError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::Filesystem),
APIError::MultipartError(_) => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::BadRequest),
APIError::InvalidJson{..} => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::External),
APIError::InvalidFeature{..} => (StatusCode::BAD_REQUEST, ErrorCode::BadRequest),
APIError::DisabledFeature{..} => (StatusCode::NOT_FOUND, ErrorCode::BadRequest),
APIError::Unauthorized{..} => (StatusCode::FORBIDDEN, ErrorCode::BadRequest),
APIError::Tokenless => (StatusCode::FORBIDDEN, ErrorCode::InvalidToken),
APIError::Other{..} => (StatusCode::INTERNAL_SERVER_ERROR, ErrorCode::External),
};
(status, Json(ErrorResponse{
status: u16::from(status),
code: code as u16,
message: self.to_string(),
})).into_response()
}
}
impl From<RepoFeature> for &str {
fn from(value: RepoFeature) -> Self {
match value {
RepoFeature::Docs => "docs",
RepoFeature::Builds => "builds",
RepoFeature::Nightly => "nightly",
RepoFeature::PreProd => "preprod",
}
}
}
impl TryFrom<&str> for RepoFeature {
type Error = APIError;
fn try_from(value: &str) -> Result<Self> {
match value.to_lowercase().as_str() {
"docs" => Ok(RepoFeature::Docs),
"builds" => Ok(RepoFeature::Builds),
"nightly" => Ok(RepoFeature::Nightly),
"preprod" => Ok(RepoFeature::PreProd),
other => Err(APIError::InvalidFeature { feature: other.to_owned() }),
}
}
}
fn as_feature_list(value: u64) -> FeatureList {
let mut v: Vec<String> = Vec::new();
for feature in RepoFeature::iter() {
let feat_num = 1 << (*feature as u64);
if (value & feat_num) == feat_num {
v.push(Into::<&str>::into(*feature).to_owned());
}
}
v
}
async fn gitea_api<T, U>(url: &str, method: Method, payload: &T, token: &str) -> Result<U> where
T: Serialize + ?Sized,
U: serde::de::DeserializeOwned + Default
{
// Make request to Gitea.
let res = RequestBuilder::from_parts(Client::new(), Request::new(method, Url::parse(url).unwrap()))
.header("User-Agent", "TestBot")
.header("Authorization", format!("token {token}"))
.json(payload)
.send()
.await?;
res.error_for_status_ref()?; // Return with error if Gitea request has error.
let mut content_type = "".to_owned();
if res.headers().contains_key(CONTENT_TYPE) {
content_type = res.headers().get(CONTENT_TYPE)
.unwrap()
.to_str()
.map_err(|_| APIError::Other { msg: "Content-Type in Gitea response invalid".to_owned() })?
.to_owned();
}
if content_type.contains("application/json") {
Ok(res.json::<U>().await?)
} else {
Ok(U::default())
}
}
async fn authorize(host: &str, token: &str, repo: &str) -> Result<Json<Value>> {
// Use repos API call to check admin permission. Return the repo JSON as
// well in case it needs to be used later.
let url = format!("{host}/api/v1/repos/{repo}");
let data = Empty{};
let json: Value = gitea_api(&url, Method::GET, &data, token).await?;
// If permission is not admin level, return error.
json.get("permissions")
.ok_or(APIError::InvalidJson{ msg: "Couldn't find 'permissions' key.".to_owned() })?
.get("admin")
.ok_or(APIError::InvalidJson{ msg: "Couldn't find 'admin' key.".to_owned() })?
.as_bool()
.ok_or(APIError::InvalidJson{ msg: "Value in 'admin' is not bool.".to_owned() })?
.then_some(0)
.ok_or(APIError::Unauthorized{ repo: repo.to_owned() })?;
Ok(Json(json))
}
#[derive(Serialize, Deserialize, Default)]
pub struct Empty();
#[derive(Deserialize)]
pub struct TokenAuth {
code: String /* Could be refresh token. */
}
#[derive(Serialize)]
pub struct TokenRequest {
client_id: String,
client_secret: String,
code: String,
grant_type: String,
redirect_uri: String
}
#[derive(Deserialize, Serialize, Default)]
pub struct TokenResponse {
access_token: String,
token_type: String,
expires_in: i32,
refresh_token: String
}
pub async fn token(State(state): State<ServerState<'_>>,
Json(payload): Json<TokenAuth>) -> Result<Json<TokenResponse>> {
let token_endpoint = format!("{}/login/oauth/access_token", state.config.gitea_host);
let redirect_uri = "http://127.0.0.1:3000";
let data = TokenRequest {
client_id: state.config.client_id,
client_secret: state.config.client_secret,
code: payload.code,
grant_type: "authorization_code".to_owned(),
redirect_uri: redirect_uri.to_owned()
};
Ok(Json(gitea_api(&token_endpoint, Method::POST, &data, "").await?))
}
#[derive(Serialize)]
pub struct RefreshTokenRequest {
client_id: String,
client_secret: String,
refresh_token: String,
grant_type: String,
}
pub async fn refresh_token(State(state): State<ServerState<'_>>,
Json(payload): Json<TokenAuth>) -> Result<Json<TokenResponse>>{
let token_endpoint = format!("{}/login/oauth/access_token", state.config.gitea_host);
let data = RefreshTokenRequest {
client_id: state.config.client_id,
client_secret: state.config.client_secret,
refresh_token: payload.code,
grant_type: "refresh_token".to_owned(),
};
Ok(Json(gitea_api(&token_endpoint, Method::POST, &data, "").await?))
}
#[derive(Serialize)]
pub struct RepoResponse {
description: String,
exists: bool,
features: FeatureList,
}
fn extract_token(headers: HeaderMap) -> Result<String> {
Ok(headers.get(AUTHORIZATION)
.ok_or(APIError::Tokenless)?
.to_str()
.map_err(|_| APIError::Tokenless)?
.to_owned())
}
pub async fn get_repo(State(state): State<ServerState<'_>>,
Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap) -> Result<Json<RepoResponse>> {
let repo = format!("{owner}/{repo}");
let token = extract_token(headers)?;
// Pull repository information from Gitea.
let json = authorize(&state.config.gitea_host, &token, &repo).await?;
let description = json.get("description")
.ok_or(APIError::InvalidJson{ msg: "Couldn't find 'description' key.".to_owned() })?
.as_str()
.ok_or(APIError::InvalidJson{ msg: "Value in 'description' is not String.".to_owned() })?
.to_owned();
// Check if entry exists and return features.
let mut features = state.sql.prepare(query::GET_REPO)?
.iter()
.bind((1, repo.as_str()))?
.map(|row| -> Result<FeatureList> {
Ok(as_feature_list(row?.read::<i64, _>("features") as u64))
})
.collect::<Result<Vec<FeatureList>>>()?;
if features.len() == 1 {
Ok(Json(RepoResponse { description, exists: true, features: features.remove(0) }))
} else {
Ok(Json(RepoResponse { description, exists: false, features: vec!() }))
}
}
#[derive(Serialize)]
pub struct GiteaSetSecret {
data: String,
}
pub async fn create_repo(State(state): State<ServerState<'_>>,
Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<()> {
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
let token = extract_token(headers)?;
let _ = authorize(&state.config.gitea_host, &token, &repo).await?;
// Create secret and insert new repository into database.
let secret = Alphanumeric.sample_string(&mut rand::rng(), 48);
let _: i32 = state.sql.prepare(crate::query::CREATE_REPO)?
.iter()
.bind_iter::<_, (_, sqlite::Value)>([
(1, repo.clone().into()),
(2, 0.into()),
(3, secret.clone().into()),
])?
.map(|_| 0)
.sum(); // Evalute statement.
// Make associated folder.
fs::create_dir_all(path::Path::new(&state.config.upload_path).join(&repo))?;
// Add secret to Gitea secrets.
let secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PACK_REPO_SECRET", &state.config.gitea_host, &repo);
let data = GiteaSetSecret { data: secret };
let _: Empty = gitea_api(secret_url.as_str(), Method::PUT, &data, &token).await?;
Ok(())
}
pub async fn delete_repo(State(state): State<ServerState<'_>>,
Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<()> {
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
let token = extract_token(headers)?;
let _ = authorize(&state.config.gitea_host, &token, &repo).await?;
let _: i32 = state.sql.prepare(crate::query::DELETE_REPO)?
.iter()
.bind((1, repo.as_str()))?
.map(|_| 0)
.sum(); // Evalute statement.
// Remove entire directory and its parent if its empty.
let dir = path::Path::new(&state.config.upload_path).join(&repo);
let parent = dir.as_path()
.parent()
.ok_or(APIError::Other { msg: "Could not find parent in repo directory. Should be impossible...".to_owned() })?;
fs::remove_dir_all(&dir)?;
if fs::read_dir(parent)?.next().is_none() {
fs::remove_dir(parent)?;
}
// Remove secret from Gitea.
let secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PACK_REPO_SECRET", &state.config.gitea_host, &repo);
let data = Empty{};
let _: Empty = gitea_api(secret_url.as_str(), Method::DELETE, &data, &token).await?;
Ok(())
}
#[derive(Deserialize)]
pub struct PatchRepoRequest {
secret: bool,
feature: String,
}
async fn update_secret(state: &ServerState<'_>, repo: &str, token: &str) -> Result<()> {
let secret = Alphanumeric.sample_string(&mut rand::rng(), 48);
let _: i32 = state.sql.prepare(crate::query::UPDATE_REPO_SECRET)?
.iter()
.bind_iter::<_, (_, sqlite::Value)>([
(1, secret.clone().into()),
(2, repo.into()),
])?
.map(|_| 0)
.sum(); // Evalute statement.
let secret_url = format!("{}/api/v1/repos/{}/actions/secrets/PACK_REPO_SECRET", &state.config.gitea_host, repo);
let data = GiteaSetSecret { data: secret };
let _: Empty = gitea_api(secret_url.as_str(), Method::PUT, &data, token).await?;
Ok(())
}
async fn update_feature(state: &ServerState<'_>, repo: &str, feature: &str) -> Result<()> {
let feat_num: u64 = 1 << (RepoFeature::try_from(feature)? as u64);
// Update feature in database. (feature = feature ^ feat_num) and get result.
let features = state.sql.prepare(crate::query::UPDATE_REPO_FEATURES)?
.iter()
.bind_iter::<_, (_, sqlite::Value)>([
(1, (feat_num as i64).into()),
(2, repo.into()),
])?
.map(|row| -> Result<u64> {
Ok(row?.read::<i64, _>("features") as u64)
})
.collect::<Result<Vec<u64>>>()?[0]; // Evalute statement.
// Check added or removed and update folders accordingly.
let added = (features & feat_num) == feat_num;
let dir = path::Path::new(&state.config.upload_path).join(repo).join(feature);
if added {
fs::create_dir(dir)?;
} else {
fs::remove_dir_all(dir)?;
}
Ok(())
}
pub async fn patch_repo(State(state): State<ServerState<'_>>,
Path((owner, repo)): Path<(String, String)>,
headers: HeaderMap,
Json(payload): Json<PatchRepoRequest>) -> Result<()> {
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
let token = extract_token(headers)?;
let _ = authorize(&state.config.gitea_host, &token, &repo).await?;
if payload.secret {
return update_secret(&state, &repo, &token).await;
}
update_feature(&state, &repo, &payload.feature).await
}
#[derive(Default)]
struct UploadFile {
name: String,
data: Bytes,
folder: String,
}
pub async fn upload(State(state): State<ServerState<'_>>,
Path((owner, repo, feature)): Path<(String, String, String)>,
headers: HeaderMap,
mut multipart: Multipart) -> Result<()> {
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
let feature = feature.to_lowercase();
let user_secret = extract_token(headers)?; // Authorization should be the secret this time.
let (secret, features) = &state.sql.prepare(crate::query::UPLOAD_QUERY)?
.iter()
.bind((1, repo.as_str()))?
.map(|row| -> Result<(String, FeatureList)> {
let row = row?;
Ok(
(row.read::<&str, _>("secret").to_owned(),
as_feature_list(row.read::<i64, _>("features") as u64))
)
})
.collect::<Result<Vec<(String, FeatureList)>>>()?[0];
(user_secret == *secret).then_some(0)
.ok_or(APIError::Unauthorized{ repo: repo.to_owned() })?;
features.contains(&feature).then_some(0)
.ok_or(APIError::DisabledFeature{ feature: feature.clone() })?;
// Process multipart.
let mut file = UploadFile{ folder: "".to_owned(), ..Default::default() };
while let Some(field) = multipart.next_field().await? {
let name = match field.name() {
Some(x) => x,
None => continue,
};
match name {
"name" => file.name = field.text().await?,
"file" => file.data = field.bytes().await?,
"folder" => file.folder = field.text().await?,
_ => continue,
}
}
let dir = path::Path::new(&state.config.upload_path).join(&repo).join(feature);
fs::write(dir.join(&file.name), file.data)?;
let tardir = dir.join(&file.folder);
if file.folder != *"" {
if fs::exists(&tardir)? {
fs::remove_dir_all(&tardir)?;
}
fs::create_dir(&tardir)?;
let output = Command::new("tar").args(["-xf", dir.join(&file.name).to_str().unwrap(), "-C", tardir.to_str().unwrap()])
.output()?;
// If there was an error, remove everything because the operation was unsucessful.
if !output.status.success() {
fs::remove_dir_all(&tardir)?;
fs::remove_file(dir.join(file.name))?;
return Err(APIError::Other{ msg: "Failed to complete untar".to_owned() })
}
}
Ok(())
}