Merge pull request 'feat: Bunny CDN integration for images' (#17) from wip/images into main
Some checks failed
ci/woodpecker/push/build-and-publish Pipeline failed

Reviewed-on: #17
This commit is contained in:
Radical 2025-05-23 23:06:21 +00:00
commit 860fa7a66e
16 changed files with 334 additions and 86 deletions

View file

@ -29,11 +29,14 @@ uuid = { version = "1.16", features = ["serde", "v7"] }
random-string = "1.1"
actix-ws = "0.3.0"
futures-util = "0.3.31"
bunny-api-tokio = "0.2.1"
bindet = "0.3.2"
deadpool = "0.12"
diesel = { version = "2.2", features = ["uuid"] }
diesel-async = { version = "0.5", features = ["deadpool", "postgres", "async-connection-wrapper"] }
diesel_migrations = { version = "2.2.0", features = ["postgres"] }
thiserror = "2.0.12"
actix-multipart = "0.7.2"
[dependencies.tokio]
version = "1.44"

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE guilds DROP COLUMN icon;

View file

@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE guilds ADD COLUMN icon VARCHAR(100) DEFAULT NULL;

View file

@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
ALTER TABLE users DROP COLUMN avatar;

View file

@ -0,0 +1,2 @@
-- Your SQL goes here
ALTER TABLE users ADD COLUMN avatar varchar(100) DEFAULT NULL;

View file

@ -0,0 +1,36 @@
use actix_web::{put, web, HttpRequest, HttpResponse};
use uuid::Uuid;
use futures_util::StreamExt as _;
use crate::{error::Error, api::v1::auth::check_access_token, structs::{Guild, Member}, utils::get_auth_header, Data};
#[put("{uuid}/icon")]
pub async fn upload(
req: HttpRequest,
path: web::Path<(Uuid,)>,
mut payload: web::Payload,
data: web::Data<Data>,
) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let guild_uuid = path.into_inner().0;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
Member::fetch_one(&mut conn, uuid, guild_uuid).await?;
let mut guild = Guild::fetch_one(&mut conn, guild_uuid).await?;
let mut bytes = web::BytesMut::new();
while let Some(item) = payload.next().await {
bytes.extend_from_slice(&item?);
}
guild.set_icon(&data.bunny_cdn, &mut conn, data.config.bunny.cdn_url.clone(), bytes).await?;
Ok(HttpResponse::Ok().finish())
}

View file

@ -4,6 +4,7 @@ use uuid::Uuid;
mod channels;
mod invites;
mod roles;
mod icon;
use crate::{
error::Error,
@ -31,6 +32,8 @@ pub fn web() -> Scope {
// Invites
.service(invites::get)
.service(invites::create)
// Icon
.service(icon::upload)
}
#[get("/{uuid}")]

View file

@ -1,20 +1,8 @@
use actix_web::{HttpRequest, HttpResponse, get, web};
use diesel::{prelude::Queryable, ExpressionMethods, QueryDsl, Selectable, SelectableHelper};
use diesel_async::RunQueryDsl;
use log::error;
use serde::Serialize;
use uuid::Uuid;
use actix_web::{get, patch, web, HttpRequest, HttpResponse};
use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm};
use serde::Deserialize;
use crate::{error::Error, api::v1::auth::check_access_token, schema::users::{self, dsl}, utils::get_auth_header, Data};
#[derive(Serialize, Queryable, Selectable)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct Response {
uuid: Uuid,
username: String,
display_name: Option<String>,
}
use crate::{error::Error, structs::Me, api::v1::auth::check_access_token, utils::get_auth_header, Data};
#[get("/me")]
pub async fn res(req: HttpRequest, data: web::Data<Data>) -> Result<HttpResponse, Error> {
@ -26,16 +14,63 @@ pub async fn res(req: HttpRequest, data: web::Data<Data>) -> Result<HttpResponse
let uuid = check_access_token(auth_header, &mut conn).await?;
let user: Result<Response, diesel::result::Error> = dsl::users
.filter(dsl::uuid.eq(uuid))
.select(Response::as_select())
.get_result(&mut conn)
.await;
let me = Me::get(&mut conn, uuid).await?;
if let Err(error) = user {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish())
Ok(HttpResponse::Ok().json(me))
}
#[derive(Debug, Deserialize)]
struct NewInfo {
username: Option<String>,
display_name: Option<String>,
password: Option<String>,
email: Option<String>,
}
#[derive(Debug, MultipartForm)]
struct UploadForm {
#[multipart(limit = "100MB")]
avatar: Option<TempFile>,
json: Option<MpJson<NewInfo>>,
}
#[patch("/me")]
pub async fn update(req: HttpRequest, MultipartForm(form): MultipartForm<UploadForm>, data: web::Data<Data>) -> Result<HttpResponse, Error> {
let headers = req.headers();
let auth_header = get_auth_header(headers)?;
let mut conn = data.pool.get().await?;
let uuid = check_access_token(auth_header, &mut conn).await?;
let mut me = Me::get(&mut conn, uuid).await?;
if let Some(avatar) = form.avatar {
let bytes = tokio::fs::read(avatar.file).await?;
let byte_slice: &[u8] = &bytes;
me.set_avatar(&data.bunny_cdn, &mut conn, data.config.bunny.cdn_url.clone(), byte_slice.into()).await?;
}
Ok(HttpResponse::Ok().json(user.unwrap()))
if let Some(new_info) = form.json {
if let Some(username) = &new_info.username {
todo!();
}
if let Some(display_name) = &new_info.display_name {
todo!();
}
if let Some(password) = &new_info.password {
todo!();
}
if let Some(email) = &new_info.email {
todo!();
}
}
Ok(HttpResponse::Ok().finish())
}

