feat: reimplement initial API structure with user authentication and file management

This commit is contained in:
Mercurio 2025-05-31 18:18:24 +02:00
parent 822628d4e3
commit 9b895c98d6
11 changed files with 702 additions and 0 deletions

6
api/.env Normal file
View file

@ -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

6
api/.env.example Normal file
View file

@ -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

27
api/cargo.toml Normal file
View file

@ -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"

24
api/schema.sql Normal file
View file

@ -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
);

97
api/src/admin.rs Normal file
View file

@ -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<PgPool>, user: AuthenticatedUser) -> Result<Json<Vec<User>>, 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 = "<data>")]
pub async fn update_role(pool: &State<PgPool>, user: AuthenticatedUser, data: Json<UpdateRole>) -> 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 = "<data>")]
pub async fn update_quota(pool: &State<PgPool>, user: AuthenticatedUser, data: Json<UpdateQuota>) -> 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
}

89
api/src/auth.rs Normal file
View file

@ -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<String, argon2::password_hash::Error> {
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<Claims> {
let secret = env::var("JWT_SECRET").ok()?;
decode::<Claims>(
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<i32>,
pub role: String,
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for AuthenticatedUser {
type Error = ();
async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> {
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<Self, Self::Error> {
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, ()))
}
}
}

18
api/src/encryption.rs Normal file
View file

@ -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<u8> {
let key = Key::<Aes256Gcm>::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<u8> {
let key = Key::<Aes256Gcm>::from_slice(key_bytes);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(nonce_bytes);
cipher.decrypt(nonce, data).expect("decryption failed")
}

243
api/src/file.rs Normal file
View file

@ -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 = "<upload>")]
pub async fn upload_file(
pool: &State<PgPool>,
user: AuthenticatedUser,
upload: Form<Upload<'_>>
) -> Result<Status, Status> {
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<PgPool>, user: AuthenticatedUser) -> Json<Vec<File>> {
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<u8>,
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/<id>")]
pub async fn download_file(pool: &State<PgPool>, user: AuthenticatedUser, id: i32) -> Option<FileDownload> {
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/<id>")]
pub async fn delete_file(pool: &State<PgPool>, 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<i64>,
}
#[post("/share", data = "<req>")]
pub async fn share_file(pool: &State<PgPool>, user: AuthenticatedUser, req: Json<ShareRequest>) -> Result<Json<String>, 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/<link>")]
pub async fn download_shared(pool: &State<PgPool>, link: &str) -> Option<FileDownload> {
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,
})
}

68
api/src/main.rs Normal file
View file

@ -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<Json<&'static str>> {
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(())
}

32
api/src/models.rs Normal file
View file

@ -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<i32>,
pub name: String,
pub password_hash: String,
pub role: String,
pub quota: Option<i64>, // bytes
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct File {
pub id: i32,
pub user_id: Option<i32>,
pub original_name: String,
pub storage_path: String,
pub uploaded_at: DateTime<Utc>,
pub size: i64,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Share {
pub id: Uuid,
pub file_id: Option<i32>, // Changed to Option<i32>
pub shared_by: i32,
pub created_at: DateTime<Utc>,
pub expires_at: Option<chrono::NaiveDateTime>,
}

92
api/src/user.rs Normal file
View file

@ -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 = "<input>")]
pub async fn register(pool: &State<PgPool>, input: Json<RegisterInput>) -> Result<Created<Json<TokenResponse>>, Custom<String>> {
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 = "<input>")]
pub async fn login(pool: &State<PgPool>, input: Json<LoginInput>) -> Result<Json<TokenResponse>, Custom<String>> {
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<AuthenticatedUser> {
Json(user)
}
#[delete("/me")]
pub async fn delete_user(pool: &State<PgPool>, user: AuthenticatedUser) -> Status {
let _ = sqlx::query!(
"DELETE FROM users WHERE id = $1",
user.user_id
)
.execute(pool.inner())
.await;
Status::NoContent
}