feat: opentelemetry crate and files/autumn integration (#499)

Signed-off-by: izzy <me@insrt.uk>
GitHub-Pull-Request: https://github.com/stoatchat/stoatchat/pull/499
Change-Id: If576743ccf6c7bea288a4ebb3fc485708cd21b51
Reviewed-on: https://gerrit.stoatinternal.com/c/stoatchat/+/201
Tested-by: Stoat Code Review <code-review@stoat.chat>
Reviewed-by: insert <insert@stoat.chat>
This commit is contained in:
izzy
2026-10-13 19:12:43 +01:00
committed by Stoat Code Review
parent 3bfb8de24c
commit fe348db834
42 changed files with 1409 additions and 688 deletions

3
.gitignore vendored
View File

@@ -13,4 +13,5 @@ venv/
livekit.yml
.idea
.vscode/launch.json
.vscode/launch.json
.mise/config.local.toml

View File

@@ -5,7 +5,7 @@ pnpm = "10.28.1"
gh = "2.25.0"
rust = "1.92.0"
"cargo:cargo-nextest" = "0.9.122"
"github:nextest-rs/nextest" = { version = "0.9.122", version_prefix = "cargo-nextest-" }
"cargo:cargo-edit" = "0.13.13"
"github:git-town/git-town" = "22.7.1"
@@ -21,12 +21,11 @@ wait_for = ["docker:start", "build"]
run = [{ task = "service:*" }]
[env]
RUST_LOG = "debug,opentelemetry=info,hyper=info,reqwest=info,mobc=info"
BUILDER = "cargo"
SYSTEM_DEPS_DAV1D_BUILD_INTERNAL = "auto" # enable building dav1d iff the system doesn't have a new enough version
DOCKER_NETWORK_NAME = "stoatchat_default"
DATABASE_PORT = "27017"
RABBIT_PORT = "5672"
REDIS_PORT = "6379"
_.file = { path = ".env", tools = true }
[tasks."release:bump"]

View File

@@ -2,12 +2,5 @@
#MISE description="Start Docker containers"
set -e
docker compose up -d
docker run \
--network=${DOCKER_NETWORK_NAME} \
--name wait \
--rm dokku/wait -c \
rabbit:${RABBIT_PORT},\
database:${DATABASE_PORT},\
redis:${REDIS_PORT}
docker compose up -d "$@"
docker compose run --rm wait

View File

@@ -2,4 +2,4 @@
#MISE description="Stop Docker containers"
set -e
docker compose down
docker compose down "$@"

182
Cargo.lock generated
View File

@@ -2719,7 +2719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3885,7 +3885,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.5.10",
"socket2 0.6.4",
"system-configuration 0.7.0",
"tokio",
"tower-service",
@@ -4214,7 +4214,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi 0.5.2",
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4851,7 +4851,7 @@ dependencies = [
"os_info",
"parking_lot",
"pbjson-types",
"prost",
"prost 0.12.6",
"rand 0.9.4",
"reqwest 0.12.28",
"rustls-native-certs 0.8.4",
@@ -4873,7 +4873,7 @@ checksum = "dd59f3759f1a14e60b6bc6d4d415414e1aed686c8e4d2c7862542d95301cdc70"
dependencies = [
"pbjson",
"pbjson-types",
"prost",
"prost 0.12.6",
"serde",
]
@@ -5437,7 +5437,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5806,6 +5806,89 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "opentelemetry"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cdb0b1b267eb9db3331b434ed9ddab10d50e280a9adf9d13e5233e2002b61b5"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "opentelemetry-appender-tracing"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2b304e8d8f11326ba366a99ca5c7aaf7cd397b67268a332437175aeb42a40db"
dependencies = [
"opentelemetry",
"tracing",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "opentelemetry-http"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee2c3625b8aa04209f7e01e513bc8044da9687fa38a9eacf59628ca5f3b87300"
dependencies = [
"async-trait",
"bytes",
"http 1.4.2",
"opentelemetry",
"reqwest 0.13.4",
]
[[package]]
name = "opentelemetry-otlp"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "699a67345e21962a231955b9059a157e428b219fa5df8efe11f08346fb23a344"
dependencies = [
"http 1.4.2",
"httpdate",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost 0.14.4",
"reqwest 0.13.4",
"thiserror 2.0.18",
]
[[package]]
name = "opentelemetry-proto"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25da1ac11a0aeccf38d7f77ee0348715adaf8340f65ad46c94a02c6b20e2f65d"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"prost 0.14.4",
]
[[package]]
name = "opentelemetry_sdk"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb39533d9d1c912123efd7d41d7e0c29d16917b60ce15b4c8d87cb1af7f67520"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"portable-atomic",
"rand 0.9.4",
"thiserror 2.0.18",
]
[[package]]
name = "ordered-multimap"
version = "0.4.3"
@@ -5950,7 +6033,7 @@ checksum = "2580e33f2292d34be285c5bc3dba5259542b083cfad6037b6d70345f24dcb735"
dependencies = [
"heck 0.4.1",
"itertools 0.11.0",
"prost",
"prost 0.12.6",
"prost-types",
]
@@ -5964,7 +6047,7 @@ dependencies = [
"chrono",
"pbjson",
"pbjson-build",
"prost",
"prost 0.12.6",
"prost-build",
"serde",
]
@@ -6641,7 +6724,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29"
dependencies = [
"bytes",
"prost-derive",
"prost-derive 0.12.6",
]
[[package]]
name = "prost"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
dependencies = [
"bytes",
"prost-derive 0.14.4",
]
[[package]]
@@ -6658,7 +6751,7 @@ dependencies = [
"once_cell",
"petgraph",
"prettyplease",
"prost",
"prost 0.12.6",
"prost-types",
"regex",
"syn 2.0.118",
@@ -6678,13 +6771,26 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "prost-derive"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote 1.0.46",
"syn 2.0.118",
]
[[package]]
name = "prost-types"
version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0"
dependencies = [
"prost",
"prost 0.12.6",
]
[[package]]
@@ -6794,7 +6900,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.41",
"socket2 0.5.10",
"socket2 0.6.4",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -6832,7 +6938,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.5.10",
"socket2 0.6.4",
"tracing",
"windows-sys 0.60.2",
]
@@ -7232,7 +7338,9 @@ dependencies = [
"base64 0.22.1",
"bytes",
"encoding_rs",
"futures-channel",
"futures-core",
"futures-util",
"h2 0.4.15",
"http 1.4.2",
"http-body 1.0.1",
@@ -7314,6 +7422,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"simdutf8",
"stoat-otel",
"strum_macros",
"tempfile",
"thumbhash",
@@ -7457,6 +7566,7 @@ dependencies = [
"sha1 0.10.6",
"tokio",
"totp-lite",
"tracing",
"ulid",
"unicode-segmentation",
"url-escape",
@@ -7705,6 +7815,7 @@ dependencies = [
"sentry",
"serde",
"serde_json",
"strum_macros",
"utoipa",
]
@@ -8130,7 +8241,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8260,7 +8371,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9038,7 +9149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -9106,6 +9217,23 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "stoat-otel"
version = "0.0.0"
dependencies = [
"axum",
"opentelemetry",
"opentelemetry-appender-tracing",
"opentelemetry-otlp",
"opentelemetry_sdk",
"revolt-result",
"serde",
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "strict-num"
version = "0.1.1"
@@ -9388,7 +9516,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -9819,12 +9947,14 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [
"bitflags 2.13.0",
"bytes",
"futures-util",
"http 1.4.2",
"http-body 1.0.1",
"http-body-util",
"pin-project-lite",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -9911,6 +10041,22 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-opentelemetry"
version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a904802a1b902f43638b677ff2a650847e3b4404101b6c586d648e8c1e3e8fe"
dependencies = [
"js-sys",
"opentelemetry",
"smallvec",
"tracing",
"tracing-core",
"tracing-log",
"tracing-subscriber",
"web-time",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
@@ -10619,7 +10765,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -7,6 +7,7 @@ members = [
"crates/core/*",
"crates/services/*",
"crates/daemons/*",
"crates/common/otel",
]
[patch.crates-io]
@@ -152,10 +153,11 @@ tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = [
"env-filter",
] } # consider https://crates.io/crates/better-tracing
opentelemetry = { version = "0.31.0", features = ["logs"] }
opentelemetry_sdk = { version = "0.31.0", features = ["logs"] }
opentelemetry-otlp = { version = "0.31.0", features = ["logs"] }
opentelemetry-appender-tracing = "0.31.1"
opentelemetry = { version = "0.33.0", features = ["logs"] }
opentelemetry_sdk = { version = "0.33.0", features = ["logs"] }
opentelemetry-otlp = { version = "0.33.0", features = ["logs"] }
opentelemetry-appender-tracing = "0.33.0"
tracing-opentelemetry = "0.34.0"
# RabbitMQ
lapin = "4.7.1"
@@ -204,3 +206,6 @@ revolt-permissions = { version = "0.16.0-rc.0", path = "crates/core/permissions"
revolt-presence = { version = "0.16.0-rc.0", path = "crates/core/presence" }
revolt-ratelimits = { version = "0.16.0-rc.0", path = "crates/core/ratelimits" }
revolt-result = { version = "0.16.0-rc.0", path = "crates/core/result" }
# Local packages (unpublished)
stoat-otel = { path = "crates/common/otel" }

View File

@@ -33,6 +33,7 @@ COPY crates/core/presence/Cargo.toml ./crates/core/presence/
COPY crates/core/result/Cargo.toml ./crates/core/result/
COPY crates/core/coalesced/Cargo.toml ./crates/core/coalesced/
COPY crates/core/ratelimits/Cargo.toml ./crates/core/ratelimits/
COPY crates/common/otel/Cargo.toml ./crates/common/otel/
COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/
COPY crates/services/january/Cargo.toml ./crates/services/january/
COPY crates/services/gifbox/Cargo.toml ./crates/services/gifbox/

View File

@@ -29,6 +29,7 @@ COPY crates/core/presence/Cargo.toml ./crates/core/presence/
COPY crates/core/result/Cargo.toml ./crates/core/result/
COPY crates/core/coalesced/Cargo.toml ./crates/core/coalesced/
COPY crates/core/ratelimits/Cargo.toml ./crates/core/ratelimits/
COPY crates/common/otel/Cargo.toml ./crates/common/otel/
COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/
COPY crates/services/january/Cargo.toml ./crates/services/january/
COPY crates/services/gifbox/Cargo.toml ./crates/services/gifbox/

View File

@@ -60,6 +60,7 @@ As a heads-up, the development environment uses the following ports:
| Maildev | 14025<br>14080 |
| Revolt Web App | 14701 |
| RabbitMQ | 5672<br>15672 |
| Grafana LGTM (OTLP) | 13000<br>4318 |
| `crates/delta` | 14702 |
| `crates/bonfire` | 14703 |
| `crates/services/autumn` | 14704 |
@@ -117,6 +118,11 @@ If you'd like to change anything, create a `Revolt.overrides.toml` file and spec
> ports: !override
> - "14072:5672"
> - "14672:15672"
>
> otel-lgtm:
> ports: !override
> - "13000:3000"
> - "14318:4318"
> ```
>
> With the corresponding Revolt configuration:
@@ -125,20 +131,18 @@ If you'd like to change anything, create a `Revolt.overrides.toml` file and spec
> # Revolt.overrides.toml
> # and Revolt.test-overrides.toml
> [database]
> mongodb = "mongodb://127.0.0.1:14017"
> mongodb = "mongodb://127.0.0.1:14017/?directConnection=true"
> redis = "redis://127.0.0.1:14079/"
>
> [rabbit]
> port = 14072
> ```
>
> And mise configuration
> And the OTLP endpoint:
>
> ```bash
> #.env
> DATABASE_PORT = "14017"
> RABBIT_PORT = "14072"
> REDIS_PORT = "14079"
> OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:14318"
> ```
Then continue:
@@ -166,6 +170,21 @@ When signing up, go to http://localhost:14080 to find confirmation/password rese
To stop all services, hit (CTRL + c) in the terminal you ran `mise start` and run `mise docker:stop`
## Environment Variables
This list is non-exhaustive right now.
| Variable | Default | Purpose |
| ------------------------------------- | ----------------------- | --------------------------------------------------------------------- |
| `RUST_LOG` | `info` | Log filter, e.g. `info,revolt_autumn=debug` |
| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | | OTLP/HTTP endpoint for logs |
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | | OTLP/HTTP endpoint for traces |
| `OTEL_EXPORTER_OTLP_METRICS_ENDPOINT` | | OTLP/HTTP endpoint for metrics |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | `http://localhost:4318` | Base URL for a single collector; appends `/v1/{logs,traces,metrics}` |
| `OTEL_RESOURCE_ATTRIBUTES` | | Extra resource attributes, e.g. `deployment.environment.name=staging` |
| `OTEL_TRACES_SAMPLER` | `parentbased_always_on` | Trace sampler, e.g. `parentbased_traceidratio` |
| `OTEL_TRACES_SAMPLER_ARG` | `1.0` | Sampling ratio, e.g. `0.1` |
## Deployment Guide
### Cutting new crate releases

View File

@@ -1,3 +1,5 @@
name: stoat-backend
services:
# Redis
redis:
@@ -80,9 +82,23 @@ services:
MAILDEV_INCOMING_USER: smtp
MAILDEV_INCOMING_PASS: smtp
# LiveKit
livekit:
image: ghcr.io/stoatchat/livekit-server:v1.9.13
command: --config /etc/livekit.yml
network_mode: "host"
volumes:
- ./livekit.yml:/etc/livekit.yml
# Grafana LGTM (OTel Collector + Loki + Tempo + Prometheus + Grafana)
otel-lgtm:
image: grafana/otel-lgtm:0.35.0
ports:
- "13000:3000" # Grafana UI
- "4318:4318" # OTLP/HTTP
# Waits for dependencies, used by `mise docker:start`
wait:
image: dokku/wait
command: -c rabbit:5672,database:27017,redis:6379
profiles: [tools]

View File

@@ -0,0 +1,29 @@
[package]
name = "stoat-otel"
version = "0.0.0"
edition = "2024"
license = "AGPL-3.0-or-later"
authors = ["izzy <me@insrt.uk>"]
description = "Common OpenTelemetry configuration"
publish = false
[features]
axum = ["dep:axum", "dep:uuid", "dep:revolt-result", "dep:serde"]
[dependencies]
# Logging
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# OpenTelemetry
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
opentelemetry-otlp = { workspace = true }
opentelemetry-appender-tracing = { workspace = true }
tracing-opentelemetry = { workspace = true }
# HTTP
axum = { workspace = true, optional = true }
uuid = { workspace = true, optional = true, features = ["v4"] }
revolt-result = { workspace = true, optional = true, features = ["axum"] }
serde = { workspace = true, optional = true }

View File

@@ -0,0 +1,3 @@
mod otel_log_impl;
pub(crate) use otel_log_impl::WIDE_LOGGER;

View File

@@ -0,0 +1,70 @@
use std::sync::OnceLock;
use opentelemetry::{
Key,
logs::{AnyValue, LogRecord, Logger, Severity},
};
use opentelemetry_sdk::logs::SdkLogger;
use tracing::Level;
use crate::models::HttpRequestEvent;
pub(crate) static WIDE_LOGGER: OnceLock<SdkLogger> = OnceLock::new();
impl HttpRequestEvent {
pub(crate) fn emit(self, attributes: Vec<(Key, AnyValue)>) {
let level = self.level();
let message = self.summary();
let outcome = if self.rejected() { "error" } else { "success" };
let duration_ms = self.duration.as_millis() as i64;
let logger = WIDE_LOGGER
.get()
.filter(|_| self.sampled || level == Level::ERROR);
if let Some(logger) = logger {
let (severity, severity_text) = match level {
Level::ERROR => (Severity::Error, "ERROR"),
Level::WARN => (Severity::Warn, "WARN"),
_ => (Severity::Info, "INFO"),
};
let mut record = logger.create_log_record();
record.set_event_name("http.request");
record.set_severity_number(severity);
record.set_severity_text(severity_text);
record.set_body(message.clone().into());
record.add_attribute("request_id", self.request_id);
record.add_attribute("method", self.method);
record.add_attribute("path", self.path);
record.add_attribute("route", self.route);
record.add_attribute("status_code", self.status as i64);
record.add_attribute("outcome", outcome);
record.add_attribute("duration_ms", duration_ms);
if let Some(error) = self.error {
let kind: &'static str = (&error.error_type).into();
record.add_attribute("error.type", kind);
record.add_attribute("error.message", format!("{:?}", error.error_type));
if let Some(location) = error.location {
record.add_attribute("error.location", location);
}
}
record.add_attributes(attributes);
logger.emit(record);
}
let status = self.status;
match level {
Level::ERROR => {
tracing::error!(target: "http_request", status, duration_ms, "{message}")
}
Level::WARN => {
tracing::warn!(target: "http_request", status, duration_ms, "{message}")
}
_ => tracing::info!(target: "http_request", status, duration_ms, "{message}"),
}
}
}

View File

@@ -0,0 +1,16 @@
#[cfg(feature = "axum")]
mod implementation;
pub mod middleware;
#[cfg(feature = "axum")]
mod models;
#[cfg(feature = "axum")]
mod repositories;
mod telemetry;
#[cfg(feature = "axum")]
pub use repositories::*;
pub use telemetry::Telemetry;
pub fn init(service: &'static str, version: &'static str) -> Telemetry {
Telemetry::create(service, version)
}

View File

@@ -0,0 +1,18 @@
use std::convert::Infallible;
use ::axum::{async_trait, extract::FromRequestParts, http::request::Parts};
use crate::WideContextRepository;
#[async_trait]
impl<S: Send + Sync> FromRequestParts<S> for WideContextRepository {
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
Ok(parts
.extensions
.get::<WideContextRepository>()
.cloned()
.unwrap_or_default())
}
}

View File

@@ -0,0 +1,102 @@
use std::time::{Duration, Instant};
use ::axum::{
Json,
extract::{MatchedPath, Request},
http::{HeaderValue, header::CONTENT_LENGTH},
middleware::Next,
response::{IntoResponse, Response},
};
use opentelemetry::trace::TraceContextExt;
use serde::Serialize;
use tracing::{Instrument, Span, field::Empty, info_span};
use tracing_opentelemetry::OpenTelemetrySpanExt;
use uuid::Uuid;
use crate::{WideContextRepository, models::HttpRequestEvent};
#[derive(Serialize)]
struct ErrorBody<'a> {
#[serde(flatten)]
error: &'a revolt_result::Error,
request_id: &'a str,
}
impl HttpRequestEvent {
fn from_axum_request(request: &Request) -> Self {
let path = request.uri().path().to_owned();
HttpRequestEvent {
request_id: request
.headers()
.get("x-request-id")
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
.unwrap_or_else(|| Uuid::new_v4().to_string()),
method: request.method().to_string(),
route: request
.extensions()
.get::<MatchedPath>()
.map(|matched| matched.as_str().to_owned())
.unwrap_or_else(|| path.clone()),
path,
start: Instant::now(),
status: 0,
duration: Duration::ZERO,
sampled: false,
error: None,
}
}
fn finish_axum(&mut self, response: &mut Response, span: &Span) {
self.status = response.status().as_u16();
self.duration = self.start.elapsed();
self.sampled = span.context().span().span_context().is_sampled();
self.error = response.extensions_mut().remove::<revolt_result::Error>();
}
}
pub async fn wide_events(mut request: Request, next: Next) -> Response {
let context = WideContextRepository::default();
request.extensions_mut().insert(context.clone());
let mut event = HttpRequestEvent::from_axum_request(&request);
let span = info_span!(
"http.request",
otel.name = format!("{} {}", event.method, event.route),
otel.kind = "server",
request_id = event.request_id,
http.request.method = event.method,
http.route = event.route,
url.path = event.path,
http.response.status_code = Empty,
otel.status_code = Empty,
);
let mut response = next.run(request).instrument(span.clone()).await;
event.finish_axum(&mut response, &span);
span.record("http.response.status_code", event.status);
if event.failed() {
span.record("otel.status_code", "error");
}
if let Some(error) = &event.error {
let (mut parts, _) = response.into_parts();
parts.headers.remove(CONTENT_LENGTH);
let body = Json(ErrorBody {
error,
request_id: &event.request_id,
});
response = Response::from_parts(parts, body.into_response().into_body());
}
if let Ok(request_id) = HeaderValue::from_str(&event.request_id) {
response.headers_mut().insert("x-request-id", request_id);
}
span.in_scope(|| event.emit(context.take()));
response
}

View File

@@ -0,0 +1,6 @@
mod context;
mod events;
mod panic;
pub use events::wide_events;
pub use panic::panic_response;

View File

@@ -0,0 +1,12 @@
use std::any::Any;
use ::axum::response::{IntoResponse, Response};
use revolt_result::{Error, ErrorType};
pub fn panic_response(_panic: Box<dyn Any + Send + 'static>) -> Response {
Error {
error_type: ErrorType::InternalError,
location: None,
}
.into_response()
}

View File

@@ -0,0 +1,2 @@
#[cfg(feature = "axum")]
pub mod axum;

View File

@@ -0,0 +1,57 @@
use std::time::{Duration, Instant};
use tracing::Level;
const SLOW_REQUEST_THRESHOLD: Duration = Duration::from_millis(500);
pub(crate) struct HttpRequestEvent {
pub request_id: String,
pub method: String,
pub path: String,
pub route: String,
pub start: Instant,
pub status: u16,
pub duration: Duration,
pub sampled: bool,
pub error: Option<revolt_result::Error>,
}
impl HttpRequestEvent {
pub(crate) fn failed(&self) -> bool {
self.status >= 500
}
pub(crate) fn rejected(&self) -> bool {
self.status >= 400
}
fn slow(&self) -> bool {
self.duration > SLOW_REQUEST_THRESHOLD
}
pub(crate) fn level(&self) -> Level {
if self.failed() {
Level::ERROR
} else if self.rejected() || self.slow() {
Level::WARN
} else {
Level::INFO
}
}
pub(crate) fn summary(&self) -> String {
let outcome = match &self.error {
Some(error) => {
let kind: &str = (&error.error_type).into();
format!("ERROR {kind}")
}
None if self.rejected() => "ERROR".to_owned(),
None => "OK".to_owned(),
};
let slow = if self.slow() { "[SLOW] " } else { "" };
let Self { method, route, .. } = self;
format!("{slow}{method} {route} ({outcome})")
}
}

View File

@@ -0,0 +1,3 @@
mod http_request_event;
pub(crate) use http_request_event::HttpRequestEvent;

View File

@@ -0,0 +1,3 @@
mod wide_context_repository;
pub use wide_context_repository::WideContextRepository;

View File

@@ -0,0 +1,21 @@
use std::sync::{Arc, Mutex};
use opentelemetry::{Key, logs::AnyValue};
#[derive(Clone, Default)]
pub struct WideContextRepository {
attributes: Arc<Mutex<Vec<(Key, AnyValue)>>>,
}
impl WideContextRepository {
pub fn add(&self, key: impl Into<Key>, value: impl Into<AnyValue>) {
self.attributes
.lock()
.unwrap()
.push((key.into(), value.into()));
}
pub(crate) fn take(&self) -> Vec<(Key, AnyValue)> {
std::mem::take(&mut *self.attributes.lock().unwrap())
}
}

View File

@@ -0,0 +1,122 @@
use opentelemetry::KeyValue;
#[cfg(feature = "axum")]
use opentelemetry::logs::LoggerProvider;
use opentelemetry::trace::TracerProvider;
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::{LogExporter, Protocol, SpanExporter, WithExportConfig};
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::logs::SdkLoggerProvider;
use opentelemetry_sdk::trace::SdkTracerProvider;
use tracing::debug;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::prelude::*;
#[cfg(feature = "axum")]
use crate::implementation::WIDE_LOGGER;
pub struct Telemetry {
logger_provider: SdkLoggerProvider,
tracer_provider: SdkTracerProvider,
}
impl Telemetry {
pub fn create(service: &'static str, version: &'static str) -> Telemetry {
let resource = Resource::builder()
.with_service_name(service)
.with_attribute(KeyValue::new("service.version", version))
.build();
let log_exporter = LogExporter::builder()
.with_http()
.with_protocol(Protocol::HttpBinary)
.build()
.expect("Failed to create log exporter");
let logger_provider = SdkLoggerProvider::builder()
.with_resource(resource.clone())
.with_batch_exporter(log_exporter)
.build();
let span_exporter = SpanExporter::builder()
.with_http()
.with_protocol(Protocol::HttpBinary)
.build()
.expect("Failed to create span exporter");
let tracer_provider = SdkTracerProvider::builder()
.with_resource(resource)
.with_batch_exporter(span_exporter)
.build();
#[cfg(feature = "axum")]
let _ = WIDE_LOGGER.set(logger_provider.logger("stoat_otel::wide"));
let otel_layer =
OpenTelemetryTracingBridge::new(&logger_provider).with_filter(export_filter());
let trace_layer = tracing_opentelemetry::layer()
.with_tracer(tracer_provider.tracer("stoat_otel"))
.with_filter(export_filter());
let fmt_layer = tracing_subscriber::fmt::layer()
.with_thread_names(true)
.with_filter(env_filter());
tracing_subscriber::registry()
.with(otel_layer)
.with(trace_layer)
.with(fmt_layer)
.init();
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let message = info.payload_as_str().unwrap_or_default();
let location = info.location();
tracing::error!(
exception.type = "panic",
exception.message = message,
code.file.path = location.map(|location| location.file()),
code.line.number = location.map(|location| location.line()),
"panicked: {message}"
);
default_hook(info);
}));
debug!("hai i'm a log :3 (initialised OTLP successfully)");
Telemetry {
logger_provider,
tracer_provider,
}
}
}
fn env_filter() -> EnvFilter {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))
}
fn export_filter() -> EnvFilter {
env_filter()
.add_directive("hyper=off".parse().unwrap())
.add_directive("tonic=off".parse().unwrap())
.add_directive("h2=off".parse().unwrap())
.add_directive("reqwest=off".parse().unwrap())
.add_directive("aws_smithy_runtime=off".parse().unwrap())
.add_directive("aws_sdk_s3=off".parse().unwrap())
.add_directive("tower_http::catch_panic=off".parse().unwrap())
.add_directive("http_request=off".parse().unwrap())
}
impl Drop for Telemetry {
fn drop(&mut self) {
if let Err(e) = self.logger_provider.shutdown() {
eprintln!("otel logger shutdown failed: {e}");
}
if let Err(e) = self.tracer_provider.shutdown() {
eprintln!("otel tracer shutdown failed: {e}");
}
}
}

View File

@@ -1,613 +1,5 @@
#[cfg(feature = "test")]
use std::sync::OnceLock;
use std::{collections::HashMap, path::Path, sync::LazyLock};
mod sentry;
mod settings;
use cached::proc_macro::cached;
use config::{Config, Environment, File, FileFormat};
use futures_locks::RwLock;
use serde::Deserialize;
#[cfg(feature = "sentry")]
pub use sentry::{Level, capture_error, capture_message};
#[cfg(feature = "anyhow")]
pub use sentry_anyhow::capture_anyhow;
#[cfg(all(feature = "report-macros", feature = "sentry"))]
#[macro_export]
macro_rules! report_error {
( $expr: expr, $error: ident $( $tt:tt )? ) => {
$expr
.inspect_err(|err| {
$crate::capture_message(
&format!("{err:?} ({}:{}:{})", file!(), line!(), column!()),
$crate::Level::Error,
);
})
.map_err(|_| ::revolt_result::create_error!($error))
};
}
#[cfg(all(feature = "report-macros", feature = "sentry"))]
#[macro_export]
macro_rules! capture_internal_error {
( $expr: expr ) => {
$crate::capture_message(
&format!("{:?} ({}:{}:{})", $expr, file!(), line!(), column!()),
$crate::Level::Error,
);
};
}
#[cfg(all(feature = "report-macros", feature = "sentry"))]
#[macro_export]
macro_rules! report_internal_error {
( $expr: expr ) => {
$expr
.inspect_err(|err| {
$crate::capture_message(
&format!("{err:?} ({}:{}:{})", file!(), line!(), column!()),
$crate::Level::Error,
);
})
.map_err(|_| ::revolt_result::create_error!(InternalError))
};
}
/// Paths to search for configuration
static CONFIG_SEARCH_PATHS: [&str; 3] = [
// current working directory
"Revolt.toml",
// current working directory - overrides file
"Revolt.overrides.toml",
// root directory, for Docker containers
"/Revolt.toml",
];
/// Path to search for test overrides
static TEST_OVERRIDE_PATH: &str = "Revolt.test-overrides.toml";
/// Configuration builder
static CONFIG_BUILDER: LazyLock<RwLock<Config>> = LazyLock::new(|| {
RwLock::new({
let mut builder = Config::builder().add_source(File::from_str(
include_str!("../Revolt.toml"),
FileFormat::Toml,
));
let cwd = std::env::current_dir().unwrap();
let mut cwd: Option<&Path> = Some(&cwd);
while let Some(path) = cwd {
for config_path in CONFIG_SEARCH_PATHS {
let config_path = path.join(config_path);
if config_path.exists() {
builder = builder
.add_source(File::new(config_path.to_str().unwrap(), FileFormat::Toml));
}
}
cwd = path.parent();
}
if std::env::var("TEST_DB").is_ok() {
builder = builder.add_source(File::from_str(
include_str!("../Revolt.test.toml"),
FileFormat::Toml,
));
// recursively search upwards for an overrides file (if there is one)
if let Ok(cwd) = std::env::current_dir() {
let mut path = Some(cwd.as_path());
while let Some(current_path) = path {
let target_path = current_path.join(TEST_OVERRIDE_PATH);
if target_path.exists() {
builder = builder
.add_source(File::new(target_path.to_str().unwrap(), FileFormat::Toml));
}
path = current_path.parent();
}
}
}
builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__"));
builder.build().unwrap()
})
});
#[derive(Deserialize, Debug, Clone)]
pub struct Database {
pub mongodb: String,
pub redis: String,
pub redis_pubsub: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct RabbitQueues {
pub acks: String,
pub events: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Rabbit {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub default_exchange: String,
pub queues: RabbitQueues,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Hosts {
pub app: String,
pub api: String,
pub events: String,
pub autumn: String,
pub january: String,
pub livekit: HashMap<String, String>,
pub assets: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiRegistration {
pub invite_only: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSmtp {
pub host: String,
pub username: String,
pub password: String,
pub from_address: String,
pub reply_to: Option<String>,
pub port: Option<i32>,
pub use_tls: Option<bool>,
pub use_starttls: Option<bool>,
pub expiry: EmailExpiry,
}
/// Email expiration config
#[derive(Deserialize, Debug, Clone)]
pub struct EmailExpiry {
/// How long email verification codes should last for (in seconds)
pub expire_verification: i64,
/// How long password reset codes should last for (in seconds)
pub expire_password_reset: i64,
/// How long account deletion codes should last for (in seconds)
pub expire_account_deletion: i64,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PushVapid {
pub queue: String,
pub private_key: String,
pub public_key: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PushFcm {
pub queue: String,
pub key_type: String,
pub project_id: String,
pub private_key_id: String,
pub private_key: String,
pub client_email: String,
pub client_id: String,
pub auth_uri: String,
pub token_uri: String,
pub auth_provider_x509_cert_url: String,
pub client_x509_cert_url: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PushApn {
pub queue: String,
pub sandbox: bool,
pub pkcs8: String,
pub key_id: String,
pub team_id: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurityCaptcha {
pub hcaptcha_key: String,
pub hcaptcha_sitekey: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurityShield {
pub host: String,
pub key: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurity {
pub shield: ApiSecurityShield,
pub captcha: ApiSecurityCaptcha,
pub trust_cloudflare: bool,
pub easypwned: String,
pub tenor_key: String,
pub admin_keys: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiWorkers {
pub max_concurrent_connections: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiLiveKit {
pub call_ring_duration: usize,
pub nodes: HashMap<String, LiveKitNode>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LiveKitNode {
pub url: String,
pub lat: f64,
pub lon: f64,
pub key: String,
pub secret: String,
// whether to hide the node in the nodes list
#[serde(default)]
pub private: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>,
pub min_username_length: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiAuditLogs {
/// How long audit log entries last before being removed, in seconds
pub expires_after: u64,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Api {
pub registration: ApiRegistration,
pub smtp: ApiSmtp,
pub security: ApiSecurity,
pub workers: ApiWorkers,
pub livekit: ApiLiveKit,
pub users: ApiUsers,
pub audit_logs: ApiAuditLogs,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Pushd {
pub production: bool,
pub exchange: String,
pub mass_mention_chunk_size: usize,
pub render_cache_time: usize,
// Queues
pub message_queue: String,
pub mass_mention_queue: String,
pub dm_call_queue: String,
pub fr_accepted_queue: String,
pub fr_received_queue: String,
pub generic_queue: String,
pub ack_queue: String,
pub vapid: PushVapid,
pub fcm: PushFcm,
pub apn: PushApn,
}
impl Pushd {
fn get_routing_key(&self, key: String) -> String {
match self.production {
true => key + "-prd",
false => key + "-tst",
}
}
pub fn get_ack_routing_key(&self) -> String {
self.get_routing_key(self.ack_queue.clone())
}
pub fn get_message_routing_key(&self) -> String {
self.get_routing_key(self.message_queue.clone())
}
pub fn get_mass_mention_routing_key(&self) -> String {
self.get_routing_key(self.mass_mention_queue.clone())
}
pub fn get_dm_call_routing_key(&self) -> String {
self.get_routing_key(self.dm_call_queue.clone())
}
pub fn get_fr_accepted_routing_key(&self) -> String {
self.get_routing_key(self.fr_accepted_queue.clone())
}
pub fn get_fr_received_routing_key(&self) -> String {
self.get_routing_key(self.fr_received_queue.clone())
}
pub fn get_generic_routing_key(&self) -> String {
self.get_routing_key(self.generic_queue.clone())
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct January {
pub blocked_domains: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesLimit {
pub min_file_size: usize,
pub min_resolution: [usize; 2],
pub max_mega_pixels: usize,
pub max_pixel_side: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesS3 {
pub endpoint: String,
pub path_style_buckets: bool,
pub region: String,
pub access_key_id: String,
pub secret_access_key: String,
pub default_bucket: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Files {
pub encryption_key: String,
pub webp_quality: f32,
pub blocked_mime_types: Vec<String>,
pub clamd_host: String,
pub scan_mime_types: Vec<String>,
pub limit: FilesLimit,
pub preview: HashMap<String, [usize; 2]>,
pub s3: FilesS3,
}
#[derive(Deserialize, Debug, Clone)]
pub struct GlobalLimits {
pub group_size: usize,
pub message_embeds: usize,
pub message_replies: usize,
pub message_reactions: usize,
pub server_emoji: usize,
pub server_roles: usize,
pub server_channels: usize,
pub new_user_hours: usize,
pub body_limit_size: usize,
pub restrict_server_creation: Vec<String>,
pub max_invite_duration_days: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimits {
pub outgoing_friend_requests: usize,
pub bots: usize,
pub message_length: usize,
pub message_attachments: usize,
pub servers: usize,
pub voice_quality: u32,
pub video: bool,
pub video_resolution: [u32; 2],
pub video_aspect_ratio: [f32; 2],
pub file_upload_size_limit: HashMap<String, usize>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimitsCollection {
pub global: GlobalLimits,
pub new_user: FeaturesLimits,
pub default: FeaturesLimits,
#[serde(flatten)]
pub roles: HashMap<String, FeaturesLimits>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LegalLinks {
/// Terms of Service URL
pub terms_of_service: String,
/// Privacy Policy URL
pub privacy_policy: String,
/// Guidelines URL
pub guidelines: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesAdvanced {
#[serde(default)]
pub process_message_delay_limit: u16,
#[serde(default)]
pub seen_events_cache_size: u32,
}
impl Default for FeaturesAdvanced {
fn default() -> Self {
Self {
process_message_delay_limit: 5,
seen_events_cache_size: 20,
}
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct Features {
pub limits: FeaturesLimitsCollection,
pub legal_links: LegalLinks,
pub webhooks_enabled: bool,
pub mass_mentions_send_notifications: bool,
pub mass_mentions_enabled: bool,
#[serde(default)]
pub advanced: FeaturesAdvanced,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Sentry {
pub api: String,
pub events: String,
pub voice_ingress: String,
pub files: String,
pub proxy: String,
pub pushd: String,
pub crond: String,
pub gifbox: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Settings {
pub database: Database,
pub rabbit: Rabbit,
pub hosts: Hosts,
pub api: Api,
pub pushd: Pushd,
pub january: January,
pub files: Files,
pub features: Features,
pub sentry: Sentry,
pub production: bool,
pub environment: String,
pub disable_events_dont_use: bool,
}
impl Settings {
pub fn preflight_checks(&self) {
if self.api.smtp.host.is_empty() {
log::warn!("No SMTP settings specified! Remember to configure email.");
}
if self.api.security.captcha.hcaptcha_key.is_empty() {
log::warn!("No Captcha key specified! Remember to add hCaptcha key.");
}
}
}
pub async fn init() {
println!(
":: Revolt Configuration ::\n\x1b[32m{:?}\x1b[0m",
config().await
);
}
pub async fn read() -> Config {
CONFIG_BUILDER.read().await.clone()
}
pub async fn config_no_cache() -> Settings {
let mut config = read().await.try_deserialize::<Settings>().unwrap();
// inject REDIS_URI for redis-kiss library
if std::env::var("REDIS_URI").is_err() {
unsafe {
std::env::set_var("REDIS_URI", config.database.redis.clone());
}
}
// auto-detect production nodes
if config.hosts.api.contains("https")
&& (config.hosts.api.contains("revolt.chat") || config.hosts.api.contains("stoat.chat"))
{
config.production = true;
}
config
}
#[cached(time = 30)]
pub async fn config() -> Settings {
#[cfg(feature = "test")]
if let Some(overwrites) = CONFIG_OVERWRITES.get() {
return overwrites.clone();
}
config_no_cache().await
}
#[cfg(feature = "test")]
static CONFIG_OVERWRITES: OnceLock<Settings> = OnceLock::new();
/// Modify the config values for a test, this can only be called once
///
/// This will also fail if two or more tests are running in the same process and both try to modify the config,
/// This could happen if tests where run under `cargo test` instead of `nextest`.
#[cfg(feature = "test")]
pub async fn overwrite_config(f: impl FnOnce(&mut Settings)) {
let mut config = config_no_cache().await;
f(&mut config);
CONFIG_OVERWRITES.set(config).expect(
"Cannot overwrite config multiple times, make sure you are running tests through nextest.",
);
}
/// Configure logging and common Rust variables
#[cfg(feature = "sentry")]
pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry::ClientInitGuard> {
if std::env::var("RUST_LOG").is_err() {
unsafe {
std::env::set_var("RUST_LOG", "info");
}
}
if std::env::var("ROCKET_ADDRESS").is_err() {
unsafe {
std::env::set_var("ROCKET_ADDRESS", "0.0.0.0");
}
}
pretty_env_logger::init();
log::info!("Starting {release}");
if dsn.is_empty() {
None
} else {
Some(sentry::init((
dsn,
sentry::ClientOptions {
release: Some(release.into()),
..Default::default()
},
)))
}
}
#[cfg(feature = "sentry")]
#[macro_export]
macro_rules! configure {
($application: ident) => {
let config = $crate::config().await;
let _sentry = $crate::setup_logging(
concat!(env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION")),
config.sentry.$application,
)
.await;
};
}
#[cfg(feature = "test")]
#[cfg(test)]
mod tests {
use crate::init;
#[tokio::test]
async fn it_works() {
init().await;
}
}
pub use sentry::*;
pub use settings::*;

View File

@@ -0,0 +1,89 @@
#[cfg(feature = "sentry")]
pub use sentry::{Level, capture_error, capture_message};
#[cfg(feature = "anyhow")]
pub use sentry_anyhow::capture_anyhow;
#[cfg(all(feature = "report-macros", feature = "sentry"))]
#[macro_export]
macro_rules! report_error {
( $expr: expr, $error: ident $( $tt:tt )? ) => {
$expr
.inspect_err(|err| {
$crate::capture_message(
&format!("{err:?} ({}:{}:{})", file!(), line!(), column!()),
$crate::Level::Error,
);
})
.map_err(|_| ::revolt_result::create_error!($error))
};
}
#[cfg(all(feature = "report-macros", feature = "sentry"))]
#[macro_export]
macro_rules! capture_internal_error {
( $expr: expr ) => {
$crate::capture_message(
&format!("{:?} ({}:{}:{})", $expr, file!(), line!(), column!()),
$crate::Level::Error,
);
};
}
#[cfg(all(feature = "report-macros", feature = "sentry"))]
#[macro_export]
macro_rules! report_internal_error {
( $expr: expr ) => {
$expr
.inspect_err(|err| {
$crate::capture_message(
&format!("{err:?} ({}:{}:{})", file!(), line!(), column!()),
$crate::Level::Error,
);
})
.map_err(|_| ::revolt_result::create_error!(InternalError))
};
}
/// Configure logging and common Rust variables
#[cfg(feature = "sentry")]
pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry::ClientInitGuard> {
if std::env::var("RUST_LOG").is_err() {
unsafe {
std::env::set_var("RUST_LOG", "info");
}
}
if std::env::var("ROCKET_ADDRESS").is_err() {
unsafe {
std::env::set_var("ROCKET_ADDRESS", "0.0.0.0");
}
}
pretty_env_logger::init();
log::info!("Starting {release}");
if dsn.is_empty() {
None
} else {
Some(sentry::init((
dsn,
sentry::ClientOptions {
release: Some(release.into()),
..Default::default()
},
)))
}
}
#[cfg(feature = "sentry")]
#[macro_export]
macro_rules! configure {
($application: ident) => {
let config = $crate::config().await;
let _sentry = $crate::setup_logging(
concat!(env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION")),
config.sentry.$application,
)
.await;
};
}

View File

@@ -0,0 +1,523 @@
#[cfg(feature = "test")]
use std::sync::OnceLock;
use std::{collections::HashMap, path::Path, sync::LazyLock};
use cached::proc_macro::cached;
use config::{Config, Environment, File, FileFormat};
use futures_locks::RwLock;
use serde::Deserialize;
/// Paths to search for configuration
static CONFIG_SEARCH_PATHS: [&str; 3] = [
// current working directory
"Revolt.toml",
// current working directory - overrides file
"Revolt.overrides.toml",
// root directory, for Docker containers
"/Revolt.toml",
];
/// Path to search for test overrides
static TEST_OVERRIDE_PATH: &str = "Revolt.test-overrides.toml";
/// Configuration builder
static CONFIG_BUILDER: LazyLock<RwLock<Config>> = LazyLock::new(|| {
RwLock::new({
let mut builder = Config::builder().add_source(File::from_str(
include_str!("../Revolt.toml"),
FileFormat::Toml,
));
let cwd = std::env::current_dir().unwrap();
let mut cwd: Option<&Path> = Some(&cwd);
while let Some(path) = cwd {
for config_path in CONFIG_SEARCH_PATHS {
let config_path = path.join(config_path);
if config_path.exists() {
builder = builder
.add_source(File::new(config_path.to_str().unwrap(), FileFormat::Toml));
}
}
cwd = path.parent();
}
if std::env::var("TEST_DB").is_ok() {
builder = builder.add_source(File::from_str(
include_str!("../Revolt.test.toml"),
FileFormat::Toml,
));
// recursively search upwards for an overrides file (if there is one)
if let Ok(cwd) = std::env::current_dir() {
let mut path = Some(cwd.as_path());
while let Some(current_path) = path {
let target_path = current_path.join(TEST_OVERRIDE_PATH);
if target_path.exists() {
builder = builder
.add_source(File::new(target_path.to_str().unwrap(), FileFormat::Toml));
}
path = current_path.parent();
}
}
}
builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__"));
builder.build().unwrap()
})
});
#[derive(Deserialize, Debug, Clone)]
pub struct Database {
pub mongodb: String,
pub redis: String,
pub redis_pubsub: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct RabbitQueues {
pub acks: String,
pub events: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Rabbit {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub default_exchange: String,
pub queues: RabbitQueues,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Hosts {
pub app: String,
pub api: String,
pub events: String,
pub autumn: String,
pub january: String,
pub livekit: HashMap<String, String>,
pub assets: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiRegistration {
pub invite_only: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSmtp {
pub host: String,
pub username: String,
pub password: String,
pub from_address: String,
pub reply_to: Option<String>,
pub port: Option<i32>,
pub use_tls: Option<bool>,
pub use_starttls: Option<bool>,
pub expiry: EmailExpiry,
}
/// Email expiration config
#[derive(Deserialize, Debug, Clone)]
pub struct EmailExpiry {
/// How long email verification codes should last for (in seconds)
pub expire_verification: i64,
/// How long password reset codes should last for (in seconds)
pub expire_password_reset: i64,
/// How long account deletion codes should last for (in seconds)
pub expire_account_deletion: i64,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PushVapid {
pub queue: String,
pub private_key: String,
pub public_key: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PushFcm {
pub queue: String,
pub key_type: String,
pub project_id: String,
pub private_key_id: String,
pub private_key: String,
pub client_email: String,
pub client_id: String,
pub auth_uri: String,
pub token_uri: String,
pub auth_provider_x509_cert_url: String,
pub client_x509_cert_url: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PushApn {
pub queue: String,
pub sandbox: bool,
pub pkcs8: String,
pub key_id: String,
pub team_id: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurityCaptcha {
pub hcaptcha_key: String,
pub hcaptcha_sitekey: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurityShield {
pub host: String,
pub key: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurity {
pub shield: ApiSecurityShield,
pub captcha: ApiSecurityCaptcha,
pub trust_cloudflare: bool,
pub easypwned: String,
pub tenor_key: String,
pub admin_keys: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiWorkers {
pub max_concurrent_connections: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiLiveKit {
pub call_ring_duration: usize,
pub nodes: HashMap<String, LiveKitNode>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LiveKitNode {
pub url: String,
pub lat: f64,
pub lon: f64,
pub key: String,
pub secret: String,
// whether to hide the node in the nodes list
#[serde(default)]
pub private: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>,
pub min_username_length: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiAuditLogs {
/// How long audit log entries last before being removed, in seconds
pub expires_after: u64,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Api {
pub registration: ApiRegistration,
pub smtp: ApiSmtp,
pub security: ApiSecurity,
pub workers: ApiWorkers,
pub livekit: ApiLiveKit,
pub users: ApiUsers,
pub audit_logs: ApiAuditLogs,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Pushd {
pub production: bool,
pub exchange: String,
pub mass_mention_chunk_size: usize,
pub render_cache_time: usize,
// Queues
pub message_queue: String,
pub mass_mention_queue: String,
pub dm_call_queue: String,
pub fr_accepted_queue: String,
pub fr_received_queue: String,
pub generic_queue: String,
pub ack_queue: String,
pub vapid: PushVapid,
pub fcm: PushFcm,
pub apn: PushApn,
}
impl Pushd {
fn get_routing_key(&self, key: String) -> String {
match self.production {
true => key + "-prd",
false => key + "-tst",
}
}
pub fn get_ack_routing_key(&self) -> String {
self.get_routing_key(self.ack_queue.clone())
}
pub fn get_message_routing_key(&self) -> String {
self.get_routing_key(self.message_queue.clone())
}
pub fn get_mass_mention_routing_key(&self) -> String {
self.get_routing_key(self.mass_mention_queue.clone())
}
pub fn get_dm_call_routing_key(&self) -> String {
self.get_routing_key(self.dm_call_queue.clone())
}
pub fn get_fr_accepted_routing_key(&self) -> String {
self.get_routing_key(self.fr_accepted_queue.clone())
}
pub fn get_fr_received_routing_key(&self) -> String {
self.get_routing_key(self.fr_received_queue.clone())
}
pub fn get_generic_routing_key(&self) -> String {
self.get_routing_key(self.generic_queue.clone())
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct January {
pub blocked_domains: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesLimit {
pub min_file_size: usize,
pub min_resolution: [usize; 2],
pub max_mega_pixels: usize,
pub max_pixel_side: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesS3 {
pub endpoint: String,
pub path_style_buckets: bool,
pub region: String,
pub access_key_id: String,
pub secret_access_key: String,
pub default_bucket: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Files {
pub encryption_key: String,
pub webp_quality: f32,
pub blocked_mime_types: Vec<String>,
pub clamd_host: String,
pub scan_mime_types: Vec<String>,
pub limit: FilesLimit,
pub preview: HashMap<String, [usize; 2]>,
pub s3: FilesS3,
}
#[derive(Deserialize, Debug, Clone)]
pub struct GlobalLimits {
pub group_size: usize,
pub message_embeds: usize,
pub message_replies: usize,
pub message_reactions: usize,
pub server_emoji: usize,
pub server_roles: usize,
pub server_channels: usize,
pub new_user_hours: usize,
pub body_limit_size: usize,
pub restrict_server_creation: Vec<String>,
pub max_invite_duration_days: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimits {
pub outgoing_friend_requests: usize,
pub bots: usize,
pub message_length: usize,
pub message_attachments: usize,
pub servers: usize,
pub voice_quality: u32,
pub video: bool,
pub video_resolution: [u32; 2],
pub video_aspect_ratio: [f32; 2],
pub file_upload_size_limit: HashMap<String, usize>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimitsCollection {
pub global: GlobalLimits,
pub new_user: FeaturesLimits,
pub default: FeaturesLimits,
#[serde(flatten)]
pub roles: HashMap<String, FeaturesLimits>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LegalLinks {
/// Terms of Service URL
pub terms_of_service: String,
/// Privacy Policy URL
pub privacy_policy: String,
/// Guidelines URL
pub guidelines: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesAdvanced {
#[serde(default)]
pub process_message_delay_limit: u16,
#[serde(default)]
pub seen_events_cache_size: u32,
}
impl Default for FeaturesAdvanced {
fn default() -> Self {
Self {
process_message_delay_limit: 5,
seen_events_cache_size: 20,
}
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct Features {
pub limits: FeaturesLimitsCollection,
pub legal_links: LegalLinks,
pub webhooks_enabled: bool,
pub mass_mentions_send_notifications: bool,
pub mass_mentions_enabled: bool,
#[serde(default)]
pub advanced: FeaturesAdvanced,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Sentry {
pub api: String,
pub events: String,
pub voice_ingress: String,
pub files: String,
pub proxy: String,
pub pushd: String,
pub crond: String,
pub gifbox: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Settings {
pub database: Database,
pub rabbit: Rabbit,
pub hosts: Hosts,
pub api: Api,
pub pushd: Pushd,
pub january: January,
pub files: Files,
pub features: Features,
pub sentry: Sentry,
pub production: bool,
pub environment: String,
pub disable_events_dont_use: bool,
}
impl Settings {
pub fn preflight_checks(&self) {
if self.api.smtp.host.is_empty() {
log::warn!("No SMTP settings specified! Remember to configure email.");
}
if self.api.security.captcha.hcaptcha_key.is_empty() {
log::warn!("No Captcha key specified! Remember to add hCaptcha key.");
}
}
}
pub async fn init() {
println!(
":: Revolt Configuration ::\n\x1b[32m{:?}\x1b[0m",
config().await
);
}
pub async fn read() -> Config {
CONFIG_BUILDER.read().await.clone()
}
pub async fn config_no_cache() -> Settings {
let mut config = read().await.try_deserialize::<Settings>().unwrap();
// inject REDIS_URI for redis-kiss library
if std::env::var("REDIS_URI").is_err() {
unsafe {
std::env::set_var("REDIS_URI", config.database.redis.clone());
}
}
// auto-detect production nodes
if config.hosts.api.contains("https")
&& (config.hosts.api.contains("revolt.chat") || config.hosts.api.contains("stoat.chat"))
{
config.production = true;
}
config
}
#[cached(time = 30)]
pub async fn config() -> Settings {
#[cfg(feature = "test")]
if let Some(overwrites) = CONFIG_OVERWRITES.get() {
return overwrites.clone();
}
config_no_cache().await
}
#[cfg(feature = "test")]
static CONFIG_OVERWRITES: OnceLock<Settings> = OnceLock::new();
/// Modify the config values for a test, this can only be called once
///
/// This will also fail if two or more tests are running in the same process and both try to modify the config,
/// This could happen if tests where run under `cargo test` instead of `nextest`.
#[cfg(feature = "test")]
pub async fn overwrite_config(f: impl FnOnce(&mut Settings)) {
let mut config = config_no_cache().await;
f(&mut config);
CONFIG_OVERWRITES.set(config).expect(
"Cannot overwrite config multiple times, make sure you are running tests through nextest.",
);
}
#[cfg(feature = "test")]
#[cfg(test)]
mod tests {
use super::init;
#[tokio::test]
async fn it_works() {
init().await;
}
}

View File

@@ -36,6 +36,7 @@ revolt-coalesced = { workspace = true }
# Utility
log = { workspace = true }
tracing = { workspace = true }
lru = { workspace = true }
rand = { workspace = true }
ulid = { workspace = true }

View File

@@ -28,11 +28,31 @@ extern crate bson;
#[cfg(not(feature = "tokio-runtime"))]
compile_error!("tokio-runtime feature must be enabled.");
#[macro_export]
#[doc(hidden)]
macro_rules! query_span {
( $type: ident, $collection: expr ) => {
::tracing::info_span!(
"mongodb",
otel.name = format!("{} {}", stringify!($type), $collection),
otel.kind = "client",
db.system = "mongodb",
db.collection.name = $collection,
db.operation.name = stringify!($type),
)
};
}
#[macro_export]
#[cfg(debug_assertions)]
macro_rules! query {
( $self: ident, $type: ident, $collection: expr, $($rest:expr),+ ) => {
Ok($self.$type($collection, $($rest),+).await.unwrap())
Ok(::tracing::Instrument::instrument(
$self.$type($collection, $($rest),+),
$crate::query_span!($type, $collection),
)
.await
.unwrap())
};
}
@@ -40,11 +60,15 @@ macro_rules! query {
#[cfg(not(debug_assertions))]
macro_rules! query {
( $self: ident, $type: ident, $collection: expr, $($rest:expr),+ ) => {
$self.$type($collection, $($rest),+).await
.map_err(|err| {
revolt_config::capture_internal_error!(err);
create_database_error!(stringify!($type), $collection)
})
::tracing::Instrument::instrument(
$self.$type($collection, $($rest),+),
$crate::query_span!($type, $collection),
)
.await
.map_err(|err| {
revolt_config::capture_internal_error!(err);
create_database_error!(stringify!($type), $collection)
})
};
}

View File

@@ -29,6 +29,7 @@ impl EncryptionKey {
}
impl EncryptionRepository for EncryptionKey {
#[tracing::instrument(skip_all, fields(size = buf.len()))]
fn decrypt_buffer(&self, mut buf: Vec<u8>, iv: &str) -> anyhow::Result<Vec<u8>> {
let iv = &BASE64_STANDARD.decode(iv).unwrap()[..];
let iv: &Nonce<typenum::consts::U12> = iv.into();
@@ -43,6 +44,7 @@ impl EncryptionRepository for EncryptionKey {
Ok(buf)
}
#[tracing::instrument(skip_all, fields(size = buf.len()))]
fn encrypt_buffer(&self, buf: &[u8]) -> anyhow::Result<(Vec<u8>, String)> {
let iv = Aes256Gcm::generate_nonce(&mut OsRng);

View File

@@ -61,6 +61,7 @@ impl MediaRepository for MediaImpl {
}
}
#[tracing::instrument(skip(self, v))]
fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)> {
match mime {
"image/svg+xml" => {
@@ -83,6 +84,7 @@ impl MediaRepository for MediaImpl {
}
}
#[tracing::instrument(skip(self, reader))]
fn decode_image<R: Read + BufRead + Seek>(
&self,
reader: &mut R,
@@ -146,6 +148,7 @@ impl MediaRepository for MediaImpl {
}
}
#[tracing::instrument(skip(self, reader))]
fn is_valid_image<R: Read + BufRead + Seek>(&self, reader: &mut R, mime: &str) -> bool {
match mime {
"image/jxl" => jxl_oxide::JxlImage::builder()
@@ -162,6 +165,7 @@ impl MediaRepository for MediaImpl {
}
}
#[tracing::instrument(skip(self, image))]
fn create_thumbnail(&self, image: DynamicImage, tag: &str) -> Vec<u8> {
let [w, h] = self.config.preview.get(tag).unwrap();
@@ -186,6 +190,7 @@ impl MediaRepository for MediaImpl {
}
}
#[tracing::instrument(skip_all)]
fn video_size(&self, f: &NamedTempFile) -> Option<(i64, i64)> {
if let Ok(data) = ffprobe::ffprobe(f.path())
.inspect_err(|err| tracing::error!("Failed to ffprobe file! {err:?}"))

View File

@@ -45,6 +45,7 @@ impl<ER: EncryptionRepository> S3Storage<ER> {
#[async_trait::async_trait]
impl<ER: EncryptionRepository> FileStorageRepository for S3Storage<ER> {
#[tracing::instrument(skip(self), err)]
async fn create_bucket(&self, bucket_id: &str) -> anyhow::Result<()> {
self.client
.create_bucket()
@@ -56,6 +57,7 @@ impl<ER: EncryptionRepository> FileStorageRepository for S3Storage<ER> {
Ok(())
}
#[tracing::instrument(skip(self, iv), err)]
async fn fetch_and_decrypt_file(
&self,
bucket_id: &str,
@@ -84,6 +86,7 @@ impl<ER: EncryptionRepository> FileStorageRepository for S3Storage<ER> {
}
}
#[tracing::instrument(skip(self, buf), fields(size = buf.len()), err)]
async fn encrypt_and_upload_file(
&self,
bucket_id: &str,
@@ -104,6 +107,7 @@ impl<ER: EncryptionRepository> FileStorageRepository for S3Storage<ER> {
Ok(iv)
}
#[tracing::instrument(skip(self), err)]
async fn delete_file(&self, bucket_id: &str, path: &str) -> anyhow::Result<()> {
self.client
.delete_object()

View File

@@ -36,6 +36,7 @@ revolt_okapi = { workspace = true, optional = true }
# utilities
log = { workspace = true }
strum_macros = { workspace = true }
# Axum
axum = { workspace = true, optional = true }

View File

@@ -125,6 +125,8 @@ impl IntoResponse for Error {
ErrorType::IncorrectData { .. } => StatusCode::BAD_REQUEST,
};
(status, Json(&self)).into_response()
let mut response = (status, Json(&self)).into_response();
response.extensions_mut().insert(self);
response
}
}

View File

@@ -1,6 +1,8 @@
use std::fmt::Display;
use std::panic::Location;
use strum_macros::IntoStaticStr;
#[cfg(feature = "serde")]
#[macro_use]
extern crate serde;
@@ -65,7 +67,7 @@ impl serde::Serialize for Error {
| ErrorType::UnverifiedAccount
| ErrorType::LockedOut
| ErrorType::DisallowedMFAMethod => None,
_ => Some(self.location.as_deref().unwrap()),
_ => self.location.as_deref(),
};
Body {
@@ -81,7 +83,7 @@ impl serde::Serialize for Error {
#[cfg_attr(feature = "serde", serde(tag = "type"))]
#[cfg_attr(feature = "schemas", derive(JsonSchema))]
#[cfg_attr(feature = "utoipa", derive(ToSchema))]
#[derive(Debug, Clone)]
#[derive(Debug, Clone, IntoStaticStr)]
pub enum ErrorType {
/// This error was not labeled :(
LabelMe,

View File

@@ -47,19 +47,22 @@ tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# Core crates
# Core
revolt-files = { workspace = true }
revolt-config = { workspace = true }
revolt-database = { workspace = true, features = ["axum-impl"] }
revolt-result = { workspace = true, features = ["utoipa", "axum"] }
revolt-ratelimits = { workspace = true, features = ["axum"] }
# Common
stoat-otel = { workspace = true, features = ["axum"] }
# Axum / web server
tempfile = { workspace = true }
axum-macros = { workspace = true }
axum_typed_multipart = { workspace = true }
axum = { workspace = true, features = ["multipart"] }
tower-http = { workspace = true, features = ["cors"] }
tower-http = { workspace = true, features = ["catch-panic", "cors"] }
# OpenAPI & documentation generation
utoipa-scalar = { workspace = true, features = ["axum"] }

View File

@@ -21,6 +21,7 @@ use revolt_files::{
use revolt_result::{create_error, Error, Result, ToRevoltError};
use serde::{Deserialize, Serialize};
use sha2::Digest;
use stoat_otel::WideContextRepository;
use tempfile::NamedTempFile;
use tokio::time::Instant;
use tower_http::cors::{AllowHeaders, Any, CorsLayer};
@@ -106,6 +107,7 @@ static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
#[utoipa::path(
get,
path = "/",
tag = "Files",
responses(
(status = 200, description = "Echo response", body = RootResponse)
)
@@ -163,6 +165,7 @@ pub struct UploadResponse {
#[utoipa::path(
post,
path = "/{tag}",
tag = "Files",
responses(
(status = 200, description = "Upload was successful", body = UploadResponse)
),
@@ -177,10 +180,13 @@ pub struct UploadResponse {
)]
async fn upload_file(
State(db): State<Database>,
wide_context: WideContextRepository,
user: User,
Path(tag): Path<Tag>,
TypedMultipart(UploadPayload { mut file }): TypedMultipart<UploadPayload>,
) -> Result<Json<UploadResponse>> {
wide_context.add("user_id", user.id.clone());
// Fetch configuration
let config = config().await;
@@ -358,6 +364,7 @@ pub static CACHE_CONTROL: &str = "public, max-age=604800, must-revalidate";
#[utoipa::path(
get,
path = "/{tag}/{file_id}",
tag = "Files",
responses(
(status = 200, description = "Generated preview", body = Vec<u8>)
),
@@ -452,6 +459,7 @@ async fn fetch_preview(
#[utoipa::path(
get,
path = "/{tag}/{file_id}/{file_name}",
tag = "Files",
responses(
(status = 200, description = "Original file", body = Vec<u8>)
),

View File

@@ -3,7 +3,6 @@ use std::time::Duration;
use revolt_config::{config, report_internal_error};
use revolt_result::Result;
/// Initialise ClamAV
pub async fn init() {
let config = config().await;
@@ -32,7 +31,6 @@ pub async fn init() {
}
}
/// Scan for malware
pub async fn is_malware(buf: &[u8]) -> Result<bool> {
let config = config().await;
if config.files.clamd_host.is_empty() {

View File

@@ -1,16 +1,24 @@
use std::net::{Ipv4Addr, SocketAddr};
use axum::{middleware::from_fn_with_state, Router};
use axum::{
middleware::{from_fn, from_fn_with_state},
Router,
};
use axum_macros::FromRef;
use revolt_database::{Database, DatabaseInfo};
use revolt_ratelimits::axum as ratelimiter;
use tokio::net::TcpListener;
use tokio::{
net::TcpListener,
signal::unix::{signal, SignalKind},
};
use tower_http::catch_panic::CatchPanicLayer;
use utoipa::{
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
Modify, OpenApi,
};
use utoipa_scalar::{Scalar, Servable as ScalarServable};
use tracing::info;
mod api;
pub mod clamav;
@@ -28,13 +36,12 @@ struct AppState {
#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
// Configure logging and environment
revolt_config::configure!(files);
let _telemetry = stoat_otel::init(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
info!(version = env!("CARGO_PKG_VERSION"), awawa = true, "Starting the Stoat file server!");
// Wait for ClamAV
revolt_config::config().await;
clamav::init().await;
// Configure API schema
#[derive(OpenApi)]
#[openapi(
modifiers(&SecurityAddon),
@@ -55,7 +62,7 @@ async fn main() -> Result<(), std::io::Error> {
)
),
tags(
// (name = "Files", description = "File uploads API")
(name = "Files", description = "File uploads API")
)
)]
struct ApiDoc;
@@ -77,7 +84,6 @@ async fn main() -> Result<(), std::io::Error> {
}
}
// Connect to the database
let db = DatabaseInfo::Auto.connect().await.unwrap();
let ratelimits = ratelimiter::RatelimitStorage::new(ratelimits::AutumnRatelimits);
@@ -86,7 +92,6 @@ async fn main() -> Result<(), std::io::Error> {
ratelimit_storage: ratelimits,
};
// Configure Axum and router
let app = Router::new()
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
.nest("/", api::router().await)
@@ -95,10 +100,30 @@ async fn main() -> Result<(), std::io::Error> {
state.clone(),
ratelimiter::ratelimit_middleware,
))
.layer(CatchPanicLayer::custom(stoat_otel::middleware::axum::panic_response))
.layer(from_fn(stoat_otel::middleware::axum::wide_events))
.with_state(state);
// Configure TCP listener and bind
let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 14704));
let listener = TcpListener::bind(&address).await?;
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await
info!(address = %listener.local_addr()?, "Listening for requests 🍂");
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async {
let mut terminate =
signal(SignalKind::terminate()).expect("sigterm handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = terminate.recv() => {}
}
info!("Service going offline, bye bye");
})
.await?;
Ok(())
}

View File

@@ -1,7 +1,7 @@
use std::io::Cursor;
use crate::utils::apply_icc_profile;
use image::{GenericImageView, ImageError, ImageReader};
use image::{GenericImageView, ImageReader};
use revolt_database::Metadata;
use revolt_files::{image_size, is_animated, video_size};
use tempfile::NamedTempFile;
@@ -21,7 +21,6 @@ static SUPPORTED_IMAGE_MIME: [&str; 9] = [
"image/webp",
];
/// Generate metadata from file, using mime type as a hint
pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata {
if SUPPORTED_IMAGE_MIME.contains(&mime_type) {
image_size(f)
@@ -67,7 +66,6 @@ pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata {
}
}
/// Subroutine to ensure data isn't corrupted
pub fn validate_from_metadata(
reader: Cursor<Vec<u8>>,
metadata: Metadata,

View File

@@ -1,15 +1,12 @@
use tempfile::NamedTempFile;
/// Determine the mime type of the given temporary file and filename
pub fn determine_mime_type(f: &mut NamedTempFile, buf: &[u8], file_name: &str) -> &'static str {
// Force certain extensions into particular mime types
if file_name.to_lowercase().ends_with(".apk") {
return "application/vnd.android.package-archive";
} else if file_name.to_lowercase().ends_with(".exe") {
return "application/vnd.microsoft.portable-executable";
}
// Use magic signatures to determine mime type
let kind = infer::get_from_path(f.path()).expect("file read successfully");
let mime_type = if let Some(kind) = kind {
kind.mime_type()
@@ -17,7 +14,6 @@ pub fn determine_mime_type(f: &mut NamedTempFile, buf: &[u8], file_name: &str) -
"application/octet-stream"
};
// See if the file is actually just plain Unicode/ASCII text
if mime_type == "application/octet-stream" && simdutf8::basic::from_utf8(buf).is_ok() {
if file_name.to_lowercase().ends_with(".svg") {
return "image/svg+xml";

View File

@@ -36,6 +36,7 @@ deps() {
crates/core/result/src \
crates/core/coalesced/src \
crates/core/ratelimits/src \
crates/common/otel/src \
crates/services/autumn/src \
crates/services/january/src \
crates/services/gifbox/src \
@@ -61,7 +62,8 @@ deps() {
tee crates/core/presence/src/lib.rs |
tee crates/core/result/src/lib.rs |
tee crates/core/coalesced/src/lib.rs |
tee crates/core/ratelimits/src/lib.rs
tee crates/core/ratelimits/src/lib.rs |
tee crates/common/otel/src/lib.rs
if [ -z "$TARGETARCH" ]; then
cargo build -j "${CARGO_BUILD_JOBS:-10}" --locked --release
@@ -85,7 +87,8 @@ apps() {
crates/core/presence/src/lib.rs \
crates/core/result/src/lib.rs \
crates/core/coalesced/src/lib.rs \
crates/core/ratelimits/src/lib.rs
crates/core/ratelimits/src/lib.rs \
crates/common/otel/src/lib.rs
if [ -z "$TARGETARCH" ]; then
cargo build -j "${CARGO_BUILD_JOBS:-10}" --locked --release