feat: scaffold services/january

This commit is contained in:
Paul Makles
2024-08-30 19:48:58 +01:00
parent acbc1b8956
commit c1b92ef56e
16 changed files with 678 additions and 16 deletions

View File

@@ -11,9 +11,10 @@ description = "Revolt Backend: API Models"
[features]
serde = ["dep:serde", "revolt-permissions/serde", "indexmap/serde"]
schemas = ["dep:schemars", "revolt-permissions/schemas"]
utoipa = ["dep:utoipa"]
validator = ["dep:validator"]
rocket = ["dep:rocket"]
partials = ["dep:revolt_optional_struct", "serde", "schemas"]
partials = ["dep:revolt_optional_struct", "serde", "schemas", "utoipa"]
default = ["serde", "partials", "rocket"]
@@ -37,6 +38,7 @@ iso8601-timestamp = { version = "0.2.11", features = ["schema", "bson"] }
# Spec Generation
schemars = { version = "0.8.8", optional = true, features = ["indexmap1"] }
utoipa = { version = "4.2.3", optional = true }
# Validation
validator = { version = "0.16.0", optional = true, features = ["derive"] }

View File

@@ -6,6 +6,10 @@ extern crate serde;
#[macro_use]
extern crate schemars;
#[cfg(feature = "utoipa")]
#[macro_use]
extern crate utoipa;
#[cfg(feature = "partials")]
#[macro_use]
extern crate revolt_optional_struct;
@@ -18,6 +22,7 @@ macro_rules! auto_derived {
$(
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemas", derive(JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[derive(Debug, Clone, Eq, PartialEq)]
$item
)+

View File

@@ -11,7 +11,9 @@ description = "Revolt Backend: Result and Error types"
[features]
serde = ["dep:serde"]
schemas = ["dep:schemars"]
utoipa = ["dep:utoipa"]
rocket = ["dep:rocket", "dep:serde_json"]
axum = ["dep:axum", "dep:serde_json"]
okapi = ["dep:revolt_rocket_okapi", "dep:revolt_okapi", "schemas"]
default = ["serde"]
@@ -23,8 +25,12 @@ serde = { version = "1", features = ["derive"], optional = true }
# Spec Generation
schemars = { version = "0.8.8", optional = true }
utoipa = { version = "4.2.3", optional = true }
# Rocket
rocket = { optional = true, version = "0.5.0-rc.2", default-features = false }
revolt_rocket_okapi = { version = "0.9.1", optional = true }
revolt_okapi = { version = "0.9.1", optional = true }
# Axum
axum = { version = "0.7.5", optional = true }

View File

@@ -0,0 +1,81 @@
use axum::{http::StatusCode, response::IntoResponse, Json};
use crate::{Error, ErrorType};
/// HTTP response builder for Error enum
impl IntoResponse for Error {
fn into_response(self) -> axum::response::Response {
let status = match self.error_type {
ErrorType::LabelMe => StatusCode::INTERNAL_SERVER_ERROR,
ErrorType::AlreadyOnboarded => StatusCode::FORBIDDEN,
ErrorType::UnknownUser => StatusCode::NOT_FOUND,
ErrorType::InvalidUsername => StatusCode::BAD_REQUEST,
ErrorType::UsernameTaken => StatusCode::CONFLICT,
ErrorType::DiscriminatorChangeRatelimited => StatusCode::TOO_MANY_REQUESTS,
ErrorType::AlreadyFriends => StatusCode::CONFLICT,
ErrorType::AlreadySentRequest => StatusCode::CONFLICT,
ErrorType::Blocked => StatusCode::CONFLICT,
ErrorType::BlockedByOther => StatusCode::FORBIDDEN,
ErrorType::NotFriends => StatusCode::FORBIDDEN,
ErrorType::TooManyPendingFriendRequests { .. } => StatusCode::BAD_REQUEST,
ErrorType::UnknownChannel => StatusCode::NOT_FOUND,
ErrorType::UnknownMessage => StatusCode::NOT_FOUND,
ErrorType::UnknownAttachment => StatusCode::BAD_REQUEST,
ErrorType::CannotEditMessage => StatusCode::FORBIDDEN,
ErrorType::CannotJoinCall => StatusCode::BAD_REQUEST,
ErrorType::TooManyAttachments { .. } => StatusCode::BAD_REQUEST,
ErrorType::TooManyReplies { .. } => StatusCode::BAD_REQUEST,
ErrorType::EmptyMessage => StatusCode::UNPROCESSABLE_ENTITY,
ErrorType::PayloadTooLarge => StatusCode::UNPROCESSABLE_ENTITY,
ErrorType::CannotRemoveYourself => StatusCode::BAD_REQUEST,
ErrorType::GroupTooLarge { .. } => StatusCode::FORBIDDEN,
ErrorType::AlreadyInGroup => StatusCode::CONFLICT,
ErrorType::NotInGroup => StatusCode::NOT_FOUND,
ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST,
ErrorType::NotPinned => StatusCode::BAD_REQUEST,
ErrorType::UnknownServer => StatusCode::NOT_FOUND,
ErrorType::InvalidRole => StatusCode::NOT_FOUND,
ErrorType::Banned => StatusCode::FORBIDDEN,
ErrorType::AlreadyInServer => StatusCode::CONFLICT,
ErrorType::TooManyServers { .. } => StatusCode::BAD_REQUEST,
ErrorType::TooManyEmbeds { .. } => StatusCode::BAD_REQUEST,
ErrorType::TooManyEmoji { .. } => StatusCode::BAD_REQUEST,
ErrorType::TooManyChannels { .. } => StatusCode::BAD_REQUEST,
ErrorType::TooManyRoles { .. } => StatusCode::BAD_REQUEST,
ErrorType::ReachedMaximumBots => StatusCode::BAD_REQUEST,
ErrorType::IsBot => StatusCode::BAD_REQUEST,
ErrorType::BotIsPrivate => StatusCode::FORBIDDEN,
ErrorType::CannotReportYourself => StatusCode::BAD_REQUEST,
ErrorType::MissingPermission { .. } => StatusCode::FORBIDDEN,
ErrorType::MissingUserPermission { .. } => StatusCode::FORBIDDEN,
ErrorType::NotElevated => StatusCode::FORBIDDEN,
ErrorType::NotPrivileged => StatusCode::FORBIDDEN,
ErrorType::CannotGiveMissingPermissions => StatusCode::FORBIDDEN,
ErrorType::NotOwner => StatusCode::FORBIDDEN,
ErrorType::DatabaseError { .. } => StatusCode::INTERNAL_SERVER_ERROR,
ErrorType::InternalError => StatusCode::INTERNAL_SERVER_ERROR,
ErrorType::InvalidOperation => StatusCode::BAD_REQUEST,
ErrorType::InvalidCredentials => StatusCode::UNAUTHORIZED,
ErrorType::InvalidProperty => StatusCode::BAD_REQUEST,
ErrorType::InvalidSession => StatusCode::UNAUTHORIZED,
ErrorType::DuplicateNonce => StatusCode::CONFLICT,
ErrorType::VosoUnavailable => StatusCode::BAD_REQUEST,
ErrorType::NotFound => StatusCode::NOT_FOUND,
ErrorType::NoEffect => StatusCode::OK,
ErrorType::FailedValidation { .. } => StatusCode::BAD_REQUEST,
ErrorType::ProxyError => StatusCode::BAD_REQUEST,
};
(status, Json(&self)).into_response()
}
}

View File

@@ -8,9 +8,16 @@ extern crate serde;
#[macro_use]
extern crate schemars;
#[cfg(feature = "utoipa")]
#[macro_use]
extern crate utoipa;
#[cfg(feature = "rocket")]
pub mod rocket;
#[cfg(feature = "axum")]
pub mod axum;
#[cfg(feature = "okapi")]
pub mod okapi;
@@ -20,6 +27,7 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
/// Error information
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "schemas", derive(JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[derive(Debug, Clone)]
pub struct Error {
/// Type of error and additional information
@@ -42,6 +50,7 @@ impl std::error::Error for Error {}
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(tag = "type"))]
#[cfg_attr(feature = "schemas", derive(JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[derive(Debug, Clone)]
pub enum ErrorType {
/// This error was not labeled :(
@@ -145,6 +154,9 @@ pub enum ErrorType {
error: String,
},
// ? Micro-service errors
ProxyError,
// ? Legacy errors
VosoUnavailable,
}

View File

@@ -78,6 +78,8 @@ impl<'r> Responder<'r, 'static> for Error {
ErrorType::NotFound => Status::NotFound,
ErrorType::NoEffect => Status::Ok,
ErrorType::FailedValidation { .. } => Status::BadRequest,
ErrorType::ProxyError => Status::BadRequest,
};
// Serialize the error data structure into JSON.

