This commit is contained in:
nora 2025-08-02 16:47:38 +02:00
commit b95ed7d3f3
7 changed files with 2611 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
/.envrc

2236
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

26
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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(),
})
}
}