mirror of
https://github.com/stoatchat/stoatchat.git
synced 2026-07-08 03:56:54 +00:00
Compare commits
27 Commits
feat/tenor
...
feat/oauth
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6043ba6004 | ||
|
|
657a3f08e5 | ||
|
|
af78ac0586 | ||
|
|
d65c1a1ab3 | ||
|
|
4fb99e3bd0 | ||
|
|
f0a83abcfa | ||
|
|
6f1c715b8c | ||
|
|
38dd4d1079 | ||
|
|
154204742d | ||
|
|
db55998546 | ||
|
|
5885e067a6 | ||
|
|
a92152d86d | ||
|
|
b5cd5e30ef | ||
|
|
b0c977b324 | ||
|
|
bfe4018e43 | ||
|
|
67773d3e43 | ||
|
|
14ea180683 | ||
|
|
83c15404a5 | ||
|
|
9d4bcb5e3d | ||
|
|
f895ca7b23 | ||
|
|
ef65223c89 | ||
|
|
660e646b2b | ||
|
|
5a5f84f207 | ||
|
|
11eee02cfe | ||
|
|
1e5b27ff9e | ||
|
|
3ae25fbcfe | ||
|
|
9e05e5be7e |
56
.github/workflows/docker.yaml
vendored
56
.github/workflows/docker.yaml
vendored
@@ -49,13 +49,6 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
# Authenticate with Docker Hub and GHCR
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to Github Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -72,14 +65,13 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
tags: ghcr.io/${{ github.repository_owner }}/base:latest
|
||||
|
||||
# revoltchat/server
|
||||
# stoatchat/api
|
||||
- name: Docker meta
|
||||
id: meta-delta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
docker.io/revoltchat/server
|
||||
ghcr.io/revoltchat/server
|
||||
ghcr.io/stoatchat/api
|
||||
- name: Publish
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
@@ -92,14 +84,13 @@ jobs:
|
||||
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
|
||||
labels: ${{ steps.meta-delta.outputs.labels }}
|
||||
|
||||
# revoltchat/bonfire
|
||||
# stoatchat/events
|
||||
- name: Docker meta
|
||||
id: meta-bonfire
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
docker.io/revoltchat/bonfire
|
||||
ghcr.io/revoltchat/bonfire
|
||||
ghcr.io/stoatchat/events
|
||||
- name: Publish
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
@@ -112,14 +103,13 @@ jobs:
|
||||
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
|
||||
labels: ${{ steps.meta-bonfire.outputs.labels }}
|
||||
|
||||
# revoltchat/autumn
|
||||
# stoatchat/file-server
|
||||
- name: Docker meta
|
||||
id: meta-autumn
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
docker.io/revoltchat/autumn
|
||||
ghcr.io/revoltchat/autumn
|
||||
ghcr.io/stoatchat/file-server
|
||||
- name: Publish
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
@@ -132,14 +122,13 @@ jobs:
|
||||
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
|
||||
labels: ${{ steps.meta-autumn.outputs.labels }}
|
||||
|
||||
# revoltchat/january
|
||||
# stoatchat/proxy
|
||||
- name: Docker meta
|
||||
id: meta-january
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
docker.io/revoltchat/january
|
||||
ghcr.io/revoltchat/january
|
||||
ghcr.io/stoatchat/proxy
|
||||
- name: Publish
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
@@ -152,14 +141,32 @@ jobs:
|
||||
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
|
||||
labels: ${{ steps.meta-january.outputs.labels }}
|
||||
|
||||
# revoltchat/crond
|
||||
# stoatchat/gifbox
|
||||
- name: Docker meta
|
||||
id: meta-gifbox
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/stoatchat/gifbox
|
||||
- name: Publish
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
file: crates/services/gifbox/Dockerfile
|
||||
tags: ${{ steps.meta-gifbox.outputs.tags }}
|
||||
build-args: |
|
||||
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
|
||||
labels: ${{ steps.meta-gifbox.outputs.labels }}
|
||||
|
||||
# stoatchat/crond
|
||||
- name: Docker meta
|
||||
id: meta-crond
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
docker.io/revoltchat/crond
|
||||
ghcr.io/revoltchat/crond
|
||||
ghcr.io/stoatchat/crond
|
||||
- name: Publish
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
@@ -172,14 +179,13 @@ jobs:
|
||||
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
|
||||
labels: ${{ steps.meta-crond.outputs.labels }}
|
||||
|
||||
# revoltchat/pushd
|
||||
# stoatchat/pushd
|
||||
- name: Docker meta
|
||||
id: meta-pushd
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
docker.io/revoltchat/pushd
|
||||
ghcr.io/revoltchat/pushd
|
||||
ghcr.io/stoatchat/pushd
|
||||
- name: Publish
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
|
||||
2
.github/workflows/rust.yaml
vendored
2
.github/workflows/rust.yaml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
if: github.event_name != 'pull_request' && github.ref_name == 'main'
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
repository: revoltchat/api
|
||||
repository: stoatchat/api
|
||||
path: api
|
||||
token: ${{ secrets.PAT }}
|
||||
|
||||
|
||||
2
.github/workflows/triage_issue.yml
vendored
2
.github/workflows/triage_issue.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
run: |
|
||||
gh api graphql -f query='
|
||||
query {
|
||||
organization(login: "revoltchat"){
|
||||
organization(login: "stoatchat"){
|
||||
projectV2(number: 3) {
|
||||
id
|
||||
fields(first:20) {
|
||||
|
||||
2
.github/workflows/triage_pr.yml
vendored
2
.github/workflows/triage_pr.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
run: |
|
||||
gh api graphql -f query='
|
||||
query {
|
||||
organization(login: "revoltchat"){
|
||||
organization(login: "stoatchat"){
|
||||
projectV2(number: 5) {
|
||||
id
|
||||
fields(first:20) {
|
||||
|
||||
1207
Cargo.lock
generated
1207
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -27,8 +27,11 @@ COPY crates/core/parser/Cargo.toml ./crates/core/parser/
|
||||
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/
|
||||
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/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/
|
||||
COPY crates/daemons/crond/Cargo.toml ./crates/daemons/crond/
|
||||
COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/
|
||||
RUN sh /tmp/build-image-layer.sh deps
|
||||
|
||||
@@ -23,8 +23,11 @@ COPY crates/core/parser/Cargo.toml ./crates/core/parser/
|
||||
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/
|
||||
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/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/
|
||||
COPY crates/daemons/crond/Cargo.toml ./crates/daemons/crond/
|
||||
COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/
|
||||
RUN sh /tmp/build-image-layer.sh deps
|
||||
|
||||
@@ -21,9 +21,11 @@ The services and libraries that power the Revolt service.<br/>
|
||||
| `core/permissions` | [crates/core/permissions](crates/core/permissions) | Core: Permission Logic |     |
|
||||
| `core/presence` | [crates/core/presence](crates/core/presence) | Core: User Presence |     |
|
||||
| `core/result` | [crates/core/result](crates/core/result) | Core: Result and Error types |     |
|
||||
| `core/coalesced` | [crates/core/coalesced](crates/core/coalesced) | Core: Coalescion service |     |
|
||||
| `delta` | [crates/delta](crates/delta) | REST API server |  |
|
||||
| `bonfire` | [crates/bonfire](crates/bonfire) | WebSocket events server |  |
|
||||
| `services/january` | [crates/services/january](crates/services/january) | Proxy server |  |
|
||||
| `services/gifbox` | [crates/services/gifbox](crates/services/gifbox) | Tenor proxy server |  |
|
||||
| `services/autumn` | [crates/services/autumn](crates/services/autumn) | File server |  |
|
||||
| `daemons/crond` | [crates/daemons/crond](crates/daemons/crond) | Timed data clean up daemon server |  |
|
||||
| `daemons/pushd` | [crates/daemons/pushd](crates/daemons/pushd) | Push notification daemon server |  |
|
||||
@@ -66,6 +68,7 @@ As a heads-up, the development environment uses the following ports:
|
||||
| `crates/bonfire` | 14703 |
|
||||
| `crates/services/autumn` | 14704 |
|
||||
| `crates/services/january` | 14705 |
|
||||
| `crates/services/gifbox` | 14706 |
|
||||
|
||||
Now you can clone and build the project:
|
||||
|
||||
@@ -141,6 +144,8 @@ cargo run --bin revolt-bonfire
|
||||
cargo run --bin revolt-autumn
|
||||
# run the proxy server
|
||||
cargo run --bin revolt-january
|
||||
# run the tenor proxy
|
||||
cargo run --bin revolt-gifbox
|
||||
# run the push daemon (not usually needed in regular development)
|
||||
cargo run --bin revolt-pushd
|
||||
|
||||
|
||||
@@ -40,6 +40,9 @@ port = 14025
|
||||
use_tls = false
|
||||
use_starttls = false
|
||||
|
||||
[api.security]
|
||||
token_secret = "trolt"
|
||||
|
||||
[files.s3]
|
||||
# S3 protocol endpoint
|
||||
endpoint = "http://127.0.0.1:14009"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-bonfire"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
license = "AGPL-3.0-or-later"
|
||||
edition = "2021"
|
||||
|
||||
@@ -42,7 +42,7 @@ revolt-result = { path = "../core/result" }
|
||||
revolt-models = { path = "../core/models" }
|
||||
revolt-config = { path = "../core/config" }
|
||||
revolt-database = { path = "../core/database" }
|
||||
revolt-permissions = { version = "0.8.8", path = "../core/permissions" }
|
||||
revolt-permissions = { version = "0.8.9", path = "../core/permissions" }
|
||||
revolt-presence = { path = "../core/presence", features = ["redis-is-patched"] }
|
||||
|
||||
# redis
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build Stage
|
||||
FROM ghcr.io/revoltchat/base:latest AS builder
|
||||
FROM ghcr.io/stoatchat/base:latest AS builder
|
||||
FROM debian:12 AS debian
|
||||
|
||||
# Bundle Stage
|
||||
|
||||
@@ -17,9 +17,11 @@ use redis_kiss::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
|
||||
use revolt_config::report_internal_error;
|
||||
use revolt_database::{
|
||||
events::{client::EventV1, server::ClientMessage},
|
||||
util::oauth2,
|
||||
iso8601_timestamp::Timestamp,
|
||||
Database, User, UserHint,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_presence::{create_session, delete_session};
|
||||
|
||||
use async_std::{
|
||||
@@ -88,23 +90,51 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
|
||||
return;
|
||||
};
|
||||
|
||||
// Presume the token is a proper token first
|
||||
let (user, session_id) = match User::from_token(db, token, UserHint::Any).await {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
write
|
||||
.send(config.encode(&EventV1::Error { data: err }))
|
||||
Ok((user, session_id)) => {
|
||||
db.update_session_last_seen(&session_id, Timestamp::now_utc())
|
||||
.await
|
||||
.ok();
|
||||
return;
|
||||
|
||||
(user, session_id)
|
||||
},
|
||||
Err(err) => {
|
||||
let revolt_config = revolt_config::config().await;
|
||||
|
||||
// If it fails to find the user from the token see if its an OAuth2 token
|
||||
let res = match oauth2::decode_token(&revolt_config.api.security.token_secret, token) {
|
||||
// Check if the OAuth2 token is allowed to establish an events websocket
|
||||
Ok(claims) => if !claims.scopes.contains(&v0::OAuth2Scope::Events) {
|
||||
// TODO: maybe a last_seen system for OAuth2 as well
|
||||
db.fetch_user(&claims.sub).await.map(|user| (user, claims.jti))
|
||||
} else {
|
||||
Err(create_error!(MissingScope { scope: v0::OAuth2Scope::Events.to_string() }))
|
||||
},
|
||||
// If its expired return an error
|
||||
Err(e) => if e.into_kind() == oauth2::JWTErrorKind::ExpiredSignature {
|
||||
Err(create_error!(ExpiredToken))
|
||||
} else {
|
||||
// Finally re-return the error from User::from_token if everything else fails to avoid a confusing error
|
||||
Err(err)
|
||||
}
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(user) => user,
|
||||
Err(err) => {
|
||||
write
|
||||
.send(config.encode(&EventV1::Error { data: err }))
|
||||
.await
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
info!("User {addr:?} authenticated as @{}", user.username);
|
||||
|
||||
db.update_session_last_seen(&session_id, Timestamp::now_utc())
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Create local state.
|
||||
let mut state = State::from(user, session_id);
|
||||
let user_id = state.cache.user_id.clone();
|
||||
|
||||
22
crates/core/coalesced/Cargo.toml
Normal file
22
crates/core/coalesced/Cargo.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "revolt-coalesced"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Paul Makles <me@insrt.uk>", "Zomatree <me@zomatree.live>"]
|
||||
description = "Revolt Backend: Coalescion service"
|
||||
|
||||
[features]
|
||||
tokio = ["dep:tokio"]
|
||||
queue = ["dep:indexmap"]
|
||||
cache = ["dep:lru"]
|
||||
|
||||
default = ["tokio"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.47.0", features = ["sync"], optional = true }
|
||||
indexmap = { version = "*", optional = true }
|
||||
lru = { version = "*", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.47.0", features = ["rt", "rt-multi-thread", "macros", "time"] }
|
||||
9
crates/core/coalesced/LICENSE
Normal file
9
crates/core/coalesced/LICENSE
Normal file
@@ -0,0 +1,9 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Pawel Makles
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
24
crates/core/coalesced/src/config.rs
Normal file
24
crates/core/coalesced/src/config.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
/// Config values for [`CoalescionService`].
|
||||
pub struct CoalescionServiceConfig {
|
||||
/// How many tasks are running at once
|
||||
pub max_concurrent: Option<usize>,
|
||||
/// Whether to queue tasks once `max_concurrent` is reached
|
||||
#[cfg(feature = "queue")]
|
||||
pub queue_requests: bool,
|
||||
/// Max amount of tasks in the buffer queue
|
||||
#[cfg(feature = "queue")]
|
||||
pub max_queue: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for CoalescionServiceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_concurrent: Some(100),
|
||||
#[cfg(feature = "queue")]
|
||||
queue_requests: true,
|
||||
#[cfg(feature = "queue")]
|
||||
max_queue: Some(100)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
crates/core/coalesced/src/error.rs
Normal file
27
crates/core/coalesced/src/error.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
|
||||
/// Coalescion service error.
|
||||
pub enum Error {
|
||||
/// Failed to receive the actions return from the channel for unknown reason
|
||||
RecvError,
|
||||
/// Reached the `max_concurrent` amount of actions running at once and could not queue the action
|
||||
MaxConcurrent,
|
||||
/// Reached the `max_queue` amount of actions in the queue
|
||||
MaxQueue,
|
||||
/// Failed to downcast the type to the current type being returned, this will be most likely an ID collision
|
||||
DowncastError,
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::RecvError => write!(f, "Unable to receive data from the channel"),
|
||||
Error::MaxConcurrent => write!(f, "Max number of tasks running at once"),
|
||||
Error::MaxQueue => write!(f, "Max number of tasks in queue"),
|
||||
Error::DowncastError => write!(f, "Failed to downcast type, possible key collision with different types")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
39
crates/core/coalesced/src/lib.rs
Normal file
39
crates/core/coalesced/src/lib.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
//! # Coalesced
|
||||
//!
|
||||
//! Coalescion service to group, caching and queue duplicate actions.
|
||||
//! useful for deduplicating web requests, database lookups and other similar resource
|
||||
//! intensive or rate-limited actions.
|
||||
//!
|
||||
//! ## Features
|
||||
//! - `tokio`: Uses tokio for the async backend, this is currently the only backend.
|
||||
//! - `queue`: Whether to support queueing requests to only allow X amount of actions running at once.
|
||||
//! - `cache`: Whether to cache the actions results for future actions with the same id, uses an LRU cache internally.
|
||||
//!
|
||||
//! [`CoalescionService`] uses both [`Arc`] and [`RwLock`] internally and can be cheaply cloned to
|
||||
//! use in your codebase.
|
||||
//!
|
||||
//! It is common practice to wrap the service and in your own which delegates the executions to ensure all ids are tracked in one location across your codebase.
|
||||
//!
|
||||
//! All values are stored using [`Any`] and must be [`'static`] + [`Send`] + [`Sync`], if there is an id mismatch
|
||||
//! and a type is wrong the library will return an error, values returned from the service are also
|
||||
//! wrapped in an [`Arc`] as they are shared to each duplicate action.
|
||||
//!
|
||||
//! ## Example:
|
||||
//! ```rs
|
||||
//! use revolt_coalesced::CoalescionService;
|
||||
//!
|
||||
//! let service = CoalescionService::new();
|
||||
//!
|
||||
//! let user_id = "my_user_id";
|
||||
//! let user = service.execute(user_id, || async move {
|
||||
//! database.fetch_user(user_id).await.unwrap()
|
||||
//! }).await;
|
||||
//! ```
|
||||
|
||||
mod config;
|
||||
mod error;
|
||||
mod service;
|
||||
|
||||
pub use config::CoalescionServiceConfig;
|
||||
pub use error::Error;
|
||||
pub use service::CoalescionService;
|
||||
208
crates/core/coalesced/src/service.rs
Normal file
208
crates/core/coalesced/src/service.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use std::{any::Any, collections::HashMap, fmt::Debug, future::Future, hash::Hash, sync::Arc};
|
||||
|
||||
use tokio::sync::{
|
||||
watch::{channel as watch_channel, Receiver},
|
||||
RwLock,
|
||||
};
|
||||
|
||||
#[cfg(feature = "cache")]
|
||||
use lru::LruCache;
|
||||
|
||||
#[cfg(feature = "queue")]
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use crate::{CoalescionServiceConfig, Error};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(clippy::type_complexity)]
|
||||
/// # Coalescion service
|
||||
///
|
||||
/// See module description for example usage.
|
||||
pub struct CoalescionService<Id: Hash + Clone + Eq> {
|
||||
config: Arc<CoalescionServiceConfig>,
|
||||
watchers: Arc<RwLock<HashMap<Id, Receiver<Option<Result<Arc<dyn Any + Send + Sync>, Error>>>>>>,
|
||||
#[cfg(feature = "queue")]
|
||||
queue: Arc<RwLock<IndexMap<Id, Receiver<Option<Result<Arc<dyn Any + Send + Sync>, Error>>>>>>,
|
||||
#[cfg(feature = "cache")]
|
||||
cache: Option<Arc<tokio::sync::Mutex<LruCache<Id, Arc<dyn Any + Send + Sync>>>>>,
|
||||
}
|
||||
|
||||
impl<Id: Hash + Clone + Eq> CoalescionService<Id> {
|
||||
pub fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
pub fn from_config(config: CoalescionServiceConfig) -> Self {
|
||||
Self {
|
||||
config: Arc::new(config),
|
||||
watchers: Arc::new(RwLock::new(HashMap::new())),
|
||||
#[cfg(feature = "queue")]
|
||||
queue: Arc::new(RwLock::new(IndexMap::new())),
|
||||
#[cfg(feature = "cache")]
|
||||
cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "cache")]
|
||||
pub fn from_cache(
|
||||
config: CoalescionServiceConfig,
|
||||
cache: LruCache<Id, Arc<dyn Any + Send + Sync>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cache: Some(Arc::new(Mutex::new(cache))),
|
||||
..Self::from_config(config)
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for<Value: Any + Send + Sync>(
|
||||
&self,
|
||||
mut receiver: Receiver<Option<Result<Arc<dyn Any + Send + Sync>, Error>>>,
|
||||
) -> Result<Arc<Value>, Error> {
|
||||
receiver
|
||||
.wait_for(|v| v.is_some())
|
||||
.await
|
||||
.map_err(|_| Error::RecvError)
|
||||
.and_then(|r| r.clone().unwrap())
|
||||
.and_then(|arc| Arc::downcast(arc).map_err(|_| Error::DowncastError))
|
||||
}
|
||||
|
||||
async fn insert_and_execute<
|
||||
Value: Send + Sync + 'static,
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = Value>,
|
||||
>(
|
||||
&self,
|
||||
id: Id,
|
||||
func: F,
|
||||
) -> Result<Arc<Value>, Error> {
|
||||
let (send, recv) = watch_channel(None);
|
||||
|
||||
self.watchers.write().await.insert(id.clone(), recv);
|
||||
|
||||
let value = Ok(Arc::new(func().await));
|
||||
|
||||
send.send_modify(|opt| {
|
||||
opt.replace(value.clone().map(|v| v as Arc<dyn Any + Send + Sync>));
|
||||
});
|
||||
|
||||
#[cfg(feature = "cache")]
|
||||
if let Some(cache) = self.cache.as_ref() {
|
||||
if let Ok(value) = &value {
|
||||
cache.lock().await.push(id.clone(), value.clone());
|
||||
}
|
||||
};
|
||||
|
||||
self.watchers.write().await.remove(&id);
|
||||
|
||||
value
|
||||
}
|
||||
|
||||
/// Coalesces an function, the actual function may not run if one with the same id is already running,
|
||||
/// queued to be ran, or cached, the id should be globally unique for this specific action.
|
||||
pub async fn execute<
|
||||
Value: Send + Sync + 'static,
|
||||
F: FnOnce() -> Fut,
|
||||
Fut: Future<Output = Value>,
|
||||
>(
|
||||
&self,
|
||||
id: Id,
|
||||
func: F,
|
||||
) -> Result<Arc<Value>, Error> {
|
||||
#[cfg(feature = "cache")]
|
||||
if let Some(cache) = self.cache.as_ref() {
|
||||
if let Some(value) = cache.lock().await.get(&id) {
|
||||
return Arc::downcast::<Value>(value.clone()).map_err(|_| Error::DowncastError);
|
||||
}
|
||||
};
|
||||
|
||||
let (receiver, length) = {
|
||||
let watchers = self.watchers.read().await;
|
||||
let length = watchers.len();
|
||||
|
||||
(watchers.get(&id).cloned(), length)
|
||||
};
|
||||
|
||||
if let Some(receiver) = receiver {
|
||||
self.wait_for(receiver).await
|
||||
} else {
|
||||
match self.config.max_concurrent {
|
||||
Some(max_concurrent) if length >= max_concurrent => {
|
||||
#[cfg(feature = "queue")]
|
||||
if self.config.queue_requests {
|
||||
let (receiver, length) = {
|
||||
let queue = self.queue.read().await;
|
||||
|
||||
(queue.get(&id).cloned(), queue.len())
|
||||
};
|
||||
|
||||
if let Some(receiver) = receiver {
|
||||
return self.wait_for(receiver).await;
|
||||
} else {
|
||||
if self
|
||||
.config
|
||||
.max_queue
|
||||
.is_some_and(|max_queue| max_queue >= length)
|
||||
{
|
||||
return Err(Error::MaxQueue);
|
||||
};
|
||||
|
||||
let (send, recv) = watch_channel(None);
|
||||
|
||||
self.queue.write().await.insert(id.clone(), recv);
|
||||
|
||||
loop {
|
||||
let length = self.watchers.read().await.len();
|
||||
|
||||
if length < max_concurrent {
|
||||
let first_key = {
|
||||
let queue = self.queue.read().await;
|
||||
queue.first().map(|v| v.0).cloned()
|
||||
};
|
||||
|
||||
if first_key == Some(id.clone()) {
|
||||
self.queue.write().await.shift_remove(&id);
|
||||
|
||||
let response = self.insert_and_execute(id, func).await;
|
||||
|
||||
send.send_modify(|opt| {
|
||||
opt.replace(
|
||||
response
|
||||
.clone()
|
||||
.map(|v| v as Arc<dyn Any + Send + Sync>),
|
||||
);
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Err(Error::MaxConcurrent)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "queue"))]
|
||||
Err(Error::MaxConcurrent)
|
||||
}
|
||||
_ => self.insert_and_execute(id, func).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetches the amount of currently running tasks
|
||||
pub async fn current_task_count(&self) -> usize {
|
||||
self.watchers.read().await.len()
|
||||
}
|
||||
|
||||
#[cfg(feature = "queue")]
|
||||
/// Fetches the current length of the queue
|
||||
pub async fn current_queue_len(&self) -> usize {
|
||||
self.queue.read().await.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl<Id: Hash + Clone + Eq> Default for CoalescionService<Id> {
|
||||
fn default() -> Self {
|
||||
Self::from_config(CoalescionServiceConfig::default())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-config"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
@@ -37,4 +37,4 @@ sentry = { version = "0.31.5", optional = true }
|
||||
sentry-anyhow = { version = "0.38.1", optional = true }
|
||||
|
||||
# Core
|
||||
revolt-result = { version = "0.8.8", path = "../result", optional = true }
|
||||
revolt-result = { version = "0.8.9", path = "../result", optional = true }
|
||||
|
||||
@@ -56,6 +56,10 @@ voso_legacy_token = ""
|
||||
trust_cloudflare = false
|
||||
# easypwned endpoint
|
||||
easypwned = ""
|
||||
# Secret used to encode and decode tokens
|
||||
token_secret = ""
|
||||
# Tenor API Key
|
||||
tenor_key = ""
|
||||
|
||||
[api.security.captcha]
|
||||
# hCaptcha configuration
|
||||
@@ -277,3 +281,4 @@ files = ""
|
||||
proxy = ""
|
||||
pushd = ""
|
||||
crond = ""
|
||||
gifbox = ""
|
||||
@@ -190,6 +190,8 @@ pub struct ApiSecurity {
|
||||
pub captcha: ApiSecurityCaptcha,
|
||||
pub trust_cloudflare: bool,
|
||||
pub easypwned: String,
|
||||
pub token_secret: String,
|
||||
pub tenor_key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
@@ -365,6 +367,7 @@ pub struct Sentry {
|
||||
pub proxy: String,
|
||||
pub pushd: String,
|
||||
pub crond: String,
|
||||
pub gifbox: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-database"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
@@ -24,19 +24,19 @@ default = ["mongodb", "async-std-runtime", "tasks"]
|
||||
|
||||
[dependencies]
|
||||
# Core
|
||||
revolt-config = { version = "0.8.8", path = "../config", features = [
|
||||
revolt-config = { version = "0.8.9", path = "../config", features = [
|
||||
"report-macros",
|
||||
] }
|
||||
revolt-result = { version = "0.8.8", path = "../result" }
|
||||
revolt-models = { version = "0.8.8", path = "../models", features = [
|
||||
revolt-result = { version = "0.8.9", path = "../result" }
|
||||
revolt-models = { version = "0.8.9", path = "../models", features = [
|
||||
"validator",
|
||||
] }
|
||||
revolt-presence = { version = "0.8.8", path = "../presence" }
|
||||
revolt-permissions = { version = "0.8.8", path = "../permissions", features = [
|
||||
revolt-presence = { version = "0.8.9", path = "../presence" }
|
||||
revolt-permissions = { version = "0.8.9", path = "../permissions", features = [
|
||||
"serde",
|
||||
"bson",
|
||||
] }
|
||||
revolt-parser = { version = "0.8.8", path = "../parser" }
|
||||
revolt-parser = { version = "0.8.9", path = "../parser" }
|
||||
|
||||
# Utility
|
||||
log = "0.4"
|
||||
@@ -53,6 +53,8 @@ linkify = { optional = true, version = "0.8.1" }
|
||||
url-escape = { optional = true, version = "0.1.1" }
|
||||
validator = { version = "0.16", features = ["derive"] }
|
||||
isahc = { optional = true, version = "1.7", features = ["json"] }
|
||||
jsonwebtoken = "9.3.1"
|
||||
chrono = "0.4"
|
||||
|
||||
# Serialisation
|
||||
serde_json = "1"
|
||||
|
||||
@@ -5,13 +5,14 @@ use futures::lock::Mutex;
|
||||
use crate::{
|
||||
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member,
|
||||
MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot,
|
||||
User, UserSettings, Webhook,
|
||||
User, UserSettings, Webhook, AuthorizedBotId, AuthorizedBot,
|
||||
};
|
||||
|
||||
database_derived!(
|
||||
/// Reference implementation
|
||||
#[derive(Default)]
|
||||
pub struct ReferenceDb {
|
||||
pub authorized_bots: Arc<Mutex<HashMap<AuthorizedBotId, AuthorizedBot>>>,
|
||||
pub bots: Arc<Mutex<HashMap<String, Bot>>>,
|
||||
pub channels: Arc<Mutex<HashMap<String, Channel>>>,
|
||||
pub channel_invites: Arc<Mutex<HashMap<String, Invite>>>,
|
||||
|
||||
5
crates/core/database/src/models/authorized_bots/mod.rs
Normal file
5
crates/core/database/src/models/authorized_bots/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod model;
|
||||
mod ops;
|
||||
|
||||
pub use model::*;
|
||||
pub use ops::*;
|
||||
30
crates/core/database/src/models/authorized_bots/model.rs
Normal file
30
crates/core/database/src/models/authorized_bots/model.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use iso8601_timestamp::Timestamp;
|
||||
|
||||
use crate::OAuth2Scope;
|
||||
|
||||
auto_derived! (
|
||||
/// Unique id of the user and bot
|
||||
#[derive(Hash)]
|
||||
pub struct AuthorizedBotId {
|
||||
/// User id
|
||||
pub user: String,
|
||||
|
||||
/// Bot Id
|
||||
pub bot: String,
|
||||
}
|
||||
|
||||
pub struct AuthorizedBot {
|
||||
/// Unique Id
|
||||
#[serde(rename = "_id")]
|
||||
pub id: AuthorizedBotId,
|
||||
|
||||
/// When the authorized oauth2 bot connection was created at
|
||||
pub created_at: Timestamp,
|
||||
|
||||
/// If and when the authorized oauth2 bot connection was revoked at
|
||||
pub deauthorized_at: Option<Timestamp>,
|
||||
|
||||
/// Scopes the bot has access to
|
||||
pub scope: Vec<OAuth2Scope>,
|
||||
}
|
||||
);
|
||||
27
crates/core/database/src/models/authorized_bots/ops.rs
Normal file
27
crates/core/database/src/models/authorized_bots/ops.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use revolt_result::Result;
|
||||
|
||||
use crate::{AuthorizedBot, AuthorizedBotId};
|
||||
|
||||
mod mongodb;
|
||||
mod reference;
|
||||
|
||||
#[async_trait]
|
||||
pub trait AbstractAuthorizedBots: Sync + Send {
|
||||
/// Insert emoji into database.
|
||||
async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()>;
|
||||
|
||||
/// Fetch an authorized bot by its id
|
||||
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot>;
|
||||
|
||||
/// Fetch a users authorized bot by its id
|
||||
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>>;
|
||||
|
||||
/// Deletes an authorized bot
|
||||
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()>;
|
||||
|
||||
/// Deauthorizes an authorized bot
|
||||
async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot>;
|
||||
|
||||
// Fetches all authorized bots which have been deauthorized
|
||||
async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use bson::to_bson;
|
||||
use revolt_result::Result;
|
||||
use iso8601_timestamp::Timestamp;
|
||||
|
||||
use crate::{MongoDb, AuthorizedBot, AuthorizedBotId};
|
||||
|
||||
use super::AbstractAuthorizedBots;
|
||||
|
||||
static COL: &str = "authorized_bots";
|
||||
|
||||
#[async_trait]
|
||||
impl AbstractAuthorizedBots for MongoDb {
|
||||
/// Insert an authorized bot into database.
|
||||
async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()> {
|
||||
query!(self, insert_one, COL, &authorized_bot).map(|_| ())
|
||||
}
|
||||
|
||||
/// Fetch an authorized bot by its id
|
||||
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
|
||||
query!(
|
||||
self,
|
||||
find_one,
|
||||
COL,
|
||||
doc! {
|
||||
"_id.user": &id.user,
|
||||
"_id.bot": &id.bot
|
||||
}
|
||||
)?.ok_or_else(|| create_error!(NotFound))
|
||||
}
|
||||
|
||||
/// Fetch a users authorized bot by its id
|
||||
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>> {
|
||||
query!(self, find, COL, doc! { "_id.user": &user_id })
|
||||
}
|
||||
|
||||
/// Deletes an authorized bot
|
||||
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> {
|
||||
query!(self, delete_one, COL, doc! { "_id.user": &id.user, "_id.bot": &id.bot }).map(|_| ())
|
||||
}
|
||||
|
||||
/// Deauthorizes an authorized bot
|
||||
async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
|
||||
self.col::<AuthorizedBot>(COL)
|
||||
.find_one_and_update(
|
||||
doc! {
|
||||
"_id.user": &id.user,
|
||||
"_id.bot": &id.bot
|
||||
},
|
||||
doc! {
|
||||
"$set": {
|
||||
"deauthorized_at": to_bson(&Timestamp::now_utc()).unwrap()
|
||||
}
|
||||
}
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_database_error!("find_one_and_update", COL))
|
||||
.and_then(|opt| opt.ok_or_else(|| create_database_error!("find_one_and_update", COL)))
|
||||
}
|
||||
|
||||
// Fetches all authorized bots which have been deauthorized
|
||||
async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>> {
|
||||
query!(
|
||||
self,
|
||||
find,
|
||||
COL,
|
||||
doc! {
|
||||
"deauthorized_at": { "$exists": true }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
use revolt_result::Result;
|
||||
use iso8601_timestamp::Timestamp;
|
||||
|
||||
use crate::{ReferenceDb, AuthorizedBot, AuthorizedBotId};
|
||||
|
||||
use super::AbstractAuthorizedBots;
|
||||
|
||||
#[async_trait]
|
||||
impl AbstractAuthorizedBots for ReferenceDb {
|
||||
/// Insert an authorized bot into database.
|
||||
async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()> {
|
||||
let mut authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
if authorized_bots.contains_key(&authorized_bot.id) {
|
||||
Err(create_database_error!("insert", "authorized_bots"))
|
||||
} else {
|
||||
authorized_bots.insert(authorized_bot.id.clone(), authorized_bot.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch an authorized bot by its id
|
||||
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
|
||||
let authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
authorized_bots.get(id).cloned().ok_or_else(|| create_error!(NotFound))
|
||||
}
|
||||
|
||||
/// Fetch a users authorized bot by its id
|
||||
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>> {
|
||||
let authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
Ok(authorized_bots
|
||||
.values()
|
||||
.filter(|authorized_bot| authorized_bot.id.user == user_id)
|
||||
.cloned()
|
||||
.collect()
|
||||
)
|
||||
}
|
||||
|
||||
/// Deletes an authorized bot
|
||||
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> {
|
||||
let mut authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
if authorized_bots.remove(id).is_some() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(create_error!(NotFound))
|
||||
}
|
||||
}
|
||||
|
||||
/// Deauthorizes an authorized bot
|
||||
async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
|
||||
let mut authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
if let Some(authorized_bot) = authorized_bots.get_mut(id) {
|
||||
authorized_bot.deauthorized_at = Some(Timestamp::now_utc());
|
||||
|
||||
Ok(authorized_bot.clone())
|
||||
} else {
|
||||
Err(create_error!(NotFound))
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches all authorized bots which have been deauthorized
|
||||
async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>> {
|
||||
let authorized_bots = self.authorized_bots.lock().await;
|
||||
|
||||
Ok(authorized_bots
|
||||
.values()
|
||||
.filter(|authorized_bot| authorized_bot.deauthorized_at.is_some())
|
||||
.cloned()
|
||||
.collect()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use revolt_result::Result;
|
||||
use ulid::Ulid;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{events::client::EventV1, BotInformation, Database, PartialUser, User};
|
||||
|
||||
@@ -35,6 +36,10 @@ auto_derived_partial!(
|
||||
#[serde(skip_serializing_if = "String::is_empty", default)]
|
||||
pub privacy_policy_url: String,
|
||||
|
||||
/// Oauth2 bot settings
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
pub oauth2: Option<BotOauth2>,
|
||||
|
||||
/// Enum of bot flags
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub flags: Option<i32>,
|
||||
@@ -42,11 +47,52 @@ auto_derived_partial!(
|
||||
"PartialBot"
|
||||
);
|
||||
|
||||
auto_derived!(
|
||||
#[derive(Copy, Hash)]
|
||||
pub enum OAuth2Scope {
|
||||
#[serde(rename = "read:identify")]
|
||||
ReadIdentify,
|
||||
#[serde(rename = "read:servers")]
|
||||
ReadServers,
|
||||
#[serde(rename = "write:files")]
|
||||
WriteFiles,
|
||||
#[serde(rename = "events")]
|
||||
Events,
|
||||
#[serde(rename = "full")]
|
||||
Full,
|
||||
}
|
||||
|
||||
pub struct OAuth2ScopeReasoning {
|
||||
pub allow: String,
|
||||
pub deny: String
|
||||
}
|
||||
);
|
||||
|
||||
auto_derived_partial!(
|
||||
pub struct BotOauth2 {
|
||||
/// Whether the oauth2 client is public and should not receive a secret key
|
||||
#[serde(default)]
|
||||
pub public: bool,
|
||||
/// Secret key used for authorisation, not provided if the client is public
|
||||
#[serde(default)]
|
||||
pub secret: Option<String>,
|
||||
/// Allowed redirects for the authorisation
|
||||
#[serde(default)]
|
||||
pub redirects: Vec<String>,
|
||||
/// Mapping of allowed scopes and the reasonings
|
||||
#[serde(default)]
|
||||
pub allowed_scopes: HashMap<OAuth2Scope, OAuth2ScopeReasoning>,
|
||||
},
|
||||
"PartialBotOauth2"
|
||||
);
|
||||
|
||||
auto_derived!(
|
||||
/// Optional fields on bot object
|
||||
pub enum FieldsBot {
|
||||
Token,
|
||||
InteractionsURL,
|
||||
Oauth2,
|
||||
Oauth2Secret,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -63,11 +109,24 @@ impl Default for Bot {
|
||||
interactions_url: Default::default(),
|
||||
terms_of_service_url: Default::default(),
|
||||
privacy_policy_url: Default::default(),
|
||||
oauth2: Default::default(),
|
||||
flags: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::derivable_impls)]
|
||||
impl Default for BotOauth2 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
public: false,
|
||||
secret: Some(nanoid::nanoid!(64)),
|
||||
redirects: Vec::new(),
|
||||
allowed_scopes: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
impl Bot {
|
||||
/// Create a new bot
|
||||
@@ -124,6 +183,14 @@ impl Bot {
|
||||
FieldsBot::Token => self.token = nanoid::nanoid!(64),
|
||||
FieldsBot::InteractionsURL => {
|
||||
self.interactions_url = String::new();
|
||||
},
|
||||
FieldsBot::Oauth2 => self.oauth2 = None,
|
||||
FieldsBot::Oauth2Secret => {
|
||||
if let Some(oauth2) = &mut self.oauth2 {
|
||||
if !oauth2.public {
|
||||
oauth2.secret = Some(nanoid::nanoid!(64))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,8 @@ impl IntoDocumentPath for FieldsBot {
|
||||
match self {
|
||||
FieldsBot::InteractionsURL => Some("interactions_url"),
|
||||
FieldsBot::Token => None,
|
||||
FieldsBot::Oauth2 => Some("oauth2"),
|
||||
FieldsBot::Oauth2Secret => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +440,8 @@ impl Message {
|
||||
}
|
||||
|
||||
// Verify replies are valid.
|
||||
let mut replies = HashSet::new();
|
||||
let mut replies = Vec::new();
|
||||
|
||||
if let Some(entries) = data.replies {
|
||||
if entries.len() > config.features.limits.global.message_replies {
|
||||
return Err(create_error!(TooManyReplies {
|
||||
@@ -448,6 +449,8 @@ impl Message {
|
||||
}));
|
||||
}
|
||||
|
||||
replies.reserve(entries.len());
|
||||
|
||||
for ReplyIntent {
|
||||
id,
|
||||
mention,
|
||||
@@ -461,7 +464,12 @@ impl Message {
|
||||
user_mentions.insert(message.author.to_owned());
|
||||
}
|
||||
|
||||
replies.insert(message.id);
|
||||
// This is O(n^2), but this is faster than a HashSet
|
||||
// when n < 20; as long as the message_replies limit
|
||||
// is reasonable, this will be fast.
|
||||
if !replies.contains(&message.id) {
|
||||
replies.push(message.id);
|
||||
}
|
||||
}
|
||||
// If the referenced message doesn't exist and fail_if_not_exists
|
||||
// is set to false, send the message without the reply.
|
||||
@@ -534,9 +542,7 @@ impl Message {
|
||||
}
|
||||
|
||||
if !replies.is_empty() {
|
||||
message
|
||||
.replies
|
||||
.replace(replies.into_iter().collect::<Vec<String>>());
|
||||
message.replies.replace(replies);
|
||||
}
|
||||
|
||||
// Calculate final message flags
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod admin_migrations;
|
||||
mod authorized_bots;
|
||||
mod bots;
|
||||
mod channel_invites;
|
||||
mod channel_unreads;
|
||||
@@ -19,6 +20,7 @@ mod user_settings;
|
||||
mod users;
|
||||
|
||||
pub use admin_migrations::*;
|
||||
pub use authorized_bots::*;
|
||||
pub use bots::*;
|
||||
pub use channel_invites::*;
|
||||
pub use channel_unreads::*;
|
||||
@@ -46,6 +48,7 @@ use crate::MongoDb;
|
||||
pub trait AbstractDatabase:
|
||||
Sync
|
||||
+ Send
|
||||
+ authorized_bots::AbstractAuthorizedBots
|
||||
+ admin_migrations::AbstractMigrations
|
||||
+ bots::AbstractBots
|
||||
+ channels::AbstractChannels
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::request::Parts,
|
||||
};
|
||||
use axum::{extract::{FromRef, FromRequestParts}, http::request::Parts};
|
||||
|
||||
use revolt_config::config;
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Error, Result};
|
||||
|
||||
use crate::{Database, User};
|
||||
use crate::{util::oauth2, Database, OAuth2Scope, User};
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl<S: Send + Sync> FromRequestParts<S> for User
|
||||
impl<S> FromRequestParts<S> for User
|
||||
where
|
||||
Database: FromRef<S>,
|
||||
S: Send + Sync
|
||||
{
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<User> {
|
||||
let db = Database::from_ref(state);
|
||||
|
||||
if let Some(Ok(bot_token)) = parts.headers.get("x-bot-token").map(|v| v.to_str()) {
|
||||
let bot = db.fetch_bot_by_token(bot_token).await?;
|
||||
db.fetch_user(&bot.id).await
|
||||
@@ -24,6 +25,24 @@ where
|
||||
{
|
||||
let session = db.fetch_session_by_token(session_token).await?;
|
||||
db.fetch_user(&session.user_id).await
|
||||
} else if let Some(Ok(header_oauth_token)) = parts.headers.get("x-oauth2-token").map(|v| v.to_str()) {
|
||||
let config = config().await;
|
||||
|
||||
let claims = oauth2::decode_token(
|
||||
&config.api.security.token_secret,
|
||||
header_oauth_token,
|
||||
).map_err(|_| create_error!(NotAuthenticated))?;
|
||||
|
||||
let required_scope: v0::OAuth2Scope = parts.extensions.get::<OAuth2Scope>()
|
||||
.copied()
|
||||
.ok_or_else(|| create_error!(NotAuthenticated))?
|
||||
.into();
|
||||
|
||||
if claims.scopes.contains(&v0::OAuth2Scope::Full) || claims.scopes.contains(&required_scope) {
|
||||
db.fetch_user(&claims.sub).await
|
||||
} else {
|
||||
Err(create_error!(MissingScope { scope: required_scope.to_string() }))
|
||||
}
|
||||
} else {
|
||||
Err(create_error!(NotAuthenticated))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use authifier::models::Session;
|
||||
use revolt_config::config;
|
||||
use revolt_models::v0;
|
||||
use rocket::http::Status;
|
||||
use rocket::request::{self, FromRequest, Outcome, Request};
|
||||
|
||||
use crate::util::oauth2;
|
||||
use crate::{Database, User};
|
||||
|
||||
#[rocket::async_trait]
|
||||
@@ -19,12 +22,38 @@ impl<'r> FromRequest<'r> for User {
|
||||
.next()
|
||||
.map(|x| x.to_string());
|
||||
|
||||
let header_oauth_token = request
|
||||
.headers()
|
||||
.get("x-oauth2-token")
|
||||
.next()
|
||||
.map(|x| x.to_string());
|
||||
|
||||
if let Some(bot_token) = header_bot_token {
|
||||
if let Ok(bot) = db.fetch_bot_by_token(&bot_token).await {
|
||||
if let Ok(user) = db.fetch_user(&bot.id).await {
|
||||
return Some(user);
|
||||
}
|
||||
}
|
||||
} else if let Some(header_oauth_token) = header_oauth_token {
|
||||
let config = config().await;
|
||||
|
||||
let claims = oauth2::decode_token(
|
||||
&config.api.security.token_secret,
|
||||
&header_oauth_token,
|
||||
)
|
||||
.ok()?;
|
||||
|
||||
// access the scope required for the route stored in the request local cache set via `OAuth2Scoped`
|
||||
let required_scope: v0::OAuth2Scope = request.local_cache(|| None::<crate::OAuth2Scope>)
|
||||
.as_ref()
|
||||
.copied()?
|
||||
.into();
|
||||
|
||||
if claims.scopes.contains(&v0::OAuth2Scope::Full) || claims.scopes.contains(&required_scope) {
|
||||
if let Ok(user) = db.fetch_user(&claims.sub).await {
|
||||
return Some(user);
|
||||
}
|
||||
}
|
||||
} else if let Outcome::Success(session) = request.guard::<Session>().await {
|
||||
if let Ok(user) = db.fetch_user(&session.user_id).await {
|
||||
return Some(user);
|
||||
|
||||
@@ -33,6 +33,7 @@ impl From<crate::Bot> for Bot {
|
||||
interactions_url: value.interactions_url,
|
||||
terms_of_service_url: value.terms_of_service_url,
|
||||
privacy_policy_url: value.privacy_policy_url,
|
||||
oauth2: value.oauth2.map(|oauth2| oauth2.into()),
|
||||
flags: value.flags.unwrap_or_default() as u32,
|
||||
}
|
||||
}
|
||||
@@ -43,6 +44,8 @@ impl From<FieldsBot> for crate::FieldsBot {
|
||||
match value {
|
||||
FieldsBot::InteractionsURL => crate::FieldsBot::InteractionsURL,
|
||||
FieldsBot::Token => crate::FieldsBot::Token,
|
||||
FieldsBot::Oauth2 => crate::FieldsBot::Oauth2,
|
||||
FieldsBot::Oauth2Secret => crate::FieldsBot::Oauth2Secret,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +55,78 @@ impl From<crate::FieldsBot> for FieldsBot {
|
||||
match value {
|
||||
crate::FieldsBot::InteractionsURL => FieldsBot::InteractionsURL,
|
||||
crate::FieldsBot::Token => FieldsBot::Token,
|
||||
crate::FieldsBot::Oauth2 => FieldsBot::Oauth2,
|
||||
crate::FieldsBot::Oauth2Secret => FieldsBot::Oauth2Secret,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BotOauth2> for crate::BotOauth2 {
|
||||
fn from(value: BotOauth2) -> Self {
|
||||
crate::BotOauth2 {
|
||||
public: value.public,
|
||||
secret: value.secret,
|
||||
redirects: value.redirects,
|
||||
allowed_scopes: value.allowed_scopes
|
||||
.into_iter()
|
||||
.map(|(scope, value)| (scope.into(), value.into()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::BotOauth2> for BotOauth2 {
|
||||
fn from(value: crate::BotOauth2) -> Self {
|
||||
BotOauth2 {
|
||||
public: value.public,
|
||||
secret: value.secret,
|
||||
redirects: value.redirects,
|
||||
allowed_scopes: value.allowed_scopes
|
||||
.into_iter()
|
||||
.map(|(scope, value)| (scope.into(), value.into()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::OAuth2Scope> for OAuth2Scope {
|
||||
fn from(value: crate::OAuth2Scope) -> Self {
|
||||
match value {
|
||||
crate::OAuth2Scope::ReadIdentify => OAuth2Scope::ReadIdentify,
|
||||
crate::OAuth2Scope::ReadServers => OAuth2Scope::ReadServers,
|
||||
crate::OAuth2Scope::WriteFiles => OAuth2Scope::WriteFiles,
|
||||
crate::OAuth2Scope::Events => OAuth2Scope::Events,
|
||||
crate::OAuth2Scope::Full => OAuth2Scope::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OAuth2Scope> for crate::OAuth2Scope {
|
||||
fn from(value: OAuth2Scope) -> Self {
|
||||
match value {
|
||||
OAuth2Scope::ReadIdentify => crate::OAuth2Scope::ReadIdentify,
|
||||
OAuth2Scope::ReadServers => crate::OAuth2Scope::ReadServers,
|
||||
OAuth2Scope::WriteFiles => crate::OAuth2Scope::WriteFiles,
|
||||
OAuth2Scope::Events => crate::OAuth2Scope::Events,
|
||||
OAuth2Scope::Full => crate::OAuth2Scope::Full,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::OAuth2ScopeReasoning> for OAuth2ScopeReasoning {
|
||||
fn from(value: crate::OAuth2ScopeReasoning) -> Self {
|
||||
OAuth2ScopeReasoning {
|
||||
allow: value.allow,
|
||||
deny: value.deny,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OAuth2ScopeReasoning> for crate::OAuth2ScopeReasoning {
|
||||
fn from(value: OAuth2ScopeReasoning) -> Self {
|
||||
crate::OAuth2ScopeReasoning {
|
||||
allow: value.allow,
|
||||
deny: value.deny,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1387,3 +1462,43 @@ impl From<FieldsMessage> for crate::FieldsMessage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthorizedBotId> for crate::AuthorizedBotId {
|
||||
fn from(value: AuthorizedBotId) -> Self {
|
||||
crate::AuthorizedBotId {
|
||||
user: value.user,
|
||||
bot: value.bot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::AuthorizedBotId> for AuthorizedBotId {
|
||||
fn from(value: crate::AuthorizedBotId) -> Self {
|
||||
AuthorizedBotId {
|
||||
user: value.user,
|
||||
bot: value.bot
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthorizedBot> for crate::AuthorizedBot {
|
||||
fn from(value: AuthorizedBot) -> Self {
|
||||
crate::AuthorizedBot {
|
||||
id: value.id.into(),
|
||||
created_at: value.created_at,
|
||||
deauthorized_at: value.deauthorized_at,
|
||||
scope: value.scope.into_iter().map(|scope| scope.into()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::AuthorizedBot> for AuthorizedBot {
|
||||
fn from(value: crate::AuthorizedBot) -> Self {
|
||||
AuthorizedBot {
|
||||
id: value.id.into(),
|
||||
created_at: value.created_at,
|
||||
deauthorized_at: value.deauthorized_at,
|
||||
scope: value.scope.into_iter().map(|scope| scope.into()).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,4 @@ pub mod idempotency;
|
||||
pub mod permissions;
|
||||
pub mod reference;
|
||||
pub mod test_fixtures;
|
||||
pub mod oauth2;
|
||||
17
crates/core/database/src/util/oauth2/axum.rs
Normal file
17
crates/core/database/src/util/oauth2/axum.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use axum::{extract::FromRequestParts, http::request::Parts};
|
||||
use revolt_result::{Error, Result};
|
||||
|
||||
use super::{OAuth2Scoped, scopes::OAuth2Scope};
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<S: Send + Sync, Scope: OAuth2Scope> FromRequestParts<S> for OAuth2Scoped<Scope> {
|
||||
type Rejection = Error;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self> {
|
||||
parts.extensions.insert(Scope::SCOPE);
|
||||
|
||||
Ok(OAuth2Scoped { _scope: PhantomData })
|
||||
}
|
||||
}
|
||||
120
crates/core/database/src/util/oauth2/mod.rs
Normal file
120
crates/core/database/src/util/oauth2/mod.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use chrono::{TimeDelta, Utc};
|
||||
use jsonwebtoken::{decode, encode, errors::Error, DecodingKey, EncodingKey, Header, Validation};
|
||||
use redis_kiss::AsyncCommands;
|
||||
use revolt_models::v0;
|
||||
use revolt_result::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod scopes;
|
||||
pub use scopes::OAuth2Scoped;
|
||||
|
||||
#[cfg(feature = "rocket")]
|
||||
pub mod rocket;
|
||||
|
||||
#[cfg(feature = "axum")]
|
||||
pub mod axum;
|
||||
|
||||
pub use jsonwebtoken::errors::{Error as JWTError, ErrorKind as JWTErrorKind};
|
||||
use ulid::Ulid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum TokenType {
|
||||
Auth,
|
||||
Access,
|
||||
Refresh,
|
||||
}
|
||||
|
||||
impl TokenType {
|
||||
pub fn lifetime(self) -> TimeDelta {
|
||||
match self {
|
||||
TokenType::Access => TimeDelta::weeks(1),
|
||||
TokenType::Auth => TimeDelta::minutes(5),
|
||||
TokenType::Refresh => TimeDelta::weeks(4),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub jti: String,
|
||||
pub sub: String,
|
||||
pub exp: i64,
|
||||
|
||||
pub r#type: TokenType,
|
||||
pub client_id: String,
|
||||
pub scopes: Vec<v0::OAuth2Scope>,
|
||||
pub redirect_uri: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_challange_method: Option<v0::OAuth2CodeChallangeMethod>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn encode_token(
|
||||
token_secret: &str,
|
||||
token_type: TokenType,
|
||||
user_id: String,
|
||||
client_id: String,
|
||||
redirect_uri: String,
|
||||
scopes: Vec<v0::OAuth2Scope>,
|
||||
code_challange_method: Option<v0::OAuth2CodeChallangeMethod>,
|
||||
) -> Result<String, Error> {
|
||||
let now = Utc::now();
|
||||
|
||||
let exp = now.checked_add_signed(token_type.lifetime()).unwrap();
|
||||
|
||||
println!("{now:?} {exp:}");
|
||||
|
||||
let claims = Claims {
|
||||
jti: Ulid::new().to_string(),
|
||||
sub: user_id,
|
||||
exp: exp.timestamp(),
|
||||
|
||||
r#type: token_type,
|
||||
client_id,
|
||||
scopes,
|
||||
redirect_uri,
|
||||
code_challange_method,
|
||||
};
|
||||
|
||||
let encoding_key = EncodingKey::from_secret(token_secret.as_bytes());
|
||||
|
||||
encode(&Header::default(), &claims, &encoding_key)
|
||||
}
|
||||
|
||||
pub fn decode_token(token_secret: &str, code: &str) -> Result<Claims, Error> {
|
||||
let decoding_key = DecodingKey::from_secret(token_secret.as_bytes());
|
||||
|
||||
let data = decode(
|
||||
code,
|
||||
&decoding_key,
|
||||
&Validation::new(jsonwebtoken::Algorithm::HS256),
|
||||
)?;
|
||||
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
pub async fn add_code_challange(token: &str, code_challenge: &str) -> Result<()> {
|
||||
let mut conn = redis_kiss::get_connection()
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
conn.pset_ex::<_, _, ()>(
|
||||
format!("oauth2:{token}:code_challenge"),
|
||||
code_challenge,
|
||||
TokenType::Access.lifetime().num_milliseconds() as usize,
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_code_challange(token: &str) -> Result<Option<String>> {
|
||||
let mut conn = redis_kiss::get_connection()
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
conn.get(format!("oauth2:{token}:code_challenge"))
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))
|
||||
}
|
||||
53
crates/core/database/src/util/oauth2/rocket.rs
Normal file
53
crates/core/database/src/util/oauth2/rocket.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::{convert::Infallible, marker::PhantomData};
|
||||
|
||||
use revolt_okapi::openapi3::{OAuthFlows, SecurityScheme, SecuritySchemeData};
|
||||
use revolt_rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput};
|
||||
use rocket::{request::{self, FromRequest}, Request};
|
||||
|
||||
use super::{OAuth2Scoped, scopes::OAuth2Scope};
|
||||
|
||||
|
||||
#[rocket::async_trait]
|
||||
impl<'r, Scope: OAuth2Scope> FromRequest<'r> for OAuth2Scoped<Scope> {
|
||||
type Error = Infallible;
|
||||
|
||||
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
|
||||
request.local_cache(|| Some(Scope::SCOPE));
|
||||
|
||||
request::Outcome::Success(OAuth2Scoped { _scope: PhantomData })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r, Scope: OAuth2Scope> OpenApiFromRequest<'r> for OAuth2Scoped<Scope> {
|
||||
fn from_request_input(
|
||||
_gen: &mut revolt_rocket_okapi::r#gen::OpenApiGenerator,
|
||||
_name: String,
|
||||
_required: bool,
|
||||
) -> revolt_rocket_okapi::Result<revolt_rocket_okapi::request::RequestHeaderInput> {
|
||||
Ok(RequestHeaderInput::Security(
|
||||
"OAuth2".to_owned(),
|
||||
SecurityScheme {
|
||||
description: None,
|
||||
extensions: Default::default(),
|
||||
data: SecuritySchemeData::OAuth2 {
|
||||
flows: OAuthFlows::AuthorizationCode {
|
||||
authorization_url: "todo".to_string(),
|
||||
token_url: "todo".to_string(),
|
||||
refresh_url: Some("todo".to_string()),
|
||||
scopes: revolt_okapi::map! {
|
||||
"read:identify".to_string() => "".to_string(),
|
||||
"read:servers".to_string() => "".to_string(),
|
||||
"write:files".to_string() => "".to_string(),
|
||||
"events".to_string() => "".to_string(),
|
||||
"full".to_string() => "".to_string(),
|
||||
},
|
||||
extensions: Default::default()
|
||||
}
|
||||
}
|
||||
},
|
||||
revolt_okapi::map! {
|
||||
"OAuth2".to_owned() => vec![Scope::MODEL.to_string()]
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
29
crates/core/database/src/util/oauth2/scopes.rs
Normal file
29
crates/core/database/src/util/oauth2/scopes.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
pub struct OAuth2Scoped<Scope> {
|
||||
pub(crate) _scope: PhantomData<Scope>
|
||||
}
|
||||
|
||||
pub trait OAuth2Scope {
|
||||
const SCOPE: crate::OAuth2Scope;
|
||||
const MODEL: revolt_models::v0::OAuth2Scope;
|
||||
}
|
||||
|
||||
macro_rules! define_oauth2_scope {
|
||||
($struct_name:ident) => {
|
||||
pub struct $struct_name;
|
||||
|
||||
impl OAuth2Scope for $struct_name {
|
||||
const SCOPE: crate::OAuth2Scope = crate::OAuth2Scope::$struct_name;
|
||||
const MODEL: revolt_models::v0::OAuth2Scope = revolt_models::v0::OAuth2Scope::$struct_name;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// This must match the OAuth2Scope enum
|
||||
// TODO: automatically sync this
|
||||
define_oauth2_scope!(ReadIdentify);
|
||||
define_oauth2_scope!(ReadServers);
|
||||
define_oauth2_scope!(WriteFiles);
|
||||
define_oauth2_scope!(Events);
|
||||
define_oauth2_scope!(Full);
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-files"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
@@ -20,10 +20,10 @@ typenum = "1.17.0"
|
||||
aws-config = "1.5.5"
|
||||
aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] }
|
||||
|
||||
revolt-config = { version = "0.8.8", path = "../config", features = [
|
||||
revolt-config = { version = "0.8.9", path = "../config", features = [
|
||||
"report-macros",
|
||||
] }
|
||||
revolt-result = { version = "0.8.8", path = "../result" }
|
||||
revolt-result = { version = "0.8.9", path = "../result" }
|
||||
|
||||
# image processing
|
||||
jxl-oxide = "0.8.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-models"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
@@ -20,8 +20,8 @@ default = ["serde", "partials", "rocket"]
|
||||
|
||||
[dependencies]
|
||||
# Core
|
||||
revolt-config = { version = "0.8.8", path = "../config" }
|
||||
revolt-permissions = { version = "0.8.8", path = "../permissions" }
|
||||
revolt-config = { version = "0.8.9", path = "../config" }
|
||||
revolt-permissions = { version = "0.8.9", path = "../permissions" }
|
||||
|
||||
# Utility
|
||||
regex = "1.11"
|
||||
|
||||
34
crates/core/models/src/v0/authorized_bots.rs
Normal file
34
crates/core/models/src/v0/authorized_bots.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use iso8601_timestamp::Timestamp;
|
||||
|
||||
use crate::v0::{PublicBot, OAuth2Scope};
|
||||
|
||||
auto_derived!(
|
||||
/// Unique id of the user and bot
|
||||
pub struct AuthorizedBotId {
|
||||
/// User id
|
||||
pub user: String,
|
||||
|
||||
/// Bot Id
|
||||
pub bot: String,
|
||||
}
|
||||
|
||||
pub struct AuthorizedBot {
|
||||
/// Unique Id
|
||||
#[serde(rename = "_id")]
|
||||
pub id: AuthorizedBotId,
|
||||
|
||||
/// When the authorized oauth2 bot connection was created at
|
||||
pub created_at: Timestamp,
|
||||
|
||||
/// If and when the authorized oauth2 bot connection was revoked at
|
||||
pub deauthorized_at: Option<Timestamp>,
|
||||
|
||||
/// Scopes the bot has access to
|
||||
pub scope: Vec<OAuth2Scope>,
|
||||
}
|
||||
|
||||
pub struct AuthorizedBotsResponse {
|
||||
pub public_bot: PublicBot,
|
||||
pub authorized_bot: AuthorizedBot,
|
||||
}
|
||||
);
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::User;
|
||||
use super::{User, OAuth2Scope, OAuth2ScopeReasoning};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[cfg(feature = "validator")]
|
||||
use validator::Validate;
|
||||
|
||||
auto_derived!(
|
||||
/// Bot
|
||||
@@ -48,6 +52,13 @@ auto_derived!(
|
||||
)]
|
||||
pub privacy_policy_url: String,
|
||||
|
||||
/// Oauth2 bot settings
|
||||
#[cfg_attr(
|
||||
feature = "serde",
|
||||
serde(skip_serializing_if = "Option::is_none", default)
|
||||
)]
|
||||
pub oauth2: Option<BotOauth2>,
|
||||
|
||||
/// Enum of bot flags
|
||||
#[cfg_attr(
|
||||
feature = "serde",
|
||||
@@ -60,6 +71,8 @@ auto_derived!(
|
||||
pub enum FieldsBot {
|
||||
Token,
|
||||
InteractionsURL,
|
||||
Oauth2,
|
||||
Oauth2Secret,
|
||||
}
|
||||
|
||||
/// Flags that may be attributed to a bot
|
||||
@@ -101,7 +114,7 @@ auto_derived!(
|
||||
|
||||
/// Bot Details
|
||||
#[derive(Default)]
|
||||
#[cfg_attr(feature = "validator", derive(validator::Validate))]
|
||||
#[cfg_attr(feature = "validator", derive(Validate))]
|
||||
pub struct DataCreateBot {
|
||||
/// Bot username
|
||||
#[cfg_attr(
|
||||
@@ -113,7 +126,7 @@ auto_derived!(
|
||||
|
||||
/// New Bot Details
|
||||
#[derive(Default)]
|
||||
#[cfg_attr(feature = "validator", derive(validator::Validate))]
|
||||
#[cfg_attr(feature = "validator", derive(Validate))]
|
||||
pub struct DataEditBot {
|
||||
/// Bot username
|
||||
#[cfg_attr(
|
||||
@@ -131,11 +144,24 @@ auto_derived!(
|
||||
/// Interactions URL
|
||||
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 2048)))]
|
||||
pub interactions_url: Option<String>,
|
||||
|
||||
#[cfg_attr(feature = "validator", validate)]
|
||||
pub oauth2: Option<DataEditBotOauth2>,
|
||||
|
||||
/// Fields to remove from bot object
|
||||
#[cfg_attr(feature = "serde", serde(default))]
|
||||
pub remove: Vec<FieldsBot>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[cfg_attr(feature = "validator", derive(Validate))]
|
||||
pub struct DataEditBotOauth2 {
|
||||
pub public: Option<bool>,
|
||||
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 10)))]
|
||||
pub redirects: Option<Vec<String>>,
|
||||
pub allowed_scopes: Option<HashMap<OAuth2Scope, OAuth2ScopeReasoning>>
|
||||
}
|
||||
|
||||
/// Where we are inviting a bot to
|
||||
#[serde(untagged)]
|
||||
pub enum InviteBotDestination {
|
||||
@@ -170,3 +196,22 @@ auto_derived!(
|
||||
pub user: User,
|
||||
}
|
||||
);
|
||||
|
||||
auto_derived_partial!(
|
||||
/// Bot Oauth2 information
|
||||
pub struct BotOauth2 {
|
||||
/// Whether the oauth client is public and should not receive a secret key
|
||||
#[serde(default)]
|
||||
pub public: bool,
|
||||
/// Secret key used for authorisation, not provided if the client is public
|
||||
#[serde(default)]
|
||||
pub secret: Option<String>,
|
||||
/// Allowed redirects for the authorisation
|
||||
#[serde(default)]
|
||||
pub redirects: Vec<String>,
|
||||
/// Mapping of allowed scopes and the reasonings
|
||||
#[serde(default)]
|
||||
pub allowed_scopes: HashMap<OAuth2Scope, OAuth2ScopeReasoning>,
|
||||
},
|
||||
"PartialBotOauth2"
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
mod authorized_bots;
|
||||
mod bots;
|
||||
mod channel_invites;
|
||||
mod channel_unreads;
|
||||
@@ -7,6 +8,7 @@ mod embeds;
|
||||
mod emojis;
|
||||
mod files;
|
||||
mod messages;
|
||||
mod oauth2;
|
||||
mod policy_changes;
|
||||
mod safety_reports;
|
||||
mod server_bans;
|
||||
@@ -15,6 +17,7 @@ mod servers;
|
||||
mod user_settings;
|
||||
mod users;
|
||||
|
||||
pub use authorized_bots::*;
|
||||
pub use bots::*;
|
||||
pub use channel_invites::*;
|
||||
pub use channel_unreads::*;
|
||||
@@ -24,6 +27,7 @@ pub use embeds::*;
|
||||
pub use emojis::*;
|
||||
pub use files::*;
|
||||
pub use messages::*;
|
||||
pub use oauth2::*;
|
||||
pub use policy_changes::*;
|
||||
pub use safety_reports::*;
|
||||
pub use server_bans::*;
|
||||
|
||||
140
crates/core/models/src/v0/oauth2.rs
Normal file
140
crates/core/models/src/v0/oauth2.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
use crate::v0::{PublicBot, User};
|
||||
use std::{collections::HashMap, fmt::Display};
|
||||
|
||||
auto_derived!(
|
||||
pub struct OAuth2AuthorizeAuthResponse {
|
||||
/// Redirect URI which will contain the access token
|
||||
pub redirect_uri: String,
|
||||
}
|
||||
|
||||
pub struct OAuth2ScopeReasoning {
|
||||
pub allow: String,
|
||||
pub deny: String
|
||||
}
|
||||
|
||||
pub struct OAuth2AuthorizeInfoResponse {
|
||||
pub bot: PublicBot,
|
||||
pub user: User,
|
||||
pub allowed_scopes: HashMap<OAuth2Scope, OAuth2ScopeReasoning>
|
||||
}
|
||||
|
||||
#[derive(Copy)]
|
||||
#[cfg_attr(feature = "serde", serde(rename = "lowercase"))]
|
||||
#[cfg_attr(feature = "rocket", derive(rocket::FromFormField))]
|
||||
pub enum OAuth2ResponseType {
|
||||
#[cfg_attr(feature = "rocket", field(value = "code"))]
|
||||
Code,
|
||||
#[cfg_attr(feature = "rocket", field(value = "token"))]
|
||||
Token,
|
||||
}
|
||||
|
||||
#[derive(Copy)]
|
||||
#[cfg_attr(feature = "rocket", derive(rocket::FromFormField))]
|
||||
pub enum OAuth2GrantType {
|
||||
#[cfg_attr(feature = "rocket", field(value = "authorization_code"))]
|
||||
#[cfg_attr(feature = "serde", serde(rename = "authorization_code"))]
|
||||
AuthorizationCode,
|
||||
#[cfg_attr(feature = "rocket", field(value = "implicit"))]
|
||||
#[cfg_attr(feature = "serde", serde(rename = "implicit"))]
|
||||
Implicit,
|
||||
#[cfg_attr(feature = "rocket", field(value = "refresh_token"))]
|
||||
#[cfg_attr(feature = "serde", serde(rename = "refresh_token"))]
|
||||
RefreshToken
|
||||
}
|
||||
|
||||
#[derive(Copy)]
|
||||
#[cfg_attr(feature = "rocket", derive(rocket::FromFormField))]
|
||||
pub enum OAuth2CodeChallangeMethod {
|
||||
#[cfg_attr(feature = "rocket", field(value = "plain"))]
|
||||
#[cfg_attr(feature = "serde", serde(rename = "plain"))]
|
||||
Plain,
|
||||
S256
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "rocket", derive(rocket::FromForm))]
|
||||
pub struct OAuth2AuthorizationForm {
|
||||
pub client_id: String,
|
||||
pub scope: String,
|
||||
pub redirect_uri: String,
|
||||
pub response_type: OAuth2ResponseType,
|
||||
pub state: Option<String>,
|
||||
pub code_challenge: Option<String>,
|
||||
pub code_challenge_method: Option<OAuth2CodeChallangeMethod>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "rocket", derive(rocket::FromForm))]
|
||||
pub struct OAuth2TokenExchangeForm {
|
||||
pub grant_type: OAuth2GrantType,
|
||||
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
|
||||
/// Authorization code
|
||||
pub code: Option<String>,
|
||||
// Refresh token to generate new access token
|
||||
pub refresh_token: Option<String>,
|
||||
|
||||
pub code_verifier: Option<String>,
|
||||
}
|
||||
|
||||
pub struct OAuth2TokenExchangeResponse {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub token_type: String,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Hash)]
|
||||
#[cfg_attr(feature = "serde", serde(rename = "snake_case"))]
|
||||
pub enum OAuth2Scope {
|
||||
#[cfg_attr(feature = "serde", serde(rename = "read:identify"))]
|
||||
ReadIdentify,
|
||||
#[cfg_attr(feature = "serde", serde(rename = "read:servers"))]
|
||||
ReadServers,
|
||||
#[cfg_attr(feature = "serde", serde(rename = "write:files"))]
|
||||
WriteFiles,
|
||||
#[cfg_attr(feature = "serde", serde(rename = "events"))]
|
||||
Events,
|
||||
#[cfg_attr(feature = "serde", serde(rename = "full"))]
|
||||
Full,
|
||||
}
|
||||
);
|
||||
|
||||
impl Display for OAuth2Scope {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
OAuth2Scope::ReadIdentify => "read:identify",
|
||||
OAuth2Scope::ReadServers => "read:servers",
|
||||
OAuth2Scope::WriteFiles => "write:files",
|
||||
OAuth2Scope::Events => "events",
|
||||
OAuth2Scope::Full => "full",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl OAuth2Scope {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(str: &str) -> Option<OAuth2Scope> {
|
||||
match str {
|
||||
"read:identify" => Some(OAuth2Scope::ReadIdentify),
|
||||
"read:servers" => Some(OAuth2Scope::ReadServers),
|
||||
"events" => Some(OAuth2Scope::Events),
|
||||
"full" => Some(OAuth2Scope::Full),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scopes_from_str(string: &str) -> Option<Vec<OAuth2Scope>> {
|
||||
let mut scopes = Vec::new();
|
||||
|
||||
for scope in string.split(' ') {
|
||||
if let Some(scope) = OAuth2Scope::from_str(scope) {
|
||||
scopes.push(scope);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(scopes)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-parser"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-permissions"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
@@ -21,7 +21,7 @@ async-std = { version = "1.8.0", features = ["attributes"] }
|
||||
|
||||
[dependencies]
|
||||
# Core
|
||||
revolt-result = { version = "0.8.8", path = "../result" }
|
||||
revolt-result = { version = "0.8.9", path = "../result" }
|
||||
|
||||
# Utility
|
||||
auto_ops = "0.3.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-presence"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
@@ -16,7 +16,7 @@ redis-is-patched = []
|
||||
async-std = { version = "1.8.0", features = ["attributes"] }
|
||||
|
||||
# Config for loading Redis URI
|
||||
revolt-config = { version = "0.8.8", path = "../config" }
|
||||
revolt-config = { version = "0.8.9", path = "../config" }
|
||||
|
||||
[dependencies]
|
||||
# Utility
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-ratelimits"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2024"
|
||||
|
||||
[features]
|
||||
@@ -10,9 +10,9 @@ axum = ["dep:axum", "revolt-database/axum-impl"]
|
||||
default = ["rocket", "axum"]
|
||||
|
||||
[dependencies]
|
||||
revolt-database = { version = "0.8.8", path = "../database"}
|
||||
revolt-result = { version = "0.8.8", path = "../result" }
|
||||
revolt-config = { version = "0.8.8", path = "../config" }
|
||||
revolt-database = { version = "0.8.9", path = "../database"}
|
||||
revolt-result = { version = "0.8.9", path = "../result" }
|
||||
revolt-config = { version = "0.8.9", path = "../config" }
|
||||
|
||||
rocket = { version = "0.5.1", optional = true }
|
||||
revolt_rocket_okapi = { version = "0.10.0", optional = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-result"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
|
||||
@@ -78,6 +78,9 @@ impl IntoResponse for Error {
|
||||
ErrorType::InvalidFlagValue => StatusCode::BAD_REQUEST,
|
||||
ErrorType::FeatureDisabled { .. } => StatusCode::BAD_REQUEST,
|
||||
|
||||
ErrorType::MissingScope { .. } => StatusCode::UNAUTHORIZED,
|
||||
ErrorType::ExpiredToken => StatusCode::UNAUTHORIZED,
|
||||
|
||||
ErrorType::ProxyError => StatusCode::BAD_REQUEST,
|
||||
ErrorType::FileTooSmall => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
ErrorType::FileTooLarge { .. } => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
||||
@@ -51,7 +51,7 @@ impl std::error::Error 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, Eq, PartialEq)]
|
||||
pub enum ErrorType {
|
||||
/// This error was not labeled :(
|
||||
LabelMe,
|
||||
@@ -168,6 +168,12 @@ pub enum ErrorType {
|
||||
ImageProcessingFailed,
|
||||
NoEmbedData,
|
||||
|
||||
// ? OAuth2 Errors
|
||||
ExpiredToken,
|
||||
MissingScope {
|
||||
scope: String
|
||||
},
|
||||
|
||||
// ? Legacy errors
|
||||
VosoUnavailable,
|
||||
|
||||
|
||||
@@ -84,6 +84,9 @@ impl<'r> Responder<'r, 'static> for Error {
|
||||
ErrorType::FailedValidation { .. } => Status::BadRequest,
|
||||
ErrorType::FeatureDisabled { .. } => Status::BadRequest,
|
||||
|
||||
ErrorType::MissingScope { .. } => Status::Unauthorized,
|
||||
ErrorType::ExpiredToken => Status::Unauthorized,
|
||||
|
||||
ErrorType::ProxyError => Status::BadRequest,
|
||||
ErrorType::FileTooSmall => Status::UnprocessableEntity,
|
||||
ErrorType::FileTooLarge { .. } => Status::UnprocessableEntity,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-crond"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = ["Paul Makles <me@insrt.uk>"]
|
||||
edition = "2021"
|
||||
@@ -16,7 +16,7 @@ log = "0.4"
|
||||
tokio = { version = "1" }
|
||||
|
||||
# Core
|
||||
revolt-database = { version = "0.8.8", path = "../../core/database" }
|
||||
revolt-result = { version = "0.8.8", path = "../../core/result" }
|
||||
revolt-config = { version = "0.8.8", path = "../../core/config" }
|
||||
revolt-files = { version = "0.8.8", path = "../../core/files" }
|
||||
revolt-database = { version = "0.8.9", path = "../../core/database" }
|
||||
revolt-result = { version = "0.8.9", path = "../../core/result" }
|
||||
revolt-config = { version = "0.8.9", path = "../../core/config" }
|
||||
revolt-files = { version = "0.8.9", path = "../../core/files" }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build Stage
|
||||
FROM ghcr.io/revoltchat/base:latest AS builder
|
||||
FROM ghcr.io/stoatchat/base:latest AS builder
|
||||
FROM debian:12 AS debian
|
||||
|
||||
# Bundle Stage
|
||||
|
||||
@@ -4,6 +4,8 @@ use revolt_result::Result;
|
||||
use tasks::{file_deletion, prune_dangling_files, prune_members};
|
||||
use tokio::try_join;
|
||||
|
||||
use crate::tasks::prune_authorized_bots;
|
||||
|
||||
pub mod tasks;
|
||||
|
||||
#[tokio::main]
|
||||
@@ -14,6 +16,7 @@ async fn main() -> Result<()> {
|
||||
try_join!(
|
||||
file_deletion::task(db.clone()),
|
||||
prune_dangling_files::task(db.clone()),
|
||||
prune_authorized_bots::task(db.clone()),
|
||||
prune_members::task(db.clone())
|
||||
)
|
||||
.map(|_| ())
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod file_deletion;
|
||||
pub mod prune_dangling_files;
|
||||
pub mod prune_authorized_bots;
|
||||
pub mod prune_members;
|
||||
|
||||
22
crates/daemons/crond/src/tasks/prune_authorized_bots.rs
Normal file
22
crates/daemons/crond/src/tasks/prune_authorized_bots.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use revolt_database::{iso8601_timestamp::{Duration, Timestamp}, util::oauth2::TokenType, Database};
|
||||
use revolt_result::Result;
|
||||
use tokio::time::sleep;
|
||||
|
||||
pub async fn task(db: Database) -> Result<()> {
|
||||
let lifetime = Duration::new(TokenType::Access.lifetime().num_seconds(), 0);
|
||||
|
||||
loop {
|
||||
let deauthorized_bots = db.fetch_deauthorized_authorized_bots().await?;
|
||||
|
||||
for bot in deauthorized_bots {
|
||||
if let Some(deauthorized_at) = &bot.deauthorized_at {
|
||||
if deauthorized_at.saturating_add(lifetime) < Timestamp::now_utc() {
|
||||
db.delete_authorized_bot(&bot.id).await?;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
// 1 hour
|
||||
sleep(std::time::Duration::from_secs(60 * 60)).await;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
[package]
|
||||
name = "revolt-pushd"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
revolt-result = { version = "0.8.8", path = "../../core/result" }
|
||||
revolt-config = { version = "0.8.8", path = "../../core/config", features = [
|
||||
revolt-result = { version = "0.8.9", path = "../../core/result" }
|
||||
revolt-config = { version = "0.8.9", path = "../../core/config", features = [
|
||||
"report-macros",
|
||||
"anyhow"
|
||||
] }
|
||||
revolt-database = { version = "0.8.8", path = "../../core/database" }
|
||||
revolt-models = { version = "0.8.8", path = "../../core/models", features = [
|
||||
revolt-database = { version = "0.8.9", path = "../../core/database" }
|
||||
revolt-models = { version = "0.8.9", path = "../../core/models", features = [
|
||||
"validator",
|
||||
] }
|
||||
revolt-presence = { version = "0.8.8", path = "../../core/presence", features = [
|
||||
revolt-presence = { version = "0.8.9", path = "../../core/presence", features = [
|
||||
"redis-is-patched",
|
||||
] }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build Stage
|
||||
FROM ghcr.io/revoltchat/base:latest AS builder
|
||||
FROM ghcr.io/stoatchat/base:latest AS builder
|
||||
FROM debian:12 AS debian
|
||||
|
||||
# Bundle Stage
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-delta"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
license = "AGPL-3.0-or-later"
|
||||
authors = ["Paul Makles <paulmakles@gmail.com>"]
|
||||
edition = "2018"
|
||||
@@ -83,5 +83,8 @@ revolt-result = { path = "../core/result", features = ["rocket", "okapi"] }
|
||||
revolt-permissions = { path = "../core/permissions", features = ["schemas"] }
|
||||
revolt-ratelimits = { path = "../core/ratelimits", features = ["rocket"] }
|
||||
|
||||
# oauth2
|
||||
pkce = "0.2.0"
|
||||
|
||||
[build-dependencies]
|
||||
vergen = "7.5.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build Stage
|
||||
FROM ghcr.io/revoltchat/base:latest AS builder
|
||||
FROM ghcr.io/stoatchat/base:latest AS builder
|
||||
FROM debian:12 AS debian
|
||||
|
||||
# Bundle Stage
|
||||
|
||||
@@ -37,6 +37,7 @@ pub async fn edit_bot(
|
||||
if data.public.is_none()
|
||||
&& data.analytics.is_none()
|
||||
&& data.interactions_url.is_none()
|
||||
&& data.oauth2.is_none()
|
||||
&& data.remove.is_empty()
|
||||
{
|
||||
return Ok(Json(v0::BotWithUserResponse {
|
||||
@@ -49,17 +50,43 @@ pub async fn edit_bot(
|
||||
public,
|
||||
analytics,
|
||||
interactions_url,
|
||||
oauth2,
|
||||
remove,
|
||||
..
|
||||
} = data;
|
||||
|
||||
let partial = PartialBot {
|
||||
let mut partial = PartialBot {
|
||||
public,
|
||||
analytics,
|
||||
interactions_url,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
if let Some(edit_oauth2) = oauth2 {
|
||||
let mut oauth2 = bot.oauth2.clone().unwrap_or_default();
|
||||
|
||||
if let Some(public) = edit_oauth2.public {
|
||||
if oauth2.public && !public {
|
||||
oauth2.secret = Some(nanoid::nanoid!(64))
|
||||
} else if !oauth2.public && public {
|
||||
oauth2.secret = None;
|
||||
};
|
||||
|
||||
oauth2.public = public;
|
||||
}
|
||||
|
||||
oauth2.redirects = edit_oauth2.redirects.unwrap_or(oauth2.redirects);
|
||||
oauth2.allowed_scopes = edit_oauth2.allowed_scopes
|
||||
.map(|scopes|scopes
|
||||
.into_iter()
|
||||
.map(|(scope, value)| (scope.into(), value.into()))
|
||||
.collect()
|
||||
)
|
||||
.unwrap_or(oauth2.allowed_scopes);
|
||||
|
||||
partial.oauth2 = Some(oauth2)
|
||||
}
|
||||
|
||||
bot.update(
|
||||
db,
|
||||
partial,
|
||||
|
||||
@@ -8,6 +8,7 @@ mod bots;
|
||||
mod channels;
|
||||
mod customisation;
|
||||
mod invites;
|
||||
mod oauth2;
|
||||
mod onboard;
|
||||
mod policy;
|
||||
mod push;
|
||||
@@ -31,6 +32,7 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
||||
"/channels" => channels::routes(),
|
||||
"/servers" => servers::routes(),
|
||||
"/invites" => invites::routes(),
|
||||
"/oauth2" => oauth2::routes(),
|
||||
"/custom" => customisation::routes(),
|
||||
"/safety" => safety::routes(),
|
||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||
@@ -52,6 +54,7 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
||||
"/channels" => channels::routes(),
|
||||
"/servers" => servers::routes(),
|
||||
"/invites" => invites::routes(),
|
||||
"/oauth2" => oauth2::routes(),
|
||||
"/custom" => customisation::routes(),
|
||||
"/safety" => safety::routes(),
|
||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||
@@ -74,6 +77,7 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
||||
"/channels" => channels::routes(),
|
||||
"/servers" => servers::routes(),
|
||||
"/invites" => invites::routes(),
|
||||
"/oauth2" => oauth2::routes(),
|
||||
"/custom" => customisation::routes(),
|
||||
"/safety" => safety::routes(),
|
||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||
@@ -94,6 +98,7 @@ pub fn mount(config: Settings, mut rocket: Rocket<Build>) -> Rocket<Build> {
|
||||
"/channels" => channels::routes(),
|
||||
"/servers" => servers::routes(),
|
||||
"/invites" => invites::routes(),
|
||||
"/oauth2" => oauth2::routes(),
|
||||
"/custom" => customisation::routes(),
|
||||
"/safety" => safety::routes(),
|
||||
"/auth/account" => rocket_authifier::routes::account::routes(),
|
||||
@@ -198,7 +203,13 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
"Sync",
|
||||
"Web Push"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "OAuth2",
|
||||
"tags": [
|
||||
"OAuth2",
|
||||
]
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -370,6 +381,11 @@ fn custom_openapi_spec() -> OpenApi {
|
||||
description: Some("Send messages from 3rd party services".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
Tag {
|
||||
name: "OAuth2".to_owned(),
|
||||
description: Some("Integrate Revolt into 3rd party applications".to_owned()),
|
||||
..Default::default()
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
|
||||
111
crates/delta/src/routes/oauth2/authorize_auth.rs
Normal file
111
crates/delta/src/routes/oauth2/authorize_auth.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use revolt_config::config;
|
||||
use revolt_database::{
|
||||
util::{oauth2, reference::Reference}, AuthorizedBot, AuthorizedBotId, Database, User
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
/// # OAuth2 Authorization Auth
|
||||
///
|
||||
/// Generates an OAuth2 code to be passed to the redirect URI.
|
||||
#[openapi(tag = "OAuth2")]
|
||||
#[post("/authorize?<info..>")]
|
||||
pub async fn auth(
|
||||
db: &State<Database>,
|
||||
user: User,
|
||||
info: v0::OAuth2AuthorizationForm,
|
||||
) -> Result<Json<v0::OAuth2AuthorizeAuthResponse>> {
|
||||
if user.bot.is_some() {
|
||||
return Err(create_error!(IsBot));
|
||||
};
|
||||
|
||||
let bot = Reference::from_unchecked(&info.client_id)
|
||||
.as_bot(db)
|
||||
.await?;
|
||||
|
||||
let Some(oauth2) = &bot.oauth2 else {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
let Some(scopes) = v0::OAuth2Scope::scopes_from_str(&info.scope) else {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
if scopes.iter().any(|&scope| !oauth2.allowed_scopes.contains_key(&scope.into()))
|
||||
|| !oauth2.redirects.contains(&info.redirect_uri)
|
||||
|| v0::OAuth2Scope::scopes_from_str(&info.scope).is_none()
|
||||
{
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
let config = config().await;
|
||||
|
||||
let token = match info.response_type {
|
||||
// implicit
|
||||
v0::OAuth2ResponseType::Code => {
|
||||
if info.state.is_some() || info.code_challenge.is_some() || info.code_challenge_method.is_some() {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
let token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Auth,
|
||||
user.id.clone(),
|
||||
info.client_id.clone(),
|
||||
info.redirect_uri.clone(),
|
||||
scopes.clone(),
|
||||
info.code_challenge_method,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
db.insert_authorized_bot(&AuthorizedBot {
|
||||
id: AuthorizedBotId { bot: info.client_id.clone(), user: user.id.clone() },
|
||||
created_at: Timestamp::now_utc(),
|
||||
deauthorized_at: None,
|
||||
scope: scopes.iter().map(|&scope| scope.into()).collect()
|
||||
}).await?;
|
||||
|
||||
token
|
||||
},
|
||||
// authorization code
|
||||
v0::OAuth2ResponseType::Token => {
|
||||
if let Some(((state, code_challange), code_challange_method)) = info.state.as_ref().zip(info.code_challenge.as_ref()).zip(info.code_challenge_method) {
|
||||
let is_valid = match code_challange_method {
|
||||
v0::OAuth2CodeChallangeMethod::Plain => state == code_challange,
|
||||
v0::OAuth2CodeChallangeMethod::S256 => &pkce::code_challenge(state.as_bytes()) == code_challange,
|
||||
};
|
||||
|
||||
if !is_valid || state.len() <= 43 || state.len() >= 128 {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
}
|
||||
|
||||
} else if !oauth2.public {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
};
|
||||
|
||||
let token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Auth,
|
||||
user.id.clone(),
|
||||
info.client_id.clone(),
|
||||
info.redirect_uri.clone(),
|
||||
scopes.clone(),
|
||||
info.code_challenge_method,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
if let Some(code_challenge) = info.code_challenge.as_ref() {
|
||||
oauth2::add_code_challange(&token, code_challenge).await?;
|
||||
};
|
||||
|
||||
token
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
let redirect_uri = format!("{}/?code={token}", &info.redirect_uri);
|
||||
|
||||
Ok(Json(v0::OAuth2AuthorizeAuthResponse { redirect_uri }))
|
||||
}
|
||||
40
crates/delta/src/routes/oauth2/authorize_info.rs
Normal file
40
crates/delta/src/routes/oauth2/authorize_info.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use revolt_database::{util::reference::Reference, Database, User};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, Result};
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
/// # Authorize OAuth Information
|
||||
///
|
||||
/// Fetches the information needed for displaying on the OAuth grant page
|
||||
#[openapi(tag = "OAuth2")]
|
||||
#[get("/authorize?<info..>")]
|
||||
pub async fn info(
|
||||
db: &State<Database>,
|
||||
user: User,
|
||||
info: v0::OAuth2AuthorizationForm,
|
||||
) -> Result<Json<v0::OAuth2AuthorizeInfoResponse>> {
|
||||
if user.bot.is_some() {
|
||||
return Err(create_error!(IsBot));
|
||||
};
|
||||
|
||||
let bot = Reference::from_unchecked(&info.client_id).as_bot(db).await?;
|
||||
let bot_user = Reference::from_unchecked(&bot.id).as_user(db).await?;
|
||||
let public_bot = bot.clone().into_public_bot(bot_user);
|
||||
|
||||
let Some(oauth2) = &bot.oauth2 else {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
if !oauth2.redirects.contains(&info.redirect_uri) || v0::OAuth2Scope::scopes_from_str(&info.scope).is_none() {
|
||||
return Err(create_error!(InvalidOperation));
|
||||
};
|
||||
|
||||
Ok(Json(v0::OAuth2AuthorizeInfoResponse {
|
||||
bot: public_bot,
|
||||
user: user.into(db, None).await,
|
||||
allowed_scopes: oauth2.allowed_scopes.clone()
|
||||
.into_iter()
|
||||
.map(|(scope, value)| (scope.into(), value.into()))
|
||||
.collect()
|
||||
}))
|
||||
}
|
||||
27
crates/delta/src/routes/oauth2/authorized_bots.rs
Normal file
27
crates/delta/src/routes/oauth2/authorized_bots.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use revolt_models::v0;
|
||||
use rocket::{serde::json::Json, State};
|
||||
use revolt_database::{Database, User};
|
||||
use revolt_result::Result;
|
||||
|
||||
#[openapi(tag = "OAuth2")]
|
||||
#[get("/authorized_bots")]
|
||||
pub async fn authorized_bots(
|
||||
db: &State<Database>,
|
||||
user: User
|
||||
) -> Result<Json<Vec<v0::AuthorizedBotsResponse>>> {
|
||||
let authorized_bots = db.fetch_users_authorized_bots(&user.id).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
|
||||
for authorized_bot in authorized_bots {
|
||||
let bot = db.fetch_bot(&authorized_bot.id.bot).await?;
|
||||
let bot_user = db.fetch_user(&authorized_bot.id.bot).await?;
|
||||
|
||||
response.push(v0::AuthorizedBotsResponse {
|
||||
authorized_bot: authorized_bot.into(),
|
||||
public_bot: bot.into_public_bot(bot_user)
|
||||
});
|
||||
};
|
||||
|
||||
Ok(Json(response))
|
||||
}
|
||||
24
crates/delta/src/routes/oauth2/mod.rs
Normal file
24
crates/delta/src/routes/oauth2/mod.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
|
||||
use rocket::Route;
|
||||
|
||||
mod authorize_auth;
|
||||
mod authorize_info;
|
||||
mod authorized_bots;
|
||||
mod revoke;
|
||||
mod token;
|
||||
|
||||
// TODO
|
||||
// Scopes:
|
||||
// - identity
|
||||
// - servers
|
||||
// - full
|
||||
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
authorize_auth::auth,
|
||||
authorize_info::info,
|
||||
authorized_bots::authorized_bots,
|
||||
revoke::revoke,
|
||||
token::token,
|
||||
]
|
||||
}
|
||||
50
crates/delta/src/routes/oauth2/revoke.rs
Normal file
50
crates/delta/src/routes/oauth2/revoke.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use revolt_config::config;
|
||||
use revolt_models::v0;
|
||||
use rocket::{serde::json::Json, State};
|
||||
use revolt_database::{util::oauth2::{self, TokenType}, AuthorizedBotId, Database, User};
|
||||
use revolt_result::{create_error, Result};
|
||||
|
||||
/// Revoke an OAuth2 token
|
||||
///
|
||||
/// This can either take user authorization and a client_id,
|
||||
/// or an OAuth2 token.
|
||||
#[openapi(tag = "OAuth2")]
|
||||
#[post("/revoke?<token>&<client_id>")]
|
||||
pub async fn revoke(
|
||||
db: &State<Database>,
|
||||
user: Option<User>,
|
||||
token: Option<&str>,
|
||||
client_id: Option<&str>
|
||||
) -> Result<Json<v0::AuthorizedBot>> {
|
||||
let config = config().await;
|
||||
|
||||
let (user_id, bot_id) = match (user, client_id, token) {
|
||||
(Some(user), Some(client_id), None) => {
|
||||
(user.id.clone(), client_id.to_string())
|
||||
},
|
||||
(_, None, Some(token)) => {
|
||||
let Ok(claims) = oauth2::decode_token(&config.api.security.token_secret, token) else {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
};
|
||||
|
||||
if claims.r#type == TokenType::Auth {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
}
|
||||
|
||||
(claims.sub, claims.client_id)
|
||||
},
|
||||
_ => return Err(create_error!(InvalidOperation))
|
||||
};
|
||||
|
||||
let id = AuthorizedBotId { user: user_id, bot: bot_id };
|
||||
|
||||
let authorized_bot = db.fetch_authorized_bot(&id).await?;
|
||||
|
||||
if authorized_bot.deauthorized_at.is_some() {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
}
|
||||
|
||||
db.deauthorize_authorized_bot(&id)
|
||||
.await
|
||||
.map(|bot| Json(bot.into()))
|
||||
}
|
||||
165
crates/delta/src/routes/oauth2/token.rs
Normal file
165
crates/delta/src/routes/oauth2/token.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
use chrono::Utc;
|
||||
use iso8601_timestamp::Timestamp;
|
||||
use revolt_config::config;
|
||||
use revolt_database::{
|
||||
util::{
|
||||
oauth2,
|
||||
reference::Reference,
|
||||
}, AuthorizedBot, AuthorizedBotId, Database
|
||||
};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::{create_error, ErrorType, Result};
|
||||
use rocket::{form::Form, serde::json::Json, State};
|
||||
|
||||
/// # OAuth Token Exchange
|
||||
///
|
||||
///
|
||||
#[openapi(tag = "OAuth2")]
|
||||
#[post("/token", data = "<info>")]
|
||||
pub async fn token(
|
||||
db: &State<Database>,
|
||||
info: Form<v0::OAuth2TokenExchangeForm>,
|
||||
) -> Result<Json<v0::OAuth2TokenExchangeResponse>> {
|
||||
let bot = Reference::from_unchecked(&info.client_id)
|
||||
.as_bot(db)
|
||||
.await?;
|
||||
|
||||
let config = config().await;
|
||||
|
||||
let (token, token_type) = match (&info.code, &info.refresh_token) {
|
||||
(Some(code), None) => {
|
||||
(code, oauth2::TokenType::Auth)
|
||||
},
|
||||
(None, Some(refresh_token)) => {
|
||||
(refresh_token, oauth2::TokenType::Refresh)
|
||||
},
|
||||
(Some(_), Some(_)) | (None, None) => {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
}
|
||||
};
|
||||
|
||||
let claims = oauth2::decode_token(&config.api.security.token_secret, token)
|
||||
.map_err(|_| create_error!(NotAuthenticated))?;
|
||||
|
||||
if claims.r#type != token_type || claims.client_id != info.client_id || claims.exp < Utc::now().timestamp() {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
};
|
||||
|
||||
if let Ok(authorized_bot) = db.fetch_authorized_bot(&AuthorizedBotId { user: claims.sub.clone(), bot: claims.client_id.clone() }).await {
|
||||
if authorized_bot.deauthorized_at.is_some() {
|
||||
return Err(create_error!(NotAuthenticated));
|
||||
}
|
||||
};
|
||||
|
||||
// TODO: track used tokens and dont allow them to be used twice
|
||||
// - refresh token
|
||||
// - auth tokens (only last 5 mins but still should be considered)
|
||||
|
||||
match info.grant_type {
|
||||
v0::OAuth2GrantType::AuthorizationCode => {
|
||||
if claims.r#type != oauth2::TokenType::Auth {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
}
|
||||
|
||||
if let Some((client_secret, bot_secret)) = info.client_secret.as_ref().zip(bot.oauth2.as_ref().and_then(|oauth2| oauth2.secret.as_ref())) {
|
||||
if client_secret != bot_secret {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
}
|
||||
} else if let Some((code_verifier, method)) = info.code_verifier.as_ref().zip(claims.code_challange_method) {
|
||||
let Some(server_code_challenge) = oauth2::get_code_challange(token).await? else {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
};
|
||||
|
||||
let is_valid = match method {
|
||||
v0::OAuth2CodeChallangeMethod::Plain => &server_code_challenge == code_verifier,
|
||||
v0::OAuth2CodeChallangeMethod::S256 => pkce::code_challenge(code_verifier.as_bytes()) == server_code_challenge,
|
||||
};
|
||||
|
||||
if !is_valid {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
}
|
||||
} else {
|
||||
return Err(create_error!(NotAuthenticated))
|
||||
}
|
||||
|
||||
let token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Access,
|
||||
claims.sub.clone(),
|
||||
claims.client_id.clone(),
|
||||
claims.redirect_uri.clone(),
|
||||
claims.scopes.clone(),
|
||||
None,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
let refresh_token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Refresh,
|
||||
claims.sub.clone(),
|
||||
claims.client_id.clone(),
|
||||
claims.redirect_uri.clone(),
|
||||
claims.scopes.clone(),
|
||||
None,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
let authorized_bot_id = AuthorizedBotId { bot: claims.client_id.clone(), user: claims.sub.clone() };
|
||||
let auth_bot = db.fetch_authorized_bot(&authorized_bot_id).await;
|
||||
|
||||
if auth_bot.is_err_and(|err| err.error_type == ErrorType::NotFound) {
|
||||
db.insert_authorized_bot(&AuthorizedBot {
|
||||
id: authorized_bot_id,
|
||||
created_at: Timestamp::now_utc(),
|
||||
deauthorized_at: None,
|
||||
scope: claims.scopes.iter().map(|&scope| scope.into()).collect()
|
||||
}).await?;
|
||||
}
|
||||
|
||||
Ok(Json(v0::OAuth2TokenExchangeResponse {
|
||||
access_token: token,
|
||||
refresh_token: Some(refresh_token),
|
||||
token_type: "OAuth2".to_string(),
|
||||
scope: claims.scopes.iter().map(|scope| scope.to_string()).collect::<Vec<_>>().join(" "),
|
||||
}))
|
||||
},
|
||||
v0::OAuth2GrantType::RefreshToken => {
|
||||
if claims.r#type != oauth2::TokenType::Refresh {
|
||||
return Err(create_error!(InvalidOperation))
|
||||
};
|
||||
|
||||
let token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Access,
|
||||
claims.sub.clone(),
|
||||
claims.client_id.clone(),
|
||||
claims.redirect_uri.clone(),
|
||||
claims.scopes.clone(),
|
||||
None,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
let refresh_token = oauth2::encode_token(
|
||||
&config.api.security.token_secret,
|
||||
oauth2::TokenType::Refresh,
|
||||
claims.sub.clone(),
|
||||
claims.client_id.clone(),
|
||||
claims.redirect_uri.clone(),
|
||||
claims.scopes.clone(),
|
||||
None,
|
||||
)
|
||||
.map_err(|_| create_error!(InternalError))?;
|
||||
|
||||
Ok(Json(v0::OAuth2TokenExchangeResponse {
|
||||
access_token: token,
|
||||
refresh_token: Some(refresh_token),
|
||||
token_type: "OAuth2".to_string(),
|
||||
scope: claims.scopes.iter().map(|scope| scope.to_string()).collect::<Vec<_>>().join(" "),
|
||||
}))
|
||||
}
|
||||
v0::OAuth2GrantType::Implicit => {
|
||||
// token is already an access token so this endpoint does not need to be called - in theory this should be unreachable
|
||||
Err(create_error!(InvalidOperation))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
server_create::create_server,
|
||||
server_delete::delete,
|
||||
server_fetch::fetch,
|
||||
server_fetch::fetch_server,
|
||||
server_edit::edit,
|
||||
server_ack::ack,
|
||||
channel_create::create_server_channel,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use revolt_database::{
|
||||
util::{permissions::DatabasePermissionQuery, reference::Reference},
|
||||
util::{oauth2::{scopes, OAuth2Scoped}, permissions::DatabasePermissionQuery, reference::Reference},
|
||||
Database, User,
|
||||
};
|
||||
use revolt_models::v0;
|
||||
@@ -12,8 +12,9 @@ use rocket::{serde::json::Json, State};
|
||||
/// Fetch a server by its id.
|
||||
#[openapi(tag = "Server Information")]
|
||||
#[get("/<target>?<options..>")]
|
||||
pub async fn fetch(
|
||||
pub async fn fetch_server(
|
||||
db: &State<Database>,
|
||||
_oauth2_scope: OAuth2Scoped<scopes::ReadServers>,
|
||||
user: User,
|
||||
target: Reference<'_>,
|
||||
options: v0::OptionsFetchServer,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use revolt_database::User;
|
||||
use revolt_database::{User, util::oauth2::{OAuth2Scoped, scopes}};
|
||||
use revolt_models::v0;
|
||||
use revolt_result::Result;
|
||||
use rocket::serde::json::Json;
|
||||
@@ -8,6 +8,9 @@ use rocket::serde::json::Json;
|
||||
/// Retrieve your user information.
|
||||
#[openapi(tag = "User Information")]
|
||||
#[get("/@me")]
|
||||
pub async fn fetch(user: User) -> Result<Json<v0::User>> {
|
||||
pub async fn fetch_self(
|
||||
_oauth2_scope: OAuth2Scoped<scopes::ReadIdentify>,
|
||||
user: User
|
||||
) -> Result<Json<v0::User>> {
|
||||
Ok(Json(user.into_self(false).await))
|
||||
}
|
||||
|
||||
54
crates/delta/src/routes/users/fetch_self_servers.rs
Normal file
54
crates/delta/src/routes/users/fetch_self_servers.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use revolt_database::{util::{oauth2::{scopes, OAuth2Scoped}, permissions::DatabasePermissionQuery}, Database, User};
|
||||
use revolt_models::v0;
|
||||
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
|
||||
use revolt_result::Result;
|
||||
use rocket::{serde::json::Json, State};
|
||||
|
||||
/// # Fetch current user's servers
|
||||
///
|
||||
/// Retrieve all servers the current user is in.
|
||||
#[openapi(tag = "User Information")]
|
||||
#[get("/@me/servers?<options..>")]
|
||||
pub async fn fetch_self_servers(
|
||||
db: &State<Database>,
|
||||
_oauth2_scope: OAuth2Scoped<scopes::ReadServers>,
|
||||
user: User,
|
||||
options: v0::OptionsFetchServer,
|
||||
) -> Result<Json<Vec<v0::FetchServerResponse>>> {
|
||||
let members = db.fetch_all_memberships(&user.id).await?;
|
||||
|
||||
let server_ids = members
|
||||
.iter()
|
||||
.map(|x| x.id.server.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut servers: Vec<v0::FetchServerResponse> = Vec::new();
|
||||
|
||||
for server in db.fetch_servers(&server_ids).await? {
|
||||
if let Some(true) = options.include_channels {
|
||||
let query = DatabasePermissionQuery::new(db, &user).server(&server);
|
||||
|
||||
let all_channels = db.fetch_channels(&server.channels).await?;
|
||||
let mut visible_channels: Vec<v0::Channel> = vec![];
|
||||
|
||||
for channel in all_channels {
|
||||
let mut channel_query = query.clone().channel(&channel);
|
||||
if calculate_channel_permissions(&mut channel_query)
|
||||
.await
|
||||
.has_channel_permission(ChannelPermission::ViewChannel)
|
||||
{
|
||||
visible_channels.push(channel.into());
|
||||
}
|
||||
}
|
||||
|
||||
servers.push(v0::FetchServerResponse::ServerWithChannels {
|
||||
server: server.into(),
|
||||
channels: visible_channels,
|
||||
});
|
||||
} else {
|
||||
servers.push(v0::FetchServerResponse::JustServer(server.into()))
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Json(servers))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod change_username;
|
||||
mod edit_user;
|
||||
mod fetch_dms;
|
||||
mod fetch_profile;
|
||||
mod fetch_self_servers;
|
||||
mod fetch_self;
|
||||
mod fetch_user;
|
||||
mod fetch_user_flags;
|
||||
@@ -20,7 +21,8 @@ mod unblock_user;
|
||||
pub fn routes() -> (Vec<Route>, OpenApi) {
|
||||
openapi_get_routes_spec![
|
||||
// User Information
|
||||
fetch_self::fetch,
|
||||
fetch_self::fetch_self,
|
||||
fetch_self_servers::fetch_self_servers,
|
||||
fetch_user::fetch,
|
||||
fetch_user_flags::fetch_user_flags,
|
||||
edit_user::edit,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-autumn"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -43,16 +43,16 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Core crates
|
||||
revolt-files = { version = "0.8.8", path = "../../core/files" }
|
||||
revolt-config = { version = "0.8.8", path = "../../core/config" }
|
||||
revolt-database = { version = "0.8.8", path = "../../core/database", features = [
|
||||
revolt-files = { version = "0.8.9", path = "../../core/files" }
|
||||
revolt-config = { version = "0.8.9", path = "../../core/config" }
|
||||
revolt-database = { version = "0.8.9", path = "../../core/database", features = [
|
||||
"axum-impl",
|
||||
] }
|
||||
revolt-result = { version = "0.8.8", path = "../../core/result", features = [
|
||||
revolt-result = { version = "0.8.9", path = "../../core/result", features = [
|
||||
"utoipa",
|
||||
"axum",
|
||||
] }
|
||||
revolt-ratelimits = { version = "0.8.8", path = "../../core/ratelimits", features = ["axum"] }
|
||||
revolt-ratelimits = { version = "0.8.9", path = "../../core/ratelimits", features = ["axum"] }
|
||||
|
||||
# Axum / web server
|
||||
tempfile = "3.12.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build Stage
|
||||
FROM ghcr.io/revoltchat/base:latest AS builder
|
||||
FROM ghcr.io/stoatchat/base:latest AS builder
|
||||
FROM debian:12 AS debian
|
||||
|
||||
# Bundle Stage
|
||||
|
||||
47
crates/services/gifbox/Cargo.toml
Normal file
47
crates/services/gifbox/Cargo.toml
Normal file
@@ -0,0 +1,47 @@
|
||||
[package]
|
||||
name = "revolt-gifbox"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
[dependencies]
|
||||
# Serialisation
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.68"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Web requests
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
# Core crates
|
||||
revolt-config = { version = "0.8.9", path = "../../core/config" }
|
||||
revolt-models = { version = "0.8.9", path = "../../core/models" }
|
||||
revolt-result = { version = "0.8.9", path = "../../core/result", features = [
|
||||
"utoipa",
|
||||
"axum",
|
||||
] }
|
||||
revolt-coalesced = { version = "0.8.9", path = "../../core/coalesced", features = [
|
||||
"queue",
|
||||
] }
|
||||
revolt-database = { version = "0.8.9", path = "../../core/database", features = [
|
||||
"axum-impl",
|
||||
] }
|
||||
revolt-ratelimits = { version = "0.8.9", path = "../../core/ratelimits", features = [
|
||||
"axum",
|
||||
] }
|
||||
|
||||
# Axum / web server
|
||||
axum = { version = "0.7.5" }
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
|
||||
# OpenAPI & documentation generation
|
||||
utoipa-scalar = { version = "0.1.0", features = ["axum"] }
|
||||
utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
|
||||
# Utils
|
||||
lru_time_cache = "0.11.11"
|
||||
10
crates/services/gifbox/Dockerfile
Normal file
10
crates/services/gifbox/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
# Build Stage
|
||||
FROM ghcr.io/stoatchat/base:latest AS builder
|
||||
|
||||
# Bundle Stage
|
||||
FROM gcr.io/distroless/cc-debian12:nonroot
|
||||
COPY --from=builder /home/rust/src/target/release/revolt-gifbox ./
|
||||
|
||||
EXPOSE 14706
|
||||
USER nonroot
|
||||
CMD ["./revolt-gifbox"]
|
||||
112
crates/services/gifbox/src/main.rs
Normal file
112
crates/services/gifbox/src/main.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
use axum::{extract::FromRef, middleware::from_fn_with_state, Router};
|
||||
|
||||
use revolt_config::config;
|
||||
use revolt_database::{Database, DatabaseInfo};
|
||||
use revolt_ratelimits::axum as ratelimiter;
|
||||
use tokio::net::TcpListener;
|
||||
use utoipa::{
|
||||
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
|
||||
Modify, OpenApi,
|
||||
};
|
||||
use utoipa_scalar::{Scalar, Servable as ScalarServable};
|
||||
|
||||
use crate::tenor::Tenor;
|
||||
|
||||
mod ratelimits;
|
||||
mod routes;
|
||||
mod tenor;
|
||||
mod types;
|
||||
|
||||
#[derive(Clone, FromRef)]
|
||||
struct AppState {
|
||||
pub database: Database,
|
||||
pub tenor: Tenor,
|
||||
pub ratelimit_storage: ratelimiter::RatelimitStorage,
|
||||
}
|
||||
|
||||
struct SecurityAddon;
|
||||
|
||||
impl Modify for SecurityAddon {
|
||||
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
|
||||
let components = openapi.components.get_or_insert_default();
|
||||
|
||||
components.add_security_scheme(
|
||||
"User Token",
|
||||
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new(
|
||||
"X-Session-Token".to_string(),
|
||||
))),
|
||||
);
|
||||
|
||||
components.add_security_scheme(
|
||||
"Bot Token",
|
||||
SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("X-Bot-Token".to_string()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), std::io::Error> {
|
||||
// Configure logging and environment
|
||||
revolt_config::configure!(gifbox);
|
||||
|
||||
// Configure API schema
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
modifiers(&SecurityAddon),
|
||||
paths(
|
||||
routes::categories::categories,
|
||||
routes::root::root,
|
||||
routes::search::search,
|
||||
routes::trending::trending,
|
||||
),
|
||||
tags(
|
||||
(name = "Misc", description = "Misc routes for microservice."),
|
||||
(name = "GIFs", description = "All routes for requesting GIFs from tenor.")
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
revolt_result::Error,
|
||||
revolt_result::ErrorType,
|
||||
types::MediaResult,
|
||||
types::MediaObject,
|
||||
)
|
||||
),
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
let config = config().await;
|
||||
|
||||
let database = DatabaseInfo::Auto
|
||||
.connect()
|
||||
.await
|
||||
.expect("Unable to connect to database");
|
||||
|
||||
let tenor = tenor::Tenor::new(&config.api.security.tenor_key);
|
||||
|
||||
let ratelimit_storage = ratelimiter::RatelimitStorage::new(ratelimits::GifboxRatelimits);
|
||||
|
||||
let state = AppState {
|
||||
database,
|
||||
tenor,
|
||||
ratelimit_storage,
|
||||
};
|
||||
|
||||
// Configure Axum and router
|
||||
let app = Router::new()
|
||||
.merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
|
||||
.nest("/", routes::router())
|
||||
.layer(from_fn_with_state(
|
||||
state.clone(),
|
||||
ratelimiter::ratelimit_middleware,
|
||||
))
|
||||
.with_state(state);
|
||||
|
||||
// Configure TCP listener and bind
|
||||
tracing::info!("Listening on 0.0.0.0:14706");
|
||||
tracing::info!("Play around with the API: http://localhost:14706/scalar");
|
||||
let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 14706));
|
||||
let listener = TcpListener::bind(&address).await?;
|
||||
axum::serve(listener, app.into_make_service()).await
|
||||
}
|
||||
32
crates/services/gifbox/src/ratelimits.rs
Normal file
32
crates/services/gifbox/src/ratelimits.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use axum::http::request::Parts;
|
||||
use revolt_ratelimits::ratelimiter::RatelimitResolver;
|
||||
|
||||
pub struct GifboxRatelimits;
|
||||
|
||||
impl RatelimitResolver<Parts> for GifboxRatelimits {
|
||||
fn resolve_bucket<'a>(&self, parts: &'a Parts) -> (&'a str, Option<&'a str>) {
|
||||
let path = parts
|
||||
.uri
|
||||
.path()
|
||||
.trim_matches('/')
|
||||
.split_terminator("/")
|
||||
.next();
|
||||
|
||||
match path {
|
||||
Some("categories") => ("categories", None),
|
||||
Some("trending") => ("trending", None),
|
||||
Some("search") => ("search", None),
|
||||
_ => ("any", None),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_bucket_limit(&self, bucket: &str) -> u32 {
|
||||
match bucket {
|
||||
"categories" => 2,
|
||||
"trending" => 5,
|
||||
"search" => 10,
|
||||
"any" => u32::MAX,
|
||||
_ => unreachable!("Bucket defined but no limit set"),
|
||||
}
|
||||
}
|
||||
}
|
||||
48
crates/services/gifbox/src/routes/categories.rs
Normal file
48
crates/services/gifbox/src/routes/categories.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use revolt_database::User;
|
||||
use revolt_result::{create_error, Result};
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
use crate::{tenor, types};
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct CategoriesQueryParams {
|
||||
/// Users locale
|
||||
#[param(example = "en_US")]
|
||||
pub locale: String,
|
||||
}
|
||||
|
||||
/// Trending GIF categories
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/categories",
|
||||
tag = "GIFs",
|
||||
security(("User Token" = []), ("Bot Token" = [])),
|
||||
params(CategoriesQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "Categories results", body = inline(Vec<types::CategoryResponse>))
|
||||
)
|
||||
)]
|
||||
pub async fn categories(
|
||||
_user: User,
|
||||
Query(params): Query<CategoriesQueryParams>,
|
||||
State(tenor): State<tenor::Tenor>,
|
||||
) -> Result<Json<Vec<types::CategoryResponse>>> {
|
||||
tenor
|
||||
.categories(¶ms.locale)
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))
|
||||
.map(|results| {
|
||||
(*results)
|
||||
.clone()
|
||||
.tags
|
||||
.into_iter()
|
||||
.map(|cat| cat.into())
|
||||
.collect()
|
||||
})
|
||||
.map(Json)
|
||||
}
|
||||
15
crates/services/gifbox/src/routes/mod.rs
Normal file
15
crates/services/gifbox/src/routes/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::AppState;
|
||||
use axum::routing::{get, Router};
|
||||
|
||||
pub mod categories;
|
||||
pub mod root;
|
||||
pub mod search;
|
||||
pub mod trending;
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(root::root))
|
||||
.route("/categories", get(categories::categories))
|
||||
.route("/search", get(search::search))
|
||||
.route("/trending", get(trending::trending))
|
||||
}
|
||||
22
crates/services/gifbox/src/routes/root.rs
Normal file
22
crates/services/gifbox/src/routes/root.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use axum::Json;
|
||||
|
||||
use crate::types;
|
||||
|
||||
/// Capture crate version from Cargo
|
||||
static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Root response from service
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/",
|
||||
tag = "Misc",
|
||||
responses(
|
||||
(status = 200, description = "Root response", body = inline(types::RootResponse))
|
||||
)
|
||||
)]
|
||||
pub async fn root() -> Json<types::RootResponse<'static>> {
|
||||
Json(types::RootResponse {
|
||||
message: "Gifbox lives on!",
|
||||
version: CRATE_VERSION,
|
||||
})
|
||||
}
|
||||
56
crates/services/gifbox/src/routes/search.rs
Normal file
56
crates/services/gifbox/src/routes/search.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use revolt_database::User;
|
||||
use revolt_result::{create_error, Result};
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
use crate::{tenor, types};
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct SearchQueryParams {
|
||||
/// Search query
|
||||
#[param(example = "Wave")]
|
||||
pub query: String,
|
||||
/// Users locale
|
||||
#[param(example = "en_US")]
|
||||
pub locale: String,
|
||||
/// Amount of results to respond with
|
||||
pub limit: Option<u32>,
|
||||
/// Flag for if searching in a gif category
|
||||
pub is_category: Option<bool>,
|
||||
/// Value of `next` for getting the next page of results with the current search query
|
||||
pub position: Option<String>,
|
||||
}
|
||||
|
||||
/// Searches for GIFs with a query
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/search",
|
||||
tag = "GIFs",
|
||||
security(("User Token" = []), ("Bot Token" = [])),
|
||||
params(SearchQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "Search results", body = inline(types::PaginatedMediaResponse))
|
||||
)
|
||||
)]
|
||||
pub async fn search(
|
||||
_user: User,
|
||||
Query(params): Query<SearchQueryParams>,
|
||||
State(tenor): State<tenor::Tenor>,
|
||||
) -> Result<Json<types::PaginatedMediaResponse>> {
|
||||
tenor
|
||||
.search(
|
||||
¶ms.query,
|
||||
¶ms.locale,
|
||||
params.limit.unwrap_or(50),
|
||||
params.is_category.unwrap_or_default(),
|
||||
params.position.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))
|
||||
.map(|results| results.as_ref().clone().into())
|
||||
.map(Json)
|
||||
}
|
||||
49
crates/services/gifbox/src/routes/trending.rs
Normal file
49
crates/services/gifbox/src/routes/trending.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
Json,
|
||||
};
|
||||
use revolt_database::User;
|
||||
use revolt_result::{create_error, Result};
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
use crate::{tenor, types};
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub struct TrendingQueryParams {
|
||||
#[param(example = "en_US")]
|
||||
/// Users locale
|
||||
pub locale: String,
|
||||
/// Amount of results to respond with
|
||||
pub limit: Option<u32>,
|
||||
/// Value of `next` for getting the next page of results of featured gifs
|
||||
pub position: Option<String>,
|
||||
}
|
||||
|
||||
/// Trending GIFs
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/featured",
|
||||
tag = "GIFs",
|
||||
security(("User Token" = []), ("Bot Token" = [])),
|
||||
params(TrendingQueryParams),
|
||||
responses(
|
||||
(status = 200, description = "Trending results", body = inline(types::PaginatedMediaResponse))
|
||||
)
|
||||
)]
|
||||
pub async fn trending(
|
||||
_user: User,
|
||||
Query(params): Query<TrendingQueryParams>,
|
||||
State(tenor): State<tenor::Tenor>,
|
||||
) -> Result<Json<types::PaginatedMediaResponse>> {
|
||||
tenor
|
||||
.featured(
|
||||
¶ms.locale,
|
||||
params.limit.unwrap_or(50),
|
||||
params.position.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| create_error!(InternalError))
|
||||
.map(|results| results.as_ref().clone().into())
|
||||
.map(Json)
|
||||
}
|
||||
199
crates/services/gifbox/src/tenor/mod.rs
Normal file
199
crates/services/gifbox/src/tenor/mod.rs
Normal file
@@ -0,0 +1,199 @@
|
||||
//! Internal Tenor API wrapper
|
||||
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
use lru_time_cache::LruCache;
|
||||
use reqwest::Client;
|
||||
use revolt_coalesced::{CoalescionService, CoalescionServiceConfig};
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
pub mod types;
|
||||
|
||||
const TENOR_API_BASE_URL: &str = "https://tenor.googleapis.com/v2";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TenorError {
|
||||
HttpError,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Tenor {
|
||||
pub key: Arc<str>,
|
||||
pub client: Client,
|
||||
pub coalescion: CoalescionService<String>,
|
||||
pub cache: Arc<RwLock<LruCache<String, Arc<types::PaginatedMediaResponse>>>>,
|
||||
|
||||
pub categories: Arc<RwLock<LruCache<String, Arc<types::CategoriesResponse>>>>,
|
||||
pub featured: Arc<RwLock<LruCache<String, Arc<types::PaginatedMediaResponse>>>>,
|
||||
}
|
||||
|
||||
impl Tenor {
|
||||
pub fn new(key: &str) -> Self {
|
||||
Self {
|
||||
key: Arc::from(key),
|
||||
client: Client::new(),
|
||||
coalescion: CoalescionService::from_config(CoalescionServiceConfig {
|
||||
max_concurrent: Some(100),
|
||||
queue_requests: true,
|
||||
max_queue: None,
|
||||
}),
|
||||
|
||||
// 1 hour, 1k requests
|
||||
cache: Arc::new(RwLock::new(LruCache::with_expiry_duration_and_capacity(
|
||||
Duration::from_secs(60 * 60),
|
||||
1000,
|
||||
))),
|
||||
|
||||
// 1 day, 1k requests
|
||||
categories: Arc::new(RwLock::new(LruCache::with_expiry_duration_and_capacity(
|
||||
Duration::from_secs(60 * 60 * 24),
|
||||
1000,
|
||||
))),
|
||||
|
||||
// 1 day, 1k requests
|
||||
featured: Arc::new(RwLock::new(LruCache::with_expiry_duration_and_capacity(
|
||||
Duration::from_secs(60 * 60 * 24),
|
||||
1000,
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request<T: DeserializeOwned>(&self, path: &str, query: &[Option<(&str, &str)>]) -> Result<Arc<T>, TenorError> {
|
||||
let response = self
|
||||
.client
|
||||
.get(format!("{TENOR_API_BASE_URL}{path}"))
|
||||
.query(query)
|
||||
.send()
|
||||
.await
|
||||
.inspect_err(|e| {
|
||||
revolt_config::capture_error(e);
|
||||
})
|
||||
.map_err(|_| TenorError::HttpError)?;
|
||||
|
||||
let text = response.text().await.map_err(|e| {
|
||||
revolt_config::capture_error(&e);
|
||||
TenorError::HttpError
|
||||
})?;
|
||||
|
||||
Ok(Arc::new(serde_json::from_str(&text).unwrap()))
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
&self,
|
||||
query: &str,
|
||||
locale: &str,
|
||||
limit: u32,
|
||||
is_category: bool,
|
||||
position: &str,
|
||||
) -> Result<Arc<types::PaginatedMediaResponse>, TenorError> {
|
||||
let unique_key = format!("s:{query}:{locale}:{is_category}:{position}");
|
||||
|
||||
if self.cache.read().await.contains_key(&unique_key) {
|
||||
if let Some(response) = self.cache.write().await.get(&unique_key) {
|
||||
return Ok(response.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let res = self.coalescion.execute(unique_key.clone(), || async move {
|
||||
self.request::<types::PaginatedMediaResponse>(
|
||||
"/search",
|
||||
&[
|
||||
Some(("key", &self.key)),
|
||||
Some(("q", query)),
|
||||
Some(("client_key", "Gifbox")),
|
||||
Some(("media_filter", "webm,tinywebm")),
|
||||
Some(("locale", locale)),
|
||||
Some(("contentfilter", "high")),
|
||||
Some(("limit", &limit.to_string())),
|
||||
position.is_empty().then_some(("pos", position)),
|
||||
is_category.then_some(("component", "categories"))
|
||||
]
|
||||
).await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if let Ok(resp) = &*res {
|
||||
self.cache.write().await.insert(unique_key, resp.clone());
|
||||
}
|
||||
|
||||
(*res).clone()
|
||||
}
|
||||
|
||||
pub async fn categories(
|
||||
&self,
|
||||
locale: &str,
|
||||
) -> Result<Arc<types::CategoriesResponse>, TenorError> {
|
||||
let unique_key = format!("c-{locale}");
|
||||
|
||||
if self.categories.read().await.contains_key(&unique_key) {
|
||||
if let Some(response) = self.categories.write().await.get(&unique_key) {
|
||||
return Ok(response.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let res = self
|
||||
.coalescion
|
||||
.execute(unique_key.clone(), || async move {
|
||||
self.request::<types::CategoriesResponse>(
|
||||
"/categories",
|
||||
&[
|
||||
Some(("key", &self.key)),
|
||||
Some(("client_key", "Gifbox")),
|
||||
Some(("locale", locale)),
|
||||
Some(("contentfilter", "high")),
|
||||
]
|
||||
).await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if let Ok(resp) = &*res {
|
||||
self.categories
|
||||
.write()
|
||||
.await
|
||||
.insert(unique_key, resp.clone());
|
||||
}
|
||||
|
||||
(*res).clone()
|
||||
}
|
||||
|
||||
pub async fn featured(
|
||||
&self,
|
||||
locale: &str,
|
||||
limit: u32,
|
||||
position: &str,
|
||||
) -> Result<Arc<types::PaginatedMediaResponse>, TenorError> {
|
||||
let unique_key = format!("f-{locale}-{limit}-{position}");
|
||||
|
||||
if self.categories.read().await.contains_key(&unique_key) {
|
||||
if let Some(response) = self.featured.write().await.get(&unique_key) {
|
||||
return Ok(response.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let res = self.coalescion.execute(unique_key.clone(), || async move {
|
||||
self.request::<types::PaginatedMediaResponse>(
|
||||
"/featured",
|
||||
&[
|
||||
Some(("key", &self.key)),
|
||||
Some(("client_key", "Gifbox")),
|
||||
Some(("media_filter", "webm,tinywebm")),
|
||||
Some(("locale", locale)),
|
||||
Some(("contentfilter", "high")),
|
||||
Some(("limit", &limit.to_string())),
|
||||
position.is_empty().then_some(("pos", position)),
|
||||
]
|
||||
).await
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
if let Ok(resp) = &*res {
|
||||
self.featured.write().await.insert(unique_key, resp.clone());
|
||||
}
|
||||
|
||||
(*res).clone()
|
||||
}
|
||||
}
|
||||
51
crates/services/gifbox/src/tenor/types.rs
Normal file
51
crates/services/gifbox/src/tenor/types.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Tenor API models
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct PaginatedMediaResponse {
|
||||
pub results: Vec<MediaResponse>,
|
||||
pub next: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct MediaObject {
|
||||
pub url: String,
|
||||
pub dims: Vec<u64>,
|
||||
pub duration: f64,
|
||||
pub size: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct MediaResponse {
|
||||
pub created: f64,
|
||||
#[serde(default)]
|
||||
pub hasaudio: bool,
|
||||
pub id: String,
|
||||
pub media_formats: HashMap<String, MediaObject>,
|
||||
pub tags: Vec<String>,
|
||||
pub title: String,
|
||||
pub content_description: String,
|
||||
pub itemurl: String,
|
||||
#[serde(default)]
|
||||
pub hascaption: bool,
|
||||
pub flags: Vec<String>,
|
||||
pub bg_color: Option<String>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct CategoriesResponse {
|
||||
pub locale: String,
|
||||
pub tags: Vec<CategoryResponse>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||
pub struct CategoryResponse {
|
||||
pub searchterm: String,
|
||||
pub path: String,
|
||||
pub image: String,
|
||||
pub name: String,
|
||||
}
|
||||
101
crates/services/gifbox/src/types.rs
Normal file
101
crates/services/gifbox/src/types.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::tenor::types;
|
||||
|
||||
/// Successful root response
|
||||
#[derive(Serialize, Debug, ToSchema)]
|
||||
pub struct RootResponse<'a> {
|
||||
pub message: &'a str,
|
||||
pub version: &'a str,
|
||||
}
|
||||
|
||||
/// Response containing the current results and the id of the next result for pagination.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)]
|
||||
pub struct PaginatedMediaResponse {
|
||||
/// Current gif results.
|
||||
pub results: Vec<MediaResult>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
/// Id of the next result.
|
||||
pub next: Option<String>,
|
||||
}
|
||||
|
||||
/// Indivual gif result.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)]
|
||||
pub struct MediaResult {
|
||||
/// Unique Tenor id.
|
||||
pub id: String,
|
||||
/// Mapping of each file format and url of the file.
|
||||
pub media_formats: HashMap<String, MediaObject>,
|
||||
/// Public Tenor web url for the gif.
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Represents the gif in a certain file format.
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)]
|
||||
pub struct MediaObject {
|
||||
/// File url of the gif in a certain format.
|
||||
pub url: String,
|
||||
/// Width and height of the file in px.
|
||||
pub dimensions: Vec<u64>,
|
||||
}
|
||||
|
||||
/// Represents a GIF category
|
||||
#[derive(Serialize, Deserialize, ToSchema, Clone, Debug, PartialEq)]
|
||||
pub struct CategoryResponse {
|
||||
/// Category title
|
||||
pub title: String,
|
||||
/// Category image
|
||||
pub image: String,
|
||||
}
|
||||
|
||||
impl From<types::PaginatedMediaResponse> for PaginatedMediaResponse {
|
||||
fn from(value: types::PaginatedMediaResponse) -> Self {
|
||||
Self {
|
||||
results: value
|
||||
.results
|
||||
.into_iter()
|
||||
.map(|result| result.into())
|
||||
.collect(),
|
||||
next: if value.next.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(value.next)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<types::MediaResponse> for MediaResult {
|
||||
fn from(value: types::MediaResponse) -> Self {
|
||||
Self {
|
||||
id: value.id,
|
||||
media_formats: value
|
||||
.media_formats
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, v.into()))
|
||||
.collect(),
|
||||
url: value.url,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<types::MediaObject> for MediaObject {
|
||||
fn from(value: types::MediaObject) -> Self {
|
||||
Self {
|
||||
url: value.url,
|
||||
dimensions: value.dims,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<types::CategoryResponse> for CategoryResponse {
|
||||
fn from(value: types::CategoryResponse) -> Self {
|
||||
Self {
|
||||
title: value.searchterm,
|
||||
image: value.image,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "revolt-january"
|
||||
version = "0.8.8"
|
||||
version = "0.8.9"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
||||
@@ -32,13 +32,13 @@ tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Core crates
|
||||
revolt-config = { version = "0.8.8", path = "../../core/config" }
|
||||
revolt-models = { version = "0.8.8", path = "../../core/models" }
|
||||
revolt-result = { version = "0.8.8", path = "../../core/result", features = [
|
||||
revolt-config = { version = "0.8.9", path = "../../core/config" }
|
||||
revolt-models = { version = "0.8.9", path = "../../core/models" }
|
||||
revolt-result = { version = "0.8.9", path = "../../core/result", features = [
|
||||
"utoipa",
|
||||
"axum",
|
||||
] }
|
||||
revolt-files = { version = "0.8.8", path = "../../core/files" }
|
||||
revolt-files = { version = "0.8.9", path = "../../core/files" }
|
||||
|
||||
# Axum / web server
|
||||
axum = { version = "0.7.5" }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Build Stage
|
||||
FROM ghcr.io/revoltchat/base:latest AS builder
|
||||
FROM ghcr.io/stoatchat/base:latest AS builder
|
||||
FROM debian:12 AS debian
|
||||
|
||||
# Bundle Stage
|
||||
|
||||
@@ -312,7 +312,7 @@ pub async fn populate_special(original_url: String, metadata: &mut WebsiteMetada
|
||||
Some(Special::GIF)
|
||||
} else {
|
||||
RE_APPLE_MUSIC
|
||||
.captures_iter(url)
|
||||
.captures_iter(&original_url)
|
||||
.next()
|
||||
.map(|captures| Special::AppleMusic {
|
||||
album_id: captures[1].to_string(),
|
||||
|
||||
@@ -11,6 +11,7 @@ pkgs.mkShell rec {
|
||||
# Cargo
|
||||
pkgs.cargo
|
||||
pkgs.cargo-nextest
|
||||
pkgs.cargo-release
|
||||
|
||||
# Rust
|
||||
pkgs.rustc
|
||||
|
||||
@@ -32,8 +32,11 @@ deps() {
|
||||
crates/core/permissions/src \
|
||||
crates/core/presence/src \
|
||||
crates/core/result/src \
|
||||
crates/core/coalesced/src \
|
||||
crates/core/ratelimits/src \
|
||||
crates/services/autumn/src \
|
||||
crates/services/january/src \
|
||||
crates/services/gifbox/src \
|
||||
crates/daemons/crond/src \
|
||||
crates/daemons/pushd/src
|
||||
echo 'fn main() { panic!("stub"); }' |
|
||||
@@ -41,6 +44,7 @@ deps() {
|
||||
tee crates/delta/src/main.rs |
|
||||
tee crates/services/autumn/src/main.rs |
|
||||
tee crates/services/january/src/main.rs |
|
||||
tee crates/services/gifbox/src/main.rs |
|
||||
tee crates/daemons/crond/src/main.rs |
|
||||
tee crates/daemons/pushd/src/main.rs
|
||||
echo '' |
|
||||
@@ -51,7 +55,9 @@ deps() {
|
||||
tee crates/core/parser/src/lib.rs |
|
||||
tee crates/core/permissions/src/lib.rs |
|
||||
tee crates/core/presence/src/lib.rs |
|
||||
tee crates/core/result/src/lib.rs
|
||||
tee crates/core/result/src/lib.rs |
|
||||
tee crates/core/coalesced/src/lib.rs |
|
||||
tee crates/core/ratelimits/src/lib.rs
|
||||
|
||||
if [ -z "$TARGETARCH" ]; then
|
||||
cargo build -j 10 --locked --release
|
||||
@@ -72,7 +78,9 @@ apps() {
|
||||
crates/core/parser/src/lib.rs \
|
||||
crates/core/permissions/src/lib.rs \
|
||||
crates/core/presence/src/lib.rs \
|
||||
crates/core/result/src/lib.rs
|
||||
crates/core/result/src/lib.rs \
|
||||
crates/core/coalesced/src/lib.rs \
|
||||
crates/core/ratelimits/src/lib.rs
|
||||
|
||||
if [ -z "$TARGETARCH" ]; then
|
||||
cargo build -j 10 --locked --release
|
||||
|
||||
@@ -20,21 +20,23 @@ fi
|
||||
|
||||
TAG=$1-debug
|
||||
echo "Building images, will tag for ghcr.io with $TAG!"
|
||||
docker build -t ghcr.io/revoltchat/base:latest -f Dockerfile.useCurrentArch .
|
||||
docker build -t ghcr.io/revoltchat/server:$TAG - < crates/delta/Dockerfile
|
||||
docker build -t ghcr.io/revoltchat/bonfire:$TAG - < crates/bonfire/Dockerfile
|
||||
docker build -t ghcr.io/revoltchat/autumn:$TAG - < crates/services/autumn/Dockerfile
|
||||
docker build -t ghcr.io/revoltchat/january:$TAG - < crates/services/january/Dockerfile
|
||||
docker build -t ghcr.io/revoltchat/crond:$TAG - < crates/daemons/crond/Dockerfile
|
||||
docker build -t ghcr.io/revoltchat/pushd:$TAG - < crates/daemons/pushd/Dockerfile
|
||||
docker build -t ghcr.io/stoatchat/base:latest -f Dockerfile.useCurrentArch .
|
||||
docker build -t ghcr.io/stoatchat/server:$TAG - < crates/delta/Dockerfile
|
||||
docker build -t ghcr.io/stoatchat/bonfire:$TAG - < crates/bonfire/Dockerfile
|
||||
docker build -t ghcr.io/stoatchat/autumn:$TAG - < crates/services/autumn/Dockerfile
|
||||
docker build -t ghcr.io/stoatchat/january:$TAG - < crates/services/january/Dockerfile
|
||||
docker build -t ghcr.io/stoatchat/gifbox:$TAG - < crates/services/gifbox/Dockerfile
|
||||
docker build -t ghcr.io/stoatchat/crond:$TAG - < crates/daemons/crond/Dockerfile
|
||||
docker build -t ghcr.io/stoatchat/pushd:$TAG - < crates/daemons/pushd/Dockerfile
|
||||
|
||||
if [ "$DEBUG" = "true" ]; then
|
||||
git restore Cargo.toml
|
||||
fi
|
||||
|
||||
docker push ghcr.io/revoltchat/server:$TAG
|
||||
docker push ghcr.io/revoltchat/bonfire:$TAG
|
||||
docker push ghcr.io/revoltchat/autumn:$TAG
|
||||
docker push ghcr.io/revoltchat/january:$TAG
|
||||
docker push ghcr.io/revoltchat/crond:$TAG
|
||||
docker push ghcr.io/revoltchat/pushd:$TAG
|
||||
docker push ghcr.io/stoatchat/server:$TAG
|
||||
docker push ghcr.io/stoatchat/bonfire:$TAG
|
||||
docker push ghcr.io/stoatchat/autumn:$TAG
|
||||
docker push ghcr.io/stoatchat/january:$TAG
|
||||
docker push ghcr.io/stoatchat/gifbox:$TAG
|
||||
docker push ghcr.io/stoatchat/crond:$TAG
|
||||
docker push ghcr.io/stoatchat/pushd:$TAG
|
||||
|
||||
@@ -4,10 +4,12 @@ cargo build \
|
||||
--bin revolt-delta \
|
||||
--bin revolt-bonfire \
|
||||
--bin revolt-autumn \
|
||||
--bin revolt-january
|
||||
--bin revolt-january \
|
||||
--bin revolt-gifbox
|
||||
|
||||
trap 'pkill -f revolt-' SIGINT
|
||||
cargo run --bin revolt-delta &
|
||||
cargo run --bin revolt-bonfire &
|
||||
cargo run --bin revolt-autumn &
|
||||
cargo run --bin revolt-january
|
||||
cargo run --bin revolt-january &
|
||||
cargo run --bin revolt-gifbox
|
||||
|
||||
Reference in New Issue
Block a user