estrogen for vencord

This commit is contained in:
Skye 2023-08-15 20:57:18 +09:00
commit a3dab56ddb
Signed by: me
GPG key ID: 0104BC05F41B77B8
7 changed files with 2484 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
.envrc

1909
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

29
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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())
}
}