feat: persist upload creation metadata

Add the first upload API endpoint from PLAN.md. POST /api/uploads now
validates the requested file name, generates a server-owned upload id, creates
the staging and complete directory layout, and writes durable meta.json before
returning chunk scheduling details to the browser.

Keep filesystem layout knowledge in storage.rs so later chunk upload and
completion work can reuse the same boundary. API handlers translate storage
errors into JSON HTTP responses without leaking layout details into the router.

Document the new modules and UPL_DATA_DIR configuration, and extend TESTS.md
with the automated creation coverage.

Test Plan:
- just check

Refs: PLAN.md milestone 2
This commit is contained in:
2026-05-30 16:57:48 +02:00
parent a3f369f437
commit 24ecdbd251
9 changed files with 458 additions and 4 deletions
+65
View File
@@ -0,0 +1,65 @@
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Serialize;
use crate::{
app::AppState,
model::{CreateUploadRequest, CreateUploadResponse},
storage::StorageError,
};
/// Creates an upload record and persists its metadata before returning.
///
/// # Errors
///
/// Returns an API error when request validation fails or metadata cannot be
/// written to storage.
pub async fn create_upload(
State(state): State<AppState>,
Json(request): Json<CreateUploadRequest>,
) -> Result<Json<CreateUploadResponse>, ApiError> {
let meta = state.storage.create_upload(request).await?;
Ok(Json(meta.create_response()))
}
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
message: String,
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(
self.status,
Json(ErrorResponse {
error: self.message,
}),
)
.into_response()
}
}
impl From<StorageError> for ApiError {
fn from(error: StorageError) -> Self {
let status = if error.is_invalid_input() {
StatusCode::BAD_REQUEST
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
Self {
status,
message: error.to_string(),
}
}
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
+31 -2
View File
@@ -5,17 +5,29 @@ use std::{
path::{Path, PathBuf},
};
use axum::{Router, routing::get};
use axum::{
Router,
routing::{get, post},
};
use tower_http::services::{ServeDir, ServeFile};
use crate::{api, storage::Storage};
const DEFAULT_BIND_ADDR: &str = "127.0.0.1:3000";
const STATIC_DIR_ENV: &str = "UPL_STATIC_DIR";
const DATA_DIR_ENV: &str = "UPL_DATA_DIR";
const BIND_ENV: &str = "UPL_BIND";
#[derive(Clone, Debug)]
pub struct AppConfig {
pub bind_addr: SocketAddr,
pub static_dir: PathBuf,
pub data_dir: PathBuf,
}
#[derive(Clone, Debug)]
pub struct AppState {
pub storage: Storage,
}
impl AppConfig {
@@ -29,26 +41,39 @@ impl AppConfig {
.unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_owned())
.parse()?;
let static_dir = env::var_os(STATIC_DIR_ENV).map_or_else(default_static_dir, PathBuf::from);
let data_dir = env::var_os(DATA_DIR_ENV).map_or_else(default_data_dir, PathBuf::from);
Ok(Self {
bind_addr,
static_dir,
data_dir,
})
}
#[must_use]
pub fn new(bind_addr: SocketAddr, static_dir: impl Into<PathBuf>) -> Self {
pub fn new(
bind_addr: SocketAddr,
static_dir: impl Into<PathBuf>,
data_dir: impl Into<PathBuf>,
) -> Self {
Self {
bind_addr,
static_dir: static_dir.into(),
data_dir: data_dir.into(),
}
}
}
pub fn build_router(config: &AppConfig) -> Router {
let state = AppState {
storage: Storage::new(&config.data_dir),
};
Router::new()
.route("/healthz", get(healthz))
.route("/api/uploads", post(api::create_upload))
.fallback_service(static_service(&config.static_dir))
.with_state(state)
}
async fn healthz() -> &'static str {
@@ -62,3 +87,7 @@ fn static_service(static_dir: &Path) -> ServeDir<ServeFile> {
fn default_static_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("static")
}
fn default_data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data")
}
+3
View File
@@ -1 +1,4 @@
pub mod api;
pub mod app;
pub mod model;
pub mod storage;
+42
View File
@@ -0,0 +1,42 @@
use serde::{Deserialize, Serialize};
pub const CHUNK_SIZE: u64 = 16 * 1024 * 1024;
#[derive(Debug, Deserialize)]
pub struct CreateUploadRequest {
pub name: String,
pub size: u64,
pub last_modified: i64,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CreateUploadResponse {
pub upload_id: String,
pub chunk_size: u64,
pub total_chunks: u64,
pub completed_chunks: Vec<u64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UploadMeta {
pub id: String,
pub original_name: String,
pub safe_name: String,
pub size: u64,
pub last_modified: i64,
pub chunk_size: u64,
pub total_chunks: u64,
pub created_at: String,
}
impl UploadMeta {
#[must_use]
pub fn create_response(&self) -> CreateUploadResponse {
CreateUploadResponse {
upload_id: self.id.clone(),
chunk_size: self.chunk_size,
total_chunks: self.total_chunks,
completed_chunks: Vec::new(),
}
}
}
+208
View File
@@ -0,0 +1,208 @@
use std::{
error::Error,
fmt::{self, Display},
path::{Path, PathBuf},
};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tokio::fs;
use uuid::Uuid;
use crate::model::{CHUNK_SIZE, CreateUploadRequest, UploadMeta};
#[derive(Clone, Debug)]
pub struct Storage {
data_dir: PathBuf,
}
impl Storage {
#[must_use]
pub fn new(data_dir: impl Into<PathBuf>) -> Self {
Self {
data_dir: data_dir.into(),
}
}
/// Creates a durable upload metadata record under `data/staging`.
///
/// # Errors
///
/// Returns an error when directories cannot be created, metadata cannot be
/// serialized, or the metadata file cannot be written atomically.
pub async fn create_upload(
&self,
request: CreateUploadRequest,
) -> Result<UploadMeta, StorageError> {
let original_name = request.name.trim();
if original_name.is_empty() {
return Err(StorageError::InvalidInput("file name cannot be empty"));
}
self.ensure_layout().await?;
let safe_name = safe_file_name(original_name);
let created_at = OffsetDateTime::now_utc().format(&Rfc3339)?;
for _ in 0..8 {
let id = Uuid::new_v4().simple().to_string();
let upload_dir = self.upload_dir(&id);
if fs::try_exists(&upload_dir).await? {
continue;
}
fs::create_dir_all(upload_dir.join("chunks")).await?;
let meta = UploadMeta {
id,
original_name: original_name.to_owned(),
safe_name,
size: request.size,
last_modified: request.last_modified,
chunk_size: CHUNK_SIZE,
total_chunks: total_chunks(request.size, CHUNK_SIZE),
created_at,
};
self.write_meta(&meta).await?;
return Ok(meta);
}
Err(StorageError::IdCollision)
}
#[must_use]
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
fn staging_dir(&self) -> PathBuf {
self.data_dir.join("staging")
}
fn complete_dir(&self) -> PathBuf {
self.data_dir.join("complete")
}
fn upload_dir(&self, upload_id: &str) -> PathBuf {
self.staging_dir().join(upload_id)
}
async fn ensure_layout(&self) -> Result<(), StorageError> {
fs::create_dir_all(self.staging_dir()).await?;
fs::create_dir_all(self.complete_dir()).await?;
Ok(())
}
async fn write_meta(&self, meta: &UploadMeta) -> Result<(), StorageError> {
let meta_path = self.upload_dir(&meta.id).join("meta.json");
let tmp_path = meta_path.with_extension("json.tmp");
let json = serde_json::to_vec_pretty(meta)?;
fs::write(&tmp_path, json).await?;
fs::rename(&tmp_path, &meta_path).await?;
Ok(())
}
}
#[derive(Debug)]
pub enum StorageError {
Format(time::error::Format),
IdCollision,
InvalidInput(&'static str),
Io(std::io::Error),
Json(serde_json::Error),
}
impl StorageError {
#[must_use]
pub fn is_invalid_input(&self) -> bool {
matches!(self, Self::InvalidInput(_))
}
}
impl Display for StorageError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Format(error) => write!(formatter, "failed to format timestamp: {error}"),
Self::IdCollision => formatter.write_str("could not allocate a unique upload id"),
Self::InvalidInput(message) => formatter.write_str(message),
Self::Io(error) => write!(formatter, "storage I/O error: {error}"),
Self::Json(error) => write!(formatter, "metadata JSON error: {error}"),
}
}
}
impl Error for StorageError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Format(error) => Some(error),
Self::Io(error) => Some(error),
Self::Json(error) => Some(error),
Self::IdCollision | Self::InvalidInput(_) => None,
}
}
}
impl From<std::io::Error> for StorageError {
fn from(error: std::io::Error) -> Self {
Self::Io(error)
}
}
impl From<serde_json::Error> for StorageError {
fn from(error: serde_json::Error) -> Self {
Self::Json(error)
}
}
impl From<time::error::Format> for StorageError {
fn from(error: time::error::Format) -> Self {
Self::Format(error)
}
}
fn safe_file_name(name: &str) -> String {
let safe: String = name
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
character if character.is_control() => '_',
character => character,
})
.collect();
let trimmed = safe.trim_matches(['.', ' ', '_']);
if trimmed.is_empty() {
"upload".to_owned()
} else {
trimmed.to_owned()
}
}
fn total_chunks(size: u64, chunk_size: u64) -> u64 {
if size == 0 {
0
} else {
size.div_ceil(chunk_size)
}
}
#[cfg(test)]
mod tests {
use super::{safe_file_name, total_chunks};
#[test]
fn computes_total_chunks() {
assert_eq!(total_chunks(0, 16), 0);
assert_eq!(total_chunks(1, 16), 1);
assert_eq!(total_chunks(16, 16), 1);
assert_eq!(total_chunks(17, 16), 2);
}
#[test]
fn sanitizes_file_names() {
assert_eq!(safe_file_name("../movie:mkv"), "movie_mkv");
assert_eq!(safe_file_name(" "), "upload");
}
}