init
This commit is contained in:
commit
3896c5a93c
7 changed files with 2674 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
/data
|
||||
/.env
|
2114
Cargo.lock
generated
Normal file
2114
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal 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
18
src/item_explanation.md
Normal 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
217
src/main.rs
Normal 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
35
src/metadata.rs
Normal 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
266
src/openid.rs
Normal 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)
|
||||
}
|
Loading…
Reference in a new issue