Initial commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
target/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
.env
|
||||||
|
config.json
|
||||||
|
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
21
README.md
Normal 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
12
SECURITY.md
Normal 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
120
backend/.vscode/launch.json
vendored
Normal 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
4025
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
backend/Cargo.toml
Normal file
33
backend/Cargo.toml
Normal 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"] }
|
||||||
18
backend/config.json.example
Normal file
18
backend/config.json.example
Normal 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
1
backend/development/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
TESTFILE
|
||||||
13
backend/development/ALLOCATION.md
Normal file
13
backend/development/ALLOCATION.md
Normal 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
11
backend/entity/Cargo.toml
Normal 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" }
|
||||||
35
backend/entity/access_log.rs
Normal file
35
backend/entity/access_log.rs
Normal 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
31
backend/entity/file.rs
Normal 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
8
backend/entity/mod.rs
Normal 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::*;
|
||||||
4
backend/entity/prelude.rs
Normal file
4
backend/entity/prelude.rs
Normal 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;
|
||||||
16
backend/migration/Cargo.toml
Normal file
16
backend/migration/Cargo.toml
Normal 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"]
|
||||||
12
backend/migration/src/lib.rs
Normal file
12
backend/migration/src/lib.rs
Normal 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)]
|
||||||
|
}
|
||||||
|
}
|
||||||
98
backend/migration/src/m20250114_200507_create_tables.rs
Normal file
98
backend/migration/src/m20250114_200507_create_tables.rs
Normal 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,
|
||||||
|
}
|
||||||
6
backend/migration/src/main.rs
Normal file
6
backend/migration/src/main.rs
Normal 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
12
backend/src/api/mod.rs
Normal 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;
|
||||||
30
backend/src/api/routes/configuration.rs
Normal file
30
backend/src/api/routes/configuration.rs
Normal 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)
|
||||||
|
}
|
||||||
88
backend/src/api/routes/download.rs
Normal file
88
backend/src/api/routes/download.rs
Normal 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))
|
||||||
|
}
|
||||||
129
backend/src/api/routes/upload.rs
Normal file
129
backend/src/api/routes/upload.rs
Normal 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
81
backend/src/api/server.rs
Normal 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
71
backend/src/cleanup.rs
Normal 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(())
|
||||||
|
}
|
||||||
87
backend/src/configuration.rs
Normal file
87
backend/src/configuration.rs
Normal 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
283
backend/src/database.rs
Normal 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)
|
||||||
|
}
|
||||||
71
backend/src/encryption/definitions.rs
Normal file
71
backend/src/encryption/definitions.rs
Normal 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>>;
|
||||||
|
}
|
||||||
10
backend/src/encryption/mod.rs
Normal file
10
backend/src/encryption/mod.rs
Normal 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;
|
||||||
94
backend/src/encryption/xchacha20poly1305.rs
Normal file
94
backend/src/encryption/xchacha20poly1305.rs
Normal 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
48
backend/src/error.rs
Normal 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
156
backend/src/file.rs
Normal 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)
|
||||||
|
}
|
||||||
28
backend/src/hash/argon2.rs
Normal file
28
backend/src/hash/argon2.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/src/hash/definitions.rs
Normal file
30
backend/src/hash/definitions.rs
Normal 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
9
backend/src/hash/mod.rs
Normal 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
105
backend/src/main.rs
Normal 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
134
backend/src/request.rs
Normal 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
58
backend/src/util.rs
Normal 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
17
frontend/.editorconfig
Normal 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
42
frontend/.gitignore
vendored
Normal 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
4
frontend/.vscode/extensions.json
vendored
Normal 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
20
frontend/.vscode/launch.json
vendored
Normal 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
42
frontend/.vscode/tasks.json
vendored
Normal 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
59
frontend/README.md
Normal 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
107
frontend/angular.json
Normal 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
14546
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
frontend/package.json
Normal file
41
frontend/package.json
Normal 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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
frontend/src/app/app.component.html
Normal file
1
frontend/src/app/app.component.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<router-outlet />
|
||||||
0
frontend/src/app/app.component.scss
Normal file
0
frontend/src/app/app.component.scss
Normal file
11
frontend/src/app/app.component.ts
Normal file
11
frontend/src/app/app.component.ts
Normal 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 {
|
||||||
|
}
|
||||||
14
frontend/src/app/app.config.ts
Normal file
14
frontend/src/app/app.config.ts
Normal 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())
|
||||||
|
]
|
||||||
|
};
|
||||||
23
frontend/src/app/app.routes.ts
Normal file
23
frontend/src/app/app.routes.ts
Normal 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'
|
||||||
|
}
|
||||||
|
];
|
||||||
36
frontend/src/conversion/to-readable-bytes.pipe.ts
Normal file
36
frontend/src/conversion/to-readable-bytes.pipe.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
1
frontend/src/download/download.component.html
Normal file
1
frontend/src/download/download.component.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<p>download works!</p>
|
||||||
0
frontend/src/download/download.component.scss
Normal file
0
frontend/src/download/download.component.scss
Normal file
11
frontend/src/download/download.component.ts
Normal file
11
frontend/src/download/download.component.ts
Normal 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 {
|
||||||
|
|
||||||
|
}
|
||||||
3
frontend/src/error/error.component.html
Normal file
3
frontend/src/error/error.component.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<h1>Oops! An error occurred.</h1>
|
||||||
|
<div class="error-code">{{errorCode()}}</div>
|
||||||
|
<div class="error-message">{{errorMessage()}}</div>
|
||||||
13
frontend/src/error/error.component.scss
Normal file
13
frontend/src/error/error.component.scss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
* {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
padding-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 10em;
|
||||||
|
}
|
||||||
32
frontend/src/error/error.component.ts
Normal file
32
frontend/src/error/error.component.ts
Normal 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})`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
9
frontend/src/http/http.models.ts
Normal file
9
frontend/src/http/http.models.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export interface Configuration {
|
||||||
|
BodyMaxSize: number,
|
||||||
|
DaysFileAvailable: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResponse {
|
||||||
|
id: string,
|
||||||
|
key: string,
|
||||||
|
}
|
||||||
24
frontend/src/http/http.service.ts
Normal file
24
frontend/src/http/http.service.ts
Normal 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
18
frontend/src/index.html
Normal 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
6
frontend/src/main.ts
Normal 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));
|
||||||
8
frontend/src/proxy.conf.json
Normal file
8
frontend/src/proxy.conf.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:8000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true,
|
||||||
|
"logLevel": "debug"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/src/styles.scss
Normal file
18
frontend/src/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
mat-chip-set {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mat-chip {
|
||||||
|
background-color: var(--mat-sys-on-secondary);
|
||||||
|
}
|
||||||
14
frontend/src/upload/configuration/configuration.component.ts
Normal file
14
frontend/src/upload/configuration/configuration.component.ts
Normal 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>();
|
||||||
|
}
|
||||||
10
frontend/src/upload/file-chooser/file-chooser.component.html
Normal file
10
frontend/src/upload/file-chooser/file-chooser.component.html
Normal 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)">
|
||||||
11
frontend/src/upload/file-chooser/file-chooser.component.scss
Normal file
11
frontend/src/upload/file-chooser/file-chooser.component.scss
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
mat-icon {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
38
frontend/src/upload/file-chooser/file-chooser.component.ts
Normal file
38
frontend/src/upload/file-chooser/file-chooser.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
frontend/src/upload/file-info/file-info.component.html
Normal file
13
frontend/src/upload/file-info/file-info.component.html
Normal 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>
|
||||||
17
frontend/src/upload/file-info/file-info.component.scss
Normal file
17
frontend/src/upload/file-info/file-info.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
frontend/src/upload/file-info/file-info.component.ts
Normal file
23
frontend/src/upload/file-info/file-info.component.ts
Normal 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>();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
frontend/src/upload/file-uploader/file-uploader.component.ts
Normal file
67
frontend/src/upload/file-uploader/file-uploader.component.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
6
frontend/src/upload/upload.component.html
Normal file
6
frontend/src/upload/upload.component.html
Normal 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>
|
||||||
|
}
|
||||||
23
frontend/src/upload/upload.component.scss
Normal file
23
frontend/src/upload/upload.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
31
frontend/src/upload/upload.component.ts
Normal file
31
frontend/src/upload/upload.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/tsconfig.app.json
Normal file
15
frontend/tsconfig.app.json
Normal 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
27
frontend/tsconfig.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
15
frontend/tsconfig.spec.json
Normal file
15
frontend/tsconfig.spec.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user