From c423174110416148c0ef10e3c60047b389a71b47 Mon Sep 17 00:00:00 2001 From: kossLAN Date: Sat, 1 Mar 2025 21:39:56 -0500 Subject: [PATCH] feat: functional frontend for uploading api --- README.md | 8 +- pages/home.html | 298 ++++++++++++++++++++++++++++++++++++++++ pages/login.html | 22 +++ pages/static/common.css | 97 +++++++++++++ src/main.rs | 6 +- src/server.rs | 187 ++++++++++++++++++++----- 6 files changed, 580 insertions(+), 38 deletions(-) create mode 100644 pages/home.html create mode 100644 pages/login.html create mode 100644 pages/static/common.css diff --git a/README.md b/README.md index d65140e..0cde545 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,11 @@ Essentially tinyupload is a personal cdn, that you can host yourself. Why? mostl ## Motivation -I made this to solve a problem I have with discord, by using a vencord plugin eventually I'll be able to just upload directly to my instance of tinyupload and get past Discord's low 10mb upload limit, without having to pay $10/month to do so. +My free solution to getting by discord's absurdly low upload limit, a tiny cdn. ## RoadMap - [ ] Server Side Encryption -- [ ] Vencord Plugin -- [ ] Config File +- [ ] Config File (Maybe) *and probably many others* - -## Acknowledgements -This was really my first fully fledged project in rust, which was made a lot easier from the help of [outfoxxed](https://github.com/outfoxxed), check out some of the stuff he does. \ No newline at end of file diff --git a/pages/home.html b/pages/home.html new file mode 100644 index 0000000..6815d26 --- /dev/null +++ b/pages/home.html @@ -0,0 +1,298 @@ + + + + + + {{title}} + + + + +
+

{{title}}

+ +
+
+ Drag & drop a file here or click to browse + +
+
+ + + +
+
+
+ +
+

Upload Successful!

+ + +
+
+ + + + diff --git a/pages/login.html b/pages/login.html new file mode 100644 index 0000000..19640ae --- /dev/null +++ b/pages/login.html @@ -0,0 +1,22 @@ + + + + + + {{title}} - Login + + + +
+

{{title}} login

