Skip to content

Commit adefc69

Browse files
Merge pull request #41 from Coduck-Team/36-file-manager
Implement file CRUD
2 parents b6c366d + 5187ea1 commit adefc69

File tree

11 files changed

+978
-4
lines changed

11 files changed

+978
-4
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ Cargo.lock
1616
# Per Editor Configuration
1717
.idea/
1818
.vscode/
19+
20+
# Uploads directory
21+
uploads/

Cargo.toml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ path = "src/main.rs"
1111
name = "coduck-backend"
1212

1313
[dependencies]
14-
axum = "0.8.4"
14+
anyhow = "1.0"
15+
axum = { version = "0.8.4", features = ["json", "multipart"] }
16+
chrono = { version = "0.4.38", features = ["serde"] }
17+
reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] }
18+
serde = { version = "1.0.219", features = ["derive"] }
19+
serde_json = "1.0.133"
1520
tokio = { version = "1.45.1", features = ["full"] }
21+
uuid = { version = "1.17.0", features = ["v4"] }
1622

1723
[dev-dependencies]
18-
reqwest = { version = "0.12.19", features = ["json", "rustls-tls"] }

src/errors/language.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
use serde::{Deserialize, Serialize};
2+
3+
#[derive(Debug, PartialEq, Serialize, Deserialize)]
4+
pub enum LanguageError {
5+
UnsupportedExtension(String),
6+
InvalidFilename,
7+
}
8+
9+
impl std::fmt::Display for LanguageError {
10+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11+
match self {
12+
LanguageError::UnsupportedExtension(extension) => {
13+
write!(f, "Unsupported file extension: {extension}")
14+
}
15+
LanguageError::InvalidFilename => write!(f, "Invalid filename"),
16+
}
17+
}
18+
}

src/errors/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mod language;
2+
3+
pub(crate) use language::*;

