Initial commit

This commit is contained in:
2024-12-29 12:20:01 +01:00
commit 9ebe9b55bd
87 changed files with 21545 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
target/
.idea/
.DS_Store
.env
config.json

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 - 2025 Laika Schmidt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

21
README.md Normal file
View File

@@ -0,0 +1,21 @@
# t<i>r</i>eas<i>u</i>re che<i>st</i>
## About
Similar to one-time drop but with stored, encrypted files.
Uses XChaCha20-Poly1305 to encrypt files and Argon2id to verify decryption key.
## Status
Heavily wip and not feature complete
## TODOs
- [x] Rate limit for unsuccessfully trying to download files
- [x] Store MIME type & file name
- [ ] Frontend
- [ ] Clean up code
- [ ] Tests
- [ ] Documentation
- [ ] Memory usage is pretty high. I believe flushing the files affects this and leads to memory peaks. Fix that.
## License
MIT

12
SECURITY.md Normal file
View File

@@ -0,0 +1,12 @@
# Security Policy
## Supported Versions
Currently WIP, so no support.
## Reporting a Vulnerability
If you want to report a vulnerability, check if it's possible to create an issue without endangering other users.
If that's not the case, send a mail to the address on my ([its-laika](https://github.com/its-laika)) profile.
I check my mails pretty frequently so expect a reply within a few days. From there on, I'll do my best to fix the vulnerability asap.
If you want to, you can also provide patches directly or via pull requests. A new version is pushed as soon as the vulnerability is fixed.

