juiceback/
error.rs

1//! Error types that map to HTTP responses.
2
3use axum::{
4    http::StatusCode,
5    response::{IntoResponse, Response},
6    Json,
7};
8use serde_json::json;
9
10/// Every error the app can return. Each variant maps to an HTTP status code
11/// and a JSON body with an error code and a message.
12#[derive(Debug)]
13pub enum AppError {
14    // -- 404 / 410 --
15    NotFound,
16    Gone,
17    Forbidden,
18    Conflict,
19
20    // -- Upload validation (400) --
21    /// File payload is empty (0 bytes after decompression)
22    FileEmpty,
23    /// Filename is empty after sanitisation
24    FilenameEmpty,
25    /// Original filename exceeds 255 characters
26    FilenameTooLong,
27    /// Filename contains invalid characters (sanitised away entirely)
28    InvalidFilename,
29    /// Upload body claims gzip but decompression failed
30    GzipDecodeFailed,
31    /// TTL value could not be parsed from the request
32    InvalidTtlValue,
33    /// TTL is below the minimum allowed
34    TtlTooLow,
35    /// TTL is above the maximum allowed
36    TtlTooHigh,
37    /// No file field was present in the multipart upload
38    MissingFileField,
39    /// The public_base_url config is empty and a URL cannot be generated
40    PublicUrlNotConfigured,
41    /// File ID contains invalid characters
42    InvalidIdFormat,
43
44    // -- TUS protocol errors (400 / 409 / 404) --
45    /// Upload-Length header missing or unparseable
46    TusMissingLength,
47    /// Upload-Offset header missing or unparseable
48    TusMissingOffset,
49    /// Client-supplied offset does not match server state
50    TusOffsetMismatch,
51    /// TUS upload session not found
52    TusSessionNotFound,
53    /// Failed to create the staging file for a new TUS upload
54    TusStagingError,
55
56    // -- Size / rate (413 / 429) --
57    PayloadTooLarge,
58    RateLimited,
59
60    // -- Storage backend --
61    /// Cannot reach juicehost at all (connection refused / dns)
62    JuicehostUnreachable(String),
63    /// juicehost returned a non-2xx status
64    JuicehostRejected(String),
65    /// juicehost returned 507, disk is full
66    InsufficientStorage(String),
67    /// juicehost delete returned an error
68    JuicehostDeleteFailed(String),
69    /// juicehost rename returned an error
70    JuicehostRenameFailed(String),
71
72    // -- Generic server errors (500) --
73    FilesystemError(std::io::Error),
74    DatabaseError(rusqlite::Error),
75    /// Could not acquire a connection from the database pool
76    DbPoolError(String),
77    /// A background task (spawn_blocking) panicked
78    TaskPanicked(String),
79    BadRequest(String),
80    InvalidMultipart(String),
81    Internal(String),
82}
83
84impl IntoResponse for AppError {
85    fn into_response(self) -> Response {
86        let (status, error_code, message) = match self {
87            // -- 404 / 403 / 410 / 409 --
88            AppError::NotFound => (
89                StatusCode::NOT_FOUND,
90                "FILE_NOT_FOUND",
91                "File not found",
92            ),
93            AppError::Gone => (
94                StatusCode::GONE,
95                "FILE_EXPIRED",
96                "File has expired",
97            ),
98            AppError::Forbidden => (
99                StatusCode::FORBIDDEN,
100                "FORBIDDEN",
101                "Invalid delete token",
102            ),
103            AppError::Conflict => (
104                StatusCode::CONFLICT,
105                "CONFLICT",
106                "Could not allocate a new ID, please retry",
107            ),
108
109            // -- Upload validation --
110            AppError::FileEmpty => (
111                StatusCode::BAD_REQUEST,
112                "FILE_EMPTY",
113                "Uploaded file is empty",
114            ),
115            AppError::FilenameEmpty => (
116                StatusCode::BAD_REQUEST,
117                "FILENAME_EMPTY",
118                "Filename is empty after sanitisation",
119            ),
120            AppError::FilenameTooLong => (
121                StatusCode::BAD_REQUEST,
122                "FILENAME_TOO_LONG",
123                "Filename exceeds the maximum length of 255 characters",
124            ),
125            AppError::InvalidFilename => (
126                StatusCode::BAD_REQUEST,
127                "INVALID_FILENAME",
128                "Filename contains invalid characters",
129            ),
130            AppError::GzipDecodeFailed => (
131                StatusCode::BAD_REQUEST,
132                "GZIP_DECODE_FAILED",
133                "Gzip decompression failed, the data may be corrupt or not actually gzip-compressed",
134            ),
135            AppError::InvalidTtlValue => (
136                StatusCode::BAD_REQUEST,
137                "INVALID_TTL",
138                "Retention time must be a valid number",
139            ),
140            AppError::TtlTooLow => (
141                StatusCode::BAD_REQUEST,
142                "TTL_TOO_LOW",
143                "Retention time is below the minimum allowed",
144            ),
145            AppError::TtlTooHigh => (
146                StatusCode::BAD_REQUEST,
147                "TTL_TOO_HIGH",
148                "Retention time exceeds the maximum allowed",
149            ),
150            AppError::MissingFileField => (
151                StatusCode::BAD_REQUEST,
152                "MISSING_FILE_FIELD",
153                "No file field was found in the upload request",
154            ),
155            AppError::PublicUrlNotConfigured => (
156                StatusCode::INTERNAL_SERVER_ERROR,
157                "PUBLIC_URL_NOT_CONFIGURED",
158                "Server is not properly configured, public URL is missing",
159            ),
160            AppError::InvalidIdFormat => (
161                StatusCode::BAD_REQUEST,
162                "INVALID_ID_FORMAT",
163                "File ID contains invalid characters",
164            ),
165
166            // -- TUS protocol --
167            AppError::TusMissingLength => (
168                StatusCode::BAD_REQUEST,
169                "TUS_MISSING_LENGTH",
170                "Missing or invalid Upload-Length header",
171            ),
172            AppError::TusMissingOffset => (
173                StatusCode::BAD_REQUEST,
174                "TUS_MISSING_OFFSET",
175                "Missing or invalid Upload-Offset header",
176            ),
177            AppError::TusOffsetMismatch => (
178                StatusCode::CONFLICT,
179                "TUS_OFFSET_MISMATCH",
180                "Upload offset does not match server state",
181            ),
182            AppError::TusSessionNotFound => (
183                StatusCode::NOT_FOUND,
184                "TUS_SESSION_NOT_FOUND",
185                "TUS upload session not found — it may have expired or been cancelled",
186            ),
187            AppError::TusStagingError => (
188                StatusCode::INTERNAL_SERVER_ERROR,
189                "TUS_STAGING_ERROR",
190                "Failed to initialise TUS upload on server",
191            ),
192
193            // -- Size / rate --
194            AppError::PayloadTooLarge => (
195                StatusCode::PAYLOAD_TOO_LARGE,
196                "FILE_TOO_LARGE",
197                "File exceeds the maximum allowed size",
198            ),
199            AppError::RateLimited => (
200                StatusCode::TOO_MANY_REQUESTS,
201                "RATE_LIMITED",
202                "Too many uploads — please wait before trying again",
203            ),
204
205            // -- Storage backend --
206            AppError::JuicehostUnreachable(ref e) => {
207                tracing::error!("juicehost unreachable: {}", e);
208                (
209                    StatusCode::BAD_GATEWAY,
210                    "STORAGE_UNREACHABLE",
211                    "File storage backend is unreachable — please try again later",
212                )
213            }
214            AppError::JuicehostRejected(ref e) => {
215                tracing::error!("juicehost rejected upload: {}", e);
216                // Extract clean message from "[CODE] message (status=N)" format
217                let clean = e
218                    .strip_prefix('[')
219                    .and_then(|s| s.find("] "))
220                    .map(|i| &e[i + 2..])
221                    .and_then(|s| s.rsplit_once(" (status="))
222                    .map(|(msg, _)| msg)
223                    .unwrap_or(e.as_str());
224                (
225                    StatusCode::BAD_GATEWAY,
226                    "STORAGE_REJECTED",
227                    clean,
228                )
229            }
230            AppError::InsufficientStorage(ref e) => {
231                tracing::warn!("juicehost out of storage: {}", e);
232                let clean = e
233                    .strip_prefix('[')
234                    .and_then(|s| s.find("] "))
235                    .map(|i| &e[i + 2..])
236                    .and_then(|s| s.rsplit_once(" (status="))
237                    .map(|(msg, _)| msg)
238                    .unwrap_or("This instance is out of storage! Try again later.");
239                (
240                    StatusCode::SERVICE_UNAVAILABLE,
241                    "INSUFFICIENT_STORAGE",
242                    clean,
243                )
244            }
245            AppError::JuicehostDeleteFailed(ref e) => {
246                tracing::error!("juicehost delete failed: {}", e);
247                (
248                    StatusCode::BAD_GATEWAY,
249                    "STORAGE_DELETE_FAILED",
250                    "Failed to remove file from storage backend",
251                )
252            }
253            AppError::JuicehostRenameFailed(ref e) => {
254                tracing::error!("juicehost rename failed: {}", e);
255                (
256                    StatusCode::BAD_GATEWAY,
257                    "STORAGE_RENAME_FAILED",
258                    "Failed to rename file on storage backend",
259                )
260            }
261
262            // -- Generic server errors --
263            AppError::FilesystemError(ref e) => {
264                tracing::error!("filesystem error: {}", e);
265                (
266                    StatusCode::INTERNAL_SERVER_ERROR,
267                    "DISK_ERROR",
268                    "A server disk error occurred",
269                )
270            }
271            AppError::DatabaseError(ref e) => {
272                tracing::error!("database error: {}", e);
273                (
274                    StatusCode::INTERNAL_SERVER_ERROR,
275                    "DATABASE_ERROR",
276                    "A database error occurred",
277                )
278            }
279            AppError::DbPoolError(ref e) => {
280                tracing::error!("database pool error: {}", e);
281                (
282                    StatusCode::SERVICE_UNAVAILABLE,
283                    "DB_POOL_ERROR",
284                    "The server is temporarily unable to process uploads — please try again",
285                )
286            }
287            AppError::TaskPanicked(ref e) => {
288                tracing::error!("background task panicked: {}", e);
289                (
290                    StatusCode::INTERNAL_SERVER_ERROR,
291                    "TASK_PANICKED",
292                    "An internal processing task failed unexpectedly",
293                )
294            }
295            AppError::BadRequest(ref msg) => (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg.as_str()),
296            AppError::InvalidMultipart(ref msg) => {
297                (StatusCode::BAD_REQUEST, "INVALID_MULTIPART", msg.as_str())
298            }
299            AppError::Internal(ref msg) => {
300                tracing::error!("internal error: {}", msg);
301                (
302                    StatusCode::INTERNAL_SERVER_ERROR,
303                    "INTERNAL_ERROR",
304                    msg.as_str(),
305                )
306            }
307        };
308
309        let body = Json(json!({
310            "error": error_code,
311            "message": message,
312        }));
313
314        (status, body).into_response()
315    }
316}
317
318impl From<std::io::Error> for AppError {
319    fn from(err: std::io::Error) -> Self {
320        AppError::FilesystemError(err)
321    }
322}
323
324impl From<rusqlite::Error> for AppError {
325    fn from(err: rusqlite::Error) -> Self {
326        AppError::DatabaseError(err)
327    }
328}