Compare commits
2 commits
822628d4e3
...
e087c120e7
Author | SHA1 | Date | |
---|---|---|---|
|
e087c120e7 | ||
|
9b895c98d6 |
6
api/.env
Normal file
6
api/.env
Normal 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
6
api/.env.example
Normal 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
27
api/cargo.toml
Normal 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
24
api/schema.sql
Normal 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
97
api/src/admin.rs
Normal 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
89
api/src/auth.rs
Normal 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
18
api/src/encryption.rs
Normal 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
243
api/src/file.rs
Normal 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
68
api/src/main.rs
Normal 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
32
api/src/models.rs
Normal 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
92
api/src/user.rs
Normal 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
|
||||||
|
}
|
Loading…
Reference in a new issue