120
backend/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,120 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'treasure_chest'",
"cargo": {
"args": [
"build",
"--bin=treasure_chest",
"--package=treasure_chest"
],
"filter": {
"name": "treasure_chest",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'treasure_chest'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=treasure_chest",
"--package=treasure_chest"
],
"filter": {
"name": "treasure_chest",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'entity'",
"cargo": {
"args": [
"test",
"--no-run",
"--lib",
"--package=entity"
],
"filter": {
"name": "entity",
"kind": "lib"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'migration'",
"cargo": {
"args": [
"test",
"--no-run",
"--lib",
"--package=migration"
],
"filter": {
"name": "migration",
"kind": "lib"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'migration'",
"cargo": {
"args": [
"build",
"--bin=migration",
"--package=migration"
],
"filter": {
"name": "migration",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
},
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in executable 'migration'",
"cargo": {
"args": [
"test",
"--no-run",
"--bin=migration",
"--package=migration"
],
"filter": {
"name": "migration",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

4025
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

33
backend/Cargo.toml Normal file
View File

@@ -0,0 +1,33 @@
[package]
name = "treasure_chest"
version = "0.1.0"
edition = "2024"
[workspace]
members = [".", "entity", "migration"]
[dependencies]
axum = "0.8.7"
argon2 = "0.5.3"
base64 = "0.22.1"
chacha20poly1305 = "0.10.1"
chrono = "0.4.42"
config = "0.15.19"
entity = { path = "entity" }
env_logger = "0.11.8"
futures = "0.3"
log = "0.4.28"
migration = { path = "migration" }
regex = "1.12.2"
sea-orm = { version = "1.1.19", features = [
"macros",
"runtime-tokio-rustls",
"sqlx-mysql",
"with-chrono",
"with-uuid",
] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7.17", features = ["io"] }
uuid = { version = "1.18.1", features = ["v4"] }

View File

@@ -0,0 +1,18 @@
{
// Connection string of the database that is used
"ConnectionString": "mysql://root:example@localhost/treasure_chest",
// Interface & port that will be used
"BindTo": "localhost:8000",
// Path of uploaded, encrypted files
"FilePath": "./files",
// Max download tries for a file (by all IPs)
"MaxDownloadTries": 3,
// Default lifefime (in days) of not downloaded, encrypted files
"DaysFileAvailable": 7,
// Max number of files that can be uploaded by a single IP in a day
"UserUploadsPerDay": 5,
// Name of header that will be used to indicate a requests IP. Ensure to configure your proxying server!
"IpHeaderName": "X-Forwarded-For",
// Max (unencrypted) file size in bytes. Mind that - during an upload request - both unencrypted and encrypted file are held in memory! So memory of request roughly equals {BodyMaxSize} * 2.
"BodyMaxSize": 10000000
}

1
backend/development/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
TESTFILE

View File

@@ -0,0 +1,13 @@
# Debug memory allocation
## Build test file
- 100 MB file: `if=/dev/random of=TESTFILE status=progress bs=1m count=100`
## Steps
1. `cargo install --features vendored-openssl cargo-instruments`
Installs _cargo-instruments_ without needing OpenSSL (macOS shenanigans -.-)
2. `cargo instruments -t Allocations (--release)`
3. Find PID
4. `kill -SIGINT [PID]` to stop gracefully without interrupting cargo-instruments.
5. XCode instrument _Allocations_ opens up automatically

11
backend/entity/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "entity"
version = "0.1.0"
edition = "2024"
[lib]
name = "entity"
path = "mod.rs"
[dependencies]
sea-orm = { version = "1.1.19" }

View File

@@ -0,0 +1,35 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "access_log")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Binary(16)")]
pub id: Vec<u8>,
pub ip: String,
#[sea_orm(column_type = "Binary(16)")]
pub file_id: Vec<u8>,
pub date_time: DateTime,
pub successful: i8,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::file::Entity",
from = "Column::FileId",
to = "super::file::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
File,
}
impl Related<super::file::Entity> for Entity {
fn to() -> RelationDef {
Relation::File.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

31
backend/entity/file.rs Normal file
View File

@@ -0,0 +1,31 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "file")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false, column_type = "Binary(16)")]
pub id: Vec<u8>,
#[sea_orm(unique)]
pub hash: String,
pub uploader_ip: String,
pub uploaded_at: DateTime,
pub download_until: DateTime,
#[sea_orm(column_type = "Binary(255)")]
pub encrypted_metadata: Vec<u8>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::access_log::Entity")]
AccessLog,
}
impl Related<super::access_log::Entity> for Entity {
fn to() -> RelationDef {
Relation::AccessLog.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

8
backend/entity/mod.rs Normal file
View File

@@ -0,0 +1,8 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
pub mod prelude;
pub mod access_log;
pub mod file;
pub use prelude::*;

View File

@@ -0,0 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.0
pub use super::access_log::Entity as AccessLog;
pub use super::file::Entity as File;

View File

@@ -0,0 +1,16 @@
[package]
name = "migration"
version = "0.1.0"
edition = "2024"
publish = false
[lib]
name = "migration"
path = "src/lib.rs"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
[dependencies.sea-orm-migration]
version = "1.1.19"
features = ["sqlx-mysql", "runtime-tokio-rustls"]

View File

@@ -0,0 +1,12 @@
pub use sea_orm_migration::prelude::*;
mod m20250114_200507_create_tables;
pub struct Migrator;
#[async_trait::async_trait]
impl MigratorTrait for Migrator {
fn migrations() -> Vec<Box<dyn MigrationTrait>> {
vec![Box::new(m20250114_200507_create_tables::Migration)]
}
}

View File

@@ -0,0 +1,98 @@
use sea_orm_migration::{
prelude::*,
schema::{blob, boolean, date_time, string, uuid},
};
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(File::Table)
.if_not_exists()
.col(uuid(File::Id).not_null().primary_key())
.col(string(File::Hash).not_null().unique_key())
.col(string(File::UploaderIp).not_null())
.col(date_time(File::UploadedAt).not_null())
.col(date_time(File::DownloadUntil).not_null())
.col(blob(File::EncryptedMetadata).not_null())
.to_owned(),
)
.await?;
manager
.create_table(
Table::create()
.table(AccessLog::Table)
.if_not_exists()
.col(uuid(AccessLog::Id).not_null().primary_key())
.col(string(AccessLog::Ip).not_null())
.col(uuid(AccessLog::FileId).not_null())
.col(date_time(AccessLog::DateTime).not_null())
.col(boolean(AccessLog::Successful).not_null())
.to_owned(),
)
.await?;
manager
.create_foreign_key(
ForeignKey::create()
.name("access_log_file")
.from(AccessLog::Table, AccessLog::FileId)
.to(File::Table, File::Id)
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::Cascade)
.to_owned(),
)
.await?;
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_foreign_key(ForeignKey::drop().name("access_log_file").to_owned())
.await?;
manager
.drop_table(Table::drop().table(File::Table).to_owned())
.await?;
manager
.drop_table(Table::drop().table(AccessLog::Table).to_owned())
.await?;
Ok(())
}
}
#[derive(DeriveIden)]
pub enum File {
Table,
Id,
Hash,
#[sea_orm(iden = "uploaded_at")]
UploadedAt,
#[sea_orm(iden = "uploader_ip")]
UploaderIp,
#[sea_orm(iden = "download_until")]
DownloadUntil,
#[sea_orm(iden = "encrypted_metadata")]
EncryptedMetadata,
}
#[derive(DeriveIden)]
enum AccessLog {
Table,
Id,
Ip,
#[sea_orm(iden = "file_id")]
FileId,
#[sea_orm(iden = "date_time")]
DateTime,
Successful,
}

View File

@@ -0,0 +1,6 @@
use sea_orm_migration::prelude::*;
#[async_std::main]
async fn main() {
cli::run_cli(migration::Migrator).await;
}

12
backend/src/api/mod.rs Normal file
View File

@@ -0,0 +1,12 @@
//! API module.
//!
//! This module contains the routes and server setup for the API. It includes
//! submodules for configuration, download, and upload routes, as well as the
//! server initialization.
mod routes {
pub mod configuration;
pub mod download;
pub mod upload;
}
mod server;
pub use server::listen;

View File

@@ -0,0 +1,30 @@
use crate::configuration::CONFIGURATION;
use axum::response::IntoResponse;
use axum::Json;
use serde::Serialize;
/// A struct representing the configuration response.
///
/// This struct is used to serialize the configuration settings into a JSON
/// response. The fields are renamed to match expected JSON keys.
/// This response is returned in the [`handler`] function.
#[derive(Serialize)]
pub struct Response {
#[serde(rename = "BodyMaxSize")]
pub body_max_size: usize,
#[serde(rename = "DaysFileAvailable")]
pub default_days_lifetime: u64,
}
/// Configuration endpoint.
///
/// This function creates a `Response` struct with the current configuration
/// settings and returns it as a JSON.
pub async fn handler() -> impl IntoResponse {
let response = Response {
body_max_size: CONFIGURATION.body_max_size,
default_days_lifetime: CONFIGURATION.days_file_available,
};
Json(response)
}

View File

@@ -0,0 +1,88 @@
use crate::database;
use crate::encryption;
use crate::encryption::Encoding;
use crate::encryption::Encryption;
use crate::error::Error;
use crate::file;
use crate::request;
use crate::return_logged;
use crate::util;
use axum::extract::{Path, State};
use axum::http::HeaderMap;
use axum::response::IntoResponse;
use axum::{http::StatusCode, Json};
use sea_orm::DatabaseConnection;
use serde::Deserialize;
use uuid::Uuid;
/// A struct representing the request body for the download endpoint.
///
/// This struct is used to deserialize the JSON request body containing the
/// key needed to decrypt the requested file.
#[derive(Deserialize)]
pub struct RequestBody {
pub key: String,
}
/// Handles the file download endpoint.
///
/// This function processes the download request, validates the key, logs the
/// access, decrypts the file, and returns the file content along with the
/// appropriate headers.
pub async fn handler(
State(database_connection): State<DatabaseConnection>,
id: Path<Uuid>,
headers: HeaderMap,
body: Json<RequestBody>,
) -> impl IntoResponse {
let request_ip = match request::get_request_ip(&headers) {
Ok(ip) => ip,
Err(error) => return_logged!(error, StatusCode::BAD_GATEWAY),
};
let file = match database::get_downloadable_file(&database_connection, &id).await {
Ok(None) => return Err(StatusCode::NOT_FOUND),
Ok(Some(file)) => file,
Err(error) => return_logged!(error, StatusCode::INTERNAL_SERVER_ERROR),
};
let Ok(key) = util::get_validated_key(&body.key, &file.hash) else {
if let Err(error) =
database::store_access_log(&database_connection, &request_ip, &id, false).await
{
return_logged!(error, StatusCode::INTERNAL_SERVER_ERROR);
}
return Err(StatusCode::UNAUTHORIZED);
};
if let Err(error) =
database::store_access_log(&database_connection, &request_ip, &id, true).await
{
return_logged!(error, StatusCode::INTERNAL_SERVER_ERROR)
}
let content = match file::load_data(&id)
.and_then(encryption::Data::decode)
.and_then(|data| data.decrypt(&key))
{
Ok(content) => content,
Err(error) => return_logged!(error, StatusCode::INTERNAL_SERVER_ERROR),
};
let response_headers = match encryption::Data::decode(file.encrypted_metadata)
.and_then(|data| data.decrypt(&key))
.and_then(|data| String::from_utf8(data).map_err(|_| Error::DecryptionFailed))
.and_then(|json| {
serde_json::from_str::<file::Metadata>(&json).map_err(Error::JsonSerializationFailed)
}) {
Ok(metadata) => metadata.into(),
_ => HeaderMap::new(),
};
if let Err(error) = file::delete(&id) {
log::error!("Could not delete used file {}: {error:?}", id.to_string());
}
Ok((response_headers, content))
}

View File

@@ -0,0 +1,129 @@
use crate::configuration::CONFIGURATION;
use crate::encryption::{Encoding, Encryption};
use crate::error::Error;
use crate::file;
use crate::hash::{Hash, Hashing};
use crate::request;
use crate::return_logged;
use crate::{database, encryption};
use axum::extract::State;
use axum::http::HeaderMap;
use axum::response::IntoResponse;
use axum::{extract::Request, http::StatusCode, Json};
use base64::prelude::BASE64_URL_SAFE;
use base64::Engine;
use futures::{StreamExt, TryStreamExt};
use sea_orm::DatabaseConnection;
use serde::Serialize;
use std::io::{Error as IoError, ErrorKind};
use tokio_util::io::StreamReader;
use uuid::Uuid;
// A struct representing the response for the upload endpoint.
///
/// This struct is used to serialize the response containing the file id and
/// the encryption key.
#[derive(Serialize)]
pub struct Response {
pub id: String,
pub key: String,
}
/// Handles the file upload endpoint.
///
/// This function processes the upload request, validates the request, stores
/// the file, and returns the file id and encryption key.
pub async fn handler(
State(database_connection): State<DatabaseConnection>,
headers: HeaderMap,
request: Request,
) -> impl IntoResponse {
let request_ip = match request::get_request_ip(&headers) {
Ok(ip) => ip,
Err(error) => return_logged!(error, StatusCode::BAD_GATEWAY),
};
match database::is_upload_limit_reached(&database_connection, &request_ip).await {
Ok(false) => (),
Ok(true) => return Err(StatusCode::TOO_MANY_REQUESTS),
Err(error) => return_logged!(error, StatusCode::INTERNAL_SERVER_ERROR),
}
let content = match extract_body(request).await {
Ok(content) => content,
Err(_) => return Err(StatusCode::PAYLOAD_TOO_LARGE),
};
let (encryption_data, key) = match encryption::Data::encrypt(content) {
Ok(result) => result,
Err(error) => return_logged!(error, StatusCode::INTERNAL_SERVER_ERROR),
};
let id = Uuid::new_v4();
if let Err(error) = file::store_data(&id, encryption_data.encode()) {
return_logged!(error, StatusCode::INTERNAL_SERVER_ERROR);
};
let encrypted_metadata =
match serde_json::to_string(&std::convert::Into::<file::Metadata>::into(headers))
.map_err(Error::JsonSerializationFailed)
.and_then(|json| encryption::Data::encrypt_with_key(json.bytes(), &key))
.map(encryption::definitions::Encoding::encode)
{
Ok(metadata) => metadata,
Err(error) => return_logged!(error, StatusCode::INTERNAL_SERVER_ERROR),
};
if encrypted_metadata.len() > 255 {
return Err(StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE);
}
let hash = match Hash::hash(&key) {
Ok(hash) => hash,
Err(error) => return_logged!(error, StatusCode::INTERNAL_SERVER_ERROR),
};
if let Err(error) = database::store_file(
&database_connection,
&id,
hash,
request_ip,
encrypted_metadata,
)
.await
{
return_logged!(error, StatusCode::INTERNAL_SERVER_ERROR);
};
Ok(Json(Response {
id: id.into(),
key: BASE64_URL_SAFE.encode(&key),
}))
}
async fn extract_body(request: Request) -> Result<Vec<u8>, IoError> {
let mut body = vec![];
let body_data_stream = request
.into_body()
.into_data_stream()
/* Add one byte to max size for range check later. If this byte is filled,
* we know that the body is too large. */
.take(CONFIGURATION.body_max_size + 1)
.map_err(|err| IoError::new(ErrorKind::Other, err));
let body_reader = StreamReader::new(body_data_stream);
futures::pin_mut!(body_reader);
tokio::io::copy(&mut body_reader, &mut body).await?;
if body.len() > CONFIGURATION.body_max_size {
return Err(IoError::new(
ErrorKind::StorageFull,
"Max body size exceeded",
));
}
Ok(body)
}

81
backend/src/api/server.rs Normal file
View File

@@ -0,0 +1,81 @@
use super::routes;
use crate::configuration::CONFIGURATION;
use axum::{
routing::{get, post},
Router,
};
use sea_orm::DatabaseConnection;
use std::io::Result;
use tokio::{net::TcpListener, sync::broadcast};
/// Starts the server and listens for incoming connections.
///
/// This function sets up the routes for the API, binds the server to the
/// specified address, and starts listening for incoming connections. It also
/// handles graceful shutdown when a shutdown signal is received.
///
/// # Routes
/// See [`routes`] folder for all available routes.
///
/// # Arguments
///
/// * `connection` - A `DatabaseConnection` instance used to interact with the
/// database.
/// * `shutdown` - A `shotgun::Receiver<()>` used to receive shutdown signals.
///
/// # Returns
///
/// * [`Ok<()>`] on graceful shutdown
/// * [`Err<Error>`] on error
///
/// # Example
///
/// ```rust
/// use sea_orm::DatabaseConnection;
/// use tokio::{runtime::Runtime, sync::broadcast};
///
/// let connection = DatabaseConnection::new();
/// let (shutdown_sender, shutdown_receiver) = shotgun::channel();
///
/// let rt = Runtime::new().unwrap();
/// rt.block_on(async {
/// listen(connection, shutdown_receiver).await.unwrap();
/// });
/// ```
pub async fn listen(connection: DatabaseConnection, mut shutdown: broadcast::Receiver<()>) -> Result<()> {
let app = Router::new()
.route("/api/files", post(routes::upload::handler))
.route("/api/files/{id}/download", post(routes::download::handler))
.route("/api/configuration", get(routes::configuration::handler))
.with_state(connection);
let listener = TcpListener::bind(&CONFIGURATION.listening_address).await?;
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown.recv().await;
})
.await
}
/// Logs an error and returns a specified status.
///
/// This macro logs the provided error using the `log` crate and then returns
/// the specified status.
///
/// # Arguments
///
/// * `$error` - The error message to be logged.
/// * `$status` - The HTTP status code to be returned.
///
/// # Example
///
/// ```rust
/// return_logged!(some_error, StatusCode::INTERNAL_SERVER_ERROR);
#[macro_export]
macro_rules! return_logged {
($error: expr, $status: expr) => {{
log::error!("{:?}", $error);
return Err($status);
}};
}

71
backend/src/cleanup.rs Normal file
View File

@@ -0,0 +1,71 @@
use crate::{database, error::Result, file};
use sea_orm::DatabaseConnection;
use std::time::Duration;
use tokio::{select, time, sync::broadcast};
use uuid::Uuid;
/// The interval in seconds between each cleanup operation.
/// Currently set to 10 minutes.
const CLEANUP_INTERVAL_SECONDS: u64 = 10 * 60; /* 10 minutes */
/// Runs the cleanup process in a loop, until `shutdown` signal is received.
///
/// # Arguments
///
/// * `database_connection` - A connection to the database.
/// * `shutdown` - A broadcast receiver to listen for shutdown signal.
///
/// # Returns
///
/// * [`Ok<()>`] on successful cleanup process
/// * [`Err<Error>`] on error
pub async fn run(
database_connection: DatabaseConnection,
shutdown: broadcast::Receiver<()>,
) -> Result<()> {
loop {
let mut shutdown = shutdown.resubscribe();
select! {
_ = time::sleep(Duration::from_secs(CLEANUP_INTERVAL_SECONDS)) => (),
_ = shutdown.recv() => return Ok(()),
};
log::info!("Cleaning up outdating files...");
database::remove_undownloadable_files(&database_connection).await?;
delete_outdated_files(&database_connection).await?;
}
}
/// Deletes outdated files from the file system.
///
/// # Arguments
///
/// * `database_connection` - A connection to the database.
///
/// # Returns
///
/// * [`Ok<()>`] on successful cleanup
/// * [`Err<Error>`] on error
async fn delete_outdated_files(database_connection: &DatabaseConnection) -> Result<()> {
let downloadable_file_ids = database::get_downloadable_file_ids(database_connection).await?;
let stored_file_ids = file::get_stored_file_ids()?;
let file_ids_to_delete = stored_file_ids
.iter()
.filter(|stored| {
downloadable_file_ids
.iter()
.all(|downloadable| &downloadable != stored)
})
.collect::<Vec<&Uuid>>();
for file_id in file_ids_to_delete {
file::delete(file_id)?;
log::info!("Deleted outdated file: {file_id}");
}
Ok(())
}

View File

@@ -0,0 +1,87 @@
use chrono::Days;
use config::{Environment, File, FileFormat};
use serde::Deserialize;
use std::{path::PathBuf, process::exit, sync::LazyLock};
pub const CONFIG_FILE_NAME: &str = "config.json";
pub const CONFIG_ENV_PREFIX: &str = "TREASURE_CHEST";
pub static CONFIGURATION: LazyLock<Configuration> = LazyLock::new(build);
/// Configuration that can be automatically read from Json / env,
/// containing only base types. See [`Configuration`]
#[derive(Deserialize)]
struct RawConfiguration {
#[serde(rename = "ConnectionString")]
pub connection_string: String,
#[serde(rename = "BindTo")]
pub listening_address: String,
#[serde(rename = "FilePath")]
pub file_path: PathBuf,
#[serde(rename = "DaysFileAvailable")]
pub days_file_available: u64,
#[serde(rename = "UserUploadsPerDay")]
pub user_uploads_per_day: u32,
#[serde(rename = "MaxDownloadTries")]
pub max_download_tries: u32,
#[serde(rename = "IpHeaderName")]
pub ip_header_name: String,
#[serde(rename = "BodyMaxSize")]
pub body_max_size: usize,
}
/// Configuration of program
pub struct Configuration {
/// Database connection string
pub connection_string: String,
/// Address to listen to (e.g. "_localhost:8080_")
pub listening_address: String,
/// Path of encrypted files
pub file_path: PathBuf,
/// Lifetime of uploaded files until deletion
pub file_lifetime: Days,
/// Raw value of `file_lifetime`
pub days_file_available: u64,
/// Number of max uploads by a single IP (rate limiting)
pub ip_uploads_per_day: u32,
/// Number of max tries to access a file (in case of wrong keys etc)
pub max_download_tries: u32,
/// Name of IP header, set by proxy server
pub ip_header_name: String,
/// Max size of request body (in bytes)
pub body_max_size: usize,
}
/// Builds [`Configuration`] by configuration file and env vars
///
/// # Returns
///
/// Instance of [`Configuration`]
///
/// # Note
///
/// If configuration is not buildable, it exits the program.
pub fn build() -> Configuration {
let Ok(raw) = config::Config::builder()
.add_source(File::new(CONFIG_FILE_NAME, FileFormat::Json).required(false))
.add_source(Environment::with_prefix(CONFIG_ENV_PREFIX))
.build()
.expect("Configuration is not buildable")
.try_deserialize::<RawConfiguration>()
else {
log::error!("Could not build configuration. Bye.");
exit(1);
};
Configuration {
connection_string: raw.connection_string,
listening_address: raw.listening_address,
file_path: raw.file_path,
file_lifetime: Days::new(raw.days_file_available),
days_file_available: raw.days_file_available,
max_download_tries: raw.max_download_tries,
ip_uploads_per_day: raw.user_uploads_per_day,
ip_header_name: raw.ip_header_name,
body_max_size: raw.body_max_size,
}
}

283
backend/src/database.rs Normal file
View File

@@ -0,0 +1,283 @@
use super::error::{Error, Result};
use crate::configuration::CONFIGURATION;
use chrono::{Days, Utc};
use migration::ExprTrait;
use sea_orm::sea_query::Query;
use sea_orm::{ColumnTrait, Condition, FromQueryResult};
use sea_orm::{DatabaseConnection, EntityTrait, QueryFilter, QuerySelect, Set};
use uuid::Uuid;
/// Wrapper for `COUNT(*)` queries
#[derive(FromQueryResult)]
struct CountResult {
count: i64,
}
/// Gets file from database for id that can currently be downloaded
///
/// Checks if file has already been downloaded and if it's still in time range.
///
/// # Arguments
///
/// * `database_connection` - [`DatabaseConnection`] to use
/// * `id` - Id of the file entry
///
/// # Returns
///
/// * [`Ok<Some<Model>>`] containing downloadable file model
/// * [`Ok<None>`] on file not existing or outdated
/// * [`Err<Error>`] on error
pub async fn get_downloadable_file(
database_connection: &DatabaseConnection,
id: &Uuid,
) -> Result<Option<entity::file::Model>> {
entity::File::find()
.filter(entity::file::Column::Id.eq(*id))
.filter(entity::file::Column::DownloadUntil.gte(Utc::now()))
.filter(
entity::file::Column::Id.not_in_subquery(
Query::select()
.column(entity::access_log::Column::FileId)
.from(entity::access_log::Entity)
.cond_where(Condition::all().add(entity::access_log::Column::Successful.eq(1)))
.to_owned(),
),
)
.filter(
entity::file::Column::Id.not_in_subquery(
Query::select()
.column(entity::access_log::Column::FileId)
.from(entity::access_log::Entity)
.group_by_col(entity::access_log::Column::FileId)
.cond_having(
Condition::all().add(
entity::access_log::Column::FileId
.count()
.gte(CONFIGURATION.max_download_tries),
),
)
.to_owned(),
),
)
.one(database_connection)
.await
.map_err(Error::DatabaseOperationFailed)
}
/// Gets all file ids from database that can currently be downloaded
///
/// Checks if file has already been downloaded and if it's still in time range.
///
/// # Arguments
///
/// * `database_connection` - [`DatabaseConnection`] to use
///
/// # Returns
///
/// * [`Ok<Vec<Uuid>>`] containing ids of all files that can still be downloaded
/// * [`Err<Error>`] on error
pub async fn get_downloadable_file_ids(
database_connection: &DatabaseConnection,
) -> Result<Vec<Uuid>> {
entity::File::find()
.filter(entity::file::Column::DownloadUntil.gte(Utc::now()))
.filter(
entity::file::Column::Id.not_in_subquery(
Query::select()
.column(entity::access_log::Column::FileId)
.from(entity::access_log::Entity)
.cond_where(Condition::all().add(entity::access_log::Column::Successful.eq(1)))
.to_owned(),
),
)
.filter(
entity::file::Column::Id.not_in_subquery(
Query::select()
.column(entity::access_log::Column::FileId)
.from(entity::access_log::Entity)
.group_by_col(entity::access_log::Column::FileId)
.cond_having(
Condition::all().add(
entity::access_log::Column::FileId
.count()
.gte(CONFIGURATION.max_download_tries),
),
)
.to_owned(),
),
)
.select_only()
.column(entity::file::Column::Id)
.into_tuple()
.all(database_connection)
.await
.map_err(Error::DatabaseOperationFailed)
}
/// Removes undownloadable files from the database.
///
/// This function deletes files that are either past their download expiration
/// date, have been successfully downloaded, or have exceeded the maximum
/// number of allowed download attempts.
///
/// # Arguments
///
/// * `database_connection` - A `DatabaseConnection` instance used to interact
/// with the database.
///
/// # Returns
///
/// * [`Ok<()>`] on success
/// * [`Err<Error>`] on error
pub async fn remove_undownloadable_files(database_connection: &DatabaseConnection) -> Result<()> {
entity::File::delete_many()
.filter(
Condition::any()
.add(entity::file::Column::DownloadUntil.lt(Utc::now()))
.add(
entity::file::Column::Id.in_subquery(
Query::select()
.column(entity::access_log::Column::FileId)
.from(entity::access_log::Entity)
.cond_where(
Condition::all().add(entity::access_log::Column::Successful.eq(1)),
)
.to_owned(),
),
)
.add(
entity::file::Column::Id
.in_subquery(
Query::select()
.column(entity::access_log::Column::FileId)
.from(entity::access_log::Entity)
.group_by_col(entity::access_log::Column::FileId)
.cond_having(
Condition::all().add(
entity::access_log::Column::FileId
.count()
.gte(CONFIGURATION.max_download_tries),
),
)
.to_owned(),
)
.to_owned(),
),
)
.exec(database_connection)
.await
.map(|_| ())
.map_err(Error::DatabaseOperationFailed)
}
/// Returns whether given `ip` may currently upload a file
///
/// # Arguments
///
/// * `database_connection` - [`DatabaseConnection`] to use
/// * `ip` - Ip to check
///
/// # Returns
///
/// * [`Ok<true>`] if client may upload a file
/// * [`Ok<false>`] if client must not upload a file at this time
/// * [`Err<Error>`] on error
pub async fn is_upload_limit_reached(
database_connection: &DatabaseConnection,
ip: &str,
) -> Result<bool> {
let min_uploaded_at = Utc::now()
.checked_sub_days(Days::new(1))
.ok_or(Error::DateCalculationFailed)?;
let count = entity::File::find()
.select_only()
.column_as(entity::file::Column::Id.count(), "count")
.filter(entity::file::Column::UploaderIp.eq(ip))
.filter(entity::file::Column::UploadedAt.gte(min_uploaded_at.naive_utc()))
.into_model::<CountResult>()
.one(database_connection)
.await
.map_err(Error::DatabaseOperationFailed)?
.unwrap_or(CountResult { count: 0 })
.count;
Ok(count >= CONFIGURATION.ip_uploads_per_day.into())
}
/// Store new file entry to database
///
/// # Arguments
///
/// * `database_connection` - [`DatabaseConnection`] to use
/// * `id` - Id of new file
/// * `hash` - Encryption key hash
/// * `uploader_ip` - Ip of client uploading this file
/// * `encrypted_metadata` - File metadata in encrypted form
///
/// # Returns
///
/// * [`Ok<()>`] on success
/// * [`Err<Error>`] on error
pub async fn store_file(
database_connection: &DatabaseConnection,
id: &Uuid,
hash: String,
uploader_ip: String,
encrypted_metadata: Vec<u8>,
) -> Result<()> {
let now = Utc::now();
let download_until = now
.checked_add_days(CONFIGURATION.file_lifetime)
.ok_or(Error::DateCalculationFailed)?;
let file = entity::file::ActiveModel {
id: Set((*id).into()),
hash: Set(hash),
uploader_ip: Set(uploader_ip),
uploaded_at: Set(now.naive_utc()),
download_until: Set(download_until.naive_utc()),
encrypted_metadata: Set(encrypted_metadata),
};
entity::File::insert(file)
.exec(database_connection)
.await
.map(|_| ())
.map_err(Error::DatabaseOperationFailed)
}
/// Store new access log entry to database
///
/// # Arguments
///
/// * `database_connection` - [`DatabaseConnection`] to use
/// * `ip` - Ip of the client accessing the file
/// * `file_id` - Id of the file being accessed
/// * `successful` - Whether validation was successful or not
///
/// # Returns
///
/// * [`Ok<()>`] on success
/// * [`Err<Error>`] on error
pub async fn store_access_log(
database_connection: &DatabaseConnection,
ip: &str,
file_id: &Uuid,
successful: bool,
) -> Result<()> {
let log = entity::access_log::ActiveModel {
id: Set(Uuid::new_v4().into()),
ip: Set(ip.into()),
file_id: Set((*file_id).into()),
date_time: Set(Utc::now().naive_utc()),
successful: Set(i8::from(successful)),
};
entity::AccessLog::insert(log)
.exec(database_connection)
.await
.map(|_| ())
.map_err(Error::DatabaseOperationFailed)
}

View File

@@ -0,0 +1,71 @@
use crate::error::Result;
/// Provides functions to make encrypted data store-able.
/// Handles encoding and decoding of encrypted data including things like nonce.
/// Encoded data can be stored safely.
pub trait Encoding<T> {
/// Encodes data so that it can be stored.
///
/// # Consumes
///
/// * self
///
/// # Returns
///
/// * Encoded data
fn encode(self) -> Vec<u8>;
/// Decodes previously encoded data so that it can be decrypted later.
///
/// # Arguments
///
/// * `data` - Encoded data to decode into `self`
///
/// # Returns
///
/// * [`Ok<self>`] on success
/// * [`Err<Error>`] on error
fn decode<TI: IntoIterator<Item = u8>>(data: TI) -> Result<T>;
}
/// Provides functions to create encrypted data and decrypt it back.
pub trait Encryption<T> {
/// Encrypts given data.
/// Encrypts plain data and returns encryption-data and the key as a tuple.
///
/// # Arguments
///
/// * `plain` - Plain data to encrypt
///
/// # Returns
///
/// * [`Ok<(T, Vec<u8>)>`] on success, containing (Self, decryption key)
/// * [`Err<Error>`] on error
fn encrypt<TI: IntoIterator<Item = u8>>(plain: TI) -> Result<(T, Vec<u8>)>;
// Encrypts plain data with given key and returns encryption-data.
///
///
/// # Arguments
///
/// * `plain` - Plain data to encrypt
/// * `key` - Predefined key to use
///
/// # Returns
///
/// * [`Ok<Vec<u8>>`] on success, containing encrypted data
/// * [`Err<Error>`] on error
fn encrypt_with_key<TI: IntoIterator<Item = u8>>(plain: TI, key: &[u8]) -> Result<T>;
/// Decrypts data with given key.
///
/// # Arguments
///
/// * `key` - Decryption key for this encrypted data
///
/// # Returns
///
/// * [`Ok<Vec<u8>>`] on success with decrypted data
/// * [`Err<Error>`] on error
fn decrypt(self, key: &[u8]) -> Result<Vec<u8>>;
}

View File

@@ -0,0 +1,10 @@
//! Encryption module.
//!
//! This module provides encryption functionalities. It includes submodules
//! for encryption definitions. Currently this contains the XChaCha20Poly1305
//! encryption scheme.
pub(crate) mod definitions;
mod xchacha20poly1305;
pub use definitions::*;
pub use xchacha20poly1305::XChaCha20Poly1305Data as Data;

View File

@@ -0,0 +1,94 @@
use super::definitions::{Encoding, Encryption};
use crate::error::{Error, Result};
use chacha20poly1305::{
aead::{AeadCore, AeadMutInPlace, KeyInit, OsRng},
Key, XChaCha20Poly1305, XNonce,
};
/// Container for encrypted data and the necessary information to decrypt it.
pub struct XChaCha20Poly1305Data {
// Nonce for decrypting `content`
nonce: Vec<u8>,
// Encrypted data
content: Vec<u8>,
}
impl Encoding<XChaCha20Poly1305Data> for XChaCha20Poly1305Data {
fn encode(mut self) -> Vec<u8> {
let mut data = vec![];
data.append(&mut self.nonce);
data.append(&mut self.content);
data
}
fn decode<TI: IntoIterator<Item = u8>>(data: TI) -> Result<XChaCha20Poly1305Data> {
let mut data = data.into_iter().collect::<Vec<u8>>();
if data.len() < 24 {
return Err(Error::InvalidEncryptionData("Data too short".into()));
}
let content = data.split_off(24);
Ok(Self {
nonce: data,
content,
})
}
}
impl Encryption<XChaCha20Poly1305Data> for XChaCha20Poly1305Data {
fn encrypt<TI: IntoIterator<Item = u8>>(plain: TI) -> Result<(XChaCha20Poly1305Data, Vec<u8>)> {
let key = XChaCha20Poly1305::generate_key(&mut OsRng);
let mut cipher = XChaCha20Poly1305::new(&key);
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let mut plain = plain.into_iter().collect::<Vec<u8>>();
cipher
.encrypt_in_place(&nonce, &[], &mut plain)
.map_err(|_| Error::EncryptionFailed)?;
let encryption_data = XChaCha20Poly1305Data {
nonce: nonce.to_vec(),
content: plain,
};
Ok((encryption_data, key.to_vec()))
}
fn encrypt_with_key<TI: IntoIterator<Item = u8>>(
plain: TI,
key: &[u8],
) -> Result<XChaCha20Poly1305Data> {
let key = Key::from_slice(key);
let mut cipher = XChaCha20Poly1305::new(key);
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let mut content = plain.into_iter().collect::<Vec<u8>>();
cipher
.encrypt_in_place(&nonce, &[], &mut content)
.map_err(|_| Error::EncryptionFailed)?;
Ok(XChaCha20Poly1305Data {
nonce: nonce.to_vec(),
content,
})
}
fn decrypt(mut self, key: &[u8]) -> Result<Vec<u8>> {
if key.len() != 32 {
return Err(Error::InvalidEncryptionData("Invalid key length".into()));
}
let key = Key::from_slice(key);
let mut cipher = XChaCha20Poly1305::new(key);
let nonce = XNonce::from_slice(&self.nonce);
cipher
.decrypt_in_place(nonce, &[], &mut self.content)
.map_err(|_| Error::EncryptionFailed)?;
Ok(self.content)
}
}

48
backend/src/error.rs Normal file
View File

@@ -0,0 +1,48 @@
//! Error module for this whole crate
use sea_orm::DbErr;
use std::{fmt, result};
pub type Result<T> = result::Result<T, Error>;
pub enum Error {
DateCalculationFailed,
DatabaseOperationFailed(DbErr),
IpHeaderMissing(String),
IpHeaderInvalid,
SavingFileFailed(std::io::Error),
LoadingFileFailed(std::io::Error),
DeletingFileFailed(std::io::Error),
ReadingDirectoryFailed(std::io::Error),
EncryptionFailed,
DecryptionFailed,
KeyInvalid,
JsonSerializationFailed(serde_json::Error),
InvalidEncryptionData(String),
HashingFailure(String),
HashVerificationFailure(String),
}
impl fmt::Debug for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DateCalculationFailed => write!(f, "Date calculation failed"),
Self::DatabaseOperationFailed(inner) => {
write!(f, "Database operation failed: {inner}")
}
Self::IpHeaderMissing(header_name) => write!(f, "Ip header {header_name} missing"),
Self::IpHeaderInvalid => write!(f, "Ip header invalid"),
Self::SavingFileFailed(inner) => write!(f, "Saving file failed: {inner}"),
Self::LoadingFileFailed(inner) => write!(f, "Loading file failed: {inner}"),
Self::DeletingFileFailed(inner) => write!(f, "Removing file failed: {inner}"),
Self::ReadingDirectoryFailed(inner) => write!(f, "Reading directory failed: {inner}"),
Self::EncryptionFailed => write!(f, "Encryption failed"),
Self::DecryptionFailed => write!(f, "Decryption failed"),
Self::KeyInvalid => write!(f, "Key invalid"),
Self::JsonSerializationFailed(inner) => write!(f, "JSON Serialization failed: {inner}"),
Self::InvalidEncryptionData(inner) => write!(f, "Invalid encryption data: {inner}"),
Self::HashingFailure(inner) => write!(f, "Hashing failure: {inner}"),
Self::HashVerificationFailure(inner) => write!(f, "Hash verification failure: {inner}"),
}
}
}

156
backend/src/file.rs Normal file
View File

@@ -0,0 +1,156 @@
//! Module containing functions for saving / reading encrypted data on disk
use super::error::{Error, Result};
use crate::configuration::CONFIGURATION;
use serde::{Deserialize, Serialize};
use std::io;
use std::path::PathBuf;
use std::str::FromStr;
use std::{
fs::{self, OpenOptions},
io::{Read, Write},
};
use uuid::Uuid;
/// File metadata that will be stored serialized and encrypted in the database
#[derive(Serialize, Deserialize)]
pub struct Metadata {
/// Name of the uploaded file
pub file_name: String,
/// MIME type of the uploaded file
pub mime_type: String,
}
/// Stores new file on disk
///
/// # Arguments
///
/// * `id` - File id (to use as file name)
/// * `content` - Content to store
///
/// # Returns
///
/// * [`Ok<PathBuf>`] on success with file path
/// * [`Err<Error>`] on error
pub fn store_data(id: &Uuid, content: Vec<u8>) -> Result<PathBuf> {
let mut file_path = CONFIGURATION.file_path.clone();
file_path.push(id.to_string());
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&file_path)
.map_err(Error::SavingFileFailed)?;
if let Err(error) = file.write_all(&content) {
delete(id)?;
return Err(Error::SavingFileFailed(error));
}
drop(content);
if let Err(error) = file.sync_all() {
delete(id)?;
return Err(Error::SavingFileFailed(error));
}
Ok(file_path)
}
/// Retrieves the Ids of all stored files.
///
/// This function reads the directory and collects the UUIDs of all files stored.
///
/// # Returns
///
/// * [`Ok<Vec<Uuid>>`] - A vector containing the UUIDs of all stored files.
/// * [`Err<Error>`] on error
pub fn get_stored_file_ids() -> Result<Vec<Uuid>> {
let mut file_ids = vec![];
let read_dir =
fs::read_dir(CONFIGURATION.file_path.clone()).map_err(Error::ReadingDirectoryFailed)?;
for dir_entry in read_dir {
let file_name = dir_entry
.map_err(Error::ReadingDirectoryFailed)?
.file_name();
let file_name = file_name
.to_str()
.ok_or(Error::ReadingDirectoryFailed(io::Error::new(
io::ErrorKind::Other,
"Could not get file name",
)))?;
let file_id = Uuid::from_str(file_name).map_err(|_| {
Error::ReadingDirectoryFailed(io::Error::new(
io::ErrorKind::InvalidData,
format!("File name not a Uuid: {file_name}"),
))
})?;
file_ids.push(file_id);
}
Ok(file_ids)
}
/// Load data from disk
///
/// # Arguments
///
/// * `id` - File id
///
/// # Returns
///
/// * [`Ok<Vec<u8>>`] on success, containing file content
/// * [`Err<Error>`] on error
pub fn load_data(id: &Uuid) -> Result<Vec<u8>> {
let mut file_path = CONFIGURATION.file_path.clone();
file_path.push(id.to_string());
let mut content = vec![];
let mut file = OpenOptions::new()
.read(true)
.open(&file_path)
.map_err(Error::LoadingFileFailed)?;
if let Err(error) = file.read_to_end(&mut content) {
return Err(Error::LoadingFileFailed(error));
};
Ok(content)
}
/// Ensure file is deleted
///
/// # Arguments
///
/// * `id` - File id
///
/// # Returns
///
/// * [`Ok<()>`] ensuring file doesn't exist (anymore)
/// * [`Err<Error>`] on error
pub fn delete(id: &Uuid) -> Result<()> {
let mut file_path = CONFIGURATION.file_path.clone();
file_path.push(id.to_string());
if !(fs::exists(&file_path).map_err(Error::DeletingFileFailed)?) {
return Ok(());
}
if !fs::metadata(&file_path)
.map_err(Error::DeletingFileFailed)?
.is_file()
{
return Err(Error::DeletingFileFailed(io::Error::new(
io::ErrorKind::IsADirectory,
"Directory given",
)));
}
fs::remove_file(&file_path).map_err(Error::DeletingFileFailed)
}

View File

@@ -0,0 +1,28 @@
use super::definitions::Hashing;
use crate::error::{Error, Result};
use argon2::password_hash::{
rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString,
};
/// A struct representing the Argon2 hashing algorithm.
pub struct Argon2 {}
impl Hashing for Argon2 {
fn hash(data: &[u8]) -> Result<String> {
let salt = SaltString::generate(&mut OsRng);
Ok(argon2::Argon2::default()
.hash_password(data, &salt)
.map_err(|error| Error::HashingFailure(error.to_string()))?
.to_string())
}
fn verify(data: &[u8], hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(hash)
.map_err(|error| Error::HashVerificationFailure(error.to_string()))?;
Ok(argon2::Argon2::default()
.verify_password(data, &parsed_hash)
.is_ok())
}
}

View File

@@ -0,0 +1,30 @@
use crate::error::Result;
/// Provides functions to hash data or to verify hashes
pub trait Hashing {
/// Hashes given `data`
///
/// # Arguments
///
/// * `data` - Data to hash
///
/// # Returns
///
/// * [`Ok<String>`] on success, containing the hash
/// * [`Err<Error>`] on error
fn hash(data: &[u8]) -> Result<String>;
/// Verifies given `data` against `hash`
///
/// # Arguments
///
/// * `data` - Data to verify hash against
/// * `hash` - Hash to verify
///
/// # Returns
///
/// * [`Ok<true>`] on `data` matching `hash`
/// * [`Ok<false>`] on `data` **not** matching `hash`
/// * [`Err<Error>`] on error
fn verify(data: &[u8], hash: &str) -> Result<bool>;
}

9
backend/src/hash/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
//! Hash module.
//!
//! This module provides hashing functionalities. It includes submodules
//! for Argon2 hashing and hash definitions.
mod argon2;
mod definitions;
pub use argon2::Argon2 as Hash;
pub use definitions::*;

105
backend/src/main.rs Normal file
View File

@@ -0,0 +1,105 @@
use configuration::CONFIGURATION;
use migration::{Migrator, MigratorTrait};
use sea_orm::{ConnectOptions, Database, DatabaseConnection};
use std::{process, time::Duration};
use tokio::{signal::ctrl_c, task::JoinSet, sync::broadcast};
mod api;
mod cleanup;
mod configuration;
mod database;
mod encryption;
mod error;
mod file;
mod hash;
mod request;
mod util;
#[tokio::main]
async fn main() {
env_logger::init();
/* Init configuration */
let connection_string = &CONFIGURATION.connection_string;
let database_connection = match setup_database(connection_string).await {
Some(database_connection) => database_connection,
None => {
log::error!("Bye.");
process::exit(1);
}
};
let mut join_set = JoinSet::new();
let (shutdown_tx, _) = broadcast::channel(1);
let api_database_connection = database_connection.clone();
let api_shutdown_rx = shutdown_tx.subscribe();
join_set.spawn(async move {
if let Err(error) = api::listen(api_database_connection, api_shutdown_rx).await {
log::error!("API failed: {error}");
}
});
let cleanup_database_connection = database_connection.clone();
let cleanup_shutdown_rx = shutdown_tx.subscribe();
join_set.spawn(async move {
if let Err(error) = cleanup::run(cleanup_database_connection, cleanup_shutdown_rx).await {
log::error!("Cleanup failed: {:?}", error);
}
});
join_set.spawn(async move {
use log::{error, info};
if let Err(error) = ctrl_c().await {
error!("Could not listen to ctrl+c (SIGINT): {error}");
error!("Exiting process. Bye.");
process::exit(1);
}
info!("Received ctrl+c (SIGINT)");
if let Err(error) = shutdown_tx.send(()) {
log::error!("Could not send shutdown signal: {error}");
}
});
join_set.join_all().await;
log::info!("Closing database connection...");
if let Err(error) = database_connection.close().await {
log::error!("Could not close database connection: {error}");
}
log::info!("Bye.");
}
async fn setup_database(connection_string: &str) -> Option<DatabaseConnection> {
let mut connect_options = ConnectOptions::new(connection_string);
log::info!("Connecting and setting up database (connection timeout is 8 secs)...");
connect_options
.sqlx_logging_level(log::LevelFilter::Debug)
.max_connections(5)
.min_connections(1)
.connect_timeout(Duration::from_secs(8))
.acquire_timeout(Duration::from_secs(8))
.idle_timeout(Duration::from_secs(8))
.max_lifetime(Duration::from_secs(8));
let Ok(database_connection) = Database::connect(connect_options).await else {
log::error!("Could not connect to database. Bye.");
return None;
};
if Migrator::up(&database_connection, None).await.is_err() {
log::error!("Could not migrate database");
return None;
};
Some(database_connection)
}

134
backend/src/request.rs Normal file
View File

@@ -0,0 +1,134 @@
//! Module containing functions that are related to HTTP requests
use super::error::{Error, Result};
use crate::configuration::CONFIGURATION;
use crate::file;
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
use axum::http::HeaderMap;
use regex::Regex;
use std::sync::LazyLock;
use uuid::Uuid;
const FALLBACK_CONTENT_TYPE: &str = "application/octet-stream";
static FILE_NAME_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new("filename=\"(.*?)\"").unwrap());
/// Tries getting request Ip from given `headers`
///
/// The header name defined in [`CONFIGURATION`] will be checked for an (Ip)
/// value and then returned. If the value is missing / empty, an [`Error`] is
/// returned.
///
/// # Arguments
///
/// * `headers` - Headers to check
///
/// # Returns
///
/// * [`Ok<String>`] on success, containing the request Ip
/// * [`Err<Error>`] on error
pub fn get_request_ip(headers: &HeaderMap) -> Result<String> {
Ok(headers
.get(CONFIGURATION.ip_header_name.clone())
.ok_or(Error::IpHeaderMissing(CONFIGURATION.ip_header_name.clone()))?
.to_str()
.map_err(|_| Error::IpHeaderInvalid)?
.to_string())
}
impl From<file::Metadata> for HeaderMap {
fn from(val: file::Metadata) -> Self {
let mut headers = HeaderMap::new();
if let Ok(content_disposition) =
format!("attachment; filename=\"{}\"", val.file_name).parse()
{
headers.append(CONTENT_DISPOSITION, content_disposition);
}
if let Ok(content_type) = val.mime_type.parse() {
headers.append(CONTENT_TYPE, content_type);
}
headers
}
}
impl From<HeaderMap> for file::Metadata {
fn from(value: HeaderMap) -> Self {
let file_name = value
.get(CONTENT_DISPOSITION)
.and_then(|header_value| header_value.to_str().map(String::from).ok())
.and_then(|header_value| {
FILE_NAME_REGEX
.captures(&header_value)
.and_then(|captures| captures.get(1))
.map(|capture| capture.as_str().to_string())
});
let mime_type = value
.get(CONTENT_TYPE)
.and_then(|header_value| header_value.to_str().map(String::from).ok());
Self {
file_name: file_name.unwrap_or(Uuid::new_v4().to_string()),
mime_type: mime_type.unwrap_or(FALLBACK_CONTENT_TYPE.into()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_metadata_to_headers() {
let metadata = file::Metadata {
file_name: "My file.exe".into(),
mime_type: "my/mimetype".into(),
};
let headers: HeaderMap = metadata.into();
assert_eq!(2, headers.len());
assert_eq!(
"attachment; filename=\"My file.exe\"",
headers
.get("Content-Disposition")
.unwrap()
.to_str()
.unwrap()
);
assert_eq!(
"my/mimetype",
headers.get("Content-Type").unwrap().to_str().unwrap()
)
}
#[test]
fn test_from_headers_to_metadata() {
let mut headers = HeaderMap::new();
headers.append(
"Content-Disposition",
"attachment; filename=\"My file.gif\" what=ever"
.parse()
.unwrap(),
);
headers.append("Content-Type", "my/mime+type".parse().unwrap());
let metadata: file::Metadata = headers.into();
assert_eq!("My file.gif", metadata.file_name);
assert_eq!("my/mime+type", metadata.mime_type);
}
#[test]
fn test_with_missing_headers_to_metadata() {
let headers = HeaderMap::new();
let metadata: file::Metadata = headers.into();
assert!(!metadata.file_name.is_empty());
assert_eq!("application/octet-stream", metadata.mime_type);
}
}

58
backend/src/util.rs Normal file
View File

@@ -0,0 +1,58 @@
//! Module with utilites that can't be categorized otherwise
use crate::error::{Error, Result};
use crate::hash::{Hash, Hashing};
use base64::prelude::BASE64_URL_SAFE;
use base64::Engine;
/// Decodes and validates given file encryption key
///
/// Given `encoded_key` is decoded and then checked against given `hash`.
/// If the key is valid, it will be returned, otherwise error.
///
/// # Arguments
///
/// * `encoded_key` - File encryption key to decode and check
/// * `hash` - Hash to check key against
///
/// # Returns
///
/// * [`Ok<Vec<u8>>`] containing decoded and validated key
/// * [`Err<Error>`] on error
pub fn get_validated_key(encoded_key: &str, hash: &str) -> Result<Vec<u8>> {
let key = BASE64_URL_SAFE
.decode(encoded_key)
.map_err(|_| Error::KeyInvalid)?;
match Hash::verify(&key, hash).map_err(|_| Error::KeyInvalid) {
Ok(true) => Ok(key),
_ => Err(Error::KeyInvalid),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_key_returned() {
let result = get_validated_key(
"MQ==", // "1"
"$argon2id$v=19$m=12,t=3,p=1$dzc0OGd1OWZveHMwMDAwMA$c76OJ4RDh1TlW1tdcbimWA",
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), [49]); // ["1"]
}
#[test]
fn invalid_input_handled() {
assert!(get_validated_key("MQ==", "xxxYYY").is_err());
assert!(get_validated_key(
"@@@",
"$argon2id$v=19$m=12,t=3,p=1$dzc0OGd1OWZveHMwMDAwMA$c76OJ4RDh1TlW1tdcbimWA"
)
.is_err());
}
}

17
frontend/.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

42
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
dist/
tmp/
out-tsc/
bazel-out/
# Node
node_modules/
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

4
frontend/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
frontend/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
frontend/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

59
frontend/README.md Normal file
View File

@@ -0,0 +1,59 @@
# TreasureChest
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.1.6.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

107
frontend/angular.json Normal file
View File

@@ -0,0 +1,107 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"treasure_chest": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/treasure_chest",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/rose-red.css",
"src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "treasure_chest:build:production"
},
"development": {
"buildTarget": "treasure_chest:build:development"
}
},
"defaultConfiguration": "development",
"options": {
"proxyConfig": "src/proxy.conf.json"
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"@angular/material/prebuilt-themes/rose-red.css",
"src/styles.scss"
],
"scripts": []
}
}
}
}
}
}

