Initial Commit
This commit is contained in:
parent
5b45525331
commit
7f8d940ef5
12 changed files with 2901 additions and 2 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
tinyupload.db*
|
||||||
|
|
||||||
2301
Cargo.lock
generated
Normal file
2301
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
[package]
|
||||||
|
name = "tinyupload"
|
||||||
|
version = "0.2.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.7.7", features = ["multipart"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
sqlx = { version = "0.8.2", features = [ "runtime-tokio", "sqlite" ] }
|
||||||
|
rand = "0.8.5"
|
||||||
|
sha2 = "0.10.8"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
clap = { version = "4.5.20", features = ["derive"] }
|
||||||
|
chrono = "0.4.38"
|
||||||
|
async-stream = "0.3.6"
|
||||||
|
bytes = "1.8.0"
|
||||||
19
README.md
19
README.md
|
|
@ -1,3 +1,18 @@
|
||||||
# tinyupload
|
# What is tinyupload?
|
||||||
|
|
||||||
A server program that allows uploads. /shrug
|
Essentially tinyupload is a personal cdn, that you can host yourself. Why? mostly for quicksharing content to friends/others without being limited by whatever social chat platform you are on, in this case the main target is Discord.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## RoadMap
|
||||||
|
|
||||||
|
- [ ] Server Side Encryption
|
||||||
|
- [ ] Vencord Plugin
|
||||||
|
- [ ] Config File
|
||||||
|
|
||||||
|
*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.
|
||||||
26
flake.lock
generated
Normal file
26
flake.lock
generated
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1729453369,
|
||||||
|
"narHash": "sha256-UDysmG2kJWozN0JaZHb/hRW5GL+TK5gM4RaCHMkkrVY=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "4deb3c835f2eb2e11be784d458bbdcf4affe412b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
33
flake.nix
Normal file
33
flake.nix
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
description = "A simple rust project";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = {
|
||||||
|
self,
|
||||||
|
nixpkgs,
|
||||||
|
}: let
|
||||||
|
forEachSystem = fn:
|
||||||
|
nixpkgs.lib.genAttrs
|
||||||
|
["x86_64-linux" "aarch64-linux"]
|
||||||
|
(system: fn system nixpkgs.legacyPackages.${system});
|
||||||
|
in {
|
||||||
|
# Define a package for your project
|
||||||
|
packages = forEachSystem (system: pkgs: rec {
|
||||||
|
tinyupload = pkgs.callPackage ./nix/package.nix {
|
||||||
|
inherit pkgs;
|
||||||
|
};
|
||||||
|
|
||||||
|
default = tinyupload;
|
||||||
|
});
|
||||||
|
|
||||||
|
devShells = forEachSystem (system: pkgs: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
DATABASE_URL = "sqlite:tinyupload.db";
|
||||||
|
buildInputs = with pkgs; [cargo rustc sqlx-cli sqlite];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
5
migrations/20241023074641_create_keys_table.sql
Normal file
5
migrations/20241023074641_create_keys_table.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Add migration script here
|
||||||
|
CREATE TABLE IF NOT EXISTS keys (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
key VARCHAR(32)
|
||||||
|
);
|
||||||
9
migrations/20241023074653_create_files_table.sql
Normal file
9
migrations/20241023074653_create_files_table.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Add migration script here
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
filename VARCHAR(64) NOT NULL,
|
||||||
|
mime_type VARCHAR(8) NOT NULL,
|
||||||
|
hash VARCHAR(64) NOT NULL,
|
||||||
|
last_modified DATE,
|
||||||
|
etag VARCHAR(32)
|
||||||
|
);
|
||||||
26
nix/package.nix
Normal file
26
nix/package.nix
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
stdenv,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
stdenv.mkDerivation {
|
||||||
|
pname = "tinyupload";
|
||||||
|
version = "1.0.0";
|
||||||
|
|
||||||
|
src = ../.;
|
||||||
|
|
||||||
|
buildInputs = [pkgs.cargo];
|
||||||
|
|
||||||
|
# Specify the build commands
|
||||||
|
buildPhase = ''
|
||||||
|
cargo build --release
|
||||||
|
'';
|
||||||
|
|
||||||
|
# Optionally, specify installPhase if needed
|
||||||
|
installPhase = ''
|
||||||
|
# If you have specific install steps, add them here
|
||||||
|
# For example, copying files to $out
|
||||||
|
mkdir -p $out/bin
|
||||||
|
cp target/release/tinyupload $out/bin
|
||||||
|
'';
|
||||||
|
}
|
||||||
126
src/database.rs
Normal file
126
src/database.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
|
use rand::Rng;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use sqlx::migrate::{MigrateDatabase, Migrator};
|
||||||
|
use sqlx::sqlite::SqlitePool;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct KeyRow {
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
pub struct FileRow {
|
||||||
|
pub mime_type: String,
|
||||||
|
pub last_modified: String,
|
||||||
|
pub etag: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
|
||||||
|
|
||||||
|
pub async fn init(database_url: String) -> Result<SqlitePool, sqlx::Error> {
|
||||||
|
// If the database doesn't exist, create it and run the migrations
|
||||||
|
if !sqlx::Sqlite::database_exists(&database_url).await? {
|
||||||
|
sqlx::Sqlite::create_database(&database_url).await?;
|
||||||
|
let pool = SqlitePool::connect(&database_url).await?;
|
||||||
|
MIGRATOR.run(&pool).await?;
|
||||||
|
pool.close().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pool = SqlitePool::connect(&database_url).await?;
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_key(pool: &SqlitePool, key: String) -> Result<bool, sqlx::Error> {
|
||||||
|
let rows = sqlx::query_as::<_, KeyRow>(r#"SELECT * FROM keys"#)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
if row.key == key {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_key(pool: &SqlitePool) -> Result<String, sqlx::Error> {
|
||||||
|
let key = generate_api_key();
|
||||||
|
sqlx::query("INSERT INTO keys(key) VALUES (?)")
|
||||||
|
.bind(&key)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_api_key() -> String {
|
||||||
|
let mut random_bytes = [0u8; 32];
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
rng.fill(&mut random_bytes);
|
||||||
|
let encoded_bytes = general_purpose::STANDARD.encode(random_bytes);
|
||||||
|
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(encoded_bytes);
|
||||||
|
let result = hasher.finalize();
|
||||||
|
format!("{:x}", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_file(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
filename: &str,
|
||||||
|
mime_type: &str,
|
||||||
|
hash: &str,
|
||||||
|
last_modified: String,
|
||||||
|
etag: String,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
"
|
||||||
|
INSERT INTO files(filename, mime_type, hash, last_modified, etag)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
",
|
||||||
|
)
|
||||||
|
.bind(filename)
|
||||||
|
.bind(mime_type)
|
||||||
|
.bind(hash)
|
||||||
|
.bind(last_modified)
|
||||||
|
.bind(etag)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check_file_exists(pool: &SqlitePool, hash: &str) -> Result<bool, sqlx::Error> {
|
||||||
|
let row = sqlx::query_as::<_, FileRow>(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM files
|
||||||
|
WHERE hash = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(hash)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(_) = row {
|
||||||
|
return Ok(true);
|
||||||
|
} else {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_entry(pool: &SqlitePool, hash: String) -> Result<FileRow, sqlx::Error> {
|
||||||
|
let row = sqlx::query_as::<_, FileRow>(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM files
|
||||||
|
WHERE hash = ?
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(hash)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(row)
|
||||||
|
}
|
||||||
55
src/main.rs
Normal file
55
src/main.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
mod database;
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(version, about, long_about = None)]
|
||||||
|
#[command(propagate_version = true)]
|
||||||
|
struct Cli {
|
||||||
|
/// The location of the sqlite database.
|
||||||
|
#[arg(short, long, default_value = "sqlite:tinyupload.db")]
|
||||||
|
database_url: String,
|
||||||
|
|
||||||
|
/// Path to the directory where the files will be stored.
|
||||||
|
#[arg(short, long, default_value = "files")]
|
||||||
|
files_path: String,
|
||||||
|
|
||||||
|
/// The address to listen on.
|
||||||
|
#[arg(short, long, default_value = "0.0.0.0:1337")]
|
||||||
|
address: String,
|
||||||
|
|
||||||
|
// The upload limit in Mb.
|
||||||
|
#[arg(short, long, default_value_t = 100)]
|
||||||
|
upload_size: usize,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Commands,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Commands {
|
||||||
|
/// Runs the web server.
|
||||||
|
Serve {},
|
||||||
|
|
||||||
|
/// Generate a new key.
|
||||||
|
Generate {},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
match &cli.command {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
Commands::Generate {} => {
|
||||||
|
let db = database::init(cli.database_url).await.unwrap();
|
||||||
|
let key = database::add_key(&db).await.expect("Failed to add key");
|
||||||
|
println!("Copy this generated key: {}", key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
282
src/server.rs
Normal file
282
src/server.rs
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
use crate::database;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::{DefaultBodyLimit, Multipart, Path, State},
|
||||||
|
http::{
|
||||||
|
header::{self, HeaderMap},
|
||||||
|
StatusCode,
|
||||||
|
},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
use tokio::fs::File;
|
||||||
|
use tokio::io::AsyncWriteExt;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
db: SqlitePool,
|
||||||
|
files_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
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 };
|
||||||
|
|
||||||
|
// 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("/upload", post(upload))
|
||||||
|
.with_state(state)
|
||||||
|
.layer(DefaultBodyLimit::max(1024 * 1024 * upload_size));
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(address).await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stream(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(Media { urlpath }): Path<Media>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let (hash, _) = match parse_filename(&urlpath) {
|
||||||
|
(name, Some(ext)) => (name, ext),
|
||||||
|
(name, None) => (name, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
if database::check_file_exists(&state.db, &hash).await.unwrap() {
|
||||||
|
let full_path = format!("{}/{}", state.files_path, hash);
|
||||||
|
let file_entry = database::get_entry(&state.db, hash.to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
println!("Serving file: ({})", full_path);
|
||||||
|
|
||||||
|
let file = match File::open(&full_path).await {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(e) => {
|
||||||
|
println!("Error opening file: {:?}", e);
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let file_length = file.metadata().await.unwrap().len();
|
||||||
|
|
||||||
|
let range_header = headers
|
||||||
|
.get(header::RANGE)
|
||||||
|
.and_then(|header| header.to_str().ok());
|
||||||
|
let (start, end) = if let Some(range_header) = range_header {
|
||||||
|
parse_range_header(range_header, file_length).unwrap_or((0, file_length - 1))
|
||||||
|
} else {
|
||||||
|
(0, file_length - 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_length = end - start + 1;
|
||||||
|
let content_range = format!("bytes {}-{}/{}", start, end, file_length);
|
||||||
|
|
||||||
|
let stream = async_stream::stream! {
|
||||||
|
const CHUNK_SIZE: usize = 64 * 1024; // 64KB chunks
|
||||||
|
let mut file = file;
|
||||||
|
let mut position = start;
|
||||||
|
let mut buffer = vec![0; CHUNK_SIZE];
|
||||||
|
|
||||||
|
file.seek(std::io::SeekFrom::Start(position)).await.unwrap();
|
||||||
|
|
||||||
|
while position <= end {
|
||||||
|
let remaining = (end - position + 1) as usize;
|
||||||
|
let chunk_size = CHUNK_SIZE.min(remaining);
|
||||||
|
let buf = &mut buffer[..chunk_size];
|
||||||
|
|
||||||
|
match file.read_exact(buf).await {
|
||||||
|
Ok(_) => {
|
||||||
|
yield Ok(bytes::Bytes::copy_from_slice(buf));
|
||||||
|
position += chunk_size as u64;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
yield Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::Other,
|
||||||
|
format!("Failed to read file: {}", e),
|
||||||
|
));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::PARTIAL_CONTENT)
|
||||||
|
.header(header::CONTENT_TYPE, file_entry.mime_type)
|
||||||
|
.header(header::ACCEPT_RANGES, "bytes")
|
||||||
|
.header(header::CONTENT_RANGE, content_range)
|
||||||
|
.header(header::CONTENT_LENGTH, content_length)
|
||||||
|
.header(header::CACHE_CONTROL, "public, max-age=31536000")
|
||||||
|
.header(header::LAST_MODIFIED, file_entry.last_modified)
|
||||||
|
.header(header::ETAG, file_entry.etag)
|
||||||
|
.body(Body::from_stream(stream))
|
||||||
|
.unwrap();
|
||||||
|
} else {
|
||||||
|
println!("Request for a file not found with hash: ({})", hash);
|
||||||
|
return Response::builder()
|
||||||
|
.status(StatusCode::NOT_FOUND)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_range_header(h: &str, len: u64) -> Option<(u64, u64)> {
|
||||||
|
h.strip_prefix("bytes=")?
|
||||||
|
.split_once('-')
|
||||||
|
.and_then(|(s, e)| {
|
||||||
|
let start = s.parse().ok()?;
|
||||||
|
let end = e.parse().unwrap_or(start + (1 << 20).min(len - start - 1));
|
||||||
|
(start < len && end < len && start <= end).then_some((start, end))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything below is for uploading functionality
|
||||||
|
|
||||||
|
async fn upload(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
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();
|
||||||
|
|
||||||
|
if !database::check_key(&state.db, key).await.unwrap_or(false) {
|
||||||
|
println!("Attempt to upload without a valid key.");
|
||||||
|
return response_builder
|
||||||
|
.status(StatusCode::FORBIDDEN)
|
||||||
|
.body(axum::body::Body::from("Access Denied."))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 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
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hash file name
|
||||||
|
let mut s = DefaultHasher::new();
|
||||||
|
name.hash(&mut s);
|
||||||
|
let hash = s.finish();
|
||||||
|
|
||||||
|
let full_path = format!("{}/{}", state.files_path, hash);
|
||||||
|
|
||||||
|
// Write the file data to disk :P
|
||||||
|
let mut file = match File::create(&full_path).await {
|
||||||
|
Ok(file) => file,
|
||||||
|
Err(e) => {
|
||||||
|
println!(
|
||||||
|
"Error creating file at: {}
|
||||||
|
{:?}",
|
||||||
|
full_path, e
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match file.write_all(&data).await {
|
||||||
|
Ok(_) => {
|
||||||
|
println!("New file uploaded to {}", full_path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("Error writing to file: {:?}", e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let content_type = match ext.as_str() {
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"png" => "image/png",
|
||||||
|
"gif" => "image/gif",
|
||||||
|
"json" => "application/json",
|
||||||
|
"txt" => "text/plain",
|
||||||
|
"mp4" => "video/mp4",
|
||||||
|
"webm" => "video/webm",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
};
|
||||||
|
|
||||||
|
database::add_file(
|
||||||
|
&state.db,
|
||||||
|
&name,
|
||||||
|
&content_type,
|
||||||
|
&hash.to_string(),
|
||||||
|
Utc::now().to_string(),
|
||||||
|
generate_etag(&full_path),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// The file name doesn't matter here we just spoof it
|
||||||
|
let rel_path = format!("{}.{}", hash, ext);
|
||||||
|
return response_builder
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.body(axum::body::Body::from(rel_path))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
response_builder
|
||||||
|
.status(StatusCode::NO_CONTENT)
|
||||||
|
.body(axum::body::Body::from("No files uploaded."))
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_etag(path: &str) -> String {
|
||||||
|
let modified_secs = Utc::now().timestamp();
|
||||||
|
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
path.hash(&mut hasher);
|
||||||
|
modified_secs.hash(&mut hasher);
|
||||||
|
|
||||||
|
format!("\"{:x}\"", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_filename(filename: &str) -> (&str, Option<&str>) {
|
||||||
|
match filename.rsplit_once('.') {
|
||||||
|
Some((name, ext)) => (name, Some(ext)),
|
||||||
|
None => (filename, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue