This commit is contained in:
Paul Zinselmeyer 2023-04-20 00:11:39 +02:00
commit 3896c5a93c
Signed by: pfzetto
GPG key ID: 4EEF46A5B276E648
7 changed files with 2674 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
/target
/data
/.env

2114
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

21
Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "bin"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
tokio = { version = "1.27.0", features = ["full"] }
tokio-util = { version="0.7", features=["io"]}
futures-util = "0.3"
axum = {version="0.6", features=["macros", "headers"]}
serde = "1.0"
serde_cbor = "0.11"
openidconnect = "3.0"
render = { git="https://github.com/render-rs/render.rs" }
thiserror = "1.0.40"
rand = "0.8.5"
dotenvy = "0.15"
reqwest = { version="0.11", default_features=false}
markdown = "0.3.0"

18
src/item_explanation.md Normal file
View file

@ -0,0 +1,18 @@
# zettoIT bin
An empty bin was created for you. The first HTTP POST request can upload data.
All following requests can only read the uploaded data.
To use the build-in link-shortener functionality you have to POST the URL with Content-Type: text/x-uri or Content-Type: text/uri-list.
## Upload a link
`$ curl -H "Content-Type: text/x-uri" --data "https://example.com" <bin_url>`
## Upload a image
`$ curl -H "Content-Type: image/png" --data-binary @my-image.png <bin_url>`
## Upload a big file
`$ curl -G "Content-Type: application/gzip" -T my-file.tar.gz <bin_url>`
## Accessing the data
After uploading data you can access it by accessing <bin_url> with an optional file extension that suits the data that you uploaded.
If the bin is a link you will get redirected.

217
src/main.rs Normal file
View file

@ -0,0 +1,217 @@
use std::{collections::HashMap, env, sync::Arc};
use axum::{
body::StreamBody,
extract::{BodyStream, Path, State},
headers::ContentType,
http::{header, HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect},
routing::get,
Router, TypedHeader,
};
use futures_util::StreamExt;
use metadata::Metadata;
use openid::Login;
use render::{html, raw};
use tokio::{
fs::{self, File},
io::{AsyncReadExt, AsyncWriteExt},
sync::Mutex,
};
use tokio_util::io::ReaderStream;
pub mod metadata;
pub mod openid;
// RFC 7230 section 3.1.1
// It is RECOMMENDED that all HTTP senders and recipients
// support, at a minimum, request-line lengths of 8000 octets.
const HTTP_URL_MAXLENGTH: u64 = 8000;
// support the RFC2483 with text/uri-list and the inofficial text/x-uri mimetype
const HTTP_URL_MIMETYPES: [&str; 2] = ["text/x-uri", "text/uri-list"];
#[derive(Debug, thiserror::Error)]
enum Error {
#[error("item could not be found")]
ItemNotFound,
#[error("errorfile exists")]
DataFileExists,
#[error("datafile without metafile")]
DataFileWithoutMetaFile,
}
type HandlerResult<T> = Result<T, Error>;
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
println!("main error: {:?}", self);
match self {
Self::ItemNotFound => (StatusCode::NOT_FOUND, "item could not be found"),
Self::DataFileExists => (StatusCode::CONFLICT, "item already has data"),
Self::DataFileWithoutMetaFile => {
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error")
}
}
.into_response()
}
}
#[derive(Clone)]
pub struct AppState {
path: String,
application_base: String,
issuer: String,
client_id: String,
client_secret: Option<String>,
scopes: Vec<String>,
logins: Arc<Mutex<HashMap<String, Login>>>,
}
#[tokio::main]
async fn main() {
dotenvy::dotenv().ok();
let application_base = env::var("APPLICATION_BASE").expect("APPLICATION_BASE env var");
let issuer = env::var("ISSUER").expect("ISSUER env var");
let client_id = env::var("CLIENT_ID").expect("CLIENT_ID env var");
let client_secret = env::var("CLIENT_SECRET").ok();
let scopes = env::var("SCOPES")
.expect("SCOPES env var")
.split(' ')
.into_iter()
.map(|x| x.to_owned())
.collect::<Vec<_>>();
let state: AppState = AppState {
path: "data".to_string(),
application_base,
issuer,
client_id,
client_secret,
scopes,
logins: Arc::new(Mutex::new(HashMap::new())),
};
let app = Router::new()
.route("/", get(openid::handle_login))
.route("/login/:id", get(openid::handle_callback))
.route("/:id", get(get_item).post(post_item))
.with_state(state);
axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
.serve(app.into_make_service())
.await
.unwrap();
}
async fn post_item(
Path(id): Path<String>,
State(app_state): State<AppState>,
TypedHeader(content_type): TypedHeader<ContentType>,
mut stream: BodyStream,
) -> HandlerResult<impl IntoResponse> {
let id = sanitize_id(id);
let mut metadata = Metadata::from_file(&app_state.path, &id)
.await
.map_err(|_| Error::ItemNotFound)?;
if fs::metadata(&format!("{}/{}.data", app_state.path, &id))
.await
.is_err()
{
let mut data_file = File::create(&format!("{}/{}.data", app_state.path, &id))
.await
.unwrap();
while let Some(chunk) = stream.next().await {
let buf = chunk.map(|x| x.to_vec()).unwrap_or_default();
data_file.write_all(&buf).await.unwrap();
}
metadata.mimetype = Some(content_type.to_string());
metadata.to_file(&app_state.path, &id).await.unwrap();
Ok((StatusCode::CREATED, "OK"))
} else {
Err(Error::DataFileExists)
}
}
async fn get_item(
Path(id): Path<String>,
State(app_state): State<AppState>,
) -> HandlerResult<impl IntoResponse> {
let id = sanitize_id(id);
let metadata = Metadata::from_file(&app_state.path, &id).await.ok();
let data_file = File::open(&format!("{}/{}.data", app_state.path, &id))
.await
.ok();
match (metadata, data_file) {
(None, None) => Err(Error::ItemNotFound),
(Some(_), None) => {
let body = include_str!("item_explanation.md").replace(
"<bin_url>",
&format!("{}{}", app_state.application_base, id),
);
let body = markdown::to_html(&body);
let body = html! {
<html>
<head>
<title>{"zettoit bin"}</title>
<link rel={"icon"} type={"image/svg"} href={"https://static.zettoit.eu/img/zettoit-logo.svg"}/>
</head>
<body style={"font-family: monospace; background-color: black; color: white;"}>
<div style={"margin: auto; max-width: 80ch;"}>
{raw!(body.as_str())}
</div>
</body>
</html>
};
Ok((StatusCode::ACCEPTED, Html(body)).into_response())
}
(Some(metadata), Some(mut data_file)) => {
let data_file_metadata = fs::metadata(&format!("{}/{}.data", app_state.path, &id))
.await
.unwrap();
if HTTP_URL_MIMETYPES.contains(&metadata.mimetype.as_deref().unwrap_or(""))
&& data_file_metadata.len() <= HTTP_URL_MAXLENGTH
{
let mut url = String::new();
data_file.read_to_string(&mut url).await.unwrap();
// Use the first line that doesn't start with a # to be compliant with RFC2483.
let url = url
.lines()
.into_iter()
.find(|x| !x.starts_with('#'))
.unwrap_or("");
Ok(Redirect::temporary(url).into_response())
} else {
let reader = ReaderStream::new(data_file);
let body = StreamBody::new(reader);
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
metadata.mimetype.as_deref().unwrap_or("").parse().unwrap(),
);
headers.insert(
header::CONTENT_LENGTH,
data_file_metadata.len().to_string().parse().unwrap(),
);
Ok((StatusCode::OK, headers, body).into_response())
}
}
(None, Some(_)) => Err(Error::DataFileWithoutMetaFile),
}
}
fn sanitize_id(id: String) -> String {
id.chars()
.take_while(|c| *c != '.')
.filter(|c| c.is_ascii_alphanumeric())
.collect()
}

35
src/metadata.rs Normal file
View file

