mirror of
https://github.com/Noratrieb/upload.files.noratrieb.dev.git
synced 2026-01-14 17:55:02 +01:00
init
This commit is contained in:
commit
b95ed7d3f3
7 changed files with 2611 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
/.envrc
|
||||||
2236
Cargo.lock
generated
Normal file
2236
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[package]
|
||||||
|
name = "upload-files-noratrieb-dev"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = "thin"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.8.4", default-features = false, features = [
|
||||||
|
"http1",
|
||||||
|
"multipart",
|
||||||
|
"tokio",
|
||||||
|
"tower-log",
|
||||||
|
"tracing",
|
||||||
|
] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
bs58 = "0.5.1"
|
||||||
|
color-eyre = "0.6.5"
|
||||||
|
object_store = { version = "0.12.3", default-features = false, features = ["aws"] }
|
||||||
|
rand_core = { version = "0.9.3", features = ["os_rng"] }
|
||||||
|
subtle = { version = "2.6.1", default-features = false }
|
||||||
|
tokio = { version = "1.47.1", features = ["macros", "rt", "net"] }
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
17
README.md
Normal file
17
README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# upload.files.noratrieb.dev
|
||||||
|
|
||||||
|
tiny website to upload files to <https://files.noratrieb.dev>
|
||||||
|
|
||||||
|
only for me
|
||||||
|
|
||||||
|
config:
|
||||||
|
|
||||||
|
```
|
||||||
|
export UPLOAD_FILES_NORATRIEB_DEV_USERNAME=nora
|
||||||
|
export UPLOAD_FILES_NORATRIEB_DEV_PASSWORD=test
|
||||||
|
export UPLOAD_FILES_NORATRIEB_DEV_BUCKET=files-dev.noratrieb.dev
|
||||||
|
export UPLOAD_FILES_NORATRIEB_DEV_KEYID=
|
||||||
|
export UPLOAD_FILES_NORATRIEB_DEV_ACCESS_KEY=
|
||||||
|
export UPLOAD_FILES_NORATRIEB_DEV_ENDPOINT=http://localhost:3900
|
||||||
|
export UPLOAD_FILES_NORATRIEB_DEV_REGION=garage
|
||||||
|
```
|
||||||
9
default.nix
Normal file
9
default.nix
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{ pkgs ? import <nixpkgs> { } }: pkgs.rustPlatform.buildRustPackage {
|
||||||
|
src = pkgs.lib.cleanSource ./.;
|
||||||
|
pname = "upload.files.noratrieb.dev";
|
||||||
|
version = "0.1.0";
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
meta = {
|
||||||
|
mainProgram = "upload-files-noratrieb-dev";
|
||||||
|
};
|
||||||
|
}
|
||||||
70
index.html
Normal file
70
index.html
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>nora's files</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: sans-serif;
|
||||||
|
background-color: rgb(62, 60, 60);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
html {
|
||||||
|
background-color: rgb(223, 223, 223);
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrapper">
|
||||||
|
<h1>nora's files uploader</h1>
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<div>
|
||||||
|
<label for="filename">filename (empty for default)</label>
|
||||||
|
<input type="text" id="filename" name="filename" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="file">file</label>
|
||||||
|
<input type="file" id="file" name="file" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="secret">secret upload (embed random data in path)</label>
|
||||||
|
<input type="checkbox" id="secret" name="secret" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit">upload</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
251
src/main.rs
Normal file
251
src/main.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
use axum::{
|
||||||
|
Router,
|
||||||
|
body::Bytes,
|
||||||
|
extract::{DefaultBodyLimit, FromRequestParts, Multipart, State},
|
||||||
|
http::{StatusCode, header},
|
||||||
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
use color_eyre::eyre::{self, Context, bail};
|
||||||
|
use color_eyre::{Result, eyre::OptionExt};
|
||||||
|
use object_store::ObjectStore;
|
||||||
|
use rand_core::TryRngCore;
|
||||||
|
use tracing::{error, info, level_filters::LevelFilter};
|
||||||
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct Config {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
s3_client: object_store::aws::AmazonS3,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main(flavor = "current_thread")]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
EnvFilter::builder()
|
||||||
|
.with_default_directive(LevelFilter::INFO.into())
|
||||||
|
.from_env_lossy(),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let env = |name: &str| {
|
||||||
|
std::env::var(name).wrap_err_with(|| format!("could not find environment variable {name}"))
|
||||||
|
};
|
||||||
|
|
||||||
|
let username = env("UPLOAD_FILES_NORATRIEB_DEV_USERNAME")?;
|
||||||
|
let password = env("UPLOAD_FILES_NORATRIEB_DEV_PASSWORD")?;
|
||||||
|
|
||||||
|
let s3_client = object_store::aws::AmazonS3Builder::new()
|
||||||
|
.with_bucket_name(env("UPLOAD_FILES_NORATRIEB_DEV_BUCKET")?)
|
||||||
|
.with_access_key_id(env("UPLOAD_FILES_NORATRIEB_DEV_KEYID")?)
|
||||||
|
.with_secret_access_key(env("UPLOAD_FILES_NORATRIEB_DEV_ACCESS_KEY")?)
|
||||||
|
.with_endpoint(env("UPLOAD_FILES_NORATRIEB_DEV_ENDPOINT")?)
|
||||||
|
.with_region(env("UPLOAD_FILES_NORATRIEB_DEV_REGION")?)
|
||||||
|
.with_allow_http(true)
|
||||||
|
.build()
|
||||||
|
.wrap_err("failed to build client")?;
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(|| async { Html(include_str!("../index.html")) }))
|
||||||
|
.route("/", post(upload))
|
||||||
|
.with_state(Config {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
s3_client,
|
||||||
|
})
|
||||||
|
// raise limit to 100MB
|
||||||
|
.layer(DefaultBodyLimit::max(100_000_000));
|
||||||
|
|
||||||
|
let addr = "0.0.0.0:3050";
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.wrap_err("binding listener")?;
|
||||||
|
info!(?addr, "Starting server");
|
||||||
|
|
||||||
|
axum::serve(listener, app).await.wrap_err("failed to serve")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn upload(
|
||||||
|
auth: Auth,
|
||||||
|
State(config): State<Config>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response, Response> {
|
||||||
|
if auth.username != config.username {
|
||||||
|
return Err(reject_auth("invalid username"));
|
||||||
|
}
|
||||||
|
if subtle::ConstantTimeEq::ct_ne(auth.password.as_bytes(), config.password.as_bytes()).into() {
|
||||||
|
return Err(reject_auth("invalid password"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let req = parse_req(multipart).await.map_err(|err| {
|
||||||
|
info!(?err, "Bad request for upload");
|
||||||
|
(StatusCode::BAD_REQUEST, err.to_string()).into_response()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!(path = ?req.name, "Uploading file");
|
||||||
|
|
||||||
|
config
|
||||||
|
.s3_client
|
||||||
|
.put_opts(
|
||||||
|
&req.name.as_str().into(),
|
||||||
|
object_store::PutPayload::from_bytes(req.bytes),
|
||||||
|
object_store::PutOptions {
|
||||||
|
mode: object_store::PutMode::Create,
|
||||||
|
tags: Default::default(),
|
||||||
|
attributes: Default::default(),
|
||||||
|
extensions: Default::default(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|err| match err {
|
||||||
|
object_store::Error::AlreadyExists { .. } => {
|
||||||
|
info!(
|
||||||
|
"Not uploading to {} because the path already exists",
|
||||||
|
req.name
|
||||||
|
);
|
||||||
|
(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
format!("path already exists: {}", req.name),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let err = eyre::ErrReport::new(err);
|
||||||
|
error!(?err, "failed to upload");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
info!(path = ?req.name, "Successfully uploaded file");
|
||||||
|
|
||||||
|
Ok(Redirect::to(&format!("https://files.noratrieb.dev{}", req.name)).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UploadRequest {
|
||||||
|
name: String,
|
||||||
|
bytes: Bytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn parse_req(mut multipart: Multipart) -> Result<UploadRequest> {
|
||||||
|
let mut name = None;
|
||||||
|
let mut file = None;
|
||||||
|
let mut secret = false;
|
||||||
|
while let Some(field) = multipart
|
||||||
|
.next_field()
|
||||||
|
.await
|
||||||
|
.wrap_err("reading next field")?
|
||||||
|
{
|
||||||
|
match field.name() {
|
||||||
|
Some("filename") => {
|
||||||
|
let value = field.text().await.wrap_err("failed to get filename text")?;
|
||||||
|
if !value.is_empty() {
|
||||||
|
name = Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some("file") => {
|
||||||
|
if name.is_none() {
|
||||||
|
name = Some(
|
||||||
|
field
|
||||||
|
.file_name()
|
||||||
|
.ok_or_eyre("missing filename for file field")?
|
||||||
|
.to_owned(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
file = Some(
|
||||||
|
field
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.wrap_err("failed to read file contents")?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some("secret") => {
|
||||||
|
let text = field.text().await.wrap_err("reading secret contents")?;
|
||||||
|
if text == "on" {
|
||||||
|
secret = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut name = name.ok_or_eyre("missing name")?;
|
||||||
|
|
||||||
|
if name.contains('/') {
|
||||||
|
bail!("name must not contain slashes: '{name}'")
|
||||||
|
}
|
||||||
|
|
||||||
|
if secret {
|
||||||
|
let mut random = [0_u8; 32];
|
||||||
|
rand_core::OsRng.try_fill_bytes(&mut random).unwrap();
|
||||||
|
let random = bs58::encode(&random).into_string();
|
||||||
|
|
||||||
|
name = format!("{random}/{name}");
|
||||||
|
}
|
||||||
|
|
||||||
|
name = format!("/{name}");
|
||||||
|
|
||||||
|
Ok(UploadRequest {
|
||||||
|
name,
|
||||||
|
bytes: file.ok_or_eyre("missing file")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Auth {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_auth(reason: &str) -> Response {
|
||||||
|
info!("Rejecting request authentication due to {reason}");
|
||||||
|
(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
[(
|
||||||
|
header::WWW_AUTHENTICATE,
|
||||||
|
"Basic realm=\"upload.files.noratrieb.dev\"",
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S: Sync> FromRequestParts<S> for Auth {
|
||||||
|
type Rejection = Response;
|
||||||
|
|
||||||
|
async fn from_request_parts(
|
||||||
|
parts: &mut axum::http::request::Parts,
|
||||||
|
_: &S,
|
||||||
|
) -> Result<Self, Self::Rejection> {
|
||||||
|
let Some(header) = parts.headers.get(header::AUTHORIZATION) else {
|
||||||
|
return Err(reject_auth("missing authorization header"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let header = header
|
||||||
|
.to_str()
|
||||||
|
.map_err(|_| reject_auth("authorization header is invalid UTF-8"))?;
|
||||||
|
|
||||||
|
let Some(("Basic", value)) = header.split_once(' ') else {
|
||||||
|
return Err(reject_auth(
|
||||||
|
"invalid authorization header, missing 'Basic '",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let decoded = String::from_utf8(
|
||||||
|
base64::prelude::BASE64_STANDARD
|
||||||
|
.decode(value)
|
||||||
|
.map_err(|_| reject_auth("invalid base64 value"))?,
|
||||||
|
)
|
||||||
|
.map_err(|_| reject_auth("invalid UTF-8 after base64 decode"))?;
|
||||||
|
|
||||||
|
let Some((username, password)) = decoded.split_once(':') else {
|
||||||
|
return Err(reject_auth("missing : between username and password"));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(Auth {
|
||||||
|
username: username.to_owned(),
|
||||||
|
password: password.to_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue