1use 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#[derive(Deserialize, ToSchema)]
20pub struct OwnedFilesRequest {
21 pub pairs: Vec<FilePair>,
22}
23
24#[derive(Deserialize, ToSchema)]
26pub struct FilePair {
27 pub id: String,
28 pub token: String,
29}
30
31#[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#[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 pub token: String,
116}
117
118#[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
180pub 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, ¶ms.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#[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, ¶ms.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 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}