From 548dd7e9ef59229bc82ab205e68d19fbc9cdf006 Mon Sep 17 00:00:00 2001 From: 409 <409dev@protonmail.com> Date: Sun, 13 Jul 2025 07:17:40 +0200 Subject: [PATCH] file uploads --- backend/Cargo.lock | 27 +++ backend/Cargo.toml | 2 +- backend/src/api/warrens/mod.rs | 27 ++- backend/src/api/warrens/upload_files.rs | 34 +++ backend/src/fs/file.rs | 17 +- backend/src/warrens/mod.rs | 20 +- frontend/bun.lock | 6 + .../{ => actions}/CreateDirectoryDialog.vue | 0 frontend/components/actions/UploadDialog.vue | 196 ++++++++++++++++++ frontend/layouts/default.vue | 10 +- frontend/lib/api/warrens.ts | 80 ++++++- frontend/package.json | 2 + frontend/utils/icons.ts | 30 +++ frontend/utils/index.ts | 13 ++ 14 files changed, 451 insertions(+), 13 deletions(-) create mode 100644 backend/src/api/warrens/upload_files.rs rename frontend/components/{ => actions}/CreateDirectoryDialog.vue (100%) create mode 100644 frontend/components/actions/UploadDialog.vue create mode 100644 frontend/utils/icons.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 0b96bb4..5ebcbb9 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -116,6 +116,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "rustversion", @@ -345,6 +346,15 @@ dependencies = [ "serde", ] +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "env_filter" version = "0.1.3" @@ -958,6 +968,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 7167c40..d5eab92 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -axum = { version = "0.8.4", features = ["query"] } +axum = { version = "0.8.4", features = ["multipart", "query"] } dotenv = "0.15.0" env_logger = "0.11.8" log = "0.4.27" diff --git a/backend/src/api/warrens/mod.rs b/backend/src/api/warrens/mod.rs index 9d5f3ed..4c7e45e 100644 --- a/backend/src/api/warrens/mod.rs +++ b/backend/src/api/warrens/mod.rs @@ -2,16 +2,33 @@ mod create_directory; mod delete_directory; mod get_warren_path; mod list_warrens; +mod upload_files; -use axum::routing::{delete, get, post}; +use axum::{ + extract::DefaultBodyLimit, + routing::{delete, get, post}, +}; use crate::server::Router; pub(super) fn router() -> Router { Router::new() .route("/", get(list_warrens::route)) - .route("/{warren_id}", get(get_warren_path::route)) - .route("/{warren_id}/{*rest}", get(get_warren_path::route)) - .route("/{warren_id}/{*rest}", post(create_directory::route)) - .route("/{warren_id}/{*rest}", delete(delete_directory::route)) + .route("/{warren_id}/get", get(get_warren_path::route)) + .route("/{warren_id}/get/{*rest}", get(get_warren_path::route)) + .route("/{warren_id}/create/{*rest}", post(create_directory::route)) + .route( + "/{warren_id}/delete/{*rest}", + delete(delete_directory::route), + ) + .route( + "/{warren_id}/upload", + // 536870912 bytes = 0.5GB + post(upload_files::route).route_layer(DefaultBodyLimit::max(536870912)), + ) + .route( + "/{warren_id}/upload/{*rest}", + // 536870912 bytes = 0.5GB + post(upload_files::route).route_layer(DefaultBodyLimit::max(536870912)), + ) } diff --git a/backend/src/api/warrens/upload_files.rs b/backend/src/api/warrens/upload_files.rs new file mode 100644 index 0000000..98d7939 --- /dev/null +++ b/backend/src/api/warrens/upload_files.rs @@ -0,0 +1,34 @@ +use axum::extract::{Multipart, Path, State}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::{Result, api::AppState, warrens::Warren}; + +#[derive(Deserialize)] +pub(super) struct UploadPath { + warren_id: Uuid, + rest: Option, +} + +pub(super) async fn route( + State(state): State, + Path(UploadPath { warren_id, rest }): Path, + mut multipart: Multipart, +) -> Result<()> { + let warren = Warren::get(state.pool(), &warren_id).await?; + + while let Ok(Some(field)) = multipart.next_field().await { + if field.name().is_none_or(|name| name != "files") { + continue; + }; + + let file_name = field.file_name().map(str::to_owned).unwrap(); + let data = field.bytes().await.unwrap(); + + warren + .upload(state.serve_dir(), rest.as_deref(), &file_name, &data) + .await?; + } + + Ok(()) +} diff --git a/backend/src/fs/file.rs b/backend/src/fs/file.rs index b637da5..41db4c7 100644 --- a/backend/src/fs/file.rs +++ b/backend/src/fs/file.rs @@ -1,9 +1,24 @@ use std::path::Path; -use tokio::fs; +use tokio::{fs, io::AsyncWriteExt}; use crate::Result; +pub async fn write_file

(path: P, data: &[u8]) -> Result<()> +where + P: AsRef, +{ + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .open(path) + .await?; + + file.write_all(data).await?; + + Ok(()) +} + pub async fn delete_file

(path: P) -> Result<()> where P: AsRef, diff --git a/backend/src/warrens/mod.rs b/backend/src/warrens/mod.rs index b655138..415ea7a 100644 --- a/backend/src/warrens/mod.rs +++ b/backend/src/warrens/mod.rs @@ -8,7 +8,9 @@ use uuid::Uuid; use crate::{ Result, - fs::{DirectoryEntry, FileType, create_dir, delete_dir, delete_file, get_dir_entries}, + fs::{ + DirectoryEntry, FileType, create_dir, delete_dir, delete_file, get_dir_entries, write_file, + }, }; #[derive(Debug, Clone, Serialize, FromRow)] @@ -64,6 +66,20 @@ impl Warren { FileType::Directory => delete_dir(path).await, } } + + pub async fn upload( + &self, + serve_path: &str, + rest_path: Option<&str>, + file_name: &str, + data: &[u8], + ) -> Result<()> { + let rest = format!("{}/{file_name}", rest_path.unwrap_or("")); + + let path = build_path(serve_path, &self.path, Some(&rest)); + + write_file(path, data).await + } } fn build_path(serve_path: &str, warren_path: &str, rest_path: Option<&str>) -> PathBuf { @@ -72,7 +88,7 @@ fn build_path(serve_path: &str, warren_path: &str, rest_path: Option<&str>) -> P final_path.push(warren_path.strip_prefix("/").unwrap_or(warren_path)); if let Some(ref rest) = rest_path { - final_path.push(rest); + final_path.push(rest.strip_prefix("/").unwrap_or(rest)); } final_path diff --git a/frontend/bun.lock b/frontend/bun.lock index 6f7e2f3..77915f8 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -12,6 +12,7 @@ "@pinia/nuxt": "^0.11.1", "@tailwindcss/vite": "^4.1.11", "@vueuse/core": "^13.5.0", + "byte-size": "^9.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "eslint": "^9.0.0", @@ -30,6 +31,7 @@ "devDependencies": { "@iconify-json/lucide": "^1.2.57", "@nuxtjs/color-mode": "^3.5.2", + "@types/byte-size": "^8.1.2", "eslint-config-prettier": "^10.1.5", "prettier": "^3.6.2", }, @@ -490,6 +492,8 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.9.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw=="], + "@types/byte-size": ["@types/byte-size@8.1.2", "", {}, "sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -676,6 +680,8 @@ "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], + "byte-size": ["byte-size@9.0.1", "", { "peerDependencies": { "@75lb/nature": "latest" }, "optionalPeers": ["@75lb/nature"] }, "sha512-YLe9x3rabBrcI0cueCdLS2l5ONUKywcRpTs02B8KP9/Cimhj7o3ZccGrPnRvcbyHMbb7W79/3MUJl7iGgTXKEw=="], + "c12": ["c12@3.0.4", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.5.0", "exsolve": "^1.0.5", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.1.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-t5FaZTYbbCtvxuZq9xxIruYydrAGsJ+8UdP0pZzMiK2xl/gNiSOy0OxhLzHUEEb0m1QXYqfzfvyIFEmz/g9lqg=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], diff --git a/frontend/components/CreateDirectoryDialog.vue b/frontend/components/actions/CreateDirectoryDialog.vue similarity index 100% rename from frontend/components/CreateDirectoryDialog.vue rename to frontend/components/actions/CreateDirectoryDialog.vue diff --git a/frontend/components/actions/UploadDialog.vue b/frontend/components/actions/UploadDialog.vue new file mode 100644 index 0000000..477df7d --- /dev/null +++ b/frontend/components/actions/UploadDialog.vue @@ -0,0 +1,196 @@ + + + diff --git a/frontend/layouts/default.vue b/frontend/layouts/default.vue index 2f62f60..eabe830 100644 --- a/frontend/layouts/default.vue +++ b/frontend/layouts/default.vue @@ -8,6 +8,9 @@ import { BreadcrumbPage, BreadcrumbSeparator, } from '@/components/ui/breadcrumb'; +import CreateDirectoryDialog from '~/components/actions/CreateDirectoryDialog.vue'; +import UploadDialog from '~/components/actions/UploadDialog.vue'; + const route = useRoute(); const breadcrumbs = computed(() => getBreadcrumbs(route.path)); @@ -53,13 +56,18 @@ const breadcrumbs = computed(() => getBreadcrumbs(route.path));

+ + +
diff --git a/frontend/lib/api/warrens.ts b/frontend/lib/api/warrens.ts index f876dc0..d8a9d16 100644 --- a/frontend/lib/api/warrens.ts +++ b/frontend/lib/api/warrens.ts @@ -26,8 +26,17 @@ export async function getWarrens(): Promise> { export async function getWarrenDirectory( path: string ): Promise { + // eslint-disable-next-line prefer-const + let [warrenId, rest] = splitOnce(path, '/'); + + if (rest == null) { + rest = ''; + } else { + rest = '/' + rest; + } + const { data: entries, error } = await useFetch( - getApiUrl(`warrens/${path}`), + getApiUrl(`warrens/${warrenId}/get${rest}`), { method: 'GET', } @@ -44,8 +53,17 @@ export async function createDirectory( path: string, directoryName: string ): Promise<{ success: boolean }> { + // eslint-disable-next-line prefer-const + let [warrenId, rest] = splitOnce(path, '/'); + + if (rest == null) { + rest = ''; + } else { + rest += '/'; + } + const { status } = await useFetch( - getApiUrl(`warrens/${path}/${directoryName}`), + getApiUrl(`warrens/${warrenId}/create/${rest}${directoryName}`), { method: 'POST', } @@ -74,8 +92,19 @@ export async function deleteWarrenEntry( directoryName: string, fileType: FileType ): Promise<{ success: boolean }> { + // eslint-disable-next-line prefer-const + let [warrenId, rest] = splitOnce(path, '/'); + + if (rest == null) { + rest = ''; + } else { + rest = '/' + rest; + } + const { status } = await useFetch( - getApiUrl(`warrens/${path}/${directoryName}?fileType=${fileType}`), + getApiUrl( + `warrens/${warrenId}/delete${rest}/${directoryName}?fileType=${fileType}` + ), { method: 'DELETE', } @@ -99,3 +128,48 @@ export async function deleteWarrenEntry( }); return { success: true }; } + +export async function uploadToWarren( + path: string, + files: FileList +): Promise<{ success: boolean }> { + const body = new FormData(); + for (const file of files) { + body.append('files', file); + } + + // eslint-disable-next-line prefer-const + let [warrenId, rest] = splitOnce(path, '/'); + + if (rest == null) { + rest = ''; + } else { + rest = '/' + rest; + } + + const { status } = await useFetch( + getApiUrl(`warrens/${warrenId}/upload${rest}`), + { + method: 'POST', + body, + key: 'upload-' + new Date().getTime().toString(), + } + ); + + if (status.value !== 'success') { + toast.error('Upload', { + id: 'UPLOAD_FILE_TOAST', + description: `Failed to upload`, + }); + return { success: false }; + } + + await refreshNuxtData('current-directory'); + + toast.success('Upload', { + id: 'UPLOAD_FILE_TOAST', + description: `Successfully uploaded ${files.length} file${files.length !== 1 ? 's' : ''}`, + }); + + return { success: true }; +} diff --git a/frontend/package.json b/frontend/package.json index b3b6bd7..370cdf5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@pinia/nuxt": "^0.11.1", "@tailwindcss/vite": "^4.1.11", "@vueuse/core": "^13.5.0", + "byte-size": "^9.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "eslint": "^9.0.0", @@ -37,6 +38,7 @@ "devDependencies": { "@iconify-json/lucide": "^1.2.57", "@nuxtjs/color-mode": "^3.5.2", + "@types/byte-size": "^8.1.2", "eslint-config-prettier": "^10.1.5", "prettier": "^3.6.2" } diff --git a/frontend/utils/icons.ts b/frontend/utils/icons.ts new file mode 100644 index 0000000..365d46e --- /dev/null +++ b/frontend/utils/icons.ts @@ -0,0 +1,30 @@ +export function getFileIcon(fileType: string) { + if (fileType.startsWith('image/')) { + return 'lucide:file-image'; + } + + if (fileType.startsWith('video/')) { + return 'lucide:file-video-2'; + } + + if (fileType.startsWith('audio/')) { + return 'lucide:file-audio-2'; + } + + if (fileType.startsWith('application/')) { + if (fileType === 'application/x-msdownload') { + return 'lucide:file-box'; + } + if (fileType === 'application/json') { + return 'lucide:file-json'; + } + if (fileType === 'application/pdf') { + return 'lucide:file-text'; + } + } + if (fileType === 'text/html') { + return 'lucide:file-code'; + } + + return 'lucide:file'; +} diff --git a/frontend/utils/index.ts b/frontend/utils/index.ts index face788..7fcfb53 100644 --- a/frontend/utils/index.ts +++ b/frontend/utils/index.ts @@ -47,3 +47,16 @@ export function joinPaths(base: string, other: string): string { return base + other; } + +export function splitOnce( + str: string, + search: string +): [string, string | null] { + const index = str.indexOf(search); + + if (index === -1) { + return [str, null]; + } + + return [str.slice(0, index), str.slice(index + 1)]; +}