mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-22 01:25:00 +02:00
@@ -0,0 +1,243 @@
|
||||
use actix_web::dev::ServiceRequest;
|
||||
use actix_web::http::header::ContentLength;
|
||||
use actix_web::web::{Header, Payload};
|
||||
use aws_sdk_s3::primitives::ByteStream;
|
||||
use aws_sdk_s3::types::{CompletedMultipartUpload, CompletedPart};
|
||||
use blake3::Hasher;
|
||||
use bytes::BytesMut;
|
||||
use futures_util::StreamExt;
|
||||
use size::Size;
|
||||
use tracing::*;
|
||||
|
||||
use crate::s3::S3Client;
|
||||
use crate::{
|
||||
config::CONFIG,
|
||||
postgres::{self, Pool},
|
||||
};
|
||||
|
||||
use crate::handlers::{ApiError, HandlerResult};
|
||||
|
||||
pub struct Blob {
|
||||
pub s3_key: String,
|
||||
pub length: u64,
|
||||
pub inline: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
const MULTIPART_THRESHOLD: usize = 4; // mb
|
||||
const INLINE_THRESHHOLD: usize = 100; // kb
|
||||
|
||||
fn random_key() -> String {
|
||||
ksuid::Ksuid::generate().to_base62()
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip_all, fields(s3_bucket))]
|
||||
pub async fn upload(
|
||||
s3: &S3Client,
|
||||
pool: &Pool,
|
||||
request: &mut ServiceRequest,
|
||||
payload: Payload,
|
||||
) -> Result<Blob, ApiError> {
|
||||
let span = Span::current();
|
||||
|
||||
let s3_bucket = &CONFIG.s3_bucket;
|
||||
|
||||
span.record("s3_bucket", &s3_bucket);
|
||||
|
||||
if let Ok(length) = request.extract::<Header<ContentLength>>().await
|
||||
&& length.0 < Size::from_megabytes(MULTIPART_THRESHOLD).bytes() as usize
|
||||
{
|
||||
upload_regular(
|
||||
s3,
|
||||
pool,
|
||||
&s3_bucket,
|
||||
length.0 < Size::from_kilobytes(INLINE_THRESHHOLD).bytes() as usize,
|
||||
payload,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
upload_multipart(s3, pool, &s3_bucket, payload).await
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip_all, fields(s3_key))]
|
||||
async fn upload_regular(
|
||||
s3: &S3Client,
|
||||
pool: &Pool,
|
||||
s3_bucket: &str,
|
||||
require_inline: bool,
|
||||
payload: Payload,
|
||||
) -> Result<Blob, ApiError> {
|
||||
let span = Span::current();
|
||||
|
||||
let payload = payload
|
||||
.to_bytes_limited(Size::from_megabytes(MULTIPART_THRESHOLD).bytes() as usize)
|
||||
.await
|
||||
.map_err(|_| actix_web::error::ErrorPayloadTooLarge("payload too large"))??;
|
||||
|
||||
let length = payload.len() as u64;
|
||||
let inline = if require_inline {
|
||||
Some(payload.to_vec())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut hash = Hasher::new();
|
||||
hash.update(&payload);
|
||||
|
||||
let hash = hash.finalize().to_hex().to_string();
|
||||
|
||||
let s3_key = if let Some(s3_key_found) = postgres::find_blob_by_hash(&pool, &hash).await? {
|
||||
span.record("s3_key", &s3_key_found);
|
||||
debug!(s3_key_found, "blob deduplicated");
|
||||
s3_key_found
|
||||
} else {
|
||||
let s3_key = random_key();
|
||||
span.record("s3_key", &s3_key);
|
||||
|
||||
s3.put_object()
|
||||
.bucket(s3_bucket)
|
||||
.key(&s3_key)
|
||||
.body(ByteStream::from(payload))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
postgres::insert_blob(&pool, &s3_key, &hash).await?;
|
||||
|
||||
debug!("blob created");
|
||||
|
||||
s3_key
|
||||
};
|
||||
|
||||
Ok(Blob {
|
||||
s3_key,
|
||||
length,
|
||||
inline,
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip_all, fields(upload, s3_key))]
|
||||
async fn upload_multipart(
|
||||
s3: &S3Client,
|
||||
pool: &Pool,
|
||||
s3_bucket: &str,
|
||||
mut payload: Payload,
|
||||
) -> Result<Blob, ApiError> {
|
||||
let span = Span::current();
|
||||
|
||||
let s3_key = random_key();
|
||||
|
||||
span.record("s3_key", &s3_key);
|
||||
|
||||
let create_multipart = s3
|
||||
.create_multipart_upload()
|
||||
.bucket(s3_bucket)
|
||||
.key(&s3_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let upload_id = create_multipart.upload_id().unwrap();
|
||||
|
||||
span.record("upload", &upload_id[upload_id.len().saturating_sub(16)..]);
|
||||
|
||||
debug!("upload start");
|
||||
|
||||
let upload_part = async |number, buffer: BytesMut| -> HandlerResult<CompletedPart> {
|
||||
let upload = s3
|
||||
.upload_part()
|
||||
.bucket(s3_bucket)
|
||||
.key(&s3_key)
|
||||
.upload_id(upload_id)
|
||||
.body(buffer.freeze().into())
|
||||
.part_number(number)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let part = CompletedPart::builder()
|
||||
.e_tag(upload.e_tag.unwrap())
|
||||
.part_number(number)
|
||||
.build();
|
||||
|
||||
Ok(part)
|
||||
};
|
||||
|
||||
let mut buffer = BytesMut::with_capacity(1024 * 1024 * 6);
|
||||
let mut complete = CompletedMultipartUpload::builder();
|
||||
let mut part_number = 1;
|
||||
let mut hash = Hasher::new();
|
||||
let mut total_in = 0;
|
||||
let mut total_uploaded = 0;
|
||||
|
||||
while let Some(part) = payload.next().await {
|
||||
if let Ok(part) = part {
|
||||
hash.update(&part);
|
||||
|
||||
total_in += part.len();
|
||||
|
||||
buffer.extend_from_slice(&part);
|
||||
|
||||
// each part must be at least 5MB
|
||||
if buffer.len() > 1024 * 1024 * 5 {
|
||||
trace!(length = buffer.len(), part_number, "upload part");
|
||||
|
||||
total_uploaded += buffer.len();
|
||||
|
||||
let uploaded = upload_part(part_number, buffer).await?;
|
||||
|
||||
complete = complete.parts(uploaded);
|
||||
|
||||
buffer = BytesMut::new();
|
||||
part_number += 1;
|
||||
}
|
||||
} else {
|
||||
// TODO: cleanup incomplete upload
|
||||
panic!("read error")
|
||||
}
|
||||
}
|
||||
|
||||
// the last part
|
||||
if buffer.len() > 0 {
|
||||
total_uploaded += buffer.len();
|
||||
|
||||
trace!(length = buffer.len(), part_number, "upload part");
|
||||
let uploaded = upload_part(part_number, buffer).await?;
|
||||
complete = complete.parts(uploaded);
|
||||
}
|
||||
|
||||
assert_eq!(total_in, total_uploaded);
|
||||
|
||||
let _ = s3
|
||||
.complete_multipart_upload()
|
||||
.bucket(s3_bucket)
|
||||
.key(&s3_key)
|
||||
.multipart_upload(complete.build())
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let hash = hash.finalize().to_hex().to_string();
|
||||
|
||||
debug!(hash, "upload complete");
|
||||
|
||||
let s3_key = if let Some(s3_key_found) = postgres::find_blob_by_hash(&pool, &hash).await? {
|
||||
debug!(s3_key_found, "blob deduplicated");
|
||||
|
||||
// delete uploaded
|
||||
s3.delete_object()
|
||||
.bucket(s3_bucket)
|
||||
.key(s3_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
s3_key_found
|
||||
} else {
|
||||
debug!("blob created");
|
||||
postgres::insert_blob(&pool, &s3_key, &hash).await?;
|
||||
s3_key
|
||||
};
|
||||
|
||||
Ok(Blob {
|
||||
s3_key,
|
||||
length: total_uploaded as u64,
|
||||
inline: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
use std::{path::Path, sync::LazyLock};
|
||||
|
||||
use config::FileFormat;
|
||||
use secrecy::SecretString;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct Config {
|
||||
pub bind_port: u16,
|
||||
pub bind_host: String,
|
||||
|
||||
pub token_secret: SecretString,
|
||||
|
||||
pub db_connection: String,
|
||||
pub db_scheme: String,
|
||||
|
||||
pub s3_bucket: String,
|
||||
}
|
||||
|
||||
pub mod hulyrs {
|
||||
use std::sync::LazyLock;
|
||||
|
||||
pub static CONFIG: LazyLock<hulyrs::Config> = LazyLock::new(|| match hulyrs::Config::auto() {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
eprintln!("configuration error: {}", error);
|
||||
std::process::exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub static CONFIG: LazyLock<Config> = LazyLock::new(|| {
|
||||
const DEFAULTS: &str = r#"
|
||||
bind_port = 8096
|
||||
bind_host = "0.0.0.0"
|
||||
|
||||
token_secret = "secret"
|
||||
|
||||
db_connection = "postgresql://root@huly.local:26257/defaultdb?sslmode=disable"
|
||||
db_scheme = "hulylake"
|
||||
|
||||
s3_bucket = "hulylake"
|
||||
"#;
|
||||
|
||||
let mut builder =
|
||||
config::Config::builder().add_source(config::File::from_str(DEFAULTS, FileFormat::Toml));
|
||||
|
||||
let path = Path::new("etc/config.toml");
|
||||
|
||||
if path.exists() {
|
||||
builder = builder.add_source(config::File::with_name(path.as_os_str().to_str().unwrap()));
|
||||
}
|
||||
|
||||
let settings = builder
|
||||
.add_source(config::Environment::with_prefix("HULY"))
|
||||
.build()
|
||||
.and_then(|c| c.try_deserialize::<Config>());
|
||||
|
||||
match settings {
|
||||
Ok(settings) => settings,
|
||||
Err(error) => {
|
||||
eprintln!("configuration error: {}", error);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,302 @@
|
||||
use std::{collections::HashMap, fmt::Display, sync::Arc};
|
||||
|
||||
use actix_web::{
|
||||
HttpRequest, HttpResponse,
|
||||
body::SizedStream,
|
||||
dev::ServiceRequest,
|
||||
http::{
|
||||
self,
|
||||
header::{self, ContentType},
|
||||
},
|
||||
web::{Data, Header, Path, Payload},
|
||||
};
|
||||
use aws_sdk_s3::error::SdkError;
|
||||
use bytes::Bytes;
|
||||
use futures_util::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::blob::upload;
|
||||
use crate::s3::S3Client;
|
||||
use crate::{
|
||||
config::CONFIG,
|
||||
postgres::{self, Pool},
|
||||
};
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ObjectPath {
|
||||
workspace: Uuid,
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum ApiError {
|
||||
#[error("S3 Error: {0}")]
|
||||
S3(String),
|
||||
|
||||
#[error(transparent)]
|
||||
ActixError(#[from] actix_web::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
ActixParseError(#[from] actix_web::error::ParseError),
|
||||
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
pub type HandlerResult<T> = Result<T, ApiError>;
|
||||
|
||||
impl actix_web::error::ResponseError for ApiError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
match self {
|
||||
ApiError::ActixError(error) => error.error_response(),
|
||||
|
||||
_ => {
|
||||
tracing::error!(error=%self, "Internal error in http handler");
|
||||
HttpResponse::InternalServerError().body("Internal Server Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Display + std::error::Error + 'static, B: std::fmt::Debug> From<SdkError<E, B>>
|
||||
for ApiError
|
||||
{
|
||||
fn from(error: SdkError<E, B>) -> Self {
|
||||
error.raw_response();
|
||||
|
||||
ApiError::S3(format!("{} {:#?}", error, error.raw_response()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, serde::Deserialize, Debug)]
|
||||
struct PartData {
|
||||
workspace: Uuid,
|
||||
key: String,
|
||||
part: u32,
|
||||
size: u64,
|
||||
blob: String,
|
||||
etag: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
headers: Option<HashMap<String, String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
meta: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip_all, fields(workspace, huly_key))]
|
||||
pub async fn put(request: HttpRequest, payload: Payload) -> HandlerResult<HttpResponse> {
|
||||
let span = Span::current();
|
||||
|
||||
let mut request = ServiceRequest::from_request(request);
|
||||
|
||||
let path = request.extract::<Path<ObjectPath>>().await?.into_inner();
|
||||
span.record("workspace", path.workspace.to_string());
|
||||
span.record("huly_key", &path.key);
|
||||
|
||||
let pool = request.app_data::<Data<Pool>>().unwrap().to_owned();
|
||||
let s3 = request.app_data::<Data<S3Client>>().unwrap().to_owned();
|
||||
|
||||
debug!("put request");
|
||||
|
||||
let mut headers = Vec::new();
|
||||
for (key, value) in request.headers().iter() {
|
||||
if let Some(header) = key.as_str().strip_prefix("huly-header-") {
|
||||
if let Ok(value) = value.to_str() {
|
||||
headers.push((header.to_owned(), value.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content_type = request
|
||||
.extract::<Header<header::ContentType>>()
|
||||
.await
|
||||
.unwrap_or(Header(ContentType::octet_stream()))
|
||||
.into_inner();
|
||||
headers.push((
|
||||
http::header::CONTENT_TYPE.as_str().to_owned(),
|
||||
content_type.to_string(),
|
||||
));
|
||||
|
||||
let mut meta = Vec::new();
|
||||
for (key, value) in request.headers().iter() {
|
||||
if let Some(header) = key.as_str().strip_prefix("huly-meta-") {
|
||||
if let Ok(value) = value.to_str() {
|
||||
meta.push((header.to_owned(), value.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let uploaded = upload(&s3, &pool, &mut request, payload).await?;
|
||||
|
||||
let part_data = PartData {
|
||||
workspace: path.workspace,
|
||||
key: path.key,
|
||||
part: 0,
|
||||
blob: uploaded.s3_key,
|
||||
size: uploaded.length,
|
||||
etag: ksuid::Ksuid::generate().to_base62(),
|
||||
headers: Some(headers.clone().into_iter().collect()),
|
||||
meta: Some(meta.into_iter().collect()),
|
||||
};
|
||||
|
||||
postgres::set_part(
|
||||
&pool,
|
||||
path.workspace,
|
||||
&part_data.key,
|
||||
uploaded.inline,
|
||||
&part_data,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut response = HttpResponse::Created();
|
||||
response.insert_header((header::CONTENT_LOCATION, part_data.key));
|
||||
response.insert_header((header::ETAG, part_data.etag));
|
||||
|
||||
for (key, value) in headers {
|
||||
response.insert_header((key.as_str(), value));
|
||||
}
|
||||
|
||||
Ok(response.finish())
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip_all, fields(workspace, huly_key))]
|
||||
pub async fn post(request: HttpRequest, payload: Payload) -> HandlerResult<HttpResponse> {
|
||||
let span = Span::current();
|
||||
|
||||
let mut request = ServiceRequest::from_request(request);
|
||||
|
||||
let path = request.extract::<Path<ObjectPath>>().await?.into_inner();
|
||||
span.record("workspace", path.workspace.to_string());
|
||||
span.record("huly_key", &path.key);
|
||||
|
||||
let pool = request.app_data::<Data<Pool>>().unwrap().to_owned();
|
||||
let s3 = request.app_data::<Data<S3Client>>().unwrap().to_owned();
|
||||
|
||||
let uploaded = upload(&s3, &pool, &mut request, payload).await?;
|
||||
|
||||
let parts = postgres::find_parts::<PartData>(&pool, path.workspace, &path.key).await?;
|
||||
|
||||
let part = parts
|
||||
.iter()
|
||||
.map(|p| p.data.part)
|
||||
.reduce(u32::max)
|
||||
.map(|m| m + 1)
|
||||
.unwrap_or(0);
|
||||
|
||||
let part_data = PartData {
|
||||
workspace: path.workspace,
|
||||
key: path.key,
|
||||
part,
|
||||
blob: uploaded.s3_key,
|
||||
size: uploaded.length,
|
||||
etag: ksuid::Ksuid::generate().to_base62(),
|
||||
headers: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
if parts.is_empty() {
|
||||
postgres::set_part(&pool, path.workspace, &part_data.key, None, &part_data).await?;
|
||||
} else {
|
||||
// append
|
||||
postgres::append_part(
|
||||
&pool,
|
||||
path.workspace,
|
||||
&part_data.key,
|
||||
part_data.part,
|
||||
uploaded.inline,
|
||||
&part_data,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut response = HttpResponse::Created();
|
||||
response.insert_header((header::ETAG, part_data.etag));
|
||||
|
||||
Ok(response.finish())
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip_all, fields(workspace, huly_key))]
|
||||
pub async fn get(request: HttpRequest) -> HandlerResult<HttpResponse> {
|
||||
let span = Span::current();
|
||||
|
||||
let mut request = ServiceRequest::from_request(request);
|
||||
|
||||
let path = request.extract::<Path<ObjectPath>>().await?.into_inner();
|
||||
|
||||
span.record("workspace", path.workspace.to_string());
|
||||
span.record("huly_key", &path.key);
|
||||
|
||||
let pool = request.app_data::<Data<Pool>>().unwrap().to_owned();
|
||||
let s3 = request
|
||||
.app_data::<Data<S3Client>>()
|
||||
.unwrap()
|
||||
.to_owned()
|
||||
.into_inner();
|
||||
|
||||
let parts = postgres::find_parts::<PartData>(&pool, path.workspace, &path.key).await?;
|
||||
|
||||
fn stream(
|
||||
parts: Vec<postgres::ObjectPart<PartData>>,
|
||||
s3: Arc<S3Client>,
|
||||
) -> impl Stream<Item = Result<Bytes, std::io::Error>> {
|
||||
use async_stream::stream;
|
||||
|
||||
stream! {
|
||||
for parts in parts {
|
||||
match parts.inline {
|
||||
Some(inline) => {
|
||||
yield Ok(Bytes::from(inline));
|
||||
},
|
||||
None => {
|
||||
match s3.get_object().bucket(&CONFIG.s3_bucket).key(parts.data.blob).send().await {
|
||||
Ok(mut response) => {
|
||||
while let Some(bytes) = response.body.next().await {
|
||||
yield Ok(bytes?);
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
yield Err(std::io::Error::new(std::io::ErrorKind::Other, error));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let response = if !parts.is_empty() {
|
||||
let mut content_length = 0;
|
||||
|
||||
for part in parts.iter() {
|
||||
content_length += part.data.size;
|
||||
}
|
||||
|
||||
let mut response = HttpResponse::Ok();
|
||||
|
||||
let etag = parts.last().unwrap().data.etag.to_owned();
|
||||
|
||||
response.insert_header((header::CONTENT_LENGTH, content_length));
|
||||
response.insert_header((header::ETAG, etag));
|
||||
|
||||
let headers = parts[0].data.headers.as_ref();
|
||||
if let Some(headers) = headers {
|
||||
for (header, value) in headers.iter() {
|
||||
response.insert_header((header.as_str(), value.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
response.body(SizedStream::new(content_length, stream(parts, s3)))
|
||||
} else {
|
||||
HttpResponse::NotFound().finish()
|
||||
};
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn delete(_path: Path<ObjectPath>) -> HandlerResult<HttpResponse> {
|
||||
unimplemented!("delete is not implemented")
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{
|
||||
App, Error, HttpMessage, HttpServer,
|
||||
body::MessageBody,
|
||||
dev::{ServiceRequest, ServiceResponse},
|
||||
middleware::{Next, from_fn},
|
||||
web::{self, Data, Path},
|
||||
};
|
||||
use tracing::*;
|
||||
use tracing_actix_web::TracingLogger;
|
||||
use uuid::Uuid;
|
||||
|
||||
use hulyrs::services::jwt::actix::ServiceRequestExt;
|
||||
|
||||
mod blob;
|
||||
mod config;
|
||||
mod handlers;
|
||||
mod postgres;
|
||||
mod s3;
|
||||
|
||||
use config::CONFIG;
|
||||
|
||||
fn initialize_tracing() {
|
||||
use tracing_subscriber::{filter::targets::Targets, prelude::*};
|
||||
|
||||
let filter = Targets::default()
|
||||
.with_target(env!("CARGO_BIN_NAME"), config::hulyrs::CONFIG.log)
|
||||
.with_target("actix", Level::WARN);
|
||||
let format = tracing_subscriber::fmt::layer().compact();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(filter)
|
||||
.with(format)
|
||||
.init();
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
initialize_tracing();
|
||||
|
||||
tracing::info!(
|
||||
"{}/{} started",
|
||||
env!("CARGO_PKG_NAME"),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
|
||||
let postgres = postgres::pool().await?;
|
||||
let s3 = s3::client().await;
|
||||
|
||||
let bind_to = SocketAddr::new(CONFIG.bind_host.as_str().parse()?, CONFIG.bind_port);
|
||||
|
||||
async fn auth(
|
||||
mut request: ServiceRequest,
|
||||
next: Next<impl MessageBody>,
|
||||
) -> Result<ServiceResponse<impl MessageBody>, Error> {
|
||||
let claims = request
|
||||
.extract_claims(&CONFIG.token_secret)
|
||||
.map_err(|error| {
|
||||
warn!(%error, "Unauthorized request");
|
||||
error
|
||||
})?;
|
||||
|
||||
let workspace = Uuid::parse_str(&request.extract::<Path<String>>().await?);
|
||||
|
||||
if claims.is_system() || Ok(claims.workspace.clone()) == workspace.clone().map(Some) {
|
||||
request.extensions_mut().insert(claims);
|
||||
next.call(request).await
|
||||
} else {
|
||||
warn!(
|
||||
expected = ?claims.workspace,
|
||||
actual = ?workspace,
|
||||
"Unauthorized request, workspace mismatch"
|
||||
);
|
||||
Err(actix_web::error::ErrorUnauthorized("Unauthorized").into())
|
||||
}
|
||||
}
|
||||
|
||||
let server = HttpServer::new(move || {
|
||||
let cors = Cors::default()
|
||||
.allow_any_origin()
|
||||
.allow_any_method()
|
||||
.allow_any_header()
|
||||
.supports_credentials()
|
||||
.max_age(3600);
|
||||
|
||||
const KEY_PATH: &str = "/{key:.*}";
|
||||
|
||||
App::new()
|
||||
.app_data(Data::new(postgres.clone()))
|
||||
.app_data(Data::new(s3.clone()))
|
||||
.wrap(TracingLogger::default())
|
||||
.wrap(cors)
|
||||
.service(
|
||||
web::scope("/api/{workspace}")
|
||||
.wrap(from_fn(auth))
|
||||
.route(KEY_PATH, web::get().to(handlers::get))
|
||||
.route(KEY_PATH, web::put().to(handlers::put))
|
||||
.route(KEY_PATH, web::post().to(handlers::post))
|
||||
.route(KEY_PATH, web::delete().to(handlers::delete)),
|
||||
)
|
||||
.route("/status", web::get().to(async || "ok"))
|
||||
})
|
||||
.bind(bind_to)?
|
||||
.run();
|
||||
|
||||
info!("http listener on {}", bind_to);
|
||||
|
||||
server.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use std::pin::Pin;
|
||||
|
||||
use bb8_postgres::PostgresConnectionManager;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio_postgres::NoTls;
|
||||
use tokio_postgres::{self as pg};
|
||||
use tracing::*;
|
||||
|
||||
use crate::config::CONFIG;
|
||||
|
||||
pub type Pool = bb8::Pool<PostgresConnectionManager<NoTls>>;
|
||||
|
||||
pub async fn pool() -> anyhow::Result<Pool> {
|
||||
tracing::debug!(
|
||||
connection = CONFIG.db_connection,
|
||||
"database connection string"
|
||||
);
|
||||
|
||||
let manager = bb8_postgres::PostgresConnectionManager::new_from_stringlike(
|
||||
&CONFIG.db_connection,
|
||||
tokio_postgres::NoTls,
|
||||
)?;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ConnectionCustomizer;
|
||||
|
||||
impl bb8::CustomizeConnection<pg::Client, pg::Error> for ConnectionCustomizer {
|
||||
fn on_acquire<'a>(
|
||||
&'a self,
|
||||
client: &'a mut pg::Client,
|
||||
) -> Pin<Box<dyn Future<Output = Result<(), pg::Error>> + Send + 'a>> {
|
||||
Box::pin(async {
|
||||
client
|
||||
.execute("set search_path to $1", &[&CONFIG.db_scheme])
|
||||
.await
|
||||
.unwrap();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let pool = bb8::Pool::builder()
|
||||
.max_size(15)
|
||||
.connection_customizer(Box::new(ConnectionCustomizer))
|
||||
.build(manager)
|
||||
.await?;
|
||||
|
||||
{
|
||||
let mut connection = pool.dedicated_connection().await?;
|
||||
|
||||
// query params cannot be bound in ddl statements
|
||||
connection
|
||||
.execute(
|
||||
&format!("create schema if not exists {}", CONFIG.db_scheme),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
refinery::embed_migrations!("etc/migrations");
|
||||
|
||||
let report = migrations::runner()
|
||||
.set_migration_table_name("migrations")
|
||||
.run_async(&mut connection)
|
||||
.await?;
|
||||
|
||||
for m in report.applied_migrations().iter() {
|
||||
info!(migration = m.to_string(), "apply migration");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip_all)]
|
||||
pub async fn find_blob_by_hash(pool: &Pool, hash: &str) -> anyhow::Result<Option<String>> {
|
||||
let connection = pool.get().await?;
|
||||
|
||||
let blob = connection
|
||||
.query("select key from blob where hash = $1", &[&hash])
|
||||
.await?;
|
||||
|
||||
Ok(match blob.as_slice() {
|
||||
[found] => Some(found.get::<_, String>("key")),
|
||||
[] => None,
|
||||
|
||||
_ => panic!(),
|
||||
})
|
||||
}
|
||||
|
||||
#[instrument(level = "debug", skip_all)]
|
||||
pub async fn insert_blob(pool: &Pool, key: &str, hash: &str) -> anyhow::Result<()> {
|
||||
let connection = pool.get().await?;
|
||||
|
||||
connection
|
||||
.execute(
|
||||
"insert into blob (key, hash) values ($1, $2)",
|
||||
&[&key, &hash],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ObjectPart<T: DeserializeOwned + std::fmt::Debug> {
|
||||
pub inline: Option<Vec<u8>>,
|
||||
pub data: T,
|
||||
}
|
||||
|
||||
pub async fn find_parts<T: DeserializeOwned + std::fmt::Debug>(
|
||||
pool: &Pool,
|
||||
workspace: uuid::Uuid,
|
||||
key: &str,
|
||||
) -> anyhow::Result<Vec<ObjectPart<T>>> {
|
||||
let connection = pool.get().await?;
|
||||
|
||||
let rows = connection
|
||||
.query(
|
||||
"select part, data, inline from object where workspace = $1 and key = $2 order by part",
|
||||
&[&workspace, &key],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut parts = Vec::with_capacity(rows.len());
|
||||
|
||||
for row in rows {
|
||||
let data = row.get::<_, serde_json::Value>("data");
|
||||
let inline = row.get::<_, Option<Vec<u8>>>("inline");
|
||||
|
||||
let data = serde_json::from_value(data)?;
|
||||
parts.push(ObjectPart { inline, data })
|
||||
}
|
||||
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
pub async fn append_part<D: serde::Serialize>(
|
||||
pool: &Pool,
|
||||
workspace: uuid::Uuid,
|
||||
key: &str,
|
||||
part: u32,
|
||||
inline: Option<Vec<u8>>,
|
||||
data: &D,
|
||||
) -> anyhow::Result<()> {
|
||||
let connection = pool.get().await?;
|
||||
|
||||
let data = serde_json::to_value(data)?;
|
||||
|
||||
connection
|
||||
.execute(
|
||||
"insert into object (workspace, key, part, inline, data) values ($1, $2, $3, $4, $5)",
|
||||
&[&workspace, &key, &part, &inline, &data],
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_part<D: serde::Serialize>(
|
||||
pool: &Pool,
|
||||
workspace: uuid::Uuid,
|
||||
key: &str,
|
||||
inline: Option<Vec<u8>>,
|
||||
data: &D,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut connection = pool.get().await?;
|
||||
|
||||
let transaction = connection.transaction().await?;
|
||||
|
||||
transaction
|
||||
.execute(
|
||||
"delete from object where workspace = $1 and key = $2",
|
||||
&[&workspace, &key],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let data = serde_json::to_value(data)?;
|
||||
|
||||
transaction
|
||||
.execute(
|
||||
r#"
|
||||
insert into object (workspace, key, part, inline, data) values ($1, $2, 0, $3, $4)
|
||||
on conflict (workspace, key, part) do update set
|
||||
inline = $3,
|
||||
data = $4
|
||||
"#,
|
||||
&[&workspace, &key, &inline, &data],
|
||||
)
|
||||
.await?;
|
||||
|
||||
transaction.commit().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use anyhow::Result;
|
||||
use aws_config::BehaviorVersion;
|
||||
use aws_sdk_s3::{
|
||||
Config,
|
||||
types::{CompletedMultipartUpload, CompletedPart},
|
||||
};
|
||||
use blake3::{Hash, Hasher};
|
||||
use bytes::{Bytes, BytesMut};
|
||||
use futures::stream::StreamExt;
|
||||
use futures_util::Stream;
|
||||
use tracing::*;
|
||||
|
||||
pub type S3Client = aws_sdk_s3::Client;
|
||||
|
||||
pub async fn client() -> S3Client {
|
||||
let ref sdk_config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.load()
|
||||
.await
|
||||
.into_builder()
|
||||
.build();
|
||||
|
||||
let s3_config = Config::from(sdk_config)
|
||||
.to_builder()
|
||||
.force_path_style(true)
|
||||
.build();
|
||||
|
||||
S3Client::from_conf(s3_config)
|
||||
}
|
||||
|
||||
pub struct Upload {
|
||||
pub hash: Hash,
|
||||
pub length: usize,
|
||||
}
|
||||
|
||||
async fn multipart_upload_stream(
|
||||
s3: &S3Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
upload_id: &str,
|
||||
mut source: impl Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
|
||||
) -> Result<(CompletedMultipartUpload, Upload)> {
|
||||
debug!("upload start");
|
||||
|
||||
let upload_part = async |number, buffer: Bytes| -> Result<CompletedPart> {
|
||||
let upload = s3
|
||||
.upload_part()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.body(buffer.into())
|
||||
.part_number(number)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let part = CompletedPart::builder()
|
||||
.e_tag(upload.e_tag.unwrap())
|
||||
.part_number(number)
|
||||
.build();
|
||||
|
||||
Ok(part)
|
||||
};
|
||||
|
||||
let mut buffer = BytesMut::with_capacity(1024 * 1024 * 6);
|
||||
let mut complete = CompletedMultipartUpload::builder();
|
||||
let mut part_number = 1;
|
||||
let mut hash = Hasher::new();
|
||||
let mut total_in = 0;
|
||||
let mut length = 0;
|
||||
|
||||
while let Some(part) = source.next().await {
|
||||
let part = part?;
|
||||
|
||||
hash.update(&part);
|
||||
|
||||
total_in += part.len();
|
||||
|
||||
buffer.extend_from_slice(&part);
|
||||
|
||||
// each part must be at least 5MB
|
||||
if buffer.len() > 1024 * 1024 * 5 {
|
||||
trace!(length = buffer.len(), part_number, "upload part");
|
||||
|
||||
length += buffer.len();
|
||||
|
||||
let uploaded = upload_part(part_number, buffer.freeze()).await?;
|
||||
|
||||
complete = complete.parts(uploaded);
|
||||
|
||||
buffer = BytesMut::new();
|
||||
part_number += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// the last part
|
||||
if buffer.len() > 0 {
|
||||
length += buffer.len();
|
||||
|
||||
trace!(length = buffer.len(), part_number, "upload part");
|
||||
let uploaded = upload_part(part_number, buffer.freeze()).await?;
|
||||
complete = complete.parts(uploaded);
|
||||
}
|
||||
|
||||
assert_eq!(total_in, length);
|
||||
|
||||
let hash = hash.finalize();
|
||||
|
||||
Ok((complete.build(), Upload { hash, length }))
|
||||
}
|
||||
|
||||
#[tracing::instrument(level = "debug", skip_all)]
|
||||
pub async fn multipart_upload<S>(
|
||||
s3: &S3Client,
|
||||
bucket: &str,
|
||||
key: &str,
|
||||
source: impl Stream<Item = Result<Bytes, std::io::Error>> + Unpin,
|
||||
) -> Result<Upload> {
|
||||
let span = Span::current();
|
||||
|
||||
let create_multipart = s3
|
||||
.create_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let upload_id = create_multipart.upload_id().unwrap();
|
||||
|
||||
span.record("upload", &upload_id[upload_id.len().saturating_sub(16)..]);
|
||||
|
||||
match multipart_upload_stream(s3, bucket, key, upload_id, source).await {
|
||||
Ok((complete, upload)) => {
|
||||
s3.complete_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.multipart_upload(complete)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
debug!(hash = %upload.hash, length = upload.length, "upload complete");
|
||||
|
||||
Ok(upload)
|
||||
}
|
||||
Err(error) => {
|
||||
s3.abort_multipart_upload()
|
||||
.bucket(bucket)
|
||||
.key(key)
|
||||
.upload_id(upload_id)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
error!(%error, "upload error");
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user