14546
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "treasure-chest",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"private": true,
"dependencies": {
"@angular/animations": "^19.1.0",
"@angular/cdk": "^19.1.3",
"@angular/common": "^19.1.0",
"@angular/compiler": "^19.1.0",
"@angular/core": "^19.1.0",
"@angular/forms": "^19.1.0",
"@angular/material": "^19.1.3",
"@angular/platform-browser": "^19.1.0",
"@angular/platform-browser-dynamic": "^19.1.0",
"@angular/router": "^19.1.0",
"@ngrx/signals": "^19.0.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.1.6",
"@angular/cli": "^19.1.6",
"@angular/compiler-cli": "^19.1.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.5.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1 @@
<router-outlet />

View File

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
export class AppComponent {
}

View File

@@ -0,0 +1,14 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withComponentInputBinding()),
provideAnimationsAsync(),
provideHttpClient(withInterceptorsFromDi())
]
};

View File

@@ -0,0 +1,23 @@
import { Routes } from '@angular/router';
import { UploadComponent } from '../upload/upload.component';
import { DownloadComponent } from '../download/download.component';
import { ErrorComponent } from '../error/error.component';
export const routes: Routes = [
{
'path': '',
'component': UploadComponent
},
{
'path': 'download',
'component': DownloadComponent
},
{
'path': 'error/:errorCode',
'component': ErrorComponent
},
{
'path': '**',
'redirectTo': '/error/404'
}
];

View File

@@ -0,0 +1,36 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'tcReadableBytes'
})
export class ToReadableBytesPipe implements PipeTransform {
private readonly bytesMapping = {
TB: 1_000_000_000_000,
GB: 1_000_000_000,
MB: 1_000_000,
KB: 1_000,
bytes: 1
}
transform(value: any, ...args: any[]) {
if (!value) {
return '- bytes';
}
if (!Number.isInteger(value)) {
throw new Error(`Given value '${value}' is not an int.`);
}
let suffix: keyof typeof this.bytesMapping;
for (suffix in this.bytesMapping) {
const minValue = this.bytesMapping[suffix];
if (value / minValue < 1) {
continue;
}
return `${Math.round(value / minValue)} ${suffix}`;
}
return '0';
}
}

View File

@@ -0,0 +1 @@
<p>download works!</p>

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-download',
imports: [],
templateUrl: './download.component.html',
styleUrl: './download.component.scss'
})
export class DownloadComponent {
}

View File

@@ -0,0 +1,3 @@
<h1>Oops! An error occurred.</h1>
<div class="error-code">{{errorCode()}}</div>
<div class="error-message">{{errorMessage()}}</div>

View File

@@ -0,0 +1,13 @@
* {
width: 100%;
text-align: center;
}
h1 {
padding-bottom: 50px;
}
.error-code {
font-weight: bold;
font-size: 10em;
}

View File

@@ -0,0 +1,32 @@
import { Component, computed, input } from '@angular/core';
@Component({
selector: 'app-error',
imports: [],
templateUrl: './error.component.html',
styleUrl: './error.component.scss'
})
export class ErrorComponent {
protected readonly errorCode = input.required<string>();
protected readonly errorMessage = computed(() => {
const errorCode = this.errorCode();
switch (errorCode) {
case '401':
return 'Unauthorized (Key wrong)';
case '404':
return 'Resource not found';
case '412':
return 'Payload too large (File too large)';
case '429':
return 'Too many requests (Reached daily limit)';
case '431':
return 'Request Header Fields Too Large (File name or MIME type too long)';
case '500':
case '502':
return 'Internal server error';
default:
return `An error occurred (${errorCode})`
}
});
}

View File

@@ -0,0 +1,9 @@
export interface Configuration {
BodyMaxSize: number,
DaysFileAvailable: number,
}
export interface UploadResponse {
id: string,
key: string,
}

View File

@@ -0,0 +1,24 @@
import { HttpClient, HttpHeaderResponse, HttpHeaders } from '@angular/common/http';
import { inject, Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { Configuration, UploadResponse } from './http.models';
@Injectable({
providedIn: 'root',
})
export class HttpService {
private readonly httpClient = inject(HttpClient);
public loadOptions(): Observable<Configuration> {
return this.httpClient.get<Configuration>('/api/configuration');
}
public uploadFile(file: File): Observable<UploadResponse> {
const headers = new HttpHeaders()
.append('Content-Type', file.type)
.append('Content-Disposition', `filename="${file.name}"`)
// .append('X-Forwarded-For', '127.0.0.1');
return this.httpClient.post<UploadResponse>('/api/files', file.bytes, { headers });
}
}

18
frontend/src/index.html Normal file
View File

@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>treasure_chest</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@@ -0,0 +1,8 @@
{
"/api": {
"target": "http://localhost:8000",
"secure": false,
"changeOrigin": true,
"logLevel": "debug"
}
}

18
frontend/src/styles.scss Normal file
View File

@@ -0,0 +1,18 @@
@use '@angular/material' as mat;
html {
height: 100%;
color-scheme: light dark;
@include mat.theme((color: mat.$violet-palette,
typography: Roboto,
density: 0));
body {
height: 100%;
margin: 0;
padding: 5%;
font-family: Roboto, "Helvetica Neue", sans-serif;
}
}

View File

@@ -0,0 +1,19 @@
<mat-chip-set>
<mat-chip>
<mat-icon matChipAvatar>difference</mat-icon>
<div>
Maximum file size: {{ configuration().BodyMaxSize | tcReadableBytes }}
</div>
</mat-chip>
<mat-chip>
<mat-icon matChipAvatar>calendar_month</mat-icon>
<div>
Availability: {{ configuration().DaysFileAvailable }}
@if (configuration().DaysFileAvailable === 1) {
day
} @else {
days
}
</div>
</mat-chip>
</mat-chip-set>

View File

@@ -0,0 +1,7 @@
mat-chip-set {
margin-bottom: 15px;
}
mat-chip {
background-color: var(--mat-sys-on-secondary);
}

View File

@@ -0,0 +1,14 @@
import { Component, input, signal } from '@angular/core';
import { Configuration } from '../../http/http.models';
import { ToReadableBytesPipe } from '../../conversion/to-readable-bytes.pipe';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'tc-configuration',
imports: [ToReadableBytesPipe, MatChipsModule, MatIconModule],
templateUrl: './configuration.component.html',
styleUrl: './configuration.component.scss'
})
export class ConfigurationComponent {
public readonly configuration = input.required<Configuration>();
}

