Compare commits

...

7 Commits

Author SHA1 Message Date
Zomatree
5894689a90 fix: swap to using reqwest for query building 2025-09-19 01:48:23 +01:00
Zomatree
50a926f0a2 feat: add ratelimits to gifbox 2025-09-18 21:46:08 +01:00
Zomatree
06a281ec7e docs: document revolt-coalesced 2025-09-18 21:25:44 +01:00
Zomatree
0dfa276859 feat: trending and categories routes 2025-09-18 21:25:44 +01:00
Zomatree
970c50ee1b fix: use our own result types instead of tenors types
add user auth
2025-09-18 21:25:44 +01:00
Zomatree
aebe1c6963 feat: require auth for search 2025-09-18 21:25:39 +01:00
Zomatree
797016fd2b feat: initial work on tenor gif searching 2025-09-18 21:23:37 +01:00
27 changed files with 1749 additions and 529 deletions

View File

@@ -152,6 +152,26 @@ jobs:
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-january.outputs.labels }}
# revoltchat/gifbox
- name: Docker meta
id: meta-gifbox
uses: docker/metadata-action@v4
with:
images: |
docker.io/revoltchat/gifbox
ghcr.io/revoltchat/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 }}
# revoltchat/crond
- name: Docker meta
id: meta-crond

1151
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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 | ![Crates.io Version](https://img.shields.io/crates/v/revolt-permissions) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-permissions) ![Crates.io Version](https://img.shields.io/crates/size/revolt-permissions) ![Crates.io License](https://img.shields.io/crates/l/revolt-permissions) |
| `core/presence` | [crates/core/presence](crates/core/presence) | Core: User Presence | ![Crates.io Version](https://img.shields.io/crates/v/revolt-presence) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-presence) ![Crates.io Version](https://img.shields.io/crates/size/revolt-presence) ![Crates.io License](https://img.shields.io/crates/l/revolt-presence) |
| `core/result` | [crates/core/result](crates/core/result) | Core: Result and Error types | ![Crates.io Version](https://img.shields.io/crates/v/revolt-result) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-result) ![Crates.io Version](https://img.shields.io/crates/size/revolt-result) ![Crates.io License](https://img.shields.io/crates/l/revolt-result) |
| `core/coalesced` | [crates/core/coalesced](crates/core/coalesced) | Core: Coalescion service | ![Crates.io Version](https://img.shields.io/crates/v/revolt-coalesced) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-coalesced) ![Crates.io Version](https://img.shields.io/crates/size/revolt-coalesced) ![Crates.io License](https://img.shields.io/crates/l/revolt-coalesced) |
| `delta` | [crates/delta](crates/delta) | REST API server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `bonfire` | [crates/bonfire](crates/bonfire) | WebSocket events server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/january` | [crates/services/january](crates/services/january) | Proxy server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/gifbox` | [crates/services/gifbox](crates/services/gifbox) | Tenor proxy server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/autumn` | [crates/services/autumn](crates/services/autumn) | File server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `daemons/crond` | [crates/daemons/crond](crates/daemons/crond) | Timed data clean up daemon server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `daemons/pushd` | [crates/daemons/pushd](crates/daemons/pushd) | Push notification daemon server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
@@ -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

View File

@@ -0,0 +1,22 @@
[package]
name = "revolt-coalesced"
version = "0.8.8"
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"] }

View 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.

View 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)
}
}
}

View 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 {}

View 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;

View 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())
}
}

View File

@@ -56,6 +56,8 @@ voso_legacy_token = ""
trust_cloudflare = false
# easypwned endpoint
easypwned = ""
# Tenor API Key
tenor_key = ""
[api.security.captcha]
# hCaptcha configuration
@@ -277,3 +279,4 @@ files = ""
proxy = ""
pushd = ""
crond = ""
gifbox = ""

View File

@@ -190,6 +190,7 @@ pub struct ApiSecurity {
pub captcha: ApiSecurityCaptcha,
pub trust_cloudflare: bool,
pub easypwned: String,
pub tenor_key: String,
}
#[derive(Deserialize, Debug, Clone)]
@@ -365,6 +366,7 @@ pub struct Sentry {
pub proxy: String,
pub pushd: String,
pub crond: String,
pub gifbox: String,
}
#[derive(Deserialize, Debug, Clone)]

View File

@@ -1,21 +1,20 @@
use axum::{
extract::{FromRef, FromRequestParts},
http::request::Parts,
};
use axum::{extract::{FromRef, FromRequestParts}, http::request::Parts};
use revolt_result::{create_error, Error, Result};
use crate::{Database, 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

View File

@@ -0,0 +1,47 @@
[package]
name = "revolt-gifbox"
version = "0.8.8"
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.8", path = "../../core/config" }
revolt-models = { version = "0.8.8", path = "../../core/models" }
revolt-result = { version = "0.8.8", path = "../../core/result", features = [
"utoipa",
"axum",
] }
revolt-coalesced = { version = "0.8.8", path = "../../core/coalesced", features = [
"queue",
] }
revolt-database = { version = "0.8.8", path = "../../core/database", features = [
"axum-impl",
] }
revolt-ratelimits = { version = "0.8.8", 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"

View File

@@ -0,0 +1,10 @@
# Build Stage
FROM ghcr.io/revoltchat/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"]

View 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
}

View 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"),
}
}
}

View 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(&params.locale)
.await
.map_err(|_| create_error!(InternalError))
.map(|results| {
(*results)
.clone()
.tags
.into_iter()
.map(|cat| cat.into())
.collect()
})
.map(Json)
}

View 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))
}

View 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,
})
}

View 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(
&params.query,
&params.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)
}

View 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(
&params.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)
}

View 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()
}
}

View 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,
}

View 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,
}
}
}

View File

@@ -32,8 +32,10 @@ deps() {
crates/core/permissions/src \
crates/core/presence/src \
crates/core/result/src \
crates/core/coalesced/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 +43,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 +54,8 @@ 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
if [ -z "$TARGETARCH" ]; then
cargo build -j 10 --locked --release
@@ -72,7 +76,8 @@ 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
if [ -z "$TARGETARCH" ]; then
cargo build -j 10 --locked --release

View File

@@ -25,6 +25,7 @@ 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/gifbox:$TAG - < crates/services/gifbox/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
@@ -36,5 +37,6 @@ 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/gifbox:$TAG
docker push ghcr.io/revoltchat/crond:$TAG
docker push ghcr.io/revoltchat/pushd:$TAG

View File

@@ -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