juiceback/routes/
manage.rs

1//! Endpoints for getting file info, deleting, renewing the ID, and looking up owned files.
2
3use axum::{
4    extract::{Path, Query, State},
5    http::{HeaderMap, StatusCode},
6    response::Redirect,
7    Form, Json,
8};
9use serde::{Deserialize, Serialize};
10use std::sync::Arc;
11use utoipa::{IntoParams, ToSchema};
12
13use crate::db;
14use crate::error::AppError;
15use crate::routes::noscript;
16use crate::state::AppState;
17
18/// Request body for the owned-files bulk lookup endpoint.
19#[derive(Deserialize, ToSchema)]
20pub struct OwnedFilesRequest {
21    pub pairs: Vec<FilePair>,
22}
23
24/// An ID and delete-token pair to prove you own a file.
25#[derive(Deserialize, ToSchema)]
26pub struct FilePair {
27    pub id: String,
28    pub token: String,
29}
30
31/// Response containing the caller's owned files.
32#[derive(Serialize, ToSchema)]
33pub struct OwnedFilesResponse {
34    pub files: Vec<FileInfoResponse>,
35}
36
37#[utoipa::path(
38    post,
39    path = "/api/owned-files",
40    request_body(content = OwnedFilesRequest, description = "List of file IDs and delete tokens you own"),
41    responses(
42        (status = 200, description = "List of files you own that havent expired yet", body = OwnedFilesResponse),
43    ),
44    tag = "Files",
45)]
46pub async fn owned_files_handler(
47    State(state): State<Arc<AppState>>,
48    Json(payload): Json<OwnedFilesRequest>,
49) -> Result<Json<OwnedFilesResponse>, AppError> {
50    let now = chrono::Utc::now().timestamp();
51
52    let ids: Vec<String> = payload.pairs.iter().map(|p| p.id.clone()).collect();
53    let token_map: std::collections::HashMap<String, &str> = payload
54        .pairs
55        .iter()
56        .map(|p| (p.id.clone(), p.token.as_str()))
57        .collect();
58
59    let state2 = Arc::clone(&state);
60    let records = tokio::task::spawn_blocking(move || -> Result<Vec<db::FileRecord>, AppError> {
61        let db = state2.db.get().map_err(|e| AppError::DbPoolError(e.to_string()))?;
62        db::get_files_by_ids(&db, &ids).map_err(AppError::DatabaseError)
63    })
64    .await
65    .map_err(|_| AppError::TaskPanicked("db task panicked".into()))?
66    .map_err(|e| e)?;
67
68    let files: Vec<FileInfoResponse> = records
69        .into_iter()
70        .filter(|r| {
71            r.expires_at >= now
72                && token_map
73                    .get(&r.id)
74                    .map(|t| constant_time_eq(&r.delete_token, t))
75                    .unwrap_or(false)
76        })
77        .map(|r| {
78            let url = if r.storage_host.is_empty() {
79                format!("{}/f/{}", state.config.public_base_url, &r.id)
80            } else {
81                format!("{}/f/{}", r.storage_host, &r.id)
82            };
83            FileInfoResponse {
84                url,
85                id: r.id,
86                filename: r.filename,
87                mime_type: r.mime_type,
88                size_bytes: r.size_bytes,
89                uploaded_at: r.uploaded_at,
90                expires_at: r.expires_at,
91                storage_host: r.storage_host,
92            }
93        })
94        .collect();
95
96    Ok(Json(OwnedFilesResponse { files }))
97}
98
99/// Public metadata returned by the file-info and owned-files endpoints.
100#[derive(Serialize, ToSchema)]
101pub struct FileInfoResponse {
102    pub id: String,
103    pub filename: String,
104    pub mime_type: String,
105    pub size_bytes: i64,
106    pub uploaded_at: i64,
107    pub expires_at: i64,
108    pub url: String,
109    pub storage_host: String,
110}
111
112#[derive(Deserialize, IntoParams)]
113pub struct RenewParams {
114    /// The delete token you received when uploading the file.
115    pub token: String,
116}
117
118/// Response returned after a successful file-ID renewal.
119#[derive(Serialize, ToSchema)]
120pub struct RenewResponse {
121    pub id: String,
122    pub url: String,
123    pub filename: String,
124    pub expires_at: i64,
125}
126
127#[utoipa::path(
128    get,
129    path = "/file/{id}/info",
130    params(
131        ("id" = String, Path, description = "The file ID"),
132    ),
133    responses(
134        (status = 200, description = "File metadata", body = FileInfoResponse),
135        (status = 404, description = "File not found"),
136        (status = 410, description = "File has expired"),
137    ),
138    tag = "Files",
139)]
140#[tracing::instrument(skip_all)]
141pub async fn file_info_handler(
142    State(state): State<Arc<AppState>>,
143    Path(id): Path<String>,
144) -> Result<Json<FileInfoResponse>, AppError> {
145    let state2 = Arc::clone(&state);
146    let id2 = id.clone();
147
148    let record = tokio::task::spawn_blocking(move || -> Result<Option<db::FileRecord>, AppError> {
149        let db = state2.db.get().map_err(|e| AppError::DbPoolError(e.to_string()))?;
150        db::get_file(&db, &id2).map_err(AppError::DatabaseError)
151    })
152    .await
153    .map_err(|_| AppError::TaskPanicked("db task panicked".into()))?
154    .map_err(|e| e)?
155    .ok_or(AppError::NotFound)?;
156
157    let now = chrono::Utc::now().timestamp();
158    if record.expires_at < now {
159        return Err(AppError::Gone);
160    }
161
162    let id = record.id.clone();
163    let url = if record.storage_host.is_empty() {
164        format!("{}/f/{}", state.config.public_base_url, &id)
165    } else {
166        format!("{}/f/{}", record.storage_host, &id)
167    };
168    Ok(Json(FileInfoResponse {
169        url,
170        id,
171        filename: record.filename,
172        mime_type: record.mime_type,
173        size_bytes: record.size_bytes,
174        uploaded_at: record.uploaded_at,
175        expires_at: record.expires_at,
176        storage_host: record.storage_host,
177    }))
178}
179
180/// POST /file/:id/delete - no-JS form deletion that redirects to /files.
181pub async fn delete_file_form_handler(
182    State(state): State<Arc<AppState>>,
183    Path(id): Path<String>,
184    Form(params): Form<noscript::DeleteForm>,
185) -> Result<Redirect, AppError> {
186    delete_file_inner(&state, &id, &params.token).await?;
187    Ok(Redirect::to(&format!("/files.html?deleted={}", id)))
188}
189
190async fn delete_file_inner(state: &Arc<AppState>, id: &str, token: &str) -> Result<(), AppError> {
191    let state2 = Arc::clone(state);
192    let id2 = id.to_string();
193
194    let record = tokio::task::spawn_blocking(move || -> Result<Option<db::FileRecord>, AppError> {
195        let db = state2.db.get().map_err(|e| AppError::DbPoolError(e.to_string()))?;
196        db::get_file(&db, &id2).map_err(AppError::DatabaseError)
197    })
198    .await
199    .map_err(|_| AppError::TaskPanicked("db task panicked".into()))?
200    .map_err(|e| e)?
201    .ok_or(AppError::NotFound)?;
202
203    if !constant_time_eq(&record.delete_token, token) {
204        return Err(AppError::Forbidden);
205    }
206
207    let state3 = Arc::clone(state);
208    let id3 = id.to_string();
209    tokio::task::spawn_blocking(move || -> Result<bool, AppError> {
210        let db = state3.db.get().map_err(|e| AppError::DbPoolError(e.to_string()))?;
211        db::delete_file(&db, &id3).map_err(AppError::DatabaseError)
212    })
213    .await
214    .map_err(|_| AppError::TaskPanicked("db task panicked".into()))?
215    .map_err(|e| e)?;
216
217    let host_opt = if record.storage_host.is_empty() { None } else { Some(record.storage_host.as_str()) };
218    crate::juicehost::delete_file_on_juicehost(state, id, host_opt).await;
219
220    tracing::info!("delete: id={}", id);
221    Ok(())
222}
223
224fn constant_time_eq(a: &str, b: &str) -> bool {
225    if a.len() != b.len() {
226        return false;
227    }
228    a.bytes()
229        .zip(b.bytes())
230        .fold(0u8, |acc, (x, y)| acc | (x ^ y))
231        == 0
232}
233
234#[utoipa::path(
235    delete,
236    path = "/file/{id}",
237    params(
238        ("id" = String, Path, description = "The file ID"),
239    ),
240    request_body(content_type = "application/json", content = inline(serde_json::Value), description = "Set the x-delete-token header instead of a body"),
241    responses(
242        (status = 204, description = "File deleted"),
243        (status = 403, description = "Invalid delete token"),
244        (status = 404, description = "File not found"),
245    ),
246    tag = "Files",
247)]
248pub async fn delete_file_handler(
249    State(state): State<Arc<AppState>>,
250    headers: HeaderMap,
251    Path(id): Path<String>,
252) -> Result<StatusCode, AppError> {
253    let token = headers
254        .get("x-delete-token")
255        .and_then(|v| v.to_str().ok())
256        .unwrap_or("");
257
258    delete_file_inner(&state, &id, token).await?;
259
260    Ok(StatusCode::NO_CONTENT)
261}
262
263/// Rotate a file's ID to get a new public URL.
264/// You prove ownership with the delete token. The DB updates and juicehost
265/// renames the file so it stays reachable at the new ID.
266#[utoipa::path(
267    post,
268    path = "/file/{id}/renew",
269    params(
270        ("id" = String, Path, description = "The current file ID"),
271        ("token" = String, Query, description = "The delete token to prove ownership"),
272    ),
273    responses(
274        (status = 200, description = "File ID renewed", body = RenewResponse),
275        (status = 403, description = "Invalid delete token"),
276        (status = 404, description = "File not found"),
277        (status = 410, description = "File has expired"),
278    ),
279    tag = "Files",
280)]
281pub async fn renew_file_id_handler(
282    State(state): State<Arc<AppState>>,
283    Path(id): Path<String>,
284    Query(params): Query<RenewParams>,
285) -> Result<Json<RenewResponse>, AppError> {
286    let state2 = Arc::clone(&state);
287    let id2 = id.clone();
288
289    let record = tokio::task::spawn_blocking(move || -> Result<Option<db::FileRecord>, AppError> {
290        let db = state2.db.get().map_err(|e| AppError::DbPoolError(e.to_string()))?;
291        db::get_file(&db, &id2).map_err(AppError::DatabaseError)
292    })
293    .await
294    .map_err(|_| AppError::TaskPanicked("db task panicked".into()))?
295    .map_err(|e| e)?
296    .ok_or(AppError::NotFound)?;
297
298    let now = chrono::Utc::now().timestamp();
299    if record.expires_at < now {
300        return Err(AppError::Gone);
301    }
302
303    if !constant_time_eq(&record.delete_token, &params.token) {
304        return Err(AppError::Forbidden);
305    }
306
307    let new_id = nanoid::nanoid!(8);
308
309    let state3 = Arc::clone(&state);
310    let old_id = id.clone();
311    let new_id2 = new_id.clone();
312    let updated = tokio::task::spawn_blocking(move || -> Result<bool, AppError> {
313        let db = state3.db.get().map_err(|e| AppError::DbPoolError(e.to_string()))?;
314        db::renew_file_id(&db, &old_id, &new_id2, &format!("remote-{}", new_id2)).map_err(AppError::DatabaseError)
315    })
316    .await
317    .map_err(|_| AppError::TaskPanicked("db task panicked".into()))?;
318
319    match updated {
320        Ok(true) => {}
321        Ok(false) => return Err(AppError::NotFound),
322        Err(e) => return Err(e),
323    }
324
325    // Tell juicehost to rename the file so it's reachable at the new ID
326    let state_push = Arc::clone(&state);
327    let push_old_id = id.clone();
328    let push_new_id = new_id.clone();
329    let host_clone = record.storage_host.clone();
330    tokio::spawn(async move {
331        let host_opt = if host_clone.is_empty() { None } else { Some(host_clone.as_str()) };
332        crate::juicehost::rename_file_on_juicehost(&state_push, &push_old_id, &push_new_id, host_opt).await;
333    });
334
335    let url = if record.storage_host.is_empty() {
336        format!("{}/f/{}", state.config.public_base_url, new_id)
337    } else {
338        format!("{}/f/{}", record.storage_host, new_id)
339    };
340    tracing::info!("renew: old_id={} new_id={}", id, new_id);
341
342    Ok(Json(RenewResponse {
343        id: new_id,
344        url,
345        filename: record.filename,
346        expires_at: record.expires_at,
347    }))
348}