View file

@ -1,28 +1,15 @@
use actix_web::{HttpRequest, HttpResponse, Scope, get, web};
use diesel::{prelude::Queryable, QueryDsl, Selectable, SelectableHelper};
use diesel_async::RunQueryDsl;
use serde::Serialize;
use ::uuid::Uuid;
use crate::{error::Error,api::v1::auth::check_access_token, schema::users::{self, dsl}, structs::StartAmountQuery, utils::get_auth_header, Data};
use crate::{api::v1::auth::check_access_token, error::Error, structs::{StartAmountQuery, User}, utils::get_auth_header, Data};
mod me;
mod uuid;
#[derive(Serialize, Queryable, Selectable)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct Response {
uuid: Uuid,
username: String,
display_name: Option<String>,
email: String,
}
pub fn web() -> Scope {
web::scope("/users")
.service(res)
.service(me::res)
.service(me::update)
.service(uuid::res)
}
@ -48,13 +35,7 @@ pub async fn res(
check_access_token(auth_header, &mut conn).await?;
let users: Vec<Response> = dsl::users
.order_by(dsl::username)
.offset(start)
.limit(amount)
.select(Response::as_select())
.load(&mut conn)
.await?;
let users = User::fetch_amount(&mut conn, start, amount).await?;
Ok(HttpResponse::Ok().json(users))
}

View file

