juiceback/routes/
register.rs

1//! Internal endpoint for juicehost to register a file it already stored.
2//! Avoids pushing the same data twice when juicehost already has the file.
3
4use axum::{
5    extract::State,
6    http::HeaderMap,
7    Json,
8};
9use serde::{Deserialize, Serialize};
10use std::sync::Arc;
11
12use crate::db::{self, FileRecord};
13use crate::error::AppError;
14use crate::routes::upload::sanitize_filename;
15use crate::state::AppState;
16
17/// Payload accepted by the register endpoint.
18#[derive(Deserialize)]
19pub struct RegisterRequest {
20    pub id: String,
21    pub filename: String,
22    pub mime_type: String,
23    pub size_bytes: i64,
24    pub ttl_hours: Option<u32>,
25    pub uploader_ip: Option<String>,
26    pub storage_host: Option<String>,
27}
28
29/// Response returned by the register endpoint.
30#[derive(Serialize)]
31pub struct RegisterResponse {
32    pub delete_token: String,
33    pub expires_at: i64,
34}
35
36pub async fn register_handler(
37    State(state): State<Arc<AppState>>,
38    headers: HeaderMap,
39    Json(payload): Json<RegisterRequest>,
40) -> Result<Json<RegisterResponse>, AppError> {
41    if !state.config.juicehost_api_key.is_empty() {
42        let provided = headers
43            .get("x-juicehost-api-key")
44            .and_then(|v| v.to_str().ok())
45            .unwrap_or("");
46        if provided != state.config.juicehost_api_key {
47            return Err(AppError::Forbidden);
48        }
49    }
50
51    if payload.id.is_empty() || payload.id.chars().any(|c| !c.is_alphanumeric() && c != '-' && c != '_') {
52        return Err(AppError::InvalidMultipart("Invalid file ID format".into()));
53    }
54
55    let filename = sanitize_filename(&payload.filename);
56
57    let ttl_hours = payload
58        .ttl_hours
59        .unwrap_or(state.config.default_ttl_hours)
60        .clamp(1, state.config.max_ttl_hours);
61
62    let now = chrono::Utc::now().timestamp();
63    let delete_token = uuid::Uuid::new_v4().to_string();
64    let expires_at = now + (ttl_hours as i64 * 3600);
65
66    let file_id = payload.id.clone();
67    let record = FileRecord {
68        id: payload.id,
69        filename,
70        mime_type: payload.mime_type,
71        size_bytes: payload.size_bytes,
72        storage_path: format!("remote-{}", file_id),
73        delete_token: delete_token.clone(),
74        uploaded_at: now,
75        expires_at,
76        uploader_ip: payload.uploader_ip,
77        storage_host: payload.storage_host.unwrap_or_default(),
78    };
79
80    let state2 = Arc::clone(&state);
81    let record2 = record.clone();
82
83    tokio::task::spawn_blocking(move || -> Result<(), AppError> {
84        let db = state2
85            .db
86            .get()
87            .map_err(|e| AppError::DbPoolError(e.to_string()))?;
88        db::insert_file(&db, &record2).map_err(AppError::DatabaseError)
89    })
90    .await
91    .map_err(|_| AppError::TaskPanicked("db task panicked".into()))?
92    .map_err(|e| e)?;
93
94    tracing::info!(
95        "register: id={} size={} mime={} expires_at={}",
96        record.id,
97        record.size_bytes,
98        record.mime_type,
99        record.expires_at,
100    );
101
102    Ok(Json(RegisterResponse {
103        delete_token,
104        expires_at,
105    }))
106}