estrogen for vencord
This commit is contained in:
commit
a3dab56ddb
7 changed files with 2484 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
.envrc
|
1909
Cargo.lock
generated
Normal file
1909
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
29
Cargo.toml
Normal file
29
Cargo.toml
Normal file
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "e4vc"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.72"
|
||||
axum = { version = "0.6.20", features = ["http2", "headers", "macros"] }
|
||||
base64 = "0.21.2"
|
||||
bb8 = "0.8.1"
|
||||
bb8-redis = "0.13.1"
|
||||
blake3 = "1.4.1"
|
||||
hex = "0.4.3"
|
||||
hyper = "0.14.27"
|
||||
lazy_static = "1.4.0"
|
||||
redis = { version = "0.23.2", features = ["tokio-comp"] }
|
||||
reqwest = { version = "0.11.18", default-features = false, features = ["rustls-tls", "json"] }
|
||||
sentry = { version = "0.31.5", default-features = false, features = ["backtrace", "contexts", "debug-images", "panic", "reqwest", "rustls", "tracing", "anyhow", "tower", "tower-http"] }
|
||||
sentry-tracing = "*"
|
||||
sentry-tower = "*"
|
||||
serde = { version = "1.0.183", features = ["derive"] }
|
||||
serde_json = "1.0.104"
|
||||
time = "0.3.25"
|
||||
tokio = { version = "1.31.0", features = ["macros", "rt-multi-thread"] }
|
||||
tower-http = { version = "0.4.3", features = ["cors"] }
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = "0.3.17"
|
24
flake.lock
Normal file
24
flake.lock
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1691421349,
|
||||
"narHash": "sha256-RRJyX0CUrs4uW4gMhd/X4rcDG8PTgaaCQM5rXEJOx6g=",
|
||||
"path": "/nix/store/24jqzdczdyg04kkgd71wvydc9yxr5ndh-source",
|
||||
"rev": "011567f35433879aae5024fc6ec53f2a0568a6c4",
|
||||
"type": "path"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
60
flake.nix
Normal file
60
flake.nix
Normal file
|
@ -0,0 +1,60 @@
|
|||
{
|
||||
inputs.nixpkgs.url = "nixpkgs";
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
...
|
||||
}: let
|
||||
version = builtins.substring 0 7 self.lastModifiedDate;
|
||||
|
||||
systems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
nixpkgsFor = forAllSystems (system: import nixpkgs {inherit system;});
|
||||
|
||||
packageFn = pkgs:
|
||||
pkgs.rustPlatform.buildRustPackage {
|
||||
pname = "e4vc";
|
||||
inherit version;
|
||||
|
||||
src = builtins.path {
|
||||
name = "source";
|
||||
path = ./.;
|
||||
};
|
||||
|
||||
cargoSha256 = "sha256-6aA95Zm1FGp8xiv55QSBZFfmBXWz5pygc/8uQHvy3tM=";
|
||||
};
|
||||
in rec {
|
||||
packages = forAllSystems (s: let
|
||||
pkgs = nixpkgsFor.${s};
|
||||
in rec {
|
||||
e4vc = packageFn pkgs;
|
||||
default = e4vc;
|
||||
});
|
||||
|
||||
devShells = forAllSystems (s: let
|
||||
pkgs = nixpkgsFor.${s};
|
||||
inherit (pkgs) mkShell;
|
||||
in {
|
||||
default = mkShell {
|
||||
packages = with pkgs; [rustc cargo rustfmt];
|
||||
};
|
||||
});
|
||||
|
||||
dockerImage = forAllSystems (s: let
|
||||
pkgs = nixpkgsFor.${s};
|
||||
in pkgs.dockerTools.buildImage {
|
||||
name = "e4vc";
|
||||
tag = "latest";
|
||||
config = {
|
||||
Cmd = ["${packageFn pkgs}/bin/e4vc"];
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
14
fly.toml
Normal file
14
fly.toml
Normal file
|
@ -0,0 +1,14 @@
|
|||
#:schema https://json.schemastore.org/fly.json
|
||||
|
||||
app = "e4vc"
|
||||
primary_region = "nrt"
|
||||
|
||||
[build]
|
||||
image = "e4vc:latest"
|
||||
|
||||
[http_service]
|
||||
internal_port = 8080
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 0
|
446
src/main.rs
Normal file
446
src/main.rs
Normal file
|
@ -0,0 +1,446 @@
|
|||
use std::fmt::Debug;
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
async_trait,
|
||||
extract::{FromRef, FromRequestParts, Query, RawBody, State},
|
||||
http::{header::AUTHORIZATION, request::Parts, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use base64::{engine::general_purpose as base64_engines, Engine};
|
||||
use bb8::Pool;
|
||||
use bb8_redis::RedisConnectionManager;
|
||||
use hex::FromHex;
|
||||
use hyper::{
|
||||
body::to_bytes,
|
||||
header::{CONTENT_TYPE, ETAG, IF_NONE_MATCH},
|
||||
Method,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tracing::{error, instrument, trace};
|
||||
use tracing_subscriber::prelude::*;
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[repr(transparent)]
|
||||
struct VencloudianIfNoneMatch(pub Option<u64>);
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for VencloudianIfNoneMatch
|
||||
where
|
||||
S: Send + Sync + Debug,
|
||||
{
|
||||
type Rejection = (StatusCode, Json<serde_json::Value>);
|
||||
|
||||
#[instrument]
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
Ok(Self(
|
||||
parts
|
||||
.headers
|
||||
.get(IF_NONE_MATCH)
|
||||
.and_then(|header| header.to_str().ok())
|
||||
.and_then(|header| header.parse().ok()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
#[repr(transparent)]
|
||||
struct AuthenticatedUser(pub u64);
|
||||
|
||||
#[async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
S: Send + Sync + Debug,
|
||||
{
|
||||
type Rejection = (StatusCode, Json<serde_json::Value>);
|
||||
|
||||
#[instrument]
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
if let Some(authorization) = parts.headers.get(AUTHORIZATION) {
|
||||
let authorization = base64_engines::STANDARD.decode(authorization).or(Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Unauthorized"
|
||||
})),
|
||||
)))?;
|
||||
let authorization = String::from_utf8(authorization).or(Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Unauthorized"
|
||||
})),
|
||||
)))?;
|
||||
let (mac, user_id) = authorization.split_once(':').ok_or((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Unauthorized"
|
||||
})),
|
||||
))?;
|
||||
let mac: [u8; 32] = base64_engines::STANDARD_NO_PAD
|
||||
.decode(mac)
|
||||
.or(Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Unauthorized"
|
||||
})),
|
||||
)))?
|
||||
.try_into()
|
||||
.or(Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Unauthorized"
|
||||
})),
|
||||
)))?;
|
||||
let user_id: u64 = user_id.parse().or(Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Unauthorized"
|
||||
})),
|
||||
)))?;
|
||||
let correct_mac = blake3::keyed_hash(&MAC_SECRET, &user_id.to_le_bytes());
|
||||
if correct_mac == mac {
|
||||
trace!("Authentication successful for: {}", user_id);
|
||||
Ok(Self(user_id))
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Unauthorized"
|
||||
})),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Unauthorized"
|
||||
})),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RedisConnection(bb8::PooledConnection<'static, RedisConnectionManager>);
|
||||
|
||||
#[async_trait]
|
||||
impl FromRequestParts<AppState> for RedisConnection {
|
||||
type Rejection = WrappedError;
|
||||
|
||||
#[instrument]
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
Ok(Self(state.redis_conn_pool.get_owned().await?))
|
||||
}
|
||||
}
|
||||
|
||||
struct WrappedError(anyhow::Error);
|
||||
|
||||
impl IntoResponse for WrappedError {
|
||||
fn into_response(self) -> Response {
|
||||
error!("{:?}", self.0);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({
|
||||
"error": "Internal Error"
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<E> for WrappedError
|
||||
where
|
||||
E: Into<anyhow::Error>,
|
||||
{
|
||||
fn from(value: E) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref DISCORD_ORIGINS: [HeaderValue; 3] = [
|
||||
"https://discord.com".parse().unwrap(),
|
||||
"https://ptb.discord.com".parse().unwrap(),
|
||||
"https://canary.discord.com".parse().unwrap(),
|
||||
];
|
||||
static ref DISCORD_CLIENT_ID: String = std::env::var("E4VC_DISCORD_CLIENT_ID")
|
||||
.expect("environment variable E4VC_DISCORD_CLIENT_ID should be set");
|
||||
static ref DISCORD_CLIENT_SECRET: String = std::env::var("E4VC_DISCORD_CLIENT_SECRET")
|
||||
.expect("environment variable E4VC_DISCORD_CLIENT_SECRET should be set");
|
||||
static ref DISCORD_REDIRECT_URL: String = std::env::var("E4VC_DISCORD_REDIRECT_URL")
|
||||
.expect("environment variable E4VC_DISCORD_REDIRECT_URL should be set");
|
||||
static ref MAC_SECRET: [u8; 32] = <[u8; 32]>::from_hex(
|
||||
std::env::var("E4VC_MAC_SECRET").expect("environment variable E4VC_MAC_SECRET should be set"),
|
||||
)
|
||||
.expect("environment variable E4VC_MAC_SECRET should be 32 hex-encoded bytes");
|
||||
}
|
||||
|
||||
const DISCORD_OAUTH_TOKEN_URL: &str = "https://discord.com/api/oauth2/token";
|
||||
const DISCORD_SELF_INFO_URL: &str = "https://discord.com/api/users/@me";
|
||||
|
||||
#[derive(Debug, Clone, FromRef)]
|
||||
struct AppState {
|
||||
http_client: reqwest::Client,
|
||||
redis_conn_pool: Pool<RedisConnectionManager>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let _guard = sentry::init(
|
||||
std::env::var("E4VC_SENTRY_URL")
|
||||
.expect("environment variable E4VC_SENTRY_URL should be set"),
|
||||
);
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(sentry_tracing::layer())
|
||||
.init();
|
||||
|
||||
let client = RedisConnectionManager::new(
|
||||
std::env::var("E4VC_REDIS_URL").expect("environment variable E4VC_REDIS_URL should be set"),
|
||||
)?;
|
||||
|
||||
let pool = bb8::Pool::builder().build(client).await?;
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", post(|| async { "Hello!" }))
|
||||
.route(
|
||||
"/v1",
|
||||
get(|| async { Json(json!({ "ping": "pong" })) }).layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(DISCORD_ORIGINS.clone())
|
||||
.allow_methods([Method::GET, Method::HEAD])
|
||||
.allow_headers([AUTHORIZATION]),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/v1/",
|
||||
delete(delete_data).layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(DISCORD_ORIGINS.clone())
|
||||
.allow_methods([Method::DELETE])
|
||||
.allow_headers([AUTHORIZATION]),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/v1/oauth/settings",
|
||||
get(|| async {
|
||||
Json(json!({
|
||||
"clientId": *DISCORD_CLIENT_ID,
|
||||
"redirectUri": *DISCORD_REDIRECT_URL
|
||||
}))
|
||||
})
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(DISCORD_ORIGINS.clone())
|
||||
.allow_methods([Method::GET, Method::HEAD])
|
||||
.allow_headers([AUTHORIZATION]),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/v1/oauth/callback",
|
||||
get(handle_discord_callback).layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(DISCORD_ORIGINS.clone())
|
||||
.allow_methods([Method::GET])
|
||||
.allow_headers([AUTHORIZATION]),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/v1/settings",
|
||||
get(get_data)
|
||||
.head(get_data_head)
|
||||
.put(set_data)
|
||||
.delete(delete_data)
|
||||
.layer(
|
||||
CorsLayer::new()
|
||||
.allow_origin(DISCORD_ORIGINS.clone())
|
||||
.allow_methods([Method::GET, Method::HEAD, Method::PUT, Method::DELETE])
|
||||
.allow_headers([AUTHORIZATION, CONTENT_TYPE, IF_NONE_MATCH]),
|
||||
),
|
||||
)
|
||||
.layer(sentry_tower::NewSentryLayer::<_>::new_from_top())
|
||||
.layer(sentry_tower::SentryHttpLayer::with_transaction())
|
||||
.with_state(AppState {
|
||||
http_client: client,
|
||||
redis_conn_pool: pool,
|
||||
});
|
||||
|
||||
axum::Server::bind(&"0.0.0.0:8080".parse().unwrap())
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
drop(_guard);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CodeParam {
|
||||
code: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UserInfo {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[instrument]
|
||||
async fn handle_discord_callback(
|
||||
Query(CodeParam { code }): Query<CodeParam>,
|
||||
State(client): State<reqwest::Client>,
|
||||
) -> Result<(StatusCode, Json<serde_json::Value>), WrappedError> {
|
||||
let token_response = client
|
||||
.post(DISCORD_OAUTH_TOKEN_URL)
|
||||
.form(&json!({
|
||||
"client_id": *DISCORD_CLIENT_ID,
|
||||
"client_secret": *DISCORD_CLIENT_SECRET,
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": *DISCORD_REDIRECT_URL,
|
||||
"scope": "identify"
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
if token_response.status() != StatusCode::OK {
|
||||
return Ok((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "Failed to authorize with Discord"
|
||||
})),
|
||||
));
|
||||
}
|
||||
let TokenResponse { access_token } = token_response.json().await?;
|
||||
let UserInfo { id } = client
|
||||
.get(DISCORD_SELF_INFO_URL)
|
||||
.bearer_auth(access_token)
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
let id: u64 = id.parse()?;
|
||||
let mac: [u8; 32] = blake3::keyed_hash(&MAC_SECRET, &id.to_le_bytes()).into();
|
||||
let encoded = base64_engines::STANDARD_NO_PAD.encode(mac);
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
"secret": encoded
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
#[instrument(skip(conn))]
|
||||
async fn delete_data(
|
||||
AuthenticatedUser(id): AuthenticatedUser,
|
||||
RedisConnection(mut conn): RedisConnection,
|
||||
) -> Result<StatusCode, WrappedError> {
|
||||
redis::cmd("HDEL")
|
||||
.arg(id)
|
||||
.arg("last_modified")
|
||||
.arg("settings")
|
||||
.query_async(&mut *conn)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[instrument(skip(conn))]
|
||||
async fn set_data(
|
||||
AuthenticatedUser(id): AuthenticatedUser,
|
||||
RedisConnection(mut conn): RedisConnection,
|
||||
RawBody(body): RawBody,
|
||||
) -> Result<Json<serde_json::Value>, WrappedError> {
|
||||
let now = (OffsetDateTime::now_utc().unix_timestamp_nanos() / 1000) as u64;
|
||||
let body = to_bytes(body).await?;
|
||||
redis::cmd("HSET")
|
||||
.arg(id)
|
||||
.arg("last_modified")
|
||||
.arg(now)
|
||||
.arg("settings")
|
||||
.arg(&*body)
|
||||
.query_async(&mut *conn)
|
||||
.await?;
|
||||
Ok(Json(json!({
|
||||
"written": now
|
||||
})))
|
||||
}
|
||||
|
||||
#[instrument(skip(conn))]
|
||||
async fn get_data(
|
||||
AuthenticatedUser(id): AuthenticatedUser,
|
||||
RedisConnection(mut conn): RedisConnection,
|
||||
VencloudianIfNoneMatch(tag): VencloudianIfNoneMatch,
|
||||
) -> Result<Response, WrappedError> {
|
||||
if let (Some(last_modified), Some(data)) = redis::cmd("HMGET")
|
||||
.arg(id)
|
||||
.arg("last_modified")
|
||||
.arg("settings")
|
||||
.query_async::<_, (Option<u64>, Option<Vec<u8>>)>(&mut *conn)
|
||||
.await?
|
||||
{
|
||||
if let Some(tag) = tag {
|
||||
if tag == last_modified {
|
||||
return Ok((
|
||||
StatusCode::NOT_MODIFIED,
|
||||
[(ETAG, last_modified.to_string())],
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(ETAG, last_modified.to_string()),
|
||||
(CONTENT_TYPE, "application/octet-stream".to_string()),
|
||||
],
|
||||
data,
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
Ok(StatusCode::NOT_FOUND.into_response())
|
||||
}
|
||||
}
|
||||
|
||||
#[instrument(skip(conn))]
|
||||
async fn get_data_head(
|
||||
AuthenticatedUser(id): AuthenticatedUser,
|
||||
RedisConnection(mut conn): RedisConnection,
|
||||
VencloudianIfNoneMatch(tag): VencloudianIfNoneMatch,
|
||||
) -> Result<Response, WrappedError> {
|
||||
if let Some(last_modified) = redis::cmd("HGET")
|
||||
.arg(id)
|
||||
.arg("last_modified")
|
||||
.query_async::<_, Option<u64>>(&mut *conn)
|
||||
.await?
|
||||
{
|
||||
if let Some(tag) = tag {
|
||||
if tag == last_modified {
|
||||
return Ok((
|
||||
StatusCode::NOT_MODIFIED,
|
||||
[(ETAG, last_modified.to_string())],
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[
|
||||
(ETAG, last_modified.to_string()),
|
||||
(CONTENT_TYPE, "application/octet-stream".to_string()),
|
||||
],
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
Ok(StatusCode::NOT_FOUND.into_response())
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue