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; 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 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 { 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 = core::result::Result; impl IntoResponse for ApiError { fn into_response(self) -> Response { 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 = Vec::new(); for feature in RepoFeature::iter() { let feat_num = 1 << (*feature as u64); if (value & feat_num) == feat_num { v.push(Into::::into(*feature).to_owned()); } } v } async fn gitea_api(url: &str, method: Method, payload: &T, token: &str) -> Result 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::().await?) } else { Ok(U::default()) } } async fn authorize(host: &str, token: &str, repo: &str, kind: &str) -> Result> { // 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>, Json(payload): Json) -> Result> { 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>, Json(payload): Json) -> Result>{ 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>, Path((owner, repo)): Path<(String, String)>, AuthBearer(token): AuthBearer) -> Result> { 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 { Ok(as_feature_list(row?.read::("features") as u64)) }) .collect::>>()?; 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>, 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>, 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 { Ok(row?.read::("features") as u64) }) .collect::>>()?[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>, Path((owner, repo)): Path<(String, String)>, AuthBearer(token): AuthBearer, Json(payload): Json) -> 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) -> Result { 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::>(); // 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, } pub async fn get_entry(State(state): State>, Path((owner, repo, path)): Path<(String, String, String)>, AuthBearer(token): AuthBearer) -> Result { 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::>>()?; 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>, Path((owner, repo,path)): Path<(String, String, String)>, AuthBearer(token): AuthBearer) -> Result { 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>, 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::("features") as u64)) ) }) .collect::>>()?[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>, Path((owner, path)): Path<(String, String)>, headers: HeaderMap, jar: CookieJar, mut request: axum::extract::Request, next: Next) -> Result { *request.uri_mut() = format!("/{owner}/{path}").parse::().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::().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::>>()?; entries.sort_by(sort_entry); Ok(Html(crate::compile::content_folder(&state.config, target, &entries, is_admin, no_delete)?).into_response()) }