Skip to main content

webdav_server/api/
assets.rs

1use crate::{
2    Error::ApiError,
3    api::Error::{BadRequest, InvalidHeader, NotFound},
4    app::State as AppState,
5    error, info,
6    logger::Module,
7    model::asset::{Asset, AssetMetadataRow, AssetTag},
8    storage::Payload,
9};
10use axum::{
11    Extension, Json,
12    body::Body,
13    extract::{Path, Query, State},
14    response::IntoResponse,
15};
16use hyper::{HeaderMap, StatusCode};
17use serde::{Deserialize, Serialize};
18use tokio_util::io::ReaderStream;
19
20use crate::Result;
21
22/// Handles asset deletion requests (DELETE `/api/v1/assets/{hash}`).
23///
24/// This endpoint removes the user's ownership of the specified asset hash.
25/// If the asset is no longer owned by any user, the underlying physical file
26/// is deleted from the storage vault.
27///
28/// # Errors
29/// - Returns a `BadRequest` if the hash string cannot be decoded from hex.
30/// - Returns an internal error if the database transaction or file deletion fails.
31pub async fn delete(
32    State(state): State<AppState>,
33    Extension(user_id): Extension<i64>,
34    Path(hash_str): Path<String>,
35) -> Result<impl IntoResponse> {
36    let hash_bytes = hex::decode(&hash_str)
37        .map_err(|_| BadRequest("Invalid Hash Format".into()))?;
38
39    Asset::delete(&state.db, user_id, &hash_bytes).await?;
40
41    state.storage.delete_blob(&hash_str).await?;
42
43    info!(
44        Module::Asset,
45        "deleted ownership over blob \"{hash_str}\" with success"
46    );
47    Ok(StatusCode::NO_CONTENT)
48}
49
50/// Handles asset download requests (GET `/api/v1/assets/{hash}`).
51///
52/// Verifies that the requesting user owns the asset with the specified hash,
53/// and if authorized, streams the physical file back to the client.
54///
55/// # Errors
56/// - Returns a `BadRequest` if the hash string cannot be decoded from hex.
57/// - Returns a `NotFound` if the user does not own the asset or if the physical file is missing.
58/// - Returns an internal error if the database query fails.
59///
60/// # Panics
61/// This function panics if the hardcoded "application/octet-stream" content type cannot be parsed.
62pub async fn get(
63    State(state): State<AppState>,
64    Extension(user_id): Extension<i64>,
65    Path(hash_str): Path<String>,
66) -> Result<impl IntoResponse> {
67    let hash_bytes = hex::decode(&hash_str)
68        .map_err(|_| BadRequest("Invalid Hash Format".into()))?;
69
70    let owns_file = Asset::owned_by(&state.db, user_id, &hash_bytes).await?;
71
72    if !owns_file {
73        error!(
74            Module::Asset,
75            "Attempt to access unauthorized blob '{hash_str}' by user {user_id}"
76        );
77        return Err(ApiError(NotFound(
78            "Asset not found or Unauthorized".into(),
79        )));
80    }
81
82    let file = state
83        .storage
84        .get_blob(&hash_str)
85        .await
86        .map_err(|_| NotFound("File Missing".into()))?;
87
88    let stream = ReaderStream::new(file);
89    let body = Body::from_stream(stream);
90
91    let mut headers = HeaderMap::new();
92    headers.insert(
93        "Content-Type",
94        "application/octet-stream"
95            .parse()
96            .map_err(|e| ApiError(InvalidHeader(e)))?,
97    );
98
99    Ok((StatusCode::OK, headers, body))
100}
101
102#[derive(Serialize)]
103#[serde(untagged)]
104enum FileStatus {
105    Success { file_name: String, hash: String },
106    Failure { file_name: String, error: String },
107}
108
109impl FileStatus {
110    const fn success(file_name: String, hash: String) -> Self {
111        Self::Success { file_name, hash }
112    }
113
114    fn failure(file_name: String, error: &impl ToString) -> Self {
115        Self::Failure {
116            file_name,
117            error: error.to_string(),
118        }
119    }
120}
121
122/// Handles streaming asset uploads (POST `/api/v1/upload/{tag}`).
123///
124/// This endpoint processes raw binary streams from the client, calculates the BLAKE3 hash
125/// incrementally, and commits the file to the Content-Addressable Storage (CAS) vault.
126/// The uploaded file's metadata is then recorded in the database.
127///
128/// # Errors
129/// - Returns a `BadRequest` if required headers (`X-File-Name`, `Content-Length`) are missing or malformed.
130/// - Returns a `BadRequest` if the payload size exceeds the 10GB limit.
131/// - Returns an internal error if the storage transaction or database insertion fails.
132pub async fn upload(
133    State(state): State<AppState>,
134    Path(tag_str): Path<String>,
135    headers: HeaderMap,
136    Extension(user_id): Extension<i64>,
137    body: Body,
138) -> crate::Result<impl IntoResponse> {
139    let tag = AssetTag::try_from(tag_str.as_str())
140        .map_err(|()| BadRequest("Invalid Tag".into()))?;
141
142    let file_name = headers
143        .get("X-File-Name")
144        .and_then(|v| v.to_str().ok())
145        .ok_or_else(|| BadRequest("Missing X-File-Name header".into()))?;
146
147    let expected_size: u64 = headers
148        .get("Content-Length")
149        .and_then(|x| x.to_str().ok())
150        .and_then(|x| x.parse().ok())
151        .ok_or_else(|| BadRequest("Missing content length in header".into()))?;
152
153    if expected_size > 10_000_000_000 {
154        return Err(BadRequest("Payload too Large".into()).into());
155    }
156
157    let f_stream = body.into_data_stream();
158
159    let status = match state
160        .storage
161        .try_save(file_name, Payload::new(expected_size, f_stream))
162        .await
163    {
164        Ok(metadata) => {
165            match Asset::create(&state.db, user_id, tag, &metadata).await {
166                Ok(()) => {
167                    info!(
168                        Module::Asset,
169                        "upload success, user owns {}",
170                        metadata.hash.to_string()
171                    );
172                    FileStatus::success(
173                        file_name.into(),
174                        metadata.hash.to_string(),
175                    )
176                }
177                Err(e) => {
178                    info!(
179                        Module::Asset,
180                        "user {user_id} failed to register asset ownership to database '{file_name}': {e}"
181                    );
182                    FileStatus::failure(file_name.into(), &e)
183                }
184            }
185        }
186
187        Err(e) => {
188            info!(
189                Module::Asset,
190                "user {user_id} failed to uplaod '{file_name}'. Failed to write this blob to disk with error {e}"
191            );
192            FileStatus::failure(file_name.into(), &e)
193        }
194    };
195
196    Ok(Json(status))
197}
198
199#[derive(Deserialize)]
200pub struct ListQuery {
201    pub tag: Option<i16>,
202}
203
204#[derive(Serialize)]
205pub struct ListResponse {
206    pub assets: Vec<AssetMetadataRow>,
207}
208
209pub async fn list(
210    State(state): State<AppState>,
211    Extension(user_id): Extension<i64>,
212    Query(query): Query<ListQuery>,
213) -> Result<impl IntoResponse> {
214    let assets = Asset::list(&state.db, user_id, query.tag).await?;
215
216    Ok(Json(ListResponse { assets }))
217}