View File

@@ -4,19 +4,29 @@ version = "0.7.14"
edition = "2021"
[dependencies]
# Serialisation
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.68"
# Async runtime
tokio = { version = "1.0", features = ["full"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Core crates
revolt-config = { version = "0.7.16", path = "../../core/config" }
revolt-result = { version = "0.7.16", path = "../../core/result", features = [
"utoipa",
"axum",
] }
# Axum / web server
tempfile = "3.12.0"
axum_typed_multipart = "0.12.1"
axum = { version = "0.7.5", features = ["multipart"] }
# OpenAPI & documentation generation
utoipa-scalar = { version = "0.1.0", features = ["axum"] }
utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] }

View File

@@ -28,6 +28,8 @@ async fn main() -> Result<(), std::io::Error> {
),
components(
schemas(
revolt_result::Error,
revolt_result::ErrorType,
api::RootResponse,
api::Tag,
api::UploadPayload,

View File

@@ -0,0 +1,40 @@
[package]
name = "revolt-january"
version = "0.7.14"
edition = "2021"
[dependencies]
# Utility
mime = "0.3.17"
lazy_static = "1.5.0"
moka = { version = "0.12.8", features = ["future"] }
# Serialisation
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.68"
# Async runtime
tokio = { version = "1.0", features = ["full"] }
# Web requests
reqwest = { version = "0.12", features = ["json"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Core crates
revolt-config = { version = "0.7.16", path = "../../core/config" }
revolt-models = { version = "0.7.16", path = "../../core/models" }
revolt-result = { version = "0.7.16", path = "../../core/result", features = [
"utoipa",
"axum",
] }
# Axum / web server
axum = { version = "0.7.5" }
axum-extra = { version = "0.9", features = ["typed-header"] }
# OpenAPI & documentation generation
utoipa-scalar = { version = "0.1.0", features = ["axum"] }
utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] }

View File

@@ -0,0 +1,85 @@
use axum::{body::Bytes, extract::Query, routing::get, Json, Router};
use revolt_models::v0::Embed;
use revolt_result::Result;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use crate::requests::Request;
pub async fn router() -> Router {
Router::new()
.route("/", get(root))
.route("/proxy", get(proxy))
.route("/embed", get(embed))
}
/// Successful root response
#[derive(Serialize, Debug, ToSchema)]
pub struct RootResponse {
january: &'static str,
version: &'static str,
}
/// Capture crate version from Cargo
static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Root response from service
#[utoipa::path(
get,
path = "/",
responses(
(status = 200, description = "Echo response", body = RootResponse)
)
)]
async fn root() -> Json<RootResponse> {
Json(RootResponse {
january: "Hello, I am a media proxy server!",
version: CRATE_VERSION,
})
}
#[derive(Deserialize)]
struct UrlQuery {
url: String,
}
/// Proxy a given URL and load media
#[utoipa::path(
get,
path = "/proxy",
responses(
(status = 200, description = "Requested media file", body = Vec<u8>)
),
params(
("url" = String, Query, description = "URL to fetch")
),
)]
async fn proxy(Query(UrlQuery { url }): Query<UrlQuery>) -> Result<Bytes> {
Request::proxy_file(&url).await
}
/// Generate embed for a given URL
#[utoipa::path(
get,
path = "/embed",
responses(
(status = 200, description = "Generated embed information", body = Embed)
),
params(
("url" = String, Query, description = "URL to fetch")
),
security(
("api_key" = [])
)
)]
async fn embed(
Query(UrlQuery { url }): Query<UrlQuery>,
TypedHeader(Authorization(_bearer)): TypedHeader<Authorization<Bearer>>,
) -> Result<Json<Embed>> {
Request::generate_embed(&url).await.map(Json)
}

