769 lines
29 KiB
Rust
769 lines
29 KiB
Rust
use std::{
|
|
cmp::Ordering,
|
|
fs::{self, Permissions},
|
|
fmt,
|
|
os::unix::fs::PermissionsExt,
|
|
path,
|
|
slice::Iter,
|
|
time::{Duration},
|
|
};
|
|
use bytes::Bytes;
|
|
use bytesize::ByteSize;
|
|
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, CONTENT_TYPE, AUTHORIZATION},
|
|
Uri,
|
|
},
|
|
response::{IntoResponse, Response, Redirect, Html},
|
|
middleware::Next,
|
|
};
|
|
use axum_auth::AuthBearer;
|
|
use axum_extra::extract::CookieJar;
|
|
use reqwest::{
|
|
Client, Method, Request, RequestBuilder, Url
|
|
};
|
|
use serde::{Serialize, Deserialize};
|
|
use serde_json::Value;
|
|
use tower_http::services::ServeFile;
|
|
|
|
use crate::{query, ServerState};
|
|
|
|
static FOLDER_PERM: u32 = 0o750;
|
|
static FILE_PERM: u32 = 0o640;
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
#[repr(u8)]
|
|
pub 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()
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for RepoFeature {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
fmt::Debug::fmt(self, f)
|
|
}
|
|
}
|
|
|
|
impl From<RepoFeature> for String {
|
|
fn from(value: RepoFeature) -> Self {
|
|
value.to_string().to_lowercase()
|
|
}
|
|
}
|
|
|
|
impl TryFrom<&str> for RepoFeature {
|
|
type Error = ApiError;
|
|
|
|
fn try_from(value: &str) -> Result<Self> {
|
|
for feature in RepoFeature::iter() {
|
|
if feature.to_string().to_lowercase() == value.to_lowercase() {
|
|
return Ok(*feature);
|
|
}
|
|
}
|
|
Err(ApiError::InvalidFeature { feature: value.to_owned() })
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
#[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(Clone, Copy, Debug, Error)]
|
|
pub struct CustomError(&'static str, ErrorCode);
|
|
impl fmt::Display for CustomError {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
write!(f, "{}, {:?}", self.0, self.1)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Error)]
|
|
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("{0}")]
|
|
CustomError(#[from] CustomError)
|
|
}
|
|
|
|
static ERR_GITEA_CONTENT_TYPE: CustomError = CustomError("Content-Type in Gitea response invalid", ErrorCode::External);
|
|
static ERR_MISSING_PARENT: CustomError = CustomError("Could not find parent in repo directory. Should be impossible...", ErrorCode::Filesystem);
|
|
static ERR_UNTAR: CustomError = CustomError("Failed to complete untar", ErrorCode::Filesystem);
|
|
static ERR_OS_STR_UTF: CustomError = CustomError("OsString failed to parse into UTF-8", ErrorCode::Filesystem);
|
|
static ERR_FILE_CALL_UTF: CustomError = CustomError("file command output failed to parse into UTF-8", ErrorCode::Filesystem);
|
|
static ERR_FILE_CALL_UNEXPECTED: CustomError = CustomError("file command output had unexpected output", ErrorCode::Filesystem);
|
|
pub static ERR_CONTENT_FOLDER_RENDER: CustomError = CustomError("Unable to process 'ContentFolder' template", ErrorCode::Filesystem);
|
|
static ERR_IMPOSSIBLE_ENTRY: CustomError = CustomError("Entry is neither file, directory or symlink", ErrorCode::Filesystem);
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ErrorResponse {
|
|
status: u16,
|
|
code: u16,
|
|
message: String,
|
|
}
|
|
|
|
pub 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::CustomError(ref e) => (StatusCode::INTERNAL_SERVER_ERROR, e.1),
|
|
};
|
|
(status, Json(ErrorResponse{
|
|
status: u16::from(status),
|
|
code: code as u16,
|
|
message: self.to_string(),
|
|
})).into_response()
|
|
}
|
|
}
|
|
|
|
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::<String>::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(|_| ERR_GITEA_CONTENT_TYPE )?
|
|
.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, kind: &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 'kind' permission is not true, return error.
|
|
json.get("permissions")
|
|
.ok_or(ApiError::InvalidJson{ msg: "Couldn't find 'permissions' key.".to_owned() })?
|
|
.get(kind)
|
|
.ok_or(ApiError::InvalidJson{ msg: format!("Couldn't find '{kind}' key.") })?
|
|
.as_bool()
|
|
.ok_or(ApiError::InvalidJson{ msg: format!("Value in '{kind}' is not bool.") })?
|
|
.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,
|
|
}
|
|
|
|
pub async fn get_repo(State(state): State<ServerState<'_>>,
|
|
Path((owner, repo)): Path<(String, String)>,
|
|
AuthBearer(token): AuthBearer) -> Result<Json<RepoResponse>> {
|
|
let repo = format!("{owner}/{repo}");
|
|
// Pull repository information from Gitea.
|
|
let json = authorize(&state.config.gitea_host, &token, &repo, "admin").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)>,
|
|
AuthBearer(token): AuthBearer,
|
|
) -> Result<()> {
|
|
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
|
|
let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").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(); // Evaluate statement.
|
|
|
|
// Make associated folder.
|
|
let owner_dir = path::Path::new(&state.config.upload_path).join(&owner);
|
|
let repo_dir = path::Path::new(&state.config.upload_path).join(&repo);
|
|
if !fs::exists(&owner_dir)? {
|
|
fs::create_dir(&owner_dir)?;
|
|
fs::set_permissions(owner_dir, Permissions::from_mode(FOLDER_PERM))?;
|
|
}
|
|
|
|
if !fs::exists(&repo_dir)? {
|
|
fs::create_dir(&repo_dir)?;
|
|
fs::set_permissions(repo_dir, Permissions::from_mode(FOLDER_PERM))?;
|
|
}
|
|
|
|
// 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)>,
|
|
AuthBearer(token): AuthBearer,
|
|
) -> Result<()> {
|
|
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
|
|
let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").await?;
|
|
|
|
let _: i32 = state.sql.prepare(crate::query::DELETE_REPO)?
|
|
.iter()
|
|
.bind((1, repo.as_str()))?
|
|
.map(|_| 0)
|
|
.sum(); // Evaluate 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(ERR_MISSING_PARENT)?;
|
|
|
|
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(); // Evaluate 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]; // Evaluate 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)?;
|
|
fs::set_permissions(dir, Permissions::from_mode(FOLDER_PERM))?;
|
|
} else {
|
|
fs::remove_dir_all(dir)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn patch_repo(State(state): State<ServerState<'_>>,
|
|
Path((owner, repo)): Path<(String, String)>,
|
|
AuthBearer(token): AuthBearer,
|
|
Json(payload): Json<PatchRepoRequest>) -> Result<()> {
|
|
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
|
|
let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").await?;
|
|
|
|
if payload.secret {
|
|
return update_secret(&state, &repo, &token).await;
|
|
}
|
|
update_feature(&state, &repo, &payload.feature).await
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct Entry {
|
|
pub name: String,
|
|
pub name_uri: String,
|
|
pub icon: &'static str,
|
|
pub size: String,
|
|
}
|
|
|
|
fn entry_from_dir_entry(entry: std::io::Result<fs::DirEntry>) -> Result<Entry> {
|
|
let path = entry?.path();
|
|
let name = path.file_name().unwrap().to_str().ok_or(ERR_OS_STR_UTF)?.to_owned();
|
|
let name_uri = urlencoding::encode(&name).to_string();
|
|
let metadata = path.metadata()?;
|
|
|
|
if metadata.is_dir() {
|
|
return Ok(Entry{
|
|
name, name_uri, icon: "folder", size: "—".to_owned(),
|
|
});
|
|
}
|
|
let size = ByteSize::b(metadata.len()).display().iec().to_string();
|
|
|
|
// Get file mime data.
|
|
let out = Command::new("file").args(["-b", "--mime", path.to_str().ok_or(ERR_OS_STR_UTF)?]).output()?;
|
|
let out = String::from_utf8(out.stdout).map_err(|_| ERR_FILE_CALL_UTF)?;
|
|
let out = out.trim().split("; ").collect::<Vec<_>>();
|
|
|
|
// Example expected output: text/plain; charset=us=ascii
|
|
(out.len() == 2).then_some(0).ok_or(ERR_FILE_CALL_UNEXPECTED)?;
|
|
let mime = out[0];
|
|
let charset = &out[1][8..];
|
|
|
|
let icon = if &mime[..4] == "text" {
|
|
"text"
|
|
} else if charset == "binary" {
|
|
match mime {
|
|
"application/gzip" => "archive",
|
|
"application/zip" => "archive",
|
|
_ => "binary"
|
|
}
|
|
} else {
|
|
""
|
|
};
|
|
|
|
Ok(Entry{ name, name_uri, icon, size })
|
|
}
|
|
|
|
fn sort_entry(a: &Entry, b: &Entry) -> Ordering {
|
|
// Sort list by alphabetical folder lowest (so it appears at the top).
|
|
let (a_fol, b_fol) = (a.icon == "folder", b.icon == "folder");
|
|
match (a_fol, b_fol) {
|
|
(true, true) => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
|
(true, false) => Ordering::Less,
|
|
(false, true) => Ordering::Greater,
|
|
(false, false) => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
|
}
|
|
}
|
|
|
|
|
|
#[derive(Serialize)]
|
|
pub struct GetEntryResponse {
|
|
entries: Vec<Entry>,
|
|
}
|
|
|
|
pub async fn get_entry(State(state): State<ServerState<'_>>,
|
|
Path((owner, repo, path)): Path<(String, String, String)>,
|
|
AuthBearer(token): AuthBearer) -> Result<Response> {
|
|
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
|
|
let target = format!("{repo}/{path}");
|
|
|
|
let _ = authorize(&state.config.gitea_host, &token, &repo, "pull").await?;
|
|
|
|
let dir = path::Path::new(&state.config.upload_path).join(&target);
|
|
|
|
let metadata = match fs::metadata(&dir) {
|
|
Ok(x) => x,
|
|
Err(_) => return Ok(StatusCode::NOT_FOUND.into_response()),
|
|
};
|
|
|
|
if metadata.is_dir() { // Return JSON.
|
|
let dir = fs::read_dir(&dir)?;
|
|
let mut entries = dir.map(entry_from_dir_entry).collect::<Result<Vec<Entry>>>()?;
|
|
entries.sort_by(sort_entry);
|
|
Ok(Json(entries).into_response())
|
|
} else { // Redirect to file.
|
|
Ok(Redirect::temporary(format!("/r/{target}").as_str()).into_response())
|
|
}
|
|
}
|
|
|
|
pub async fn delete_entry(State(state): State<ServerState<'_>>,
|
|
Path((owner, repo,path)): Path<(String, String, String)>,
|
|
AuthBearer(token): AuthBearer) -> Result<StatusCode> {
|
|
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
|
|
let target = format!("{repo}/{path}");
|
|
|
|
let _ = authorize(&state.config.gitea_host, &token, &repo, "admin").await?;
|
|
|
|
let dir = path::Path::new(&state.config.upload_path).join(target);
|
|
let metadata = match fs::metadata(&dir) {
|
|
Ok(x) => x,
|
|
Err(_) => return Ok(StatusCode::NOT_FOUND),
|
|
};
|
|
|
|
if metadata.is_dir() {
|
|
fs::remove_dir_all(&dir)?;
|
|
} else if metadata.is_file() || metadata.is_symlink() {
|
|
fs::remove_file(&dir)?;
|
|
} else {
|
|
return Err(ERR_IMPOSSIBLE_ENTRY)?;
|
|
}
|
|
|
|
Ok(StatusCode::OK)
|
|
}
|
|
|
|
#[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)>,
|
|
AuthBearer(user_secret): AuthBearer,
|
|
mut multipart: Multipart) -> Result<()> {
|
|
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
|
|
let feature = feature.to_lowercase();
|
|
|
|
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);
|
|
let filepath = dir.join(&file.name);
|
|
fs::write(&filepath, file.data)?;
|
|
fs::set_permissions(&filepath, Permissions::from_mode(FILE_PERM))?;
|
|
|
|
let tardir = dir.join(&file.folder);
|
|
if file.folder != *"" {
|
|
if fs::exists(&tardir)? {
|
|
fs::remove_dir_all(&tardir)?;
|
|
}
|
|
|
|
fs::create_dir(&tardir)?;
|
|
fs::set_permissions(&tardir, Permissions::from_mode(FOLDER_PERM))?;
|
|
let tardir_s = tardir.to_str().unwrap();
|
|
let output = Command::new("tar").args(["-xf", filepath.to_str().unwrap(), "-C", tardir_s])
|
|
.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(&filepath)?;
|
|
return Err(ERR_UNTAR)?
|
|
}
|
|
|
|
// Run chmod to set extracted folder permissions.
|
|
Command::new("find").args([tardir_s, "-type", "f", "-exec", "chmod", "0640", "{}", "+"])
|
|
.output()?;
|
|
Command::new("find").args([tardir_s, "-type", "d", "-exec", "chmod", "0750", "{}", "+"])
|
|
.output()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn status_to_404(request: axum::extract::Request, next: Next) -> Response {
|
|
let mut response = next.run(request).await;
|
|
*response.status_mut() = StatusCode::NOT_FOUND;
|
|
response
|
|
}
|
|
|
|
pub async fn get_not_found(request: axum::extract::Request) -> Response {
|
|
let resp = ServeFile::new("static/404.html").try_call(request).await.unwrap();
|
|
let mut resp = resp.map(Body::new);
|
|
*resp.status_mut() = StatusCode::NOT_FOUND;
|
|
resp
|
|
}
|
|
|
|
pub async fn auth_file_routing(State(state): State<ServerState<'_>>,
|
|
Path((owner, path)): Path<(String, String)>,
|
|
headers: HeaderMap,
|
|
jar: CookieJar,
|
|
mut request: axum::extract::Request,
|
|
next: Next) -> Result<Response> {
|
|
*request.uri_mut() = format!("/{owner}/{path}").parse::<Uri>().unwrap();
|
|
let target = format!("{owner}/{path}");
|
|
|
|
let mut path_s: Vec<_> = path.split("/").collect();
|
|
let repo = path_s.remove(0);
|
|
let repo = format!("{owner}/{repo}").to_lowercase(); // Repos are case-insensitive.
|
|
let no_delete = path_s.is_empty() || path_s[0].is_empty(); // We must be on the feature-selection page.
|
|
|
|
// If we find an authorization, it is probably a redirect from /api/files
|
|
let token = if let Some(x) = headers.get(AUTHORIZATION) {
|
|
x.to_str()
|
|
.map_err(|_| ApiError::Tokenless)?
|
|
.to_owned()
|
|
} else { // Otherwise, this is regular browsing, if no cookie token, user needs to login.
|
|
match jar.get("token") {
|
|
Some(x) => x.value_trimmed().to_owned(),
|
|
None => return Ok(Redirect::temporary("/").into_response()),
|
|
}
|
|
};
|
|
|
|
// We use a timed hash map to temporarily store tokens. This is to prevent
|
|
// flooding Gitea with authorization requests for each small HTTP request
|
|
// when viewing inner sites. (Ex: loading assets for docs/)
|
|
let is_admin = match state.token_map.get(&token) {
|
|
Some(x) => x, // If it returns, then continue.
|
|
None => {
|
|
// Check if user can view repo.
|
|
match authorize(&state.config.gitea_host, &token, &repo, "pull").await {
|
|
Ok(json) => {
|
|
let perm = json.get("permissions").unwrap().get("admin").unwrap().as_bool().unwrap();
|
|
// Store this in the timed hash map for 5 minutes.
|
|
state.token_map.insert(token, perm, Duration::new(300, 0));
|
|
perm
|
|
},
|
|
Err(e) => {
|
|
let e = match e {
|
|
ApiError::RequestError(x) => x,
|
|
_ => return Err(e) // Some other error, return it.
|
|
};
|
|
|
|
// If the error is unauthorized, try refreshing the token.
|
|
// If it's not found, return not found.
|
|
// Otherwise, it's unknown and we send back to "/".
|
|
let refresh = format!("/refresh?target=/r/{target}");
|
|
return match e.status() {
|
|
Some(StatusCode::UNAUTHORIZED) => Ok(Redirect::temporary(&refresh).into_response()),
|
|
Some(StatusCode::NOT_FOUND) => Ok(get_not_found(request).await),
|
|
_ => Ok(Redirect::temporary("/").into_response()),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
|
|
let dir = path::Path::new(&state.config.upload_path).join(&target);
|
|
let mut dir = match fs::read_dir(&dir) {
|
|
Ok(x) => {
|
|
match fs::exists(dir.join("index.html"))? {
|
|
true => {
|
|
if !path.ends_with('/') {
|
|
// Need to make sure for a website given to ServeDir, this needs to end in a /, othewrise
|
|
// ServerDir will issue a redirect with a {uri}/, but this is at the wrong location,
|
|
// since this does not contain the /r/ prefix.
|
|
*request.uri_mut() = format!("/{owner}/{path}/").parse::<Uri>().unwrap();
|
|
}
|
|
return Ok(next.run(request).await); // index.html exists, so pass to ServeDir.
|
|
}
|
|
false => x.peekable(), // We need to get the directories.
|
|
}
|
|
},
|
|
Err(_) => return Ok(next.run(request).await), // Not a directory or file or not found, so pass to ServeDir.
|
|
};
|
|
|
|
if dir.peek().is_none() {
|
|
return Ok(get_not_found(request).await);
|
|
}
|
|
|
|
// At this point, we should send an page with the list of files.
|
|
let mut entries = dir.map(entry_from_dir_entry).collect::<Result<Vec<Entry>>>()?;
|
|
entries.sort_by(sort_entry);
|
|
|
|
Ok(Html(crate::compile::content_folder(&state.config, target, &entries, is_admin, no_delete)?).into_response())
|
|
}
|