@ -1,20 +1,8 @@
use actix_web::{HttpRequest, HttpResponse, get, web};
use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper};
use diesel_async::RunQueryDsl;
use log::error;
use serde::Serialize;
use uuid::Uuid;
use crate::{error::Error, api::v1::auth::check_access_token, schema::users::{self, dsl}, utils::get_auth_header, Data};
use crate::{error::Error, api::v1::auth::check_access_token, structs::User, utils::get_auth_header, Data};
#[derive(Serialize, Queryable, Selectable, Clone)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::pg::Pg))]
struct Response {
uuid: Uuid,
username: String,
display_name: Option<String>,
}
#[get("/{uuid}")]
pub async fn res(
@ -32,28 +20,17 @@ pub async fn res(
check_access_token(auth_header, &mut conn).await?;
let cache_result = data.get_cache_key(uuid.to_string()).await;
if let Ok(cache_hit) = cache_result {
if let Ok(cache_hit) = data.get_cache_key(uuid.to_string()).await {
return Ok(HttpResponse::Ok()
.content_type("application/json")
.body(cache_hit));
}
let user: Response = dsl::users
.filter(dsl::uuid.eq(uuid))
.select(Response::as_select())
.get_result(&mut conn)
.await?;
let user = User::fetch_one(&mut conn, uuid).await?;
let cache_result = data
data
.set_cache_key(uuid.to_string(), user.clone(), 1800)
.await;
if let Err(error) = cache_result {
error!("{}", error);
return Ok(HttpResponse::InternalServerError().finish());
}
.await?;
Ok(HttpResponse::Ok().json(user))
}

View file

@ -1,13 +1,16 @@
use bunny_api_tokio::edge_storage::Endpoint;
use crate::error::Error;
use log::debug;
use serde::Deserialize;
use tokio::fs::read_to_string;
use url::Url;
#[derive(Debug, Deserialize)]
pub struct ConfigBuilder {
database: Database,
cache_database: CacheDatabase,
web: Option<WebBuilder>,
bunny: BunnyBuilder,
}
#[derive(Debug, Deserialize, Clone)]
@ -35,6 +38,14 @@ struct WebBuilder {
_ssl: Option<bool>,
}
#[derive(Debug, Deserialize)]
struct BunnyBuilder {
api_key: String,
endpoint: String,
storage_zone: String,
cdn_url: Url,
}
impl ConfigBuilder {
pub async fn load(path: String) -> Result<Self, Error> {
debug!("loading config from: {}", path);
@ -58,10 +69,31 @@ impl ConfigBuilder {
}
};
let endpoint = match &*self.bunny.endpoint {
"Frankfurt" => Endpoint::Frankfurt,
"London" => Endpoint::London,
"New York" => Endpoint::NewYork,
"Los Angeles" => Endpoint::LosAngeles,
"Singapore" => Endpoint::Singapore,
"Stockholm" => Endpoint::Stockholm,
"Sao Paulo" => Endpoint::SaoPaulo,
"Johannesburg" => Endpoint::Johannesburg,
"Sydney" => Endpoint::Sydney,
url => Endpoint::Custom(url.to_string()),
};
let bunny = Bunny {
api_key: self.bunny.api_key,
endpoint,
storage_zone: self.bunny.storage_zone,
cdn_url: self.bunny.cdn_url,
};
Config {
database: self.database,
cache_database: self.cache_database,
web,
bunny,
}
}
}
@ -71,6 +103,7 @@ pub struct Config {
pub database: Database,
pub cache_database: CacheDatabase,
pub web: Web,
pub bunny: Bunny,
}
#[derive(Debug, Clone)]
@ -79,6 +112,14 @@ pub struct Web {
pub port: u16,
}
#[derive(Debug, Clone)]
pub struct Bunny {
pub api_key: String,
pub endpoint: Endpoint,
pub storage_zone: String,
pub cdn_url: Url,
}
impl Database {
pub fn url(&self) -> String {
let mut url = String::from("postgres://");

View file

@ -1,6 +1,6 @@
use std::{io, time::SystemTimeError};
use actix_web::{error::ResponseError, http::{header::{ContentType, ToStrError}, StatusCode}, HttpResponse};
use actix_web::{error::{PayloadError, ResponseError}, http::{header::{ContentType, ToStrError}, StatusCode}, HttpResponse};
use deadpool::managed::{BuildError, PoolError};
use redis::RedisError;
use serde::Serialize;
@ -10,7 +10,7 @@ use diesel_async::pooled_connection::PoolError as DieselPoolError;
use tokio::task::JoinError;
use serde_json::Error as JsonError;
use toml::de::Error as TomlError;
use log::error;
use log::{debug, error};
#[derive(Debug, Error)]
pub enum Error {
@ -38,6 +38,12 @@ pub enum Error {
ToStrError(#[from] ToStrError),
#[error(transparent)]
RandomError(#[from] getrandom::Error),
#[error(transparent)]
BunnyError(#[from] bunny_api_tokio::error::Error),
#[error(transparent)]
UrlParseError(#[from] url::ParseError),
#[error(transparent)]
PayloadError(#[from] PayloadError),
#[error("{0}")]
PasswordHashError(String),
#[error("{0}")]
@ -48,6 +54,7 @@ pub enum Error {
impl ResponseError for Error {
fn error_response(&self) -> HttpResponse {
debug!("{:?}", self);
error!("{}: {}", self.status_code(), self.to_string());
HttpResponse::build(self.status_code())

View file

@ -32,9 +32,10 @@ struct Args {
pub struct Data {
pub pool: deadpool::managed::Pool<AsyncDieselConnectionManager<diesel_async::AsyncPgConnection>, Conn>,
pub cache_pool: redis::Client,
pub _config: Config,
pub config: Config,
pub argon2: Argon2<'static>,
pub start_time: SystemTime,
pub bunny_cdn: bunny_api_tokio::Client,
}
#[tokio::main]
@ -57,6 +58,10 @@ async fn main() -> Result<(), Error> {
let cache_pool = redis::Client::open(config.cache_database.url())?;
let mut bunny_cdn = bunny_api_tokio::Client::new(config.bunny.api_key.clone()).await?;
bunny_cdn.storage.init(config.bunny.endpoint.clone(), config.bunny.storage_zone.clone())?;
let database_url = config.database.url();
tokio::task::spawn_blocking(move || {
@ -90,10 +95,11 @@ async fn main() -> Result<(), Error> {
let data = Data {
pool,
cache_pool,
_config: config,
config,
// TODO: Possibly implement "pepper" into this (thinking it could generate one if it doesnt exist and store it on disk)
argon2: Argon2::default(),
start_time: SystemTime::now(),
bunny_cdn,
};
HttpServer::new(move || {

View file

@ -48,6 +48,8 @@ diesel::table! {
name -> Varchar,
#[max_length = 300]
description -> Nullable<Varchar>,
#[max_length = 100]
icon -> Nullable<Varchar>,
}
}
@ -121,6 +123,8 @@ diesel::table! {
email_verified -> Bool,
is_deleted -> Bool,
deleted_at -> Nullable<Int8>,
#[max_length = 100]
avatar -> Nullable<Varchar>,
}
}

View file

@ -1,9 +1,12 @@
use diesel::{delete, insert_into, prelude::{Insertable, Queryable}, ExpressionMethods, QueryDsl, Selectable, SelectableHelper};
use diesel::{delete, insert_into, prelude::{Insertable, Queryable}, update, ExpressionMethods, QueryDsl, Selectable, SelectableHelper};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use diesel_async::{pooled_connection::AsyncDieselConnectionManager, RunQueryDsl};
use tokio::task;
use url::Url;
use actix_web::web::BytesMut;
use crate::{error::Error, Conn, Data, schema::*};
use crate::{error::Error, schema::*, utils::image_check, Conn, Data};
fn load_or_empty<T>(query_result: Result<Vec<T>, diesel::result::Error>) -> Result<Vec<T>, diesel::result::Error> {
match query_result {
@ -238,6 +241,7 @@ struct GuildBuilder {
uuid: Uuid,
name: String,
description: Option<String>,
icon: Option<String>,
owner_uuid: Uuid,
}
@ -251,7 +255,7 @@ impl GuildBuilder {
uuid: self.uuid,
name: self.name,
description: self.description,
icon: String::from("bogus"),
icon: self.icon.and_then(|i| i.parse().ok()),
owner_uuid: self.owner_uuid,
roles: roles,
member_count: member_count,
@ -264,7 +268,7 @@ pub struct Guild {
pub uuid: Uuid,
name: String,
description: Option<String>,
icon: String,
icon: Option<Url>,
owner_uuid: Uuid,
pub roles: Vec<Role>,
member_count: i64,
@ -323,6 +327,7 @@ impl Guild {
uuid: guild_uuid,
name: name.clone(),
description: description.clone(),
icon: None,
owner_uuid,
};
@ -349,7 +354,7 @@ impl Guild {
uuid: guild_uuid,
name,
description,
icon: "bogus".to_string(),
icon: None,
owner_uuid,
roles: vec![],
member_count: 1,
@ -401,6 +406,37 @@ impl Guild {
Ok(invite)
}
// FIXME: Horrible security
pub async fn set_icon(&mut self, bunny_cdn: &bunny_api_tokio::Client, conn: &mut Conn, cdn_url: Url, icon: BytesMut) -> Result<(), Error> {
let icon_clone = icon.clone();
let image_type = task::spawn_blocking(move || image_check(icon_clone)).await??;
if let Some(icon) = &self.icon {
let relative_url = icon
.path()
.trim_start_matches('/');
bunny_cdn.storage.delete(relative_url).await?;
}
let path = format!("icons/{}/icon.{}", self.uuid, image_type);
bunny_cdn.storage.upload(path.clone(), icon.into()).await?;
let icon_url = cdn_url.join(&path)?;
use guilds::dsl;
update(guilds::table)
.filter(dsl::uuid.eq(self.uuid))
.set(dsl::icon.eq(icon_url.as_str()))
.execute(conn)
.await?;
self.icon = Some(icon_url);
Ok(())
}
}
#[derive(Serialize, Clone, Queryable, Selectable, Insertable)]
@ -571,6 +607,100 @@ impl Invite {
}
}
#[derive(Serialize, Clone, Queryable, Selectable)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct User {
uuid: Uuid,
username: String,
display_name: Option<String>,
avatar: Option<String>,
}
impl User {
pub async fn fetch_one(conn: &mut Conn, user_uuid: Uuid) -> Result<Self, Error> {
use users::dsl;
let user: User = dsl::users
.filter(dsl::uuid.eq(user_uuid))
.select(User::as_select())
.get_result(conn)
.await?;
Ok(user)
}
pub async fn fetch_amount(conn: &mut Conn, offset: i64, amount: i64) -> Result<Vec<Self>, Error> {
use users::dsl;
let users: Vec<User> = load_or_empty(
dsl::users
.limit(amount)
.offset(offset)
.select(User::as_select())
.load(conn)
.await
)?;
Ok(users)
}
}
#[derive(Serialize, Queryable, Selectable)]
#[diesel(table_name = users)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct Me {
uuid: Uuid,
username: String,
display_name: Option<String>,
avatar: Option<String>,
email: String,
email_verified: bool,
}
impl Me {
pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result<Self, Error> {
use users::dsl;
let me: Me = dsl::users
.filter(dsl::uuid.eq(user_uuid))
.select(Me::as_select())
.get_result(conn)
.await?;
Ok(me)
}
pub async fn set_avatar(&mut self, bunny_cdn: &bunny_api_tokio::Client, conn: &mut Conn, cdn_url: Url, avatar: BytesMut) -> Result<(), Error> {
let avatar_clone = avatar.clone();
let image_type = task::spawn_blocking(move || image_check(avatar_clone)).await??;
if let Some(avatar) = &self.avatar {
let avatar_url: Url = avatar.parse()?;
let relative_url = avatar_url
.path()
.trim_start_matches('/');
bunny_cdn.storage.delete(relative_url).await?;
}
let path = format!("avatar/{}/avatar.{}", self.uuid, image_type);
bunny_cdn.storage.upload(path.clone(), avatar.into()).await?;
let avatar_url = cdn_url.join(&path)?;
use users::dsl;
update(users::table)
.filter(dsl::uuid.eq(self.uuid))
.set(dsl::avatar.eq(avatar_url.as_str()))
.execute(conn)
.await?;
self.avatar = Some(avatar_url.to_string());
Ok(())
}
}
#[derive(Deserialize)]
pub struct StartAmountQuery {
pub start: Option<i64>,

View file

@ -1,7 +1,8 @@
use actix_web::{
cookie::{Cookie, SameSite, time::Duration},
http::header::HeaderMap,
cookie::{time::Duration, Cookie, SameSite},
http::header::HeaderMap, web::BytesMut,
};
use bindet::FileType;
use getrandom::fill;
use hex::encode;
use redis::RedisError;
@ -59,6 +60,22 @@ pub fn generate_refresh_token() -> Result<String, getrandom::Error> {
Ok(encode(buf))
}
pub fn image_check(icon: BytesMut) -> Result<String, Error> {
let buf = std::io::Cursor::new(icon);
let detect = bindet::detect(buf).map_err(|e| e.kind());
if let Ok(Some(file_type)) = detect {
if file_type.likely_to_be == vec![FileType::Jpg] {
return Ok(String::from("jpg"))
} else if file_type.likely_to_be == vec![FileType::Png] {
return Ok(String::from("png"))
}
}
Err(Error::BadRequest("Uploaded file is not an image".to_string()))
}
impl Data {
pub async fn set_cache_key(
&self,