src/file_manager/handlers.rs

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
use crate::file_manager::{
2+
FileMetadata, Language, UpdateFileContentRequest, UpdateFilenameRequest,
3+
};
4+
5+
use anyhow::{anyhow, Result};
6+
use axum::{
7+
extract::{Multipart, Path},
8+
http::StatusCode,
9+
response::IntoResponse,
10+
Json,
11+
};
12+
use chrono::Utc;
13+
use std::path::PathBuf;
14+
use tokio::fs;
15+
use uuid::Uuid;
16+
17+
const UPLOAD_DIR: &str = "uploads";
18+
19+
async fn file_exists(file_path: &PathBuf) -> bool {
20+
tokio::fs::metadata(file_path).await.is_ok()
21+
}
22+
23+
pub async fn upload_file(
24+
Path((problem_id, category)): Path<(u32, String)>,
25+
multipart: Multipart,
26+
) -> impl IntoResponse {
27+
let (filename, data) = match extract_multipart_data(multipart).await {
28+
Ok(data) => data,
29+
Err(e) => {
30+
return (
31+
StatusCode::BAD_REQUEST,
32+
Json(serde_json::json!({
33+
"error": e.to_string()
34+
})),
35+
)
36+
.into_response();
37+
}
38+
};
39+
40+
let file_id = Uuid::new_v4().to_string();
41+
let upload_dir = PathBuf::from(UPLOAD_DIR)
42+
.join(problem_id.to_string())
43+
.join(&category);
44+
45+
let file_path = upload_dir.join(&filename);
46+
47+
if file_exists(&file_path).await {
48+
return (
49+
StatusCode::CONFLICT,
50+
Json(serde_json::json!({
51+
"error": format!("File '{filename}' already exists in this category")
52+
})),
53+
)
54+
.into_response();
55+
}
56+
57+
let now = Utc::now()
58+
.timestamp_nanos_opt()
59+
.expect("Failed to get timestamp");
60+
let language = match Language::from_filename(&filename) {
61+
Ok(lang) => lang,
62+
Err(e) => {
63+
return (
64+
StatusCode::BAD_REQUEST,
65+
Json(serde_json::json!({
66+
"error": e.to_string()
67+
})),
68+
)
69+
.into_response();
70+
}
71+
};
72+
73+
if let Err(e) = save_file(&upload_dir, &file_path, &data).await {
74+
return (
75+
StatusCode::INTERNAL_SERVER_ERROR,
76+
Json(serde_json::json!({
77+
"error": e.to_string()
78+
})),
79+
)
80+
.into_response();
81+
}
82+
83+
let metadata = FileMetadata {
84+
id: file_id,
85+
filename: filename,
86+
language: language,
87+
category: category,
88+
size: data.len() as u64,
89+
created_at: now,
90+
updated_at: now,
91+
};
92+
(StatusCode::CREATED, Json(metadata)).into_response()
93+
}
94+
95+
async fn save_file(
96+
upload_dir: &PathBuf,
97+
file_path: &PathBuf,
98+
data: &axum::body::Bytes,
99+
) -> Result<()> {
100+
fs::create_dir_all(upload_dir)
101+
.await
102+
.map_err(|_| anyhow!("Failed to create directory"))?;
103+
104+
fs::write(file_path, data)
105+
.await
106+
.map_err(|_| anyhow!("Failed to write file"))?;
107+
108+
Ok(())
109+
}
110+
111+
async fn extract_multipart_data(mut multipart: Multipart) -> Result<(String, axum::body::Bytes)> {
112+
let field = multipart
113+
.next_field()
114+
.await?
115+
.ok_or_else(|| anyhow!("Missing multipart field"))?;
116+
117+
let filename = field
118+
.file_name()
119+
.ok_or_else(|| anyhow!("Missing filename in multipart field"))?
120+
.to_string();
121+
122+
let data = field.bytes().await?;
123+
124+
Ok((filename, data))
125+
}
126+
127+
pub async fn get_file(
128+
Path((problem_id, category, filename)): Path<(u32, String, String)>,
129+
) -> impl IntoResponse {
130+
let file_path = PathBuf::from(UPLOAD_DIR)
131+
.join(problem_id.to_string())
132+
.join(&category)
133+
.join(&filename);
134+
135+
if !file_exists(&file_path).await {
136+
return (
137+
StatusCode::NOT_FOUND,
138+
Json(serde_json::json!({
139+
"error": format!("File '{filename}' not found in category '{category}'")
140+
})),
141+
)
142+
.into_response();
143+
}
144+
145+
match fs::read(&file_path).await {
146+
Ok(content) => {
147+
let content_str = String::from_utf8_lossy(&content);
148+
149+
(
150+
StatusCode::OK,
151+
[("Content-Type", "text/html; charset=UTF-8")],
152+
content_str.to_string(),
153+
)
154+
.into_response()
155+
}
156+
Err(_) => (
157+
StatusCode::INTERNAL_SERVER_ERROR,
158+
Json(serde_json::json!({
159+
"error": "Failed to read file content"
160+
})),
161+
)
162+
.into_response(),
163+
}
164+
}
165+
166+
pub async fn get_files_by_category(
167+
Path((problem_id, category)): Path<(u32, String)>,
168+
) -> impl IntoResponse {
169+
let category_dir = PathBuf::from(UPLOAD_DIR)
170+
.join(problem_id.to_string())
171+
.join(&category);
172+
173+
if !file_exists(&category_dir).await {
174+
return (
175+
StatusCode::NOT_FOUND,
176+
Json(serde_json::json!({
177+
"error": format!("Category '{category}' not found for problem {problem_id}")
178+
})),
179+
)
180+
.into_response();
181+
}
182+
183+
let mut files = Vec::new();
184+
185+
match fs::read_dir(&category_dir).await {
186+
Ok(mut entries) => {
187+
while let Ok(Some(entry)) = entries.next_entry().await {
188+
let path = entry.path();
189+
if path.is_file() {
190+
if let Some(filename) = path.file_name().and_then(|f| f.to_str()) {
191+
files.push(filename.to_string());
192+
}
193+
}
194+
}
195+
}
196+
Err(_) => {
197+
return (
198+
StatusCode::INTERNAL_SERVER_ERROR,
199+
Json(serde_json::json!({
200+
"error": "Failed to read directory"
201+
})),
202+
)
203+
.into_response();
204+
}
205+
}
206+
207+
(StatusCode::OK, Json(files)).into_response()
208+
}
209+
210+
pub async fn delete_file(
211+
Path((problem_id, category, filename)): Path<(u32, String, String)>,
212+
) -> impl IntoResponse {
213+
let file_path = PathBuf::from(UPLOAD_DIR)
214+
.join(problem_id.to_string())
215+
.join(&category)
216+
.join(&filename);
217+
218+
if !file_exists(&file_path).await {
219+
return (
220+
StatusCode::NOT_FOUND,
221+
Json(serde_json::json!({
222+
"error": format!("File '{filename}' not found in category '{category}'")
223+
})),
224+
)
225+
.into_response();
226+
}
227+
228+
match fs::remove_file(&file_path).await {
229+
Ok(_) => (
230+
StatusCode::OK,
231+
Json(serde_json::json!({
232+
"message": format!("File '{filename}' deleted successfully")
233+
})),
234+
)
235+
.into_response(),
236+
Err(_) => (
237+
StatusCode::INTERNAL_SERVER_ERROR,
238+
Json(serde_json::json!({
239+
"error": "Failed to delete file"
240+
})),
241+
)
242+
.into_response(),
243+
}
244+
}
245+
246+
pub async fn update_file_content(
247+
Path((problem_id, category, filename)): Path<(u32, String, String)>,
248+
Json(update_request): Json<UpdateFileContentRequest>,
249+
) -> impl IntoResponse {
250+
let file_path = PathBuf::from(UPLOAD_DIR)
251+
.join(problem_id.to_string())
252+
.join(&category)
253+
.join(&filename);
254+
255+
if !file_exists(&file_path).await {
256+
return (
257+
StatusCode::NOT_FOUND,
258+
Json(serde_json::json!({
259+
"error": format!("File '{filename}' not found in category '{category}'")
260+
})),
261+
)
262+
.into_response();
263+
}
264+
265+
match fs::write(&file_path, &update_request.content).await {
266+
Ok(_) => (
267+
StatusCode::OK,
268+
Json(serde_json::json!({
269+
"message": format!("File '{filename}' updated successfully")
270+
})),
271+
)
272+
.into_response(),
273+
Err(_) => (
274+
StatusCode::INTERNAL_SERVER_ERROR,
275+
Json(serde_json::json!({
276+
"error": "Failed to update file"
277+
})),
278+
)
279+
.into_response(),
280+
}
281+
}
282+
283+
pub async fn update_filename(
284+
Path((problem_id, category)): Path<(u32, String)>,
285+
Json(update_request): Json<UpdateFilenameRequest>,
286+
) -> impl IntoResponse {
287+
let file_path = PathBuf::from(UPLOAD_DIR)
288+
.join(problem_id.to_string())
289+
.join(&category)
290+
.join(&update_request.old_filename);
291+
292+
if !file_exists(&file_path).await {
293+
return (
294+
StatusCode::NOT_FOUND,
295+
Json(serde_json::json!({
296+
"error": format!(
297+
"File '{}' not found in category '{category}'",
298+
update_request.old_filename
299+
)
300+
})),
301+
)
302+
.into_response();
303+
}
304+
305+
match fs::rename(
306+
&file_path,
307+
&file_path.with_file_name(&update_request.new_filename),
308+
)
309+
.await
310+
{
311+
Ok(_) => (
312+
StatusCode::OK,
313+
Json(serde_json::json!({
314+
"message": format!(
315+
"File '{}' updated successfully to '{}'",
316+
update_request.old_filename, update_request.new_filename
317+
)
318+
})),
319+
)
320+
.into_response(),
321+
Err(_) => (
322+
StatusCode::INTERNAL_SERVER_ERROR,
323+
Json(serde_json::json!({
324+
"error": "Failed to update filename"
325+
})),
326+
)
327+
.into_response(),
328+
}
329+
}

src/file_manager/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
mod handlers;
2+
mod models;
3+
4+
pub(crate) use handlers::*;
5+
pub use models::*;

0 commit comments

Comments
 (0)