@ -0,0 +1,35 @@
use serde::{Deserialize, Serialize};
use tokio::{
fs::File,
io::{AsyncReadExt, AsyncWriteExt},
};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("io error {:?}", 0)]
Io(#[from] tokio::io::Error),
#[error("cbor error: {:?}", 0)]
Cbor(#[from] serde_cbor::Error),
}
#[derive(Serialize, Deserialize)]
pub struct Metadata {
pub subject: String,
pub mimetype: Option<String>,
}
impl Metadata {
pub async fn from_file(path: &str, id: &str) -> Result<Self, Error> {
let mut metadata_file = File::open(&format!("{}/{}.meta", path, id)).await?;
let mut metadata = Vec::new();
metadata_file.read_to_end(&mut metadata).await.unwrap();
Ok(serde_cbor::from_slice(&metadata)?)
}
pub async fn to_file(&self, path: &str, id: &str) -> Result<(), Error> {
let metadata = serde_cbor::to_vec(self).unwrap();
let mut metadata_file = File::create(&format!("{}/{}.meta", path, id)).await?;
metadata_file.write_all(&metadata).await?;
Ok(())
}
}

266
src/openid.rs Normal file
View file

@ -0,0 +1,266 @@
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Redirect},
};
use openidconnect::{
core::{CoreAuthenticationFlow, CoreClient, CoreErrorResponseType, CoreProviderMetadata},
reqwest::async_http_client,
url::ParseError,
AccessTokenHash, AuthorizationCode, ClaimsVerificationError, ClientId, ClientSecret, CsrfToken,
DiscoveryError, IssuerUrl, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier,
RedirectUrl, RequestTokenError, Scope, SigningError, StandardErrorResponse, TokenResponse,
};
use rand::{distributions::Alphanumeric, Rng};
use serde::Deserialize;
use crate::{metadata::Metadata, AppState};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("discovery error: {:?}", 0)]
Discovery(#[from] DiscoveryError<openidconnect::reqwest::Error<reqwest::Error>>),
#[error("parse error: {:?}", 0)]
Parse(#[from] ParseError),
#[error("request token error: {:?}", 0)]
RequestToken(
#[from]
RequestTokenError<
openidconnect::reqwest::Error<reqwest::Error>,
StandardErrorResponse<CoreErrorResponseType>,
>,
),
#[error("claims verification error: {:?}", 0)]
ClaimsVerification(#[from] ClaimsVerificationError),
#[error("signing error: {:?}", 0)]
SigningError(#[from] SigningError),
#[error("id token not found")]
IdTokenNotFound,
}
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
println!("openid error: {:?}", self);
(StatusCode::INTERNAL_SERVER_ERROR, "internal server error").into_response()
}
}
#[derive(Debug, Deserialize)]
pub struct OidcBody {
pub code: String,
pub state: String,
pub session_state: String,
}
#[derive(Debug, Clone)]
pub struct Login {
csrf_token: String,
nonce: String,
pkce_verifier: String,
}
pub async fn handle_callback(
State(state): State<AppState>,
Path(auth_id): Path<String>,
Query(body): Query<OidcBody>,
) -> Result<impl IntoResponse, Error> {
let auth_instance = {
let logins = state.logins.lock().await;
logins.get(&auth_id).cloned().unwrap()
};
let client = create_oidc_client(
state.issuer.clone(),
state.client_id.clone(),
state.client_secret.clone(),
&state.application_base,
&auth_id,
)
.await?;
if auth_instance.csrf_token != body.state {
return Ok((StatusCode::BAD_REQUEST, "csrf token is invalid").into_response());
}
let pkce_verifier = PkceCodeVerifier::new(auth_instance.pkce_verifier.clone());
let nonce = Nonce::new(auth_instance.nonce.clone());
let token_response = client
.exchange_code(AuthorizationCode::new(body.code.to_string()))
// Set the PKCE code verifier.
.set_pkce_verifier(pkce_verifier)
.request_async(async_http_client)
.await?;
// Extract the ID token claims after verifying its authenticity and nonce.
let id_token = token_response.id_token().ok_or(Error::IdTokenNotFound)?;
let claims = id_token.claims(&client.id_token_verifier(), &nonce)?;
// Verify the access token hash to ensure that the access token hasn't been substituted for
// another user's.
if let Some(expected_access_token_hash) = claims.access_token_hash() {
let actual_access_token_hash =
AccessTokenHash::from_token(token_response.access_token(), &id_token.signing_alg()?)?;
if actual_access_token_hash != *expected_access_token_hash {
return Ok((StatusCode::BAD_REQUEST, "access token hash is invalid").into_response());
}
}
//let mut oidc_user = oidc_user::Entity::find()
// .filter(
// oidc_user::Column::OidcClientId
// .eq(oidc_client.id)
// .and(oidc_user::Column::Subject.eq(claims.subject().as_str())),
// )
// .one(&state.db)
// .await?
// .map(|x| x.into_active_model())
// .unwrap_or_default();
//oidc_user.oidc_client_id = ActiveValue::Set(oidc_client.id);
//oidc_user.subject = ActiveValue::Set(claims.subject().to_string());
//oidc_user.email = ActiveValue::Set(claims.email().map(|x| x.to_string()).unwrap_or_default());
//oidc_user.username = ActiveValue::Set(
// claims
// .preferred_username()
// .map(|x| x.to_string())
// .unwrap_or_default(),
//);
//oidc_user.given_name = ActiveValue::Set(
// claims
// .given_name()
// .and_then(|x| x.get(None).map(|x| x.to_string()))
// .unwrap_or_default(),
//);
//oidc_user.middle_name = ActiveValue::Set(
// claims
// .middle_name()
// .and_then(|x| x.get(None).map(|x| x.to_string()))
// .unwrap_or_default(),
//);
//oidc_user.family_name = ActiveValue::Set(
// claims
// .family_name()
// .and_then(|x| x.get(None).map(|x| x.to_string()))
// .unwrap_or_default(),
//);
//oidc_user.locale = ActiveValue::Set(claims.locale().map(|x| x.to_string()).unwrap_or_default());
//oidc_user.zoneinfo =
// ActiveValue::Set(claims.zoneinfo().map(|x| x.to_string()).unwrap_or_default());
//let oidc_user = if oidc_user.id.is_unchanged() {
// oidc_user.update(&state.db).await?
//} else {
// oidc_user.insert(&state.db).await?
//};
//let instance = if form.multiple_submissions == 0 {
// DatabaseInstance::from_userid(
// state.db.clone(),
// state.submit_producer.clone(),
// oidc_user.id,
// form.id,
// )
// .await?
//} else {
// None
//};
//let instance = match instance {
// Some(x) => x,
// None => {
// DatabaseInstance::new(
// state.db.clone(),
// state.submit_producer.clone(),
// form.id,
// Some(oidc_user.id),
// )
// .await?
// }
//};
{
state.logins.lock().await.remove(&auth_id);
}
let subject = claims.subject().to_string();
let id = rand::thread_rng()
.sample_iter(Alphanumeric)
.take(16)
.map(char::from)
.collect::<String>();
let metadata = Metadata {
subject,
mimetype: None,
};
metadata.to_file(&state.path, &id).await.unwrap();
Ok((Redirect::temporary(&format!("{}{}", state.application_base, id))).into_response())
}
pub async fn handle_login(State(state): State<AppState>) -> Result<impl IntoResponse, Error> {
let auth_id = rand::thread_rng()
.sample_iter(Alphanumeric)
.take(32)
.map(char::from)
.collect::<String>();
let client = create_oidc_client(
state.issuer.clone(),
state.client_id.clone(),
state.client_secret.clone(),
&state.application_base,
&auth_id,
)
.await?;
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_token, nonce) = {
let mut auth = client.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
);
for scope in state.scopes.iter() {
auth = auth.add_scope(Scope::new(scope.to_string()));
}
auth.set_pkce_challenge(pkce_challenge).url()
};
{
let mut logins = state.logins.lock().await;
logins.insert(
auth_id,
Login {
csrf_token: csrf_token.secret().to_string(),
nonce: nonce.secret().to_string(),
pkce_verifier: pkce_verifier.secret().to_string(),
},
);
}
Ok(Redirect::temporary(auth_url.as_str()).into_response())
}
async fn create_oidc_client(
issuer: String,
client_id: String,
client_secret: Option<String>,
application_base: &str,
auth_id: &str,
) -> Result<CoreClient, Error> {
let provider_metadata =
CoreProviderMetadata::discover_async(IssuerUrl::new(issuer)?, async_http_client).await?;
let client = CoreClient::from_provider_metadata(
provider_metadata,
ClientId::new(client_id.clone()),
client_secret.map(ClientSecret::new),
)
.set_redirect_uri(RedirectUrl::new(format!(
"{}/login/{}",
application_base, auth_id
))?);
Ok(client)
}