list warrens + explore nested folders

This commit is contained in:
2025-07-12 06:39:43 +02:00
parent f9f55895ed
commit 4d0765c53b
38 changed files with 1877 additions and 93 deletions

2
backend/.gitignore vendored
View File

@@ -1 +1,3 @@
target
serve
.env

1339
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,5 +8,10 @@ axum = "0.8.4"
dotenv = "0.15.0"
env_logger = "0.11.8"
log = "0.4.27"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio", "uuid"] }
thiserror = "2.0.12"
tokio = { version = "1.46.1", features = ["full"] }
tower-http = { version = "0.6.6", features = ["fs"] }
tower-http = { version = "0.6.6", features = ["cors", "fs"] }
uuid = { version = "1.17.0", features = ["serde"] }

View File

@@ -0,0 +1,7 @@
CREATE TABLE warrens (
id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(),
path VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_warrens_path ON warrens(path);

View File

@@ -0,0 +1 @@
ALTER TABLE warrens ADD COLUMN name VARCHAR NOT NULL;

10
backend/src/api/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
use crate::server::Router;
mod state;
mod warrens;
pub use state::AppState;
pub(super) fn router() -> Router {
Router::new().nest("/warrens", warrens::router())
}

21
backend/src/api/state.rs Normal file
View File

@@ -0,0 +1,21 @@
use sqlx::{Pool, Postgres};
#[derive(Debug, Clone)]
pub struct AppState {
pool: Pool<Postgres>,
serve_dir: String,
}
impl AppState {
pub fn new(pool: Pool<Postgres>, serve_dir: String) -> Self {
Self { pool, serve_dir }
}
pub fn pool(&self) -> &Pool<Postgres> {
&self.pool
}
pub fn serve_dir(&self) -> &str {
&self.serve_dir
}
}

View File

@@ -0,0 +1,27 @@
use axum::Json;
use axum::extract::{Path, State};
use serde::Deserialize;
use uuid::Uuid;
use crate::Result;
use crate::api::AppState;
use crate::warrens::Warren;
use crate::fs::DirectoryEntry;
#[derive(Deserialize)]
pub(super) struct WarrenRequestPath {
warren_id: Uuid,
rest: Option<String>,
}
pub(super) async fn route(
State(state): State<AppState>,
Path(WarrenRequestPath { warren_id, rest }): Path<WarrenRequestPath>,
) -> Result<Json<Vec<DirectoryEntry>>> {
let warren = Warren::get(state.pool(), &warren_id).await?;
let entries = warren.read_path(state.serve_dir(), rest).await?;
Ok(Json(entries))
}

View File

@@ -0,0 +1,9 @@
use axum::{Json, extract::State};
use crate::{Result, api::AppState, warrens::Warren};
pub(super) async fn route(State(state): State<AppState>) -> Result<Json<Vec<Warren>>> {
let warrens = Warren::list(state.pool()).await?;
Ok(Json(warrens))
}

View File

@@ -0,0 +1,13 @@
mod get_warren_path;
mod list_warrens;
use axum::routing::get;
use crate::server::Router;
pub(super) fn router() -> Router {
Router::new()
.route("/", get(list_warrens::route))
.route("/{warren_id}", get(get_warren_path::route))
.route("/{warren_id}/{*rest}", get(get_warren_path::route))
}

28
backend/src/db/mod.rs Normal file
View File

@@ -0,0 +1,28 @@
use std::env;
use sqlx::{Connection, PgConnection, Pool, Postgres, postgres::PgPoolOptions};
use crate::error::AppError;
pub async fn get_postgres_pool() -> Result<Pool<Postgres>, AppError> {
let host = env::var("POSTGRES_HOST")?;
let port = env::var("POSTGRES_PORT")?;
let user = env::var("POSTGRES_USER")?;
let password = env::var("POSTGRES_PASSWORD")?;
let database = env::var("POSTGRES_DATABASE")?;
let base_url = format!("postgres://{user}:{password}@{host}:{port}");
let mut connection = PgConnection::connect(&base_url).await?;
let _ = sqlx::query(&format!("CREATE DATABASE {database}"))
.execute(&mut connection)
.await;
connection.close().await?;
let url = format!("{base_url}/{database}");
let pool = PgPoolOptions::new().connect(&url).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(pool)
}

44
backend/src/error.rs Normal file
View File

@@ -0,0 +1,44 @@
use axum::{
body::Body,
http::{StatusCode, header::InvalidHeaderValue},
response::{IntoResponse, Response},
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("IO: {0}")]
Io(#[from] std::io::Error),
#[error("Could not get file name")]
FileName,
#[error("SQLx: {0}")]
Sqlx(#[from] sqlx::Error),
#[error("Failed to migrate the database")]
DatabaseMigration(#[from] sqlx::migrate::MigrateError),
#[error("Var: {0}")]
Var(#[from] std::env::VarError),
#[error("InvalidHeaderValue: {0}")]
InvalidHeaderValue(#[from] InvalidHeaderValue),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match self {
Self::Io(error) => match error.kind() {
std::io::ErrorKind::NotFound => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
},
Self::FileName => StatusCode::INTERNAL_SERVER_ERROR,
// TODO: Improve
Self::Sqlx(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::DatabaseMigration(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Var(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::InvalidHeaderValue(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
Response::builder()
.status(status)
.body(Body::empty())
.unwrap()
}
}

38
backend/src/fs/dir.rs Normal file
View File

@@ -0,0 +1,38 @@
use std::path::Path;
use tokio::fs;
use crate::{Result, error::AppError};
use super::{DirectoryEntry, FileType};
pub async fn get_dir_entries<P>(path: P) -> Result<Vec<DirectoryEntry>>
where
P: AsRef<Path>,
{
let mut dir = fs::read_dir(path).await?;
let mut files = Vec::new();
while let Ok(Some(entry)) = dir.next_entry().await {
let name = entry
.file_name()
.into_string()
.map_err(|_| AppError::FileName)?;
let file_type = {
let file_type = entry.file_type().await?;
if file_type.is_dir() {
FileType::Directory
} else if file_type.is_file() {
FileType::File
} else {
continue;
}
};
files.push(DirectoryEntry::new(name, file_type));
}
Ok(files)
}

24
backend/src/fs/mod.rs Normal file
View File

@@ -0,0 +1,24 @@
mod dir;
pub use dir::*;
use serde::Serialize;
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum FileType {
File,
Directory,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirectoryEntry {
name: String,
file_type: FileType,
}
impl DirectoryEntry {
pub fn new(name: String, file_type: FileType) -> Self {
Self { name, file_type }
}
}

View File

@@ -1,7 +1,17 @@
use db::get_postgres_pool;
use error::AppError;
pub mod api;
pub mod db;
pub mod error;
pub mod fs;
mod server;
pub mod warrens;
pub type Result<T> = std::result::Result<T, AppError>;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
async fn main() -> std::result::Result<(), AppError> {
dotenv::dotenv().ok();
env_logger::builder()
@@ -11,7 +21,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.parse_default_env()
.init();
server::start().await?;
let pool = get_postgres_pool().await?;
server::start(pool).await?;
Ok(())
}

View File

@@ -1,11 +1,32 @@
use std::{env, error::Error};
use std::env;
use axum::Router;
use axum::http::HeaderValue;
use sqlx::{Pool, Postgres};
use tokio::net::TcpListener;
use tower_http::services::ServeDir;
use tower_http::{
cors::{self, CorsLayer},
services::ServeDir,
};
pub(super) async fn start() -> Result<(), Box<dyn Error>> {
let mut app = Router::new();
use crate::{
Result,
api::{self, AppState},
};
pub type Router = axum::Router<AppState>;
pub(super) async fn start(pool: Pool<Postgres>) -> Result<()> {
let cors_origin = env::var("CORS_ALLOW_ORIGIN").unwrap_or("*".to_string());
let serve_dir = std::env::var("SERVE_DIRECTORY")?;
let mut app = Router::new()
.nest("/api", api::router())
.layer(
CorsLayer::new().allow_origin(cors::AllowOrigin::exact(HeaderValue::from_str(
&cors_origin,
)?)),
)
.with_state(AppState::new(pool, serve_dir));
if !cfg!(debug_assertions) {
let frontend_path = env::var("STATIC_FRONTEND_DIR").unwrap_or("./frontend".to_string());

49
backend/src/warrens/db.rs Normal file
View File

@@ -0,0 +1,49 @@
use sqlx::{Acquire, PgExecutor, Postgres};
use uuid::Uuid;
use crate::Result;
use super::Warren;
impl Warren {
pub async fn get<'c, E>(e: E, id: &Uuid) -> Result<Self>
where
E: PgExecutor<'c> + Acquire<'c, Database = Postgres>,
{
let warren: Self = sqlx::query_as(
"
SELECT
*
FROM
warrens
WHERE
id = $1
",
)
.bind(id)
.fetch_one(e)
.await?;
Ok(warren)
}
pub async fn list<'c, E>(e: E) -> Result<Vec<Self>>
where
E: PgExecutor<'c> + Acquire<'c, Database = Postgres>,
{
let warren: Vec<Self> = sqlx::query_as(
"
SELECT
*
FROM
warrens
LIMIT
50
",
)
.fetch_all(e)
.await?;
Ok(warren)
}
}

View File

@@ -0,0 +1,53 @@
pub mod db;
use std::path::PathBuf;
use serde::Serialize;
use sqlx::prelude::FromRow;
use uuid::Uuid;
use crate::{
Result,
fs::{DirectoryEntry, get_dir_entries},
};
#[derive(Debug, Clone, Serialize, FromRow)]
#[serde(rename_all = "camelCase")]
pub struct Warren {
id: Uuid,
name: String,
#[serde(skip)]
path: String,
}
impl Warren {
pub fn id(&self) -> &Uuid {
&self.id
}
pub fn name(&self) -> &str {
&self.name
}
pub fn path(&self) -> &str {
&self.path
}
}
impl Warren {
pub async fn read_path(
&self,
serve_path: &str,
path: Option<String>,
) -> Result<Vec<DirectoryEntry>> {
let mut final_path = PathBuf::from(serve_path);
final_path.push(self.path.strip_prefix("/").unwrap_or(&self.path));
if let Some(ref rest) = path {
final_path.push(rest);
}
get_dir_entries(final_path).await
}
}