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}}
+
+
+
+
+
Upload Successful!
+
+
Copy Link
+
+
+
+
+
+
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
+
+
+
+
+
+
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();