full backend rewrite.
calling this v0.4.0
This commit is contained in:
@@ -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, ())),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
}))
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user