+
+
+ + +
+ +
+
+
+ + diff --git a/pages/static/common.css b/pages/static/common.css new file mode 100644 index 0000000..5bba4b7 --- /dev/null +++ b/pages/static/common.css @@ -0,0 +1,97 @@ +:root { + --primary-color: lightblue; + --secondary-color: lightgrey; + --accent-color: #4fc3f7; + --background-color: #2A2E32; + --text-color: white; + --success-color: #4caf50; + --container-bg: #40464C; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: var(--background-color); + color: var(--text-color); + line-height: 1.6; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100vh; +} + +.container { + width: 90%; + max-width: 600px; + background-color: var(--container-bg); + border-radius: 10px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + padding: 2rem; + margin: 2rem 0; + text-align: center; +} + +h1 { + color: var(--primary-color); + margin-bottom: 1.5rem; +} + +button { + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 5px; + padding: 0.8rem 1.5rem; + font-size: 1rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.3s ease; + width: 100%; +} + +button:hover { + background-color: var(--secondary-color); +} + +button:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +.form-group { + margin-bottom: 1.5rem; +} + +label { + display: block; + font-weight: 500; + margin-bottom: 0.5rem; + color: var(--secondary-color); + text-align: left; +} + +input { + width: 100%; + padding: 0.8rem; + border: 2px solid var(--secondary-color); + border-radius: 5px; + background-color: rgba(255, 255, 255, 0.1); + color: var(--text-color); + font-size: 1rem; + box-sizing: border-box; +} + +input:focus { + border-color: var(--accent-color); + outline: none; +} + +.error { + color: #f44336; + text-align: center; + margin-top: 1rem; + font-size: 0.9rem; +} + diff --git a/src/main.rs b/src/main.rs index e63de95..7673fa4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,10 @@ struct Cli { #[arg(short, long, default_value = "0.0.0.0:1337")] address: String, + /// The title displayed in the frontend of tinyupload. + #[arg(short, long, default_value = "tinyupload")] + title: String, + // The upload limit in Mb. #[arg(short, long, default_value_t = 100)] upload_size: usize, @@ -43,7 +47,7 @@ async fn main() { Commands::Serve {} => { println!("Starting server on {}", cli.address); let db = database::init(cli.database_url).await.unwrap(); - server::init(db, cli.files_path, cli.upload_size, cli.address).await; + server::init(db, cli.files_path, cli.upload_size, cli.address, cli.title).await; } Commands::Generate {} => { diff --git a/src/server.rs b/src/server.rs index b030711..36ba72d 100644 --- a/src/server.rs +++ b/src/server.rs @@ -2,12 +2,13 @@ use crate::database; use axum::{ body::Body, - extract::{DefaultBodyLimit, Multipart, Path, State}, + extract::{DefaultBodyLimit, Form, Multipart, Path, State}, http::{ header::{self, HeaderMap}, - StatusCode, + Request, StatusCode, }, - response::{IntoResponse, Response}, + middleware::{self, Next}, + response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, Router, }; @@ -24,6 +25,7 @@ use tokio::io::{AsyncReadExt, AsyncSeekExt}; struct AppState { db: SqlitePool, files_path: String, + title: String, } #[derive(Deserialize)] @@ -31,25 +33,120 @@ struct Media { urlpath: String, } -pub async fn init(db: SqlitePool, files_path: String, upload_size: usize, address: String) { - // Initialize the state - let state = AppState { db, files_path }; +#[derive(Deserialize)] +struct LoginForm { + key: String, +} + +const AUTH_COOKIE: &str = "auth_token"; + +pub async fn init( + db: SqlitePool, + files_path: String, + upload_size: usize, + address: String, + title: String, +) { + let state = AppState { + db, + files_path, + title, + }; - // Initialize tracing tracing_subscriber::fmt::init(); - // Build our application with a route let app = Router::new() - // Testing serving a file - .route("/:urlpath", get(stream)) + .route("/", get(home)) + .route("/login", get(login_page).post(process_login)) + .route("/static/common.css", get(css_file)) + .route("/file/:urlpath", get(stream)) .route("/upload", post(upload)) - .with_state(state) - .layer(DefaultBodyLimit::max(1024 * 1024 * upload_size)); + .layer(middleware::from_fn_with_state( + state.clone(), + auth_middleware, + )) + .layer(DefaultBodyLimit::max(1024 * 1024 * upload_size)) + .with_state(state); let listener = tokio::net::TcpListener::bind(address).await.unwrap(); axum::serve(listener, app).await.unwrap(); } +async fn home(State(state): State) -> impl IntoResponse { + let html = include_str!("../pages/home.html").replace("{{title}}", &state.title); + Html(html) +} + +async fn login_page(State(state): State) -> impl IntoResponse { + let html = include_str!("../pages/login.html").replace("{{title}}", &state.title); + Html(html) +} + +async fn css_file() -> impl IntoResponse { + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, "text/css".parse().unwrap()); + (headers, include_str!("../pages/static/common.css")) +} + +async fn process_login( + State(state): State, + Form(form): Form, +) -> impl IntoResponse { + if database::check_key(&state.db, form.key.clone()) + .await + .unwrap_or(false) + { + let mut response = Redirect::to("/").into_response(); + + let cookie = format!( + "{}={}; Path=/; HttpOnly; Max-Age=604800", + AUTH_COOKIE, form.key + ); + + response.headers_mut().insert( + header::SET_COOKIE, + header::HeaderValue::from_str(&cookie).unwrap(), + ); + + response + } else { + Redirect::to("/login").into_response() + } +} + +async fn auth_middleware( + State(state): State, + request: Request, + next: Next, +) -> Response { + let path = request.uri().path(); + let allowed_paths = ["/login", "/upload", "/static/common.css"]; + if allowed_paths.contains(&path) || path.starts_with("/file/") { + return next.run(request).await; + } + + let cookie_value = request + .headers() + .get(header::COOKIE) + .and_then(|_| get_cookie_value(request.headers(), AUTH_COOKIE)); + + let is_authenticated = match cookie_value { + Some(value) => match database::check_key(&state.db, value).await { + Ok(is_valid) => is_valid, + Err(_) => false, + }, + None => false, + }; + + // Allow other requests if user is authenticated + if is_authenticated { + next.run(request).await + } else { + Redirect::to("/login").into_response() + } +} + +// TODO: This only really works for video's right now, need to put some checks in for other file types async fn stream( State(state): State, Path(Media { urlpath }): Path, @@ -153,25 +250,33 @@ fn parse_range_header(h: &str, len: u64) -> Option<(u64, u64)> { } // Everything below is for uploading functionality - async fn upload( State(state): State, headers: HeaderMap, mut multipart: Multipart, ) -> Response { - // Create a response builder let mut response_builder = Response::builder(); - // Add CORS headers response_builder = response_builder.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"); - // Key validation - let key = headers - .get("x-api-key") - .unwrap_or(&"None".parse().unwrap()) - .to_str() - .unwrap() - .into(); + let scheme = headers + .get("x-forwarded-proto") + .and_then(|h| h.to_str().ok()) + .unwrap_or("http"); + + let host = headers + .get(header::HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost:1337"); + + // Key validation, check cookie if it, otherwise fall back to API key header (for API uploads) + let key = get_cookie_value(&headers, AUTH_COOKIE).unwrap_or_else(|| { + headers + .get("x-api-key") + .and_then(|v| v.to_str().ok()) + .unwrap_or("None") + .to_string() + }); if !database::check_key(&state.db, key).await.unwrap_or(false) { println!("Attempt to upload without a valid key."); @@ -183,17 +288,16 @@ async fn upload( // Processing the multipart form data while let Some(field) = multipart.next_field().await.unwrap_or(None) { - let (name, ext) = parse_filename(field.name().unwrap()); - let (name, ext) = (name.to_string(), ext.unwrap_or("").to_string()); + let field_name = field.name().unwrap_or("file").to_string(); + let original_filename = field.file_name().unwrap_or("unnamed_file").to_string(); + + let (_, ext) = parse_filename(&original_filename); + let (name, ext) = (field_name, ext.unwrap_or("").to_string()); let data = match field.bytes().await { Ok(data) => data, Err(e) => { - println!( - "Error reading field data: {:?} - (Chances are that the upload size is not enough)", - e - ); + println!("Error reading field data: {:?}", e); continue; } }; @@ -201,6 +305,8 @@ async fn upload( // Hash file name let mut s = DefaultHasher::new(); name.hash(&mut s); + original_filename.hash(&mut s); + data.hash(&mut s); let hash = s.finish(); let full_path = format!("{}/{}", state.files_path, hash); @@ -223,7 +329,11 @@ async fn upload( println!("New file uploaded to {}", full_path); } Err(e) => { - println!("Error writing to file: {:?}", e); + println!( + "Error writing to file: + {:?}", + e + ); continue; } }; @@ -251,7 +361,7 @@ async fn upload( .unwrap(); // The file name doesn't matter here we just spoof it - let rel_path = format!("{}.{}", hash, ext); + let rel_path = format!("{}://{}/file/{}.{}", scheme, host, hash, ext); return response_builder .status(StatusCode::OK) .body(axum::body::Body::from(rel_path)) @@ -264,6 +374,21 @@ async fn upload( .unwrap() } +fn get_cookie_value(headers: &HeaderMap, name: &str) -> Option { + let cookies = headers.get(header::COOKIE)?; + let cookies_str = cookies.to_str().ok()?; + + cookies_str.split(';').map(|s| s.trim()).find_map(|cookie| { + let mut parts = cookie.split('='); + let cookie_name = parts.next()?; + if cookie_name == name { + parts.next().map(|value| value.to_string()) + } else { + None + } + }) +} + fn generate_etag(path: &str) -> String { let modified_secs = Utc::now().timestamp();