View File

@@ -0,0 +1,71 @@
use std::net::{Ipv4Addr, SocketAddr};
use axum::Router;
use tokio::net::TcpListener;
use utoipa::{
openapi::security::{Http, HttpAuthScheme, SecurityScheme},
Modify, OpenApi,
};
use utoipa_scalar::{Scalar, Servable as ScalarServable};
mod api;
pub mod requests;
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// Configure logging and environment
revolt_config::configure!(api);
// Configure API schema
#[derive(OpenApi)]
#[openapi(
modifiers(&SecurityAddon),
paths(
api::root,
api::proxy,
api::embed
),
components(
schemas(
api::RootResponse,
revolt_result::Error,
revolt_result::ErrorType,
revolt_models::v0::ImageSize,
revolt_models::v0::Image,
revolt_models::v0::Video,
revolt_models::v0::TwitchType,
revolt_models::v0::LightspeedType,
revolt_models::v0::BandcampType,
revolt_models::v0::Special,
revolt_models::v0::WebsiteMetadata,
revolt_models::v0::Text,
revolt_models::v0::Embed
)
)
)]
struct ApiDoc;
struct SecurityAddon;
impl Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"api_key",
SecurityScheme::Http(Http::new(HttpAuthScheme::Bearer)),
)
}
}
}
// Configure Axum and router
let app = Router::new()
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
.nest("/", api::router().await);
// Configure TCP listener and bind
let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 3000));
let listener = TcpListener::bind(&address).await?;
axum::serve(listener, app.into_make_service()).await
}

