full backend rewrite.

calling this v0.4.0
This commit is contained in:
2026-04-06 00:57:23 +01:00
parent a2f7f5a505
commit bda1ef251a
55 changed files with 2945 additions and 1464 deletions
+134
View File
@@ -0,0 +1,134 @@
use crate::error::ApiResult;
use crate::model::auth::{AccessTokenForm, AuthResponse, LoginCredentials, SignupCredentials};
use crate::svc::auth_svc::AuthService;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use rocket::http::Status;
use rocket::request::{FromRequest, Outcome};
use rocket::serde::json::Json;
use rocket::serde::{Deserialize, Serialize};
use rocket::{Request, State};
use std::sync::LazyLock;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::svc::access_token_svc::AccessTokenService;
#[post("/signup", data = "<cred>")]
pub async fn signup(
cred: Json<SignupCredentials>,
svc: &State<AuthService>
) -> ApiResult<Json<AuthResponse>> {
let response = svc
.signup(
&cred.email, &cred.username, &cred.password, &cred.access_token,
).await?;
Ok(Json(response))
}
#[post("/login", data = "<cred>")]
pub async fn login(
cred: Json<LoginCredentials>,
svc: &State<AuthService>
) -> ApiResult<Json<AuthResponse>> {
Ok(Json(svc.login(&cred.username, &cred.password).await?))
}
#[post("/invite", data = "<form>")]
pub async fn generate_invite(
session: Session,
form: Json<AccessTokenForm>,
svc: &State<AccessTokenService>
) -> ApiResult<String> {
svc.create(
session.uid, &form.name, form.max_uses,
form.start_date, form.expiry_date).await
}
static JWT_SECRET: LazyLock<String> = LazyLock::new(|| std::env::var("JWT_SECRET").unwrap());
#[derive(Serialize, Deserialize, PartialEq, Debug, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum TokenScope {
Full,
TotpPending,
}
pub struct Session {
pub uid: i64,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Session {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match Claims::from_request(req).await {
Outcome::Success(user) if user.scope == TokenScope::Full => Outcome::Success(Session {
uid: user.sub as i64,
}),
Outcome::Success(_) => {
eprintln!("warning: user with scope other than Full attempted to access session");
Outcome::Error((Status::Forbidden, ()))
}
Outcome::Error(err) => {
eprintln!("Session request guard failed: {:?}", err);
Outcome::Error(err)
}
_ => unreachable!("forward should never be called"),
}
}
}
#[derive(Serialize, Deserialize)]
pub struct Claims {
pub sub: i32,
pub exp: usize,
pub scope: TokenScope,
}
impl Claims {
pub fn new(user_id: usize, scope: TokenScope) -> Self {
Self {
sub: user_id as i32,
exp: (SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
+ 3600) as usize,
scope,
}
}
pub fn encode(&self) -> String {
encode(
&Header::default(),
self,
&EncodingKey::from_secret(JWT_SECRET.as_bytes()),
)
.expect("unable to encode jwt")
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Claims {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
let token = req
.headers()
.get_one("Authorization")
.and_then(|v| v.strip_prefix("Bearer "));
match token {
None => Outcome::Error((Status::Unauthorized, ())),
Some(t) => {
match decode::<Claims>(
t,
&DecodingKey::from_secret(JWT_SECRET.as_bytes()),
&Validation::default(),
) {
Ok(data) => Outcome::Success(data.claims),
Err(_) => Outcome::Error((Status::Unauthorized, ())),
}
}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
use std::{
fs,
path::{Path, PathBuf},
};
use image::{ImageFormat, imageops::FilterType};
use rocket::{
form::Form,
fs::{NamedFile, TempFile},
http::Status,
serde::json::Json,
};
use serde::Serialize;
use tokio::io::AsyncReadExt;
pub fn routes() -> Vec<rocket::Route> {
routes![profile_pic, general]
}
#[get("/profile/<user_id>")]
pub async fn profile_pic(user_id: usize) -> Option<NamedFile> {
if let Ok(image) =
NamedFile::open(Path::new("./cdn/profiles/full/").join(format!("{}.jpg", user_id))).await
{
Some(image)
} else {
Some(
NamedFile::open("../../cdn/profiles/full/default.svg")
.await
.ok()?,
)
}
}
#[get("/<file_name..>")]
pub async fn general(file_name: PathBuf) -> Option<NamedFile> {
NamedFile::open(Path::new("./cdn/").join(file_name))
.await
.ok()
}
#[derive(Serialize)]
pub struct UploadResponse {
success: bool,
message: String,
url: Option<String>,
}
// Upload endpoint - handles image upload and creates multiple sizes
#[post("/profile/<user_id>/upload", data = "<file>")]
pub async fn upload_profile_pic(
user_id: usize,
file: Form<TempFile<'_>>,
) -> Result<Json<UploadResponse>, Status> {
const MAX_FILE_SIZE: u64 = 5 * 1024 * 1024;
if file.len() > MAX_FILE_SIZE {
return Ok(Json(UploadResponse {
success: false,
message: "File size exceeds 5MB limit".to_string(),
url: None,
}));
}
// Read file contents into buffer
let mut buffer = Vec::new();
file.open()
.await
.map_err(|e| {
eprintln!("Failed to open file: {}", e);
Status::BadRequest
})?
.read_to_end(&mut buffer)
.await
.map_err(|e| {
eprintln!("Failed to read file: {}", e);
Status::BadRequest
})?;
// Check if buffer is empty
if buffer.is_empty() {
return Ok(Json(UploadResponse {
success: false,
message: "Uploaded file is empty".to_string(),
url: None,
}));
}
// Validate format from buffer
let format = image::guess_format(&buffer).map_err(|e| {
eprintln!("Failed to guess format: {}", e);
Status::BadRequest
})?;
// Only allow specific image formats
let allowed_formats = [
ImageFormat::Jpeg,
ImageFormat::Png,
ImageFormat::WebP,
ImageFormat::Gif,
];
if !allowed_formats.contains(&format) {
return Ok(Json(UploadResponse {
success: false,
message: "Unsupported image format. Only JPEG, PNG, WebP, and GIF are allowed."
.to_string(),
url: None,
}));
}
// Decode and validate the image before persisting anything
let img = image::load_from_memory(&buffer).map_err(|e| {
eprintln!("Image decode error: {}", e);
Status::BadRequest
})?;
// Now that we know it's valid, ensure directories exist
let base_path = Path::new("./cdn/profiles");
fs::create_dir_all(base_path.join("thumb")).map_err(|_| Status::InternalServerError)?;
fs::create_dir_all(base_path.join("full")).map_err(|_| Status::InternalServerError)?;
// Create thumbnail (64x64) for chat lists
let thumb = img.resize_to_fill(64, 64, FilterType::Lanczos3);
let thumb_path = base_path.join("thumb").join(format!("{}.jpg", user_id));
thumb
.save_with_format(&thumb_path, ImageFormat::Jpeg)
.map_err(|_| Status::InternalServerError)?;
// Create full size (256x256) for profile views
let full = img.resize_to_fill(256, 256, FilterType::Lanczos3);
let full_path = base_path.join("full").join(format!("{}.jpg", user_id));
full.save_with_format(&full_path, ImageFormat::Jpeg)
.map_err(|_| Status::InternalServerError)?;
Ok(Json(UploadResponse {
success: true,
message: "Profile picture uploaded successfully".to_string(),
url: Some(format!("/cdn/profile/{}", user_id)),
}))
}
+70
View File
@@ -0,0 +1,70 @@
use crate::api::auth::Session;
use crate::error::ApiResult;
use crate::svc::chat_svc::ChatService;
use chrono::{DateTime, Utc};
use rocket::response::stream::Event;
use rocket::serde::json::Json;
use rocket::serde::{Deserialize, Serialize};
use rocket::{Shutdown, State, ___internal_EventStream as EventStream};
use sqlx::FromRow;
use tokio::select;
use tokio::sync::broadcast;
/// ---------- Rocket routes ----------
#[derive(Debug, Serialize, Deserialize, Clone, FromRow)]
pub struct ChatMsg {
pub display_name: Option<String>,
pub user_id: i64,
pub text: String,
pub timestamp: DateTime<Utc>,
}
#[post("/chat/<channel_id>", format = "json", data = "<msg>")]
pub async fn post_message(
msg: Json<ChatMsg>,
chat: &State<ChatService>,
session: Session,
channel_id: i64,
) -> ApiResult<()> {
chat.send(channel_id, session.uid, &msg.text, Utc::now()).await
}
#[get("/events/<channel_id>")]
pub async fn event_stream(
chat: &State<ChatService>,
s: Session,
mut shutdown: Shutdown,
channel_id: i64,
) -> ApiResult<EventStream![]> {
let messages = chat.get_messages(channel_id, 100)
.await?; // if get message returned err, inform user.
let mut rx = chat.subscribe(channel_id).await;
let id = s.uid;
Ok(EventStream! {
for msg in messages {
yield Event::json(&msg);
}
loop {
select!{
_ = &mut shutdown => break, // exit early on shutdown
msg = rx.recv() => match msg {
Ok(msg) => {
tracing::info!("yielding message!");
yield Event::json(&msg)
},
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("Receiver lagging on channel {channel_id} by {n} events",);
yield Event::comment("RecvError::Lagged");
}
Err(broadcast::error::RecvError::Closed) => {
tracing::info!("Broadcaster hung up on channel {channel_id}!");
break
},
},
}
}
})
}
+7
View File
@@ -0,0 +1,7 @@
pub mod auth;
pub mod chat;
pub mod totp;
pub mod settings;
pub mod cdn;
pub mod profile;
pub mod space;
+13
View File
@@ -0,0 +1,13 @@
use rocket::State;
use crate::api::auth::Session;
use crate::error::ApiResult;
use crate::svc::user_svc::UserService;
#[get("/users/<id>")]
pub async fn display_name(
id: i64,
_ag: Session,
svc: &State<UserService>,
) -> ApiResult<String> {
svc.get_username(id).await
}
+68
View File
@@ -0,0 +1,68 @@
use crate::api::auth::Session;
use crate::error::ApiResult;
use crate::svc::settings_svc::SettingsService;
use rocket::serde::json::Json;
use rocket::serde::{Deserialize, Serialize};
use rocket::State;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PasswordForm {
pub old_password: String,
pub new_password: String,
}
#[post("/settings/password", data = "<form>")]
pub async fn change_password(
session: Session,
form: Json<PasswordForm>,
settings: &State<SettingsService>
) -> ApiResult<()> {
settings.change_password(
session.uid, &form.old_password, &form.new_password
).await
}
#[derive(Deserialize, Debug, Clone)]
pub struct DisplayNameForm {
pub display_name: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PasswordAnd2faForm {
pub password: String,
pub totp_code: Option<String>,
}
#[delete("/settings", data = "<data>")]
pub async fn delete_account(
session: Session,
data: Json<PasswordAnd2faForm>,
settings: &State<SettingsService>
) -> ApiResult<()> {
settings.delete_account(
session.uid, &data.password, &data.totp_code
).await
}
#[patch("/settings/display_name", data = "<new>")]
pub async fn change_display_name(
session: Session,
new: Json<DisplayNameForm>,
settings: &State<SettingsService>
) -> ApiResult<()> {
settings.change_display_name(session.uid, new.display_name.clone()).await
}
#[derive(Deserialize)]
pub struct UsernameForm {
pub username: String,
}
#[patch("/settings/username", data = "<new>")]
pub async fn change_username(
session: Session,
new: Json<UsernameForm>,
settings: &State<SettingsService>
) -> ApiResult<()> {
settings.change_username(session.uid, &new.username).await
}
+36
View File
@@ -0,0 +1,36 @@
use crate::error::ApiResult;
use crate::model::space::{Space, SpaceDto};
use crate::model::space::Channel;
use crate::repo::{SpaceRepo, ChannelRepo};
use rocket::serde::json::Json;
use rocket::State;
use std::sync::Arc;
use crate::api::auth::Session;
use crate::svc::chat_svc::ChatService;
#[get("/spaces")]
pub async fn list_spaces(
space_repo: &State<Arc<dyn SpaceRepo>>
) -> ApiResult<Json<Vec<Space>>> {
let spaces = space_repo.get_all().await?;
Ok(Json(spaces))
}
#[get("/spaces/<space_id>/channels")]
pub async fn list_channels(
space_id: i64,
channel_repo: &State<Arc<dyn ChannelRepo>>
) -> ApiResult<Json<Vec<Channel>>> {
let channels = channel_repo.get_by_space_id(space_id).await?;
Ok(Json(channels))
}
#[get("/accessible_channels")]
pub async fn get_accessible_channels(
session: Session,
svc: &State<ChatService>
) -> ApiResult<Json<Vec<SpaceDto>>> {
let space = svc.get_accessible_channels(session.uid).await?;
println!("{:?}", space);
Ok(Json(space))
}
+120
View File
@@ -0,0 +1,120 @@
use crate::api::auth::{Claims, Session, TokenScope};
use crate::error::{ApiResult, AppError};
use crate::model::auth::AuthResponse;
use crate::svc::auth_svc::AuthService;
use rocket::serde::json::Json;
use rocket::serde::{Deserialize, Serialize};
use rocket::State;
use totp_rs::{Algorithm, TOTP};
#[derive(Debug, Deserialize)]
pub struct TOTPSixDigitCode {
code: String,
}
#[derive(Debug, sqlx::Type, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
#[sqlx(type_name = "totp_status", rename_all = "lowercase")]
pub enum TotpStatus {
Enabled,
Pending,
Disabled,
}
#[derive(Serialize)]
pub struct QrResponse {
qr_code: String,
}
#[derive(Deserialize)]
pub struct TotpVerifyRequest {
pub code: String,
}
#[derive(Deserialize)]
pub struct PasswordConfirmation {
password: String,
}
#[derive(Deserialize)]
pub struct PasswordAnd2fa {
pub password: String,
pub totp_code: String,
}
pub fn totp_gen(user_id: i64, secret: &[u8]) -> ApiResult<TOTP> {
TOTP::new(
Algorithm::SHA1,
6,
1,
30,
secret.to_owned(),
Some("chat.zxq5.dev".to_string()),
format!("{}", user_id),
)
.map_err(|_| AppError::internal("failed to generate totp"))
}
#[post("/totp", data = "<form>")]
pub async fn confirm_totp(
user: Session,
form: Json<TOTPSixDigitCode>,
svc: &State<AuthService>,
) -> ApiResult<()> {
svc.confirm_totp(user.uid, &form.code).await
}
#[post("/totp.jpg", data = "<form>")]
pub async fn get_totp(
user: Session,
form: Json<PasswordConfirmation>,
svc: &State<AuthService>,
) -> ApiResult<Json<QrResponse>> {
let secret = svc.get_or_create_totp_secret(user.uid, &form.password).await?;
let qr_b64 = totp_gen(user.uid, secret.as_bytes())
.map_err(|_| AppError::internal("invalid totp secret"))?
.get_qr_base64()
.map_err(|_| AppError::internal("failed to generate qr code"))?;
Ok(Json(QrResponse {
qr_code: format!("data:image/png;base64,{}", qr_b64),
}))
}
#[get("/totp/status")]
pub async fn get_totp_status(
user: Session,
svc: &State<AuthService>,
) -> ApiResult<Json<TotpStatus>> {
Ok(Json(
svc.get_totp_status(user.uid).await?
.then_some(TotpStatus::Enabled)
.unwrap_or(TotpStatus::Disabled),
))
}
#[delete("/totp", data = "<form>")]
pub async fn disable_totp(
user: Session,
form: Json<PasswordAnd2fa>,
svc: &State<AuthService>,
) -> ApiResult<Json<AuthResponse>> {
let response = svc.disable_totp(user.uid, &form.password, &form.totp_code).await?;
Ok(Json(response))
}
#[post("/totp/verify", data = "<body>")]
pub async fn verify_totp(
claims: Claims,
body: Json<TotpVerifyRequest>,
svc: &State<AuthService>,
) -> ApiResult<Json<AuthResponse>> {
// reject if they somehow got here with a full token
if claims.scope != TokenScope::TotpPending {
return Err(AppError::Forbidden);
}
let response = svc.login_totp(claims.sub as i64, &body.code).await?;
Ok(Json(response))
}