From 9b895c98d6864389d2560273304750eb0e5b7f43 Mon Sep 17 00:00:00 2001 From: Mercurio <47455213+NotLugozzi@users.noreply.github.com> Date: Sat, 31 May 2025 18:18:24 +0200 Subject: [PATCH] feat: reimplement initial API structure with user authentication and file management --- api/.env | 6 ++ api/.env.example | 6 ++ api/cargo.toml | 27 +++++ api/schema.sql | 24 +++++ api/src/admin.rs | 97 +++++++++++++++++ api/src/auth.rs | 89 ++++++++++++++++ api/src/encryption.rs | 18 ++++ api/src/file.rs | 243 ++++++++++++++++++++++++++++++++++++++++++ api/src/main.rs | 68 ++++++++++++ api/src/models.rs | 32 ++++++ api/src/user.rs | 92 ++++++++++++++++ 11 files changed, 702 insertions(+) create mode 100644 api/.env create mode 100644 api/.env.example create mode 100644 api/cargo.toml create mode 100644 api/schema.sql create mode 100644 api/src/admin.rs create mode 100644 api/src/auth.rs create mode 100644 api/src/encryption.rs create mode 100644 api/src/file.rs create mode 100644 api/src/main.rs create mode 100644 api/src/models.rs create mode 100644 api/src/user.rs diff --git a/api/.env b/api/.env new file mode 100644 index 0000000..7cd99d5 --- /dev/null +++ b/api/.env @@ -0,0 +1,6 @@ +DATABASE_URL=postgres://postgres:password@192.168.1.120:6532/postgres +JWT_SECRET=supersecurejwtsecret +ENCRYPTION_KEY=0123456789abcdef0123456789abcdef +ROCKET_ADDRESS=0.0.0.0 +ROCKET_PORT=8082 +ROCKET_LOG=info diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..ed44208 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,6 @@ +DATABASE_URL=postgres://postgres:password@db:5432/litecloud +JWT_SECRET=supersecurejwtsecret +ENCRYPTION_KEY=0123456789abcdef0123456789abcdef +ROCKET_ADDRESS=0.0.0.0 +ROCKET_PORT=8080 +ROCKET_LOG=info diff --git a/api/cargo.toml b/api/cargo.toml new file mode 100644 index 0000000..ce4acaa --- /dev/null +++ b/api/cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "litecloud" +version = "0.1.0" +edition = "2024" + +[dependencies] +rocket = { version = "0.5.0-rc.3", features = ["json", "secrets", "tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde_derive = "1.0" +tokio = { version = "1.0", features = ["full"] } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "macros", "chrono"] } +dotenv = "0.15" +jsonwebtoken = "9.2" +uuid = { version = "1.4", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +base64 = "0.21" +rand = "0.8" +sha2 = "0.10" +aes-gcm = "0.10" +rocket_cors = "0.6" +lazy_static = "1.4" +password-hash = "0.5" +argon2 = "0.5" + +[build-dependencies] +dotenv = "0.15" diff --git a/api/schema.sql b/api/schema.sql new file mode 100644 index 0000000..88680d8 --- /dev/null +++ b/api/schema.sql @@ -0,0 +1,24 @@ +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + quota BIGINT DEFAULT 104857600 -- 100 MB default +); + +CREATE TABLE files ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + original_name TEXT NOT NULL, + storage_path TEXT NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + size BIGINT NOT NULL +); + +CREATE TABLE shares ( + id UUID PRIMARY KEY, + file_id INTEGER REFERENCES files(id) ON DELETE CASCADE, + shared_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ +); diff --git a/api/src/admin.rs b/api/src/admin.rs new file mode 100644 index 0000000..84c1e89 --- /dev/null +++ b/api/src/admin.rs @@ -0,0 +1,97 @@ +use rocket::{State, http::Status}; +use rocket::serde::{Deserialize, json::Json}; +use sqlx::PgPool; + +use crate::auth::AuthenticatedUser; +use crate::models::User; + +#[get("/users")] +pub async fn list_users(pool: &State, user: AuthenticatedUser) -> Result>, Status> { + if user.role != "admin" { + return Err(Status::Forbidden); + } + + let users = sqlx::query_as!( + User, + "SELECT id, role, quota, created_at, password_hash, name FROM users" + ) + .fetch_all(pool.inner()) + .await + .map_err(|_| Status::InternalServerError)?; // Fixed error handling + + Ok(Json(users)) +} + +#[derive(Deserialize)] +pub struct UpdateRole { + pub user_id: i32, + pub new_role: String, // e.g. "admin" or "user" +} + +#[post("/user/role", data = "")] +pub async fn update_role(pool: &State, user: AuthenticatedUser, data: Json) -> Status { + if user.role != "admin" { + return Status::Forbidden; + } + + if let Err(_) = sqlx::query!( + "UPDATE users SET role = $1 WHERE id = $2", + data.new_role, + data.user_id + ) + .execute(pool.inner()) + .await + { + return Status::InternalServerError; + } + + if let Err(_) = sqlx::query!( + "UPDATE users SET role = $1 WHERE id = $2", + data.new_role, + data.user_id + ) + .execute(pool.inner()) + .await + { + return Status::InternalServerError; + } + + Status::Ok +} + +#[derive(Deserialize)] +pub struct UpdateQuota { + pub user_id: i32, + pub quota: i64, // in bytes +} + +#[post("/user/quota", data = "")] +pub async fn update_quota(pool: &State, user: AuthenticatedUser, data: Json) -> Status { + if user.role != "admin" { + return Status::Forbidden; + } + + if let Err(_) = sqlx::query!( + "UPDATE users SET quota = $1 WHERE id = $2", + data.quota, + data.user_id + ) + .execute(pool.inner()) + .await + { + return Status::InternalServerError; + } + + if let Err(_) = sqlx::query!( + "UPDATE users SET quota = $1 WHERE id = $2", + data.quota, + data.user_id + ) + .execute(pool.inner()) + .await + { + return Status::InternalServerError; + } + + Status::Ok +} diff --git a/api/src/auth.rs b/api/src/auth.rs new file mode 100644 index 0000000..4a18117 --- /dev/null +++ b/api/src/auth.rs @@ -0,0 +1,89 @@ +use rocket::request::{FromRequest, Outcome, Request}; +use rocket::http::Status; +use serde::{Deserialize, Serialize}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation, Algorithm}; +use chrono::{Utc, Duration}; +use std::env; +use argon2::{Argon2, PasswordHash, PasswordVerifier, password_hash::SaltString, PasswordHasher}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: i32, // user id + pub role: String, + pub exp: usize, +} + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut rand::thread_rng()); + Argon2::default().hash_password(password.as_bytes(), &salt).map(|h| h.to_string()) +} + +pub fn verify_password(password: &str, hash: &str) -> bool { + if let Ok(parsed_hash) = PasswordHash::new(hash) { + Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() + } else { + false + } +} + +pub fn generate_token(user_id: i32, role: &str) -> String { + let expiration = Utc::now() + Duration::days(7); + let claims = Claims { + sub: user_id, + role: role.to_string(), + exp: expiration.timestamp() as usize, + }; + + let secret = env::var("JWT_SECRET").expect("JWT_SECRET must be set"); + encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes())) + .expect("JWT encoding failed") +} + +pub fn decode_token(token: &str) -> Option { + let secret = env::var("JWT_SECRET").ok()?; + decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::new(Algorithm::HS256), + ).ok().map(|data| data.claims) +} + + +#[derive(Serialize)] +pub struct AuthenticatedUser { + pub user_id: Option, + pub role: String, +} + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AuthenticatedUser { + type Error = (); + + async fn from_request(req: &'r Request<'_>) -> Outcome { + if let Some(auth_header) = req.headers().get_one("Authorization") { + if let Some(token) = auth_header.strip_prefix("Bearer ") { + if let Some(claims) = decode_token(token) { + return Outcome::Success(Self { + user_id: Some(claims.sub), + role: claims.role, + }); + } + } + } + Outcome::Error((Status::Unauthorized, ())) + } +} + +pub struct AdminOnly(pub i32); + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for AdminOnly { + type Error = (); + + async fn from_request(req: &'r Request<'_>) -> Outcome { + match AuthenticatedUser::from_request(req).await { + Outcome::Success(user) if user.role == "admin" && user.user_id.is_some() => Outcome::Success(AdminOnly(user.user_id.unwrap())), + _ => Outcome::Error((Status::Unauthorized, ())) + } + } +} diff --git a/api/src/encryption.rs b/api/src/encryption.rs new file mode 100644 index 0000000..b571558 --- /dev/null +++ b/api/src/encryption.rs @@ -0,0 +1,18 @@ +use aes_gcm::{Aes256Gcm, Key, Nonce}; +use aes_gcm::aead::{Aead, KeyInit}; + +pub fn encrypt_data(data: &[u8], key_bytes: &[u8], nonce_bytes: &[u8]) -> Vec { + let key = Key::::from_slice(key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(nonce_bytes); + + cipher.encrypt(nonce, data).expect("encryption failed") +} + +pub fn decrypt_data(data: &[u8], key_bytes: &[u8], nonce_bytes: &[u8]) -> Vec { + let key = Key::::from_slice(key_bytes); + let cipher = Aes256Gcm::new(key); + let nonce = Nonce::from_slice(nonce_bytes); + + cipher.decrypt(nonce, data).expect("decryption failed") +} diff --git a/api/src/file.rs b/api/src/file.rs new file mode 100644 index 0000000..669cf5f --- /dev/null +++ b/api/src/file.rs @@ -0,0 +1,243 @@ +use rocket::Request; +use rocket::{State, fs::TempFile, form::Form, response::Responder, http::ContentType, response::Response}; +use rocket::serde::json::Json; +use rocket::tokio::fs; +use rocket::http::Status; +use serde::Deserialize; +use tokio::io::AsyncReadExt; +use uuid::Uuid; +use std::io::Cursor; +use chrono::{Utc, Duration}; +use sqlx::PgPool; + +use crate::auth::AuthenticatedUser; +use crate::encryption::{encrypt_data, decrypt_data}; +use crate::models::{File, Share}; + +#[derive(FromForm)] +pub struct Upload<'r> { + file: TempFile<'r>, +} + +#[post("/upload", data = "")] +pub async fn upload_file( + pool: &State, + user: AuthenticatedUser, + upload: Form> +) -> Result { + let mut buffer = Vec::new(); + if let Err(_) = { + let mut file = match upload.file.open().await { + Ok(file) => file, + Err(_) => return Err(Status::BadRequest), + }; + file.read_to_end(&mut buffer).await + } { + return Err(Status::BadRequest); + } + + let nonce = rand::random::<[u8; 12]>(); + let key = std::env::var("ENCRYPTION_KEY").expect("ENCRYPTION_KEY must be set"); + let encrypted = encrypt_data(&buffer, key.as_bytes(), &nonce); + + let file_id = Uuid::new_v4().to_string(); + let storage_path = format!("/data/{}", file_id); + + fs::write(&storage_path, &encrypted).await.ok(); + fs::write(format!("{}.nonce", &storage_path), &nonce).await.ok(); + + let original_name = upload.file.name().unwrap_or("file.bin").to_string(); + let size = buffer.len() as i64; + + sqlx::query!( + "INSERT INTO files (user_id, original_name, storage_path, uploaded_at, size) VALUES ($1, $2, $3, $4, $5)", + user.user_id, + original_name, + storage_path, + Utc::now(), + size, + ) + .execute(pool.inner()) + .await + .map_err(|_| Status::InternalServerError)?; + + Ok(Status::Created) +} + +#[get("/files")] +pub async fn list_user_files(pool: &State, user: AuthenticatedUser) -> Json> { + let Some(user_id) = user.user_id else { + return Json(Vec::new()); + }; + + let files = sqlx::query_as!( + File, + "SELECT * FROM files WHERE user_id = $1", + user_id + ) + .fetch_all(pool.inner()) + .await + .unwrap_or_default(); + + Json(files) +} + +pub struct FileDownload { + pub data: Vec, + pub filename: String, +} + +impl<'r> Responder<'r, 'static> for FileDownload { + fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'static> { + Response::build() + .header(ContentType::Binary) + .raw_header("Content-Disposition", format!("attachment; filename=\"{}\"", self.filename)) + .sized_body(self.data.len(), Cursor::new(self.data)) + .ok() + } +} + +#[get("/files/")] +pub async fn download_file(pool: &State, user: AuthenticatedUser, id: i32) -> Option { + let file = sqlx::query_as!( + File, + "SELECT * FROM files WHERE id = $1 AND user_id = $2", + id, + user.user_id + ) + .fetch_optional(pool.inner()) + .await.ok()??; + + let data = fs::read(&file.storage_path).await.ok()?; + let nonce = fs::read(format!("{}.nonce", &file.storage_path)).await.ok()?; + let key = std::env::var("ENCRYPTION_KEY").unwrap().into_bytes(); + let nonce_array: [u8; 12] = nonce.try_into().map_err(|_| "Invalid nonce size").ok()?; + let decrypted = decrypt_data(&data, &key, &nonce_array); + + Some(FileDownload { + data: decrypted, + filename: file.original_name, + }) +} + +#[delete("/files/")] +pub async fn delete_file(pool: &State, user: AuthenticatedUser, id: i32) -> Status { + let file = sqlx::query_as!( + File, + "SELECT * FROM files WHERE id = $1 AND user_id = $2", + id, + user.user_id + ) + .fetch_optional(pool.inner()) + .await + .ok() + .flatten(); + + if let Some(file) = file { + let _ = fs::remove_file(&file.storage_path).await; + let _ = fs::remove_file(format!("{}.nonce", &file.storage_path)).await; + + sqlx::query!("DELETE FROM files WHERE id = $1", file.id) + .execute(pool.inner()) + .await + .ok(); + + Status::NoContent + } else { + Status::NotFound + } +} + +#[derive(Deserialize)] +pub struct ShareRequest { + pub file_id: i32, + pub expires_in_days: Option, +} + +#[post("/share", data = "")] +pub async fn share_file(pool: &State, user: AuthenticatedUser, req: Json) -> Result, Status> { + let file = sqlx::query!( + "SELECT id FROM files WHERE id = $1 AND user_id = $2", + req.file_id, + user.user_id + ) + .fetch_optional(pool.inner()) + .await + .map_err(|_| Status::InternalServerError)?; + + if file.is_none() { + return Err(Status::NotFound); + } + + let id = Uuid::new_v4(); + let expires = req.expires_in_days.map(|d| Utc::now() + Duration::days(d)); + + sqlx::query!( + "INSERT INTO shares (id, file_id, shared_by, created_at, expires_at) VALUES ($1, $2, $3, $4, $5)", + id, + req.file_id, + user.user_id, + Utc::now(), + expires, + ) + .execute(pool.inner()) + .await + .map_err(|_| Status::InternalServerError)?; + + let share = sqlx::query_as::<_, Share>( + "SELECT id, file_id, shared_by, created_at, expires_at FROM shares WHERE id = $1" + ) + .bind(id) + .fetch_one(pool.inner()) + .await + .map_err(|_| Status::InternalServerError)?; + + Ok(Json(share.id.to_string())) +} + +#[get("/shared/")] +pub async fn download_shared(pool: &State, link: &str) -> Option { + let uuid = Uuid::parse_str(link).ok()?; + let share_record = sqlx::query!( + "SELECT id, file_id, shared_by, created_at, expires_at FROM shares WHERE id = $1", + uuid + ) + .fetch_optional(pool.inner()) + .await + .ok()??; + + let share = Share { + id: share_record.id, + file_id: share_record.file_id, + shared_by: share_record.shared_by?, + created_at: share_record.created_at, + expires_at: share_record.expires_at.map(|dt| dt.naive_utc()), + }; + + if let Some(expiry) = share.expires_at { + if expiry < Utc::now().naive_utc() { + return None; + } + } + + let file_id = share.file_id?; + let file = sqlx::query_as!( + File, + "SELECT * FROM files WHERE id = $1", + file_id + ) + .fetch_optional(pool.inner()) + .await + .ok()??; + + let data = fs::read(&file.storage_path).await.ok()?; + let nonce = fs::read(format!("{}.nonce", &file.storage_path)).await.ok()?; + let key = std::env::var("ENCRYPTION_KEY").unwrap().into_bytes(); + let nonce_array: [u8; 12] = nonce.try_into().map_err(|_| "Invalid nonce size").ok()?; + let decrypted = decrypt_data(&data, &key, &nonce_array); + + Some(FileDownload { + data: decrypted, + filename: file.original_name, + }) +} \ No newline at end of file diff --git a/api/src/main.rs b/api/src/main.rs new file mode 100644 index 0000000..d7a6936 --- /dev/null +++ b/api/src/main.rs @@ -0,0 +1,68 @@ +#[macro_use] +extern crate rocket; + +use rocket::{fs::FileServer}; +use rocket::serde::json::Json; +use rocket::http::Status; +use rocket::Request; +use rocket::response::status::Custom; + +use sqlx::postgres::PgPoolOptions; + +use std::env; +use dotenv::dotenv; + +mod auth; +mod encryption; +mod file; +mod user; +mod admin; +mod models; + +use crate::file::*; +use crate::user::*; +use crate::admin::*; + +#[catch(404)] +fn not_found(_: &Request) -> Custom> { + Custom(Status::NotFound, Json("Not Found")) +} + +#[rocket::main] +async fn main() -> Result<(), rocket::Error> { + dotenv().ok(); + + let db_url = env::var("DATABASE_URL").expect("DATABASE_URL not set"); + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(&db_url) + .await + .expect("Failed to connect to database"); + + let figment = rocket::Config::figment() + .merge(("port", 8082)) + .merge(("address", "0.0.0.0")); + + rocket::custom(figment) + .manage(pool) + .mount("/api", routes![ + register, + login, + get_user_info, + delete_user, + upload_file, + download_file, + download_shared, + list_user_files, + delete_file, + share_file, + update_role, + update_quota, + ]) + //.mount("/", FileServer::from("/app/static").rank(10)) + //.register("/", catchers![not_found]) + .launch() + .await?; + + Ok(()) +} diff --git a/api/src/models.rs b/api/src/models.rs new file mode 100644 index 0000000..85da13e --- /dev/null +++ b/api/src/models.rs @@ -0,0 +1,32 @@ +use serde::{Serialize, Deserialize}; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct User { + pub id: Option, + pub name: String, + pub password_hash: String, + pub role: String, + pub quota: Option, // bytes + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct File { + pub id: i32, + pub user_id: Option, + pub original_name: String, + pub storage_path: String, + pub uploaded_at: DateTime, + pub size: i64, +} + +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] +pub struct Share { + pub id: Uuid, + pub file_id: Option, // Changed to Option + pub shared_by: i32, + pub created_at: DateTime, + pub expires_at: Option, +} \ No newline at end of file diff --git a/api/src/user.rs b/api/src/user.rs new file mode 100644 index 0000000..afce9e0 --- /dev/null +++ b/api/src/user.rs @@ -0,0 +1,92 @@ +use rocket::serde::{Deserialize, Serialize, json::Json}; +use rocket::State; +use sqlx::PgPool; +use rocket::http::Status; +use rocket::response::status::{Created, Custom}; +use crate::models::User; +use crate::auth::{hash_password, verify_password, generate_token, AuthenticatedUser}; + +#[derive(Debug, Deserialize)] +pub struct RegisterInput { + pub username: String, + pub password: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginInput { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct TokenResponse { + pub token: String, +} + +#[post("/register", data = "")] +pub async fn register(pool: &State, input: Json) -> Result>, Custom> { + let password_hash = hash_password(&input.password).map_err(|_| Custom(Status::InternalServerError, "Hash error".into()))?; + + let existing = sqlx::query_scalar!( + "SELECT id FROM users WHERE name = $1", + input.username + ) + .fetch_optional(pool.inner()) + .await + .map_err(|_| Custom(Status::InternalServerError, "DB error".into()))?; + + if existing.is_some() { + return Err(Custom(Status::Conflict, "Username already exists".into())); + } + + let rec = sqlx::query!( + "INSERT INTO users (name, password_hash, role, quota) VALUES ($1, $2, 'user', 104857600) RETURNING id", + input.username, + password_hash, + ) + .fetch_one(pool.inner()) + .await + .map_err(|_| Custom(Status::InternalServerError, "Insert error".into()))?; + + let token = generate_token(rec.id, "user"); + + Ok(Created::new("/login").body(Json(TokenResponse { token }))) +} + +#[post("/login", data = "")] +pub async fn login(pool: &State, input: Json) -> Result, Custom> { + let user = sqlx::query_as!( + User, + "SELECT * FROM users WHERE name = $1", + input.username + ) + .fetch_optional(pool.inner()) + .await + .map_err(|_| Custom(Status::InternalServerError, "DB error".into()))?; + + let user = user.ok_or_else(|| Custom(Status::Unauthorized, "Invalid credentials".into()))?; + + if !verify_password(&input.password, &user.password_hash) { + return Err(Custom(Status::Unauthorized, "Invalid credentials".into())); + } + + let user_id = user.id.ok_or_else(|| Custom(Status::InternalServerError, "User ID missing".into()))?; + let token = generate_token(user_id, &user.role); + Ok(Json(TokenResponse { token })) +} + +#[get("/me")] +pub async fn get_user_info(user: AuthenticatedUser) -> Json { + Json(user) +} + +#[delete("/me")] +pub async fn delete_user(pool: &State, user: AuthenticatedUser) -> Status { + let _ = sqlx::query!( + "DELETE FROM users WHERE id = $1", + user.user_id + ) + .execute(pool.inner()) + .await; + Status::NoContent +}