View File

@@ -0,0 +1,91 @@
use std::time::Duration;
use axum::body::Bytes;
use lazy_static::lazy_static;
use mime::Mime;
use reqwest::{header::CONTENT_TYPE, redirect, Client, Response};
use revolt_models::v0::Embed;
use revolt_result::{create_error, Result};
lazy_static! {
static ref CLIENT: Client = reqwest::Client::builder()
.user_agent("Mozilla/5.0 (compatible; January/2.0; +https://github.com/revoltchat/backend)")
.timeout(Duration::from_secs(10)) // TODO config
.connect_timeout(Duration::from_secs(5)) // TODO config
.redirect(redirect::Policy::custom(|attempt| {
if attempt.previous().len() > 5 { // TODO config
attempt.error("too many redirects")
} else if attempt.url().host_str() == Some("jan.revolt.chat") { // TODO config
attempt.stop()
} else {
attempt.follow()
}
}))
.build()
.expect("reqwest Client");
/// Cache for proxy results
static ref PROXY_CACHE: moka::future::Cache<String, Result<Bytes>> = moka::future::Cache::builder()
.max_capacity(10_000) // TODO config
.time_to_live(Duration::from_secs(60)) // TODO config
.build();
/// Cache for embed results
static ref EMBED_CACHE: moka::future::Cache<String, Result<Embed>> = moka::future::Cache::builder()
.max_capacity(1_000) // TODO config
.time_to_live(Duration::from_secs(60)) // TODO config
.build();
}
/// Information about a successful request
pub struct Request {
response: Response,
mime: Mime,
}
impl Request {
/// Proxy a given URL
pub async fn proxy_file(url: &str) -> Result<Bytes> {
if let Some(hit) = PROXY_CACHE.get(url).await {
hit
} else {
todo!()
}
}
/// Generate embed for a given URL
pub async fn generate_embed(url: &str) -> Result<Embed> {
if let Some(hit) = EMBED_CACHE.get(url).await {
hit
} else {
todo!()
}
}
/// Send a new request to a service
pub async fn new(url: &str) -> Result<Request> {
let response = CLIENT
.get(url)
.send()
.await
.map_err(|_| create_error!(ProxyError))?;
if !response.status().is_success() {
tracing::error!("{:?}", response);
return Err(create_error!(ProxyError));
}
let content_type = response
.headers()
.get(CONTENT_TYPE)
.ok_or(create_error!(ProxyError))?
.to_str()
.map_err(|_| create_error!(ProxyError))?;
let mime: mime::Mime = content_type
.parse()
.map_err(|_| create_error!(ProxyError))?;
Ok(Request { response, mime })
}
}