Compare commits

...

11 commits

Author SHA1 Message Date
860fa7a66e 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
2025-05-23 23:06:21 +00:00
97072d54d1 feat: user avatars 2025-05-23 20:33:58 +02:00
d6364a0dc0 feat: add debug error printing
Got a random error message while coding (still have no idea what sent it), this will let you run the code with debug logging if you arent sure where errors are coming from
2025-05-23 20:33:42 +02:00
81f7527c79 feat: move image check to utils.rs 2025-05-23 20:32:43 +02:00
149b81973d Merge branch 'main' into wip/images 2025-05-23 13:45:17 +02:00
f655ced060 Merge branch 'main' into wip/images 2025-05-20 22:53:13 +02:00
85f6db499f fix: use patch request for updating user 2025-05-20 22:20:45 +02:00
4124b08bb2 style: change function name 2025-05-20 22:20:32 +02:00
b66c8f0613 feat: implement proper user and me structs 2025-05-20 18:04:44 +02:00
cee1b41e89 feat: implement server icons! 2025-05-20 14:54:47 +02:00
cf333b4eba feat: add bunny-api-tokio 2025-05-20 14:54:34 +02:00
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))
}
Ok(HttpResponse::Ok().json(user.unwrap()))
#[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?;
}
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,