View File

@@ -0,0 +1,10 @@
<button mat-flat-button type="file" (click)="onClick()">
<mat-icon>upload_file</mat-icon>
@if (hasFile()) {
Change file
} @else {
Choose file
}
</button>
<input #fileInput type="file" (change)="onFileSelected($event)">

View File

@@ -0,0 +1,11 @@
button {
width: 100%;
mat-icon {
display: contents;
}
}
input {
display: none;
}

View File

@@ -0,0 +1,38 @@
import { CommonModule } from '@angular/common';
import { Component, ElementRef, inject, output, signal, viewChild } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'tc-file-chooser',
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
],
templateUrl: './file-chooser.component.html',
styleUrl: './file-chooser.component.scss'
})
export class FileChooserComponent {
protected fileInput = viewChild<ElementRef>('fileInput')
protected readonly file = output<File>();
protected readonly hasFile = signal<boolean>(false);
protected onClick() {
this.fileInput()?.nativeElement.click();
}
protected onFileSelected(event: Event) {
event.preventDefault();
const files = (event.target as HTMLInputElement).files ?? [];
const file = files.length > 0 ? files[0] : null;
if (!file) {
return;
}
this.file.emit(file);
this.hasFile.set(true);
}
}

View File

@@ -0,0 +1,13 @@
<mat-card class="info-card" appearance="outlined">
<mat-card-header>
<mat-card-title>
{{ file().name || '-'}}
</mat-card-title>
</mat-card-header>
<mat-card-footer class="footer">
<mat-chip-set>
<mat-chip>{{ file().size | tcReadableBytes }}</mat-chip>
<mat-chip>{{ file().type || 'unknown/unknown'}}</mat-chip>
</mat-chip-set>
</mat-card-footer>
</mat-card>

View File

@@ -0,0 +1,17 @@
.info-card {
width: 100%;
margin-bottom: 20px;
mat-card-header {
padding: 16px;
}
button {
width: initial;
margin: 0 15px 15px 15px;
}
.footer {
padding: 16px;
}
}

View File

@@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { Component, input, } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { ToReadableBytesPipe } from '../../conversion/to-readable-bytes.pipe';
@Component({
selector: 'tc-file-info',
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatCardModule,
MatChipsModule,
ToReadableBytesPipe],
templateUrl: './file-info.component.html',
styleUrl: './file-info.component.scss'
})
export class FileInfoComponent {
public readonly file = input.required<File>();
}

View File

@@ -0,0 +1,35 @@
@let configuration = config();
@let uploadedFile = file();
@let canUpload = uploadAllowed();
<h1>Upload new file</h1>
<div class="container">
@if (configuration !== null) {
<tc-configuration [configuration]="configuration"></tc-configuration>
}
@if (uploadedFile) {
<tc-file-info [file]="uploadedFile"></tc-file-info>
}
<div class="buttons">
<tc-file-chooser (file)="onFile($event)"></tc-file-chooser>
@if (!uploadedFile || canUpload) {
<button mat-flat-button type="file" (click)="onUploadClick()" [disabled]="!canUpload">
<mat-icon>cloud_upload</mat-icon>
Upload file
</button>
} @else {
<button mat-flat-button type="file" (click)="onUploadClick()" disabled="true">
<mat-icon>cloud_upload</mat-icon>
File too large.
</button>
}
</div>
</div>
<p>
Some explaination about what this does yada yada
</p>

View File

@@ -0,0 +1,23 @@
.container {
line-break: anywhere;
.buttons {
width: 100%;
margin: 15px 0;
display: flex;
gap: 15px;
align-content: stretch;
flex-wrap: wrap;
tc-file-chooser,
button {
min-width: 150px;
flex-grow: 1;
}
}
.error-message {
display: flex;
color: #d00;
}
}

View File

@@ -0,0 +1,67 @@
import { CommonModule } from '@angular/common';
import { Component, computed, DestroyRef, inject, output, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { Configuration, UploadResponse } from '../../http/http.models';
import { HttpService } from '../../http/http.service';
import { ConfigurationComponent } from '../configuration/configuration.component';
import { FileChooserComponent } from '../file-chooser/file-chooser.component';
import { FileInfoComponent } from '../file-info/file-info.component';
@Component({
selector: 'tc-file-uploader',
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatCardModule,
MatChipsModule,
ConfigurationComponent,
FileInfoComponent,
FileChooserComponent,
],
templateUrl: './file-uploader.component.html',
styleUrl: './file-uploader.component.scss'
})
export class FileUploaderComponent {
public readonly encryptedFile = output<UploadResponse>();
private readonly httpService = inject(HttpService);
private readonly destroyRef = inject(DestroyRef)
protected readonly config = signal<Configuration | null>(null);
protected readonly file = signal<File | null>(null);
protected readonly uploadAllowed = computed(() => {
const configuration = this.config();
const file = this.file();
return !!configuration && !!file && file.size <= configuration.BodyMaxSize;
});
constructor() {
this.httpService.loadOptions()
// TODO: Error handling
.pipe(takeUntilDestroyed())
.subscribe(this.config.set);
}
protected onFile(file: File) {
this.file.set(file);
}
protected onUploadClick() {
const file = this.file();
if (!file) {
return;
}
this.httpService.uploadFile(file)
// TODO: Error handling
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(response => this.encryptedFile.emit(response));
}
}

View File

@@ -0,0 +1,7 @@
<div class="container">
<h1>Success!</h1>
<p>Your file has been uploaded successfully.</p>
Link to file: /api/files/{{ file().id }}/download<br>
Key: {{ file().key }}
</div>

View File

@@ -0,0 +1,23 @@
import { CommonModule } from '@angular/common';
import { Component, computed, input, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { UploadResponse } from '../../http/http.models';
@Component({
selector: 'tc-upload-information',
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatCardModule,
MatChipsModule,
],
templateUrl: './upload-information.component.html',
styleUrl: './upload-information.component.scss'
})
export class UploadInformationComponent {
readonly file = input.required<UploadResponse>();
}

View File

@@ -0,0 +1,6 @@
@let file = encryptedFile();
@if (file) {
<tc-upload-information [file]="file"></tc-upload-information>
} @else {
<tc-file-uploader (encryptedFile)="onEncryptedFile($event)"></tc-file-uploader>
}

View File

@@ -0,0 +1,23 @@
.container {
line-break: anywhere;
.buttons {
width: 100%;
margin: 15px 0;
display: flex;
gap: 15px;
align-content: stretch;
flex-wrap: wrap;
tc-file-chooser,
button {
min-width: 150px;
flex-grow: 1;
}
}
.error-message {
display: flex;
color: #d00;
}
}

View File

@@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common';
import { Component, signal } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatIconModule } from '@angular/material/icon';
import { UploadResponse } from '../http/http.models';
import { FileUploaderComponent } from "./file-uploader/file-uploader.component";
import { UploadInformationComponent } from './upload-information/upload-information.component';
@Component({
selector: 'tc-upload',
imports: [
CommonModule,
MatButtonModule,
MatIconModule,
MatCardModule,
MatChipsModule,
FileUploaderComponent,
UploadInformationComponent,
],
templateUrl: './upload.component.html',
styleUrl: './upload.component.scss'
})
export class UploadComponent {
protected readonly encryptedFile = signal<UploadResponse | null>(null);
protected onEncryptedFile(encryptedFile: UploadResponse) {
this.encryptedFile.set(encryptedFile);
}
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

27
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}