Compare commits

...

6 Commits

Author SHA1 Message Date
409
676f0ca01c fix share list including expired shares 2025-09-07 18:04:36 +02:00
409
92b6d6f1dd fix DirectoryEntry menu action clicks clearing selection 2025-09-07 17:54:43 +02:00
409
754dd8b053 fix TextEditor initial line number 2025-09-07 17:35:11 +02:00
409
6fa26b3ddb remove sqlite extensions to fix docker issue
UUIDs are now generated in the backend before insertion
2025-09-07 17:35:07 +02:00
409
a1c9832515 migrate to sqlite
NOTE: extension loading crashes docker (for some reason)
2025-09-07 15:09:14 +02:00
409
5c3057e998 fix file rename 2025-09-06 21:15:46 +02:00
50 changed files with 1049 additions and 316 deletions

View File

@@ -12,3 +12,4 @@ frontend/node_modules
backend/target
backend/.gitignore
backend/data

1
backend/.gitignore vendored
View File

@@ -1,3 +1,4 @@
target
serve
.env
data

2
backend/Cargo.lock generated
View File

@@ -1235,6 +1235,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
@@ -2720,6 +2721,7 @@ version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
dependencies = [
"getrandom 0.3.3",
"js-sys",
"serde",
"wasm-bindgen",

View File

@@ -13,7 +13,7 @@ path = "src/bin/backend/main.rs"
[dependencies]
anyhow = "1.0.98"
argon2 = "0.5.3"
argon2 = { version = "0.5.3", features = ["std"] }
axum = { version = "0.8.4", features = ["multipart", "query"] }
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
base64 = "0.22.1"
@@ -29,13 +29,7 @@ regex = "1.11.1"
rustix = { version = "1.0.8", features = ["fs"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.8.6", features = [
"chrono",
"postgres",
"runtime-tokio",
"time",
"uuid",
] }
sqlx = { version = "0.8.6", features = ["chrono", "runtime-tokio", "sqlite", "time", "uuid"] }
thiserror = "2.0.12"
tokio = { version = "1.46.1", features = ["full"] }
tokio-stream = "0.1.17"
@@ -45,5 +39,5 @@ tower-http = { version = "0.6.6", features = ["cors", "fs", "trace"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.19"
url = "2.5.4"
uuid = { version = "1.17.0", features = ["serde"] }
uuid = { version = "1.17.0", features = ["serde", "v4"] }
zip = "4.5.0"

View File

@@ -1,7 +0,0 @@
CREATE TABLE warrens (
id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(),
path VARCHAR NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_warrens_path ON warrens(path);

View File

@@ -1 +0,0 @@
ALTER TABLE warrens ADD COLUMN name VARCHAR NOT NULL;

View File

@@ -1,9 +0,0 @@
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(),
name VARCHAR NOT NULL,
email VARCHAR NOT NULL,
hash VARCHAR NOT NULL,
admin BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1 +0,0 @@
ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);

View File

@@ -1,6 +0,0 @@
CREATE TABLE auth_sessions (
session_id VARCHAR NOT NULL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,11 +0,0 @@
CREATE TABLE user_warrens (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
warren_id UUID NOT NULL REFERENCES warrens(id) ON DELETE CASCADE,
can_create_children BOOLEAN NOT NULL,
can_list_files BOOLEAN NOT NULL,
can_read_files BOOLEAN NOT NULL,
can_modify_files BOOLEAN NOT NULL,
can_delete_files BOOLEAN NOT NULL,
can_delete_warren BOOLEAN NOT NULL,
PRIMARY KEY(user_id, warren_id)
);

View File

@@ -1 +0,0 @@
ALTER TABLE user_warrens DROP COLUMN can_create_children, DROP COLUMN can_delete_warren;

View File

@@ -1,2 +0,0 @@
ALTER TABLE users ALTER COLUMN hash DROP NOT NULL;
ALTER TABLE users ADD COLUMN oidc_sub VARCHAR UNIQUE;

View File

@@ -1,9 +0,0 @@
CREATE TABLE shares (
id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(),
creator_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
warren_id UUID NOT NULL REFERENCES warrens(id) ON DELETE CASCADE,
path VARCHAR NOT NULL,
password_hash VARCHAR NOT NULL,
expires_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,21 +0,0 @@
ALTER TABLE
user_warrens
ADD COLUMN
can_list_shares BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN
can_create_shares BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN
can_modify_shares BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN
can_delete_shares BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE
user_warrens
ALTER COLUMN
can_list_shares DROP DEFAULT,
ALTER COLUMN
can_create_shares DROP DEFAULT,
ALTER COLUMN
can_modify_shares DROP DEFAULT,
ALTER COLUMN
can_delete_shares DROP DEFAULT;

View File

@@ -1 +0,0 @@
ALTER TABLE shares ALTER COLUMN password_hash DROP NOT NULL;

View File

@@ -1 +0,0 @@
CREATE INDEX idx_shares_path ON shares(path);

View File

@@ -1,11 +0,0 @@
INSERT INTO users (
name,
email,
hash,
admin
) VALUES (
'admin',
'admin@example.com',
'$argon2id$v=19$m=19456,t=2,p=1$H1WsElL4921/WD5oPkY7JQ$aHudNG8z0ns3pRULfuDpuEkxPUbGxq9AHC4QGyt5odc',
true
);

View File

@@ -0,0 +1,55 @@
CREATE TABLE users (
id BLOB NOT NULL PRIMARY KEY,
oidc_sub TEXT UNIQUE,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
admin BOOLEAN NOT NULL DEFAULT FALSE,
hash TEXT,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE warrens (
id BLOB NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL UNIQUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_warrens (
user_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE,
warren_id BLOB NOT NULL REFERENCES warrens(id) ON DELETE CASCADE,
can_list_files BOOLEAN NOT NULL,
can_read_files BOOLEAN NOT NULL,
can_modify_files BOOLEAN NOT NULL,
can_delete_files BOOLEAN NOT NULL,
can_list_shares BOOLEAN NOT NULL,
can_create_shares BOOLEAN NOT NULL,
can_modify_shares BOOLEAN NOT NULL,
can_delete_shares BOOLEAN NOT NULL,
PRIMARY KEY(user_id, warren_id)
);
CREATE TABLE shares (
id BLOB NOT NULL PRIMARY KEY,
creator_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE,
warren_id BLOB NOT NULL REFERENCES warrens(id) ON DELETE CASCADE,
path TEXT NOT NULL,
password_hash TEXT,
expires_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_shares_path ON shares(path);
CREATE TABLE auth_sessions (
session_id TEXT NOT NULL PRIMARY KEY,
user_id BLOB NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE application_options (
key TEXT NOT NULL PRIMARY KEY,
value TEXT NOT NULL
);

View File

@@ -7,7 +7,7 @@ use warren::{
metrics_debug_logger::MetricsDebugLogger,
notifier_debug_logger::NotifierDebugLogger,
oidc::{Oidc, OidcConfig},
postgres::{Postgres, PostgresConfig},
sqlite::{Sqlite, SqliteConfig},
},
};
@@ -25,16 +25,15 @@ async fn main() -> anyhow::Result<()> {
let metrics = MetricsDebugLogger::new();
let notifier = NotifierDebugLogger::new();
let postgres_config =
PostgresConfig::new(config.database_url.clone(), config.database_name.clone());
let postgres = Postgres::new(postgres_config).await?;
let sqlite_config = SqliteConfig::new(config.database_url.clone());
let sqlite = Sqlite::new(sqlite_config).await?;
let fs_config = FileSystemConfig::from_env(config.serve_dir.clone())?;
let fs = FileSystem::new(fs_config)?;
let fs_service = domain::warren::service::file_system::Service::new(fs, metrics, notifier);
let warren_service = domain::warren::service::warren::Service::new(
postgres.clone(),
sqlite.clone(),
metrics,
notifier,
fs_service.clone(),
@@ -47,13 +46,18 @@ async fn main() -> anyhow::Result<()> {
None
};
let option_service =
domain::warren::service::option::Service::new(sqlite.clone(), metrics, notifier);
let auth_service = domain::warren::service::auth::Service::new(
postgres,
sqlite,
metrics,
notifier,
config.auth,
oidc_service,
);
option_service,
)
.await?;
let server_config = HttpServerConfig::new(
&config.server_address,

View File

@@ -6,7 +6,6 @@ use tracing::level_filters::LevelFilter;
use crate::domain::warren::service::auth::AuthConfig;
const DATABASE_URL_KEY: &str = "DATABASE_URL";
const DATABASE_NAME_KEY: &str = "DATABASE_NAME";
const SERVER_ADDRESS_KEY: &str = "SERVER_ADDRESS";
const SERVER_PORT_KEY: &str = "SERVER_PORT";
@@ -28,7 +27,6 @@ pub struct Config {
pub static_frontend_dir: Option<String>,
pub database_url: String,
pub database_name: String,
pub log_level: LevelFilter,
@@ -45,7 +43,6 @@ impl Config {
let static_frontend_dir = Self::load_env(STATIC_FRONTEND_DIRECTORY).ok();
let database_url = Self::load_env(DATABASE_URL_KEY)?;
let database_name = Self::load_env(DATABASE_NAME_KEY)?;
let log_level =
LevelFilter::from_str(&Self::load_env(LOG_LEVEL_KEY).unwrap_or("INFO".to_string()))
@@ -62,7 +59,6 @@ impl Config {
static_frontend_dir,
database_url,
database_name,
log_level,

View File

@@ -1,5 +1,6 @@
pub mod auth_session;
pub mod file;
pub mod option;
pub mod share;
pub mod user;
pub mod user_warren;

View File

@@ -0,0 +1,74 @@
mod requests;
use derive_more::Display;
pub use requests::*;
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Display)]
pub struct OptionKey(String);
#[derive(Debug, Error)]
pub enum OptionKeyError {
#[error("An OptionKey must not be empty")]
Empty,
}
impl OptionKey {
pub fn new(raw: &str) -> Result<Self, OptionKeyError> {
let raw = raw.trim();
if raw.is_empty() {
return Err(OptionKeyError::Empty);
}
Ok(Self(raw.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Debug)]
pub struct OptionValue<T>(T)
where
T: OptionType;
impl<T> OptionValue<T>
where
T: OptionType,
{
pub fn new(value: T) -> Self {
Self(value)
}
pub fn inner(&self) -> &T {
&self.0
}
pub fn get_inner(self) -> T {
self.0
}
}
pub trait OptionType: std::fmt::Debug + Clone + Send + Sync {
type Error: std::fmt::Debug;
fn parse(raw: &str) -> Result<Self, Self::Error>;
fn to_string(&self) -> String;
}
impl OptionType for bool {
type Error = anyhow::Error;
fn parse(raw: &str) -> Result<Self, Self::Error> {
Ok(match raw.to_lowercase().as_str() {
"true" => true,
"false" => false,
_ => anyhow::bail!("Expected 'true' or 'false': {raw}"),
})
}
fn to_string(&self) -> String {
if *self { "true" } else { "false" }.to_string()
}
}

View File

@@ -0,0 +1,47 @@
use thiserror::Error;
use crate::domain::warren::models::option::OptionKey;
#[derive(Clone, Debug)]
pub struct DeleteOptionRequest {
key: OptionKey,
}
impl DeleteOptionRequest {
pub fn new(key: OptionKey) -> Self {
Self { key }
}
pub fn key(&self) -> &OptionKey {
&self.key
}
}
impl From<DeleteOptionRequest> for OptionKey {
fn from(value: DeleteOptionRequest) -> Self {
value.key
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct DeleteOptionResponse {
key: OptionKey,
}
impl DeleteOptionResponse {
pub fn new(key: OptionKey) -> Self {
Self { key }
}
pub fn key(&self) -> &OptionKey {
&self.key
}
}
#[derive(Debug, Error)]
pub enum DeleteOptionError {
#[error("Could not find option with key: {0}")]
NotFound(OptionKey),
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}

View File

@@ -0,0 +1,57 @@
use thiserror::Error;
use crate::domain::warren::models::option::{OptionKey, OptionType};
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GetOptionRequest {
key: OptionKey,
}
impl GetOptionRequest {
pub fn new(key: OptionKey) -> Self {
Self { key }
}
pub fn key(&self) -> &OptionKey {
&self.key
}
}
impl From<GetOptionRequest> for OptionKey {
fn from(value: GetOptionRequest) -> Self {
value.key
}
}
#[derive(Clone, Debug)]
pub struct GetOptionResponse<T: OptionType> {
key: OptionKey,
value: T,
}
impl<T> GetOptionResponse<T>
where
T: OptionType,
{
pub fn new(key: OptionKey, value: T) -> Self {
Self { key, value }
}
pub fn key(&self) -> &OptionKey {
&self.key
}
pub fn value(&self) -> &T {
&self.value
}
}
#[derive(Debug, Error)]
pub enum GetOptionError {
#[error("Could not find option with key: {0}")]
NotFound(OptionKey),
#[error("Could not parse the option value with the specified type")]
Parse,
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}

View File

@@ -0,0 +1,6 @@
mod delete;
mod get;
mod set;
pub use delete::*;
pub use get::*;
pub use set::*;

View File

@@ -0,0 +1,83 @@
use thiserror::Error;
use crate::domain::warren::models::option::{OptionKey, OptionType, OptionValue};
#[derive(Clone, Debug)]
pub struct SetOptionRequest<T>
where
T: OptionType,
{
key: OptionKey,
value: OptionValue<T>,
}
impl<T> SetOptionRequest<T>
where
T: OptionType,
{
pub fn new(key: OptionKey, value: T) -> Self {
Self {
key,
value: OptionValue::new(value),
}
}
pub fn key(&self) -> &OptionKey {
&self.key
}
pub fn value(&self) -> &OptionValue<T> {
&self.value
}
pub fn unpack(self) -> (OptionKey, OptionValue<T>) {
(self.key, self.value)
}
}
impl<T> From<SetOptionRequest<T>> for OptionKey
where
T: OptionType,
{
fn from(value: SetOptionRequest<T>) -> Self {
value.key
}
}
impl<T> From<SetOptionRequest<T>> for OptionValue<T>
where
T: OptionType,
{
fn from(value: SetOptionRequest<T>) -> Self {
value.value
}
}
#[derive(Clone, Debug)]
pub struct SetOptionResponse<T: OptionType> {
key: OptionKey,
value: T,
}
impl<T> SetOptionResponse<T>
where
T: OptionType,
{
pub fn new(key: OptionKey, value: T) -> Self {
Self { key, value }
}
pub fn key(&self) -> &OptionKey {
&self.key
}
pub fn value(&self) -> &T {
&self.value
}
}
#[derive(Debug, Error)]
pub enum SetOptionError {
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}

View File

@@ -9,6 +9,8 @@ pub struct RegisterUserRequest {
name: UserName,
email: UserEmail,
password: UserPassword,
bypass_registration_flag: bool,
admin: bool,
}
impl RegisterUserRequest {
@@ -17,6 +19,23 @@ impl RegisterUserRequest {
name,
email,
password,
bypass_registration_flag: false,
admin: false,
}
}
pub fn new_bypass_flag(
name: UserName,
email: UserEmail,
password: UserPassword,
admin: bool,
) -> Self {
Self {
name,
email,
password,
bypass_registration_flag: true,
admin,
}
}
@@ -31,11 +50,19 @@ impl RegisterUserRequest {
pub fn password(&self) -> &UserPassword {
&self.password
}
pub fn admin(&self) -> bool {
self.admin
}
pub fn bypass_registration_flag(&self) -> bool {
self.bypass_registration_flag
}
}
impl From<RegisterUserRequest> for CreateUserRequest {
fn from(value: RegisterUserRequest) -> Self {
Self::new(value.name, value.email, value.password, false)
Self::new(value.name, value.email, value.password, value.admin)
}
}

View File

@@ -185,3 +185,14 @@ pub trait AuthMetrics: Clone + Send + Sync + 'static {
fn record_auth_share_deletion_success(&self) -> impl Future<Output = ()> + Send;
fn record_auth_share_deletion_failure(&self) -> impl Future<Output = ()> + Send;
}
pub trait OptionMetrics: Clone + Send + Sync + 'static {
fn record_option_get_success(&self) -> impl Future<Output = ()> + Send;
fn record_option_get_failure(&self) -> impl Future<Output = ()> + Send;
fn record_option_set_success(&self) -> impl Future<Output = ()> + Send;
fn record_option_set_failure(&self) -> impl Future<Output = ()> + Send;
fn record_option_delete_success(&self) -> impl Future<Output = ()> + Send;
fn record_option_delete_failure(&self) -> impl Future<Output = ()> + Send;
}

View File

@@ -20,6 +20,11 @@ use super::models::{
RmRequest, SaveError, SaveRequest, SaveResponse, StatError, StatRequest, StatResponse,
TouchError, TouchRequest,
},
option::{
DeleteOptionError, DeleteOptionRequest, DeleteOptionResponse, GetOptionError,
GetOptionRequest, GetOptionResponse, OptionType, SetOptionError, SetOptionRequest,
SetOptionResponse,
},
share::{
CreateShareBaseRequest, CreateShareError, CreateShareRequest, CreateShareResponse,
DeleteShareError, DeleteShareRequest, DeleteShareResponse, GetShareError, GetShareRequest,
@@ -337,3 +342,18 @@ pub trait AuthService: Clone + Send + Sync + 'static {
warren_service: &WS,
) -> impl Future<Output = Result<DeleteShareResponse, AuthError<DeleteShareError>>> + Send;
}
pub trait OptionService: Clone + Send + Sync + 'static {
fn get_option<T: OptionType>(
&self,
request: GetOptionRequest,
) -> impl Future<Output = Result<GetOptionResponse<T>, GetOptionError>> + Send;
fn set_option<T: OptionType>(
&self,
request: SetOptionRequest<T>,
) -> impl Future<Output = Result<SetOptionResponse<T>, SetOptionError>> + Send;
fn delete_option(
&self,
request: DeleteOptionRequest,
) -> impl Future<Output = Result<DeleteOptionResponse, DeleteOptionError>> + Send;
}

View File

@@ -3,6 +3,7 @@ use uuid::Uuid;
use crate::domain::warren::models::{
auth_session::requests::FetchAuthSessionResponse,
file::{AbsoluteFilePath, AbsoluteFilePathList, LsResponse},
option::{DeleteOptionResponse, GetOptionResponse, OptionType, SetOptionResponse},
share::{
CreateShareResponse, DeleteShareResponse, GetShareResponse, ListSharesResponse,
ShareCatResponse, ShareLsResponse, VerifySharePasswordResponse,
@@ -219,3 +220,15 @@ pub trait AuthNotifier: Clone + Send + Sync + 'static {
response: &DeleteShareResponse,
) -> impl Future<Output = ()> + Send;
}
pub trait OptionNotifier: Clone + Send + Sync + 'static {
fn got_option<T: OptionType>(
&self,
response: &GetOptionResponse<T>,
) -> impl Future<Output = ()> + Send;
fn set_option<T: OptionType>(
&self,
response: &SetOptionResponse<T>,
) -> impl Future<Output = ()> + Send;
fn deleted_option(&self, response: &DeleteOptionResponse) -> impl Future<Output = ()> + Send;
}

View File

@@ -12,6 +12,11 @@ use crate::domain::warren::models::{
RmRequest, SaveError, SaveRequest, SaveResponse, StatError, StatRequest, StatResponse,
TouchError, TouchRequest,
},
option::{
DeleteOptionError, DeleteOptionRequest, DeleteOptionResponse, GetOptionError,
GetOptionRequest, GetOptionResponse, OptionType, SetOptionError, SetOptionRequest,
SetOptionResponse,
},
share::{
CreateShareError, CreateShareRequest, CreateShareResponse, DeleteShareError,
DeleteShareRequest, DeleteShareResponse, GetShareError, GetShareRequest, ListSharesError,
@@ -188,3 +193,18 @@ pub trait AuthRepository: Clone + Send + Sync + 'static {
request: FetchUserWarrenRequest,
) -> impl Future<Output = Result<UserWarren, FetchUserWarrenError>> + Send;
}
pub trait OptionRepository: Clone + Send + Sync + 'static {
fn get_option<T: OptionType>(
&self,
request: GetOptionRequest,
) -> impl Future<Output = Result<GetOptionResponse<T>, GetOptionError>> + Send;
fn set_option<T: OptionType>(
&self,
request: SetOptionRequest<T>,
) -> impl Future<Output = Result<SetOptionResponse<T>, SetOptionError>> + Send;
fn delete_option(
&self,
request: DeleteOptionRequest,
) -> impl Future<Output = Result<DeleteOptionResponse, DeleteOptionError>> + Send;
}

View File

@@ -12,6 +12,7 @@ use crate::{
},
},
file::FileStream,
option::{GetOptionError, GetOptionRequest, OptionKey, SetOptionRequest},
share::{
CreateShareBaseRequest, CreateShareError, CreateShareResponse,
DeleteShareError, DeleteShareRequest, DeleteShareResponse, ListSharesError,
@@ -24,7 +25,7 @@ use crate::{
ListAllUsersAndWarrensRequest, ListAllUsersAndWarrensResponse, ListUsersError,
ListUsersRequest, LoginUserError, LoginUserOidcError, LoginUserOidcRequest,
LoginUserOidcResponse, LoginUserRequest, LoginUserResponse, RegisterUserError,
RegisterUserRequest, User,
RegisterUserRequest, User, UserEmail, UserName, UserPassword,
},
user_warren::{
UserWarren,
@@ -46,7 +47,10 @@ use crate::{
WarrenTouchResponse,
},
},
ports::{AuthMetrics, AuthNotifier, AuthRepository, AuthService, WarrenService},
ports::{
AuthMetrics, AuthNotifier, AuthRepository, AuthService, OptionService,
WarrenService,
},
},
},
};
@@ -97,54 +101,100 @@ impl AuthConfig {
}
#[derive(Debug, Clone)]
pub struct Service<R, M, N, OIDC>
pub struct Service<R, M, N, OIDC, O>
where
R: AuthRepository,
M: AuthMetrics,
N: AuthNotifier,
OIDC: OidcService,
O: OptionService,
{
repository: R,
metrics: M,
notifier: N,
oidc: Option<OIDC>,
option_service: O,
config: AuthConfig,
}
impl<R, M, N, OIDC> Service<R, M, N, OIDC>
impl<R, M, N, OIDC, O> Service<R, M, N, OIDC, O>
where
R: AuthRepository,
M: AuthMetrics,
N: AuthNotifier,
OIDC: OidcService,
O: OptionService,
{
pub fn new(
pub async fn new(
repository: R,
metrics: M,
notifier: N,
config: AuthConfig,
oidc: Option<OIDC>,
) -> Self {
Self {
option_service: O,
) -> anyhow::Result<Self> {
let service = Self {
repository,
metrics,
notifier,
config,
oidc,
}
option_service,
};
service.init().await?;
Ok(service)
}
pub fn oidc(&self) -> Option<&OIDC> {
self.oidc.as_ref()
}
async fn init(&self) -> anyhow::Result<()> {
self.create_admin_user_if_init().await?;
Ok(())
}
async fn create_admin_user_if_init(&self) -> anyhow::Result<()> {
const CREATED_ADMIN_USER_KEY: &str = "CREATED_ADMIN_USER";
let key = OptionKey::new(CREATED_ADMIN_USER_KEY)?;
let request = GetOptionRequest::new(key.clone());
match self.option_service.get_option::<bool>(request).await {
// If the option is already set and true we don't have to do anything anymore
Ok(opt) if *opt.value() => return Ok(()),
Err(e) => match e {
// The option is not yet set so we proceed with the admin user creation
GetOptionError::NotFound(_) => (),
_ => return Err(e.into()),
},
// The option was set but it was false so we proceed with the admin user creation
_ => (),
}
let name = UserName::new("admin")?;
let email = UserEmail::new("admin@example.com")?;
let password = UserPassword::new("admin1234567")?;
let request = RegisterUserRequest::new_bypass_flag(name, email, password, true);
self.register_user(request).await?;
self.option_service
.set_option(SetOptionRequest::new(key, true))
.await?;
Ok(())
}
}
impl<R, M, N, OIDC> AuthService for Service<R, M, N, OIDC>
impl<R, M, N, OIDC, O> AuthService for Service<R, M, N, OIDC, O>
where
R: AuthRepository,
M: AuthMetrics,
N: AuthNotifier,
OIDC: OidcService,
O: OptionService,
{
async fn create_warren<WS: WarrenService>(
&self,
@@ -253,7 +303,7 @@ where
}
async fn register_user(&self, request: RegisterUserRequest) -> Result<User, RegisterUserError> {
if !self.config.allow_registration {
if !self.config.allow_registration && !request.bypass_registration_flag() {
self.metrics.record_user_registration_failure().await;
return Err(RegisterUserError::Disabled);
}
@@ -972,12 +1022,13 @@ where
}
}
impl<R, M, N, OIDC> Service<R, M, N, OIDC>
impl<R, M, N, OIDC, O> Service<R, M, N, OIDC, O>
where
R: AuthRepository,
M: AuthMetrics,
N: AuthNotifier,
OIDC: OidcService,
O: OptionService,
{
/// A helper to get a [UserWarren], [User] and the underlying request from an [AuthRequest]
async fn get_session_data_and_user_warren<T, E>(

View File

@@ -1,3 +1,4 @@
pub mod auth;
pub mod file_system;
pub mod option;
pub mod warren;

View File

@@ -0,0 +1,90 @@
use crate::domain::warren::{
models::option::{
DeleteOptionError, DeleteOptionRequest, DeleteOptionResponse, GetOptionError,
GetOptionRequest, GetOptionResponse, OptionType, SetOptionError, SetOptionRequest,
SetOptionResponse,
},
ports::{OptionMetrics, OptionNotifier, OptionRepository, OptionService},
};
#[derive(Debug, Clone)]
pub struct Service<R, M, N>
where
R: OptionRepository,
M: OptionMetrics,
N: OptionNotifier,
{
repository: R,
metrics: M,
notifier: N,
}
impl<R, M, N> Service<R, M, N>
where
R: OptionRepository,
M: OptionMetrics,
N: OptionNotifier,
{
pub fn new(repository: R, metrics: M, notifier: N) -> Self {
Self {
repository,
metrics,
notifier,
}
}
}
impl<R, M, N> OptionService for Service<R, M, N>
where
R: OptionRepository,
M: OptionMetrics,
N: OptionNotifier,
{
async fn get_option<T: OptionType>(
&self,
request: GetOptionRequest,
) -> Result<GetOptionResponse<T>, GetOptionError> {
let result = self.repository.get_option(request).await;
if let Ok(response) = result.as_ref() {
self.metrics.record_option_get_success().await;
self.notifier.got_option(response).await;
} else {
self.metrics.record_option_get_failure().await;
}
result
}
async fn set_option<T: OptionType>(
&self,
request: SetOptionRequest<T>,
) -> Result<SetOptionResponse<T>, SetOptionError> {
let result = self.repository.set_option(request).await;
if let Ok(response) = result.as_ref() {
self.metrics.record_option_set_success().await;
self.notifier.set_option(response).await;
} else {
self.metrics.record_option_set_failure().await;
}
result
}
async fn delete_option(
&self,
request: DeleteOptionRequest,
) -> Result<DeleteOptionResponse, DeleteOptionError> {
let result = self.repository.delete_option(request).await;
if let Ok(response) = result.as_ref() {
self.metrics.record_option_delete_success().await;
self.notifier.deleted_option(response).await;
} else {
self.metrics.record_option_delete_failure().await;
}
result
}
}

View File

@@ -1,6 +1,6 @@
use crate::domain::{
oidc::ports::OidcMetrics,
warren::ports::{AuthMetrics, FileSystemMetrics, WarrenMetrics},
warren::ports::{AuthMetrics, FileSystemMetrics, OptionMetrics, WarrenMetrics},
};
#[derive(Debug, Clone, Copy)]
@@ -452,3 +452,26 @@ impl OidcMetrics for MetricsDebugLogger {
tracing::debug!("[Metrics] OIDC get user info failed");
}
}
impl OptionMetrics for MetricsDebugLogger {
async fn record_option_get_success(&self) {
tracing::debug!("[Metrics] Get option succeeded");
}
async fn record_option_get_failure(&self) {
tracing::debug!("[Metrics] Get option failed");
}
async fn record_option_set_success(&self) {
tracing::debug!("[Metrics] Set option succeeded");
}
async fn record_option_set_failure(&self) {
tracing::debug!("[Metrics] Set option failed");
}
async fn record_option_delete_success(&self) {
tracing::debug!("[Metrics] Delete option succeeded");
}
async fn record_option_delete_failure(&self) {
tracing::debug!("[Metrics] Delete option failed");
}
}

View File

@@ -2,4 +2,4 @@ pub mod file_system;
pub mod metrics_debug_logger;
pub mod notifier_debug_logger;
pub mod oidc;
pub mod postgres;
pub mod sqlite;

View File

@@ -9,6 +9,7 @@ use crate::domain::{
models::{
auth_session::requests::FetchAuthSessionResponse,
file::{AbsoluteFilePath, AbsoluteFilePathList, LsResponse},
option::{DeleteOptionResponse, GetOptionResponse, OptionType, SetOptionResponse},
share::{
CreateShareResponse, DeleteShareResponse, GetShareResponse, ListSharesResponse,
ShareCatResponse, ShareLsResponse, VerifySharePasswordResponse,
@@ -22,7 +23,7 @@ use crate::domain::{
WarrenRmResponse, WarrenSaveResponse, WarrenTouchResponse,
},
},
ports::{AuthNotifier, FileSystemNotifier, WarrenNotifier},
ports::{AuthNotifier, FileSystemNotifier, OptionNotifier, WarrenNotifier},
},
};
@@ -515,3 +516,25 @@ impl OidcNotifier for NotifierDebugLogger {
);
}
}
impl OptionNotifier for NotifierDebugLogger {
async fn got_option<T: OptionType>(&self, response: &GetOptionResponse<T>) {
tracing::debug!(
"[Notifier] Got option {}: {}",
response.key().to_string(),
response.value().to_string(),
);
}
async fn set_option<T: OptionType>(&self, response: &SetOptionResponse<T>) {
tracing::debug!(
"[Notifier] Set option {} to {}",
response.key().to_string(),
response.value().to_string(),
);
}
async fn deleted_option(&self, response: &DeleteOptionResponse) {
tracing::debug!("[Notifier] Deleted option {}", response.key().to_string());
}
}

View File

@@ -1,95 +0,0 @@
use std::{str::FromStr as _, time::Duration};
use anyhow::Context as _;
use sqlx::{
ConnectOptions as _, Connection as _, PgConnection, PgPool,
postgres::{PgConnectOptions, PgPoolOptions},
};
use tokio::task::JoinHandle;
pub mod auth;
pub mod share;
pub mod warrens;
#[derive(Debug, Clone)]
pub struct PostgresConfig {
database_url: String,
database_name: String,
}
impl PostgresConfig {
pub fn new(database_url: String, database_name: String) -> Self {
Self {
database_url,
database_name,
}
}
}
#[derive(Debug, Clone)]
pub struct Postgres {
pool: PgPool,
}
impl Postgres {
pub async fn new(config: PostgresConfig) -> anyhow::Result<Self> {
let opts = PgConnectOptions::from_str(&config.database_url)?.disable_statement_logging();
let mut connection = PgConnection::connect_with(&opts)
.await
.context("Failed to connect to the PostgreSQL database")?;
match sqlx::query("SELECT datname FROM pg_database WHERE datname = $1")
.bind(&config.database_name)
.fetch_one(&mut connection)
.await
{
Ok(_) => (),
Err(sqlx::Error::RowNotFound) => {
sqlx::query(&format!("CREATE DATABASE {}", config.database_name))
.execute(&mut connection)
.await?;
}
Err(e) => return Err(e.into()),
};
connection.close().await?;
let pool = PgPoolOptions::new()
.connect_with(opts.database(&config.database_name))
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
// 3600 seconds = 1 hour
Self::start_cleanup_tasks(pool.clone(), Duration::from_secs(3600));
Ok(Self { pool })
}
pub(super) fn start_cleanup_tasks(pool: PgPool, interval: Duration) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
{
let Ok(mut connection) = pool.acquire().await else {
break;
};
if let Ok(count) = Self::delete_expired_auth_sessions(&mut connection).await {
tracing::debug!("Removed {count} expired auth session(s)");
}
if let Ok(count) = Self::delete_expired_shares(&mut connection).await {
tracing::debug!("Deleted {count} expired share(s)");
}
}
tokio::time::sleep(interval).await;
}
tracing::debug!("Session cleanup task stopped");
})
}
}
pub(super) fn is_not_found_error(err: &sqlx::Error) -> bool {
matches!(err, sqlx::Error::RowNotFound)
}

View File

@@ -7,7 +7,7 @@ use argon2::{
},
};
use chrono::Utc;
use sqlx::{Acquire as _, PgConnection};
use sqlx::{Acquire as _, SqliteConnection};
use uuid::Uuid;
use crate::domain::warren::{
@@ -40,15 +40,15 @@ use crate::domain::warren::{
ports::{AuthRepository, WarrenService},
};
use super::{Postgres, is_not_found_error};
use super::{Sqlite, is_not_found_error};
impl AuthRepository for Postgres {
impl AuthRepository for Sqlite {
async fn create_user(&self, request: CreateUserRequest) -> Result<User, CreateUserError> {
let mut connection = self
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let user = self
.create_user(
@@ -72,7 +72,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let user = self
.create_or_update_user(
@@ -93,7 +93,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let user = self
.edit_user(
@@ -115,7 +115,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
self.delete_user_from_database(&mut connection, request.user_id())
.await
@@ -136,7 +136,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let user = self
.get_user_from_email(&mut connection, request.email())
@@ -166,7 +166,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let session = self
.create_session(&mut connection, request.user(), request.expiration())
@@ -184,7 +184,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let session = self
.get_auth_session(&mut connection, request.session_id())
@@ -212,7 +212,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let user_warren = self
.add_user_to_warren(&mut connection, request.user_warren())
@@ -230,7 +230,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let user_warren = self
.update_user_warren(&mut connection, request.user_warren())
@@ -248,7 +248,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let user_warren = self
.remove_user_from_warren(&mut connection, request.user_id(), request.warren_id())
@@ -272,7 +272,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let user_warrens = self
.get_user_warrens(&mut connection, request.user_id())
@@ -290,7 +290,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let user_warrens = self
.get_all_user_warrens(&mut connection)
@@ -308,7 +308,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
self.get_user_warren(&mut connection, request.user_id(), request.warren_id())
.await
@@ -326,7 +326,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let users = self
.fetch_users(&mut connection)
@@ -345,7 +345,7 @@ impl AuthRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let users = self
.fetch_users(&mut connection)
@@ -368,9 +368,9 @@ impl AuthRepository for Postgres {
}
}
impl Postgres {
impl Sqlite {
pub(super) async fn delete_expired_auth_sessions(
connection: &mut PgConnection,
connection: &mut SqliteConnection,
) -> Result<u64, sqlx::Error> {
let delete_count = sqlx::query(
"
@@ -389,7 +389,7 @@ impl Postgres {
async fn create_user(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
name: &UserName,
email: &UserEmail,
password: &UserPassword,
@@ -402,6 +402,7 @@ impl Postgres {
let user: User = sqlx::query_as(
"INSERT INTO users (
id,
name,
email,
hash,
@@ -411,12 +412,14 @@ impl Postgres {
$1,
$2,
$3,
$4
$4,
$5
)
RETURNING
*
",
)
.bind(Uuid::new_v4())
.bind(name)
.bind(email)
.bind(password_hash)
@@ -431,7 +434,7 @@ impl Postgres {
async fn create_or_update_user(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
sub: &String,
name: &UserName,
email: &UserEmail,
@@ -546,7 +549,7 @@ impl Postgres {
async fn edit_user(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
id: &Uuid,
name: &UserName,
email: &UserEmail,
@@ -592,7 +595,7 @@ impl Postgres {
async fn delete_user_sessions(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
user_id: &Uuid,
) -> Result<u64, sqlx::Error> {
let rows_affected = sqlx::query(
@@ -613,7 +616,7 @@ impl Postgres {
async fn delete_user_from_database(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
user_id: &Uuid,
) -> Result<User, sqlx::Error> {
let user: User = sqlx::query_as(
@@ -635,7 +638,7 @@ impl Postgres {
async fn get_user_from_id(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
id: &Uuid,
) -> Result<User, sqlx::Error> {
let user: User = sqlx::query_as(
@@ -657,7 +660,7 @@ impl Postgres {
async fn get_user_from_email(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
email: &UserEmail,
) -> Result<User, sqlx::Error> {
let user: User = sqlx::query_as(
@@ -698,7 +701,7 @@ impl Postgres {
async fn create_session(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
user: &User,
expiration: &SessionExpirationTime,
) -> anyhow::Result<AuthSession> {
@@ -721,7 +724,7 @@ impl Postgres {
) VALUES (
$1,
$2,
TO_TIMESTAMP($3::double precision / 1000)
datetime($3, 'unixepoch')
)
RETURNING
*
@@ -729,7 +732,7 @@ impl Postgres {
)
.bind(session_id)
.bind(user.id())
.bind(expiration_time)
.bind(expiration_time / 1000)
.fetch_one(&mut *tx)
.await?;
@@ -740,7 +743,7 @@ impl Postgres {
async fn get_auth_session(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
session_id: &AuthSessionId,
) -> Result<AuthSession, sqlx::Error> {
let session: AuthSession = sqlx::query_as(
@@ -762,7 +765,7 @@ impl Postgres {
async fn get_user_warrens(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
user_id: &Uuid,
) -> Result<Vec<UserWarren>, sqlx::Error> {
let user_warrens: Vec<UserWarren> = sqlx::query_as(
@@ -784,7 +787,7 @@ impl Postgres {
async fn get_all_user_warrens(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
) -> Result<Vec<UserWarren>, sqlx::Error> {
let user_warrens: Vec<UserWarren> = sqlx::query_as(
"
@@ -802,7 +805,7 @@ impl Postgres {
async fn get_user_warren(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
user_id: &Uuid,
warren_id: &Uuid,
) -> Result<UserWarren, sqlx::Error> {
@@ -825,7 +828,10 @@ impl Postgres {
Ok(ids)
}
async fn fetch_users(&self, connection: &mut PgConnection) -> Result<Vec<User>, sqlx::Error> {
async fn fetch_users(
&self,
connection: &mut SqliteConnection,
) -> Result<Vec<User>, sqlx::Error> {
let users: Vec<User> = sqlx::query_as(
"
SELECT
@@ -844,7 +850,7 @@ impl Postgres {
async fn add_user_to_warren(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
user_warren: &UserWarren,
) -> Result<UserWarren, sqlx::Error> {
let user_warren: UserWarren = sqlx::query_as(
@@ -855,14 +861,22 @@ impl Postgres {
can_list_files,
can_read_files,
can_modify_files,
can_delete_files
can_delete_files,
can_list_shares,
can_create_shares,
can_modify_shares,
can_delete_shares
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6
$6,
$7,
$8,
$9,
$10
)
RETURNING
*
@@ -874,6 +888,10 @@ impl Postgres {
.bind(user_warren.can_read_files())
.bind(user_warren.can_modify_files())
.bind(user_warren.can_delete_files())
.bind(user_warren.can_list_shares())
.bind(user_warren.can_create_shares())
.bind(user_warren.can_modify_shares())
.bind(user_warren.can_delete_shares())
.fetch_one(connection)
.await?;
@@ -882,7 +900,7 @@ impl Postgres {
async fn update_user_warren(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
user_warren: &UserWarren,
) -> Result<UserWarren, sqlx::Error> {
let user_warren: UserWarren = sqlx::query_as(
@@ -923,7 +941,7 @@ impl Postgres {
async fn remove_user_from_warren(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
user_id: &Uuid,
warren_id: &Uuid,
) -> Result<UserWarren, sqlx::Error> {

View File

@@ -0,0 +1,72 @@
use std::{str::FromStr as _, time::Duration};
use sqlx::{
ConnectOptions as _, SqlitePool,
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
};
use tokio::task::JoinHandle;
pub mod auth;
pub mod options;
pub mod share;
pub mod warrens;
#[derive(Debug, Clone)]
pub struct SqliteConfig {
database_url: String,
}
impl SqliteConfig {
pub fn new(database_url: String) -> Self {
Self { database_url }
}
}
#[derive(Debug, Clone)]
pub struct Sqlite {
pool: SqlitePool,
}
impl Sqlite {
pub async fn new(config: SqliteConfig) -> anyhow::Result<Self> {
let opts = SqliteConnectOptions::from_str(&config.database_url)?
.create_if_missing(true)
.read_only(false)
.disable_statement_logging();
let pool = SqlitePoolOptions::new().connect_with(opts).await?;
sqlx::migrate!("./migrations").run(&pool).await?;
// 3600 seconds = 1 hour
Self::start_cleanup_tasks(pool.clone(), Duration::from_secs(3600));
Ok(Self { pool })
}
pub(super) fn start_cleanup_tasks(pool: SqlitePool, interval: Duration) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
{
let Ok(mut connection) = pool.acquire().await else {
break;
};
if let Ok(count) = Self::delete_expired_auth_sessions(&mut connection).await {
tracing::debug!("Removed {count} expired auth session(s)");
}
if let Ok(count) = Self::delete_expired_shares(&mut connection).await {
tracing::debug!("Deleted {count} expired share(s)");
}
}
tokio::time::sleep(interval).await;
}
tracing::debug!("Session cleanup task stopped");
})
}
}
pub(super) fn is_not_found_error(err: &sqlx::Error) -> bool {
matches!(err, sqlx::Error::RowNotFound)
}

View File

@@ -0,0 +1,134 @@
use anyhow::Context;
use sqlx::FromRow;
use crate::domain::warren::{
models::option::{
DeleteOptionError, DeleteOptionRequest, DeleteOptionResponse, GetOptionError,
GetOptionRequest, GetOptionResponse, OptionKey, OptionType, SetOptionError,
SetOptionRequest, SetOptionResponse,
},
ports::OptionRepository,
};
use super::{Sqlite, is_not_found_error};
#[derive(Debug, FromRow)]
struct OptionRow {
key: String,
value: String,
}
impl OptionRepository for Sqlite {
async fn get_option<T: OptionType>(
&self,
request: GetOptionRequest,
) -> Result<GetOptionResponse<T>, GetOptionError> {
let mut connection = self
.pool
.acquire()
.await
.context("Failed to get a Sqlite connection")?;
let key: OptionKey = request.into();
let row: OptionRow = sqlx::query_as(
"
SELECT
key,
value
FROM
application_options
WHERE
key = $1",
)
.bind(key.as_str())
.fetch_one(&mut *connection)
.await
.map_err(|e| {
if is_not_found_error(&e) {
GetOptionError::NotFound(key)
} else {
GetOptionError::Unknown(e.into())
}
})?;
let parsed = T::parse(&row.value).map_err(|_| GetOptionError::Parse)?;
Ok(GetOptionResponse::new(
OptionKey::new(&row.key).unwrap(),
parsed,
))
}
async fn set_option<T: OptionType>(
&self,
request: SetOptionRequest<T>,
) -> Result<SetOptionResponse<T>, SetOptionError> {
let mut connection = self
.pool
.acquire()
.await
.context("Failed to get a Sqlite connection")?;
let (key, value) = request.unpack();
sqlx::query_as::<_, OptionRow>(
"
INSERT INTO application_options (
key,
value
) VALUES (
$1,
$2
)
RETURNING
key,
value
",
)
.bind(key.as_str())
.bind(value.inner().to_string())
.fetch_one(&mut *connection)
.await
.map_err(|e| SetOptionError::Unknown(e.into()))?;
Ok(SetOptionResponse::new(key, value.get_inner()))
}
async fn delete_option(
&self,
request: DeleteOptionRequest,
) -> Result<DeleteOptionResponse, DeleteOptionError> {
let mut connection = self
.pool
.acquire()
.await
.context("Failed to get a Sqlite connection")?;
let key: OptionKey = request.into();
sqlx::query_as::<_, OptionRow>(
"
DELETE FROM
application_options
WHERE
key = $1
RETURNING
key,
value
",
)
.bind(key.as_str())
.fetch_one(&mut *connection)
.await
.map_err(|e| {
if is_not_found_error(&e) {
DeleteOptionError::NotFound(key.clone())
} else {
DeleteOptionError::Unknown(e.into())
}
})?;
Ok(DeleteOptionResponse::new(key))
}
}

View File

@@ -1,10 +1,10 @@
use anyhow::anyhow;
use argon2::{
Argon2, PasswordHash, PasswordVerifier as _,
password_hash::{PasswordHasher as _, SaltString, rand_core::OsRng},
password_hash::{PasswordHasher as _, SaltString},
};
use chrono::{NaiveDateTime, Utc};
use sqlx::{Acquire as _, PgConnection};
use sqlx::{Acquire as _, SqliteConnection};
use thiserror::Error;
use uuid::Uuid;
@@ -17,7 +17,7 @@ use crate::domain::warren::models::{
warren::HasWarrenId as _,
};
use super::{Postgres, is_not_found_error};
use super::{Sqlite, is_not_found_error};
#[derive(sqlx::FromRow)]
struct ShareRow {
@@ -62,7 +62,7 @@ impl TryFrom<ShareRow> for Share {
}
pub(super) async fn get_share(
connection: &mut PgConnection,
connection: &mut SqliteConnection,
request: GetShareRequest,
) -> anyhow::Result<Share> {
let share_row: ShareRow = sqlx::query_as(
@@ -90,7 +90,7 @@ pub(super) async fn get_share(
}
pub(super) async fn list_shares(
connection: &mut PgConnection,
connection: &mut SqliteConnection,
request: ListSharesRequest,
) -> anyhow::Result<Vec<Share>> {
let share_rows: Vec<ShareRow> = sqlx::query_as(
@@ -107,7 +107,8 @@ pub(super) async fn list_shares(
shares
WHERE
warren_id = $1 AND
path = $2
path = $2 AND
(expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)
ORDER BY
created_at DESC
",
@@ -126,13 +127,13 @@ pub(super) async fn list_shares(
}
pub(super) async fn create_share(
connection: &mut PgConnection,
connection: &mut SqliteConnection,
request: CreateShareRequest,
) -> anyhow::Result<Share> {
let mut tx = connection.begin().await?;
let password_hash = if let Some(password) = request.base().password() {
let salt = SaltString::generate(&mut OsRng);
let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
let argon2 = Argon2::default();
Some(
@@ -154,6 +155,7 @@ pub(super) async fn create_share(
let share: ShareRow = sqlx::query_as(
"
INSERT INTO shares (
id,
creator_id,
warren_id,
path,
@@ -164,17 +166,19 @@ pub(super) async fn create_share(
$2,
$3,
$4,
TO_TIMESTAMP($5::double precision / 1000)
$5,
datetime($6, 'unixepoch')
)
RETURNING
*
",
)
.bind(Uuid::new_v4())
.bind(request.creator_id())
.bind(request.warren_id())
.bind(request.base().path())
.bind(password_hash)
.bind(expires_at)
.bind(expires_at.map(|v| v / 1000))
.fetch_one(&mut *tx)
.await?;
@@ -184,7 +188,7 @@ pub(super) async fn create_share(
}
pub(super) async fn delete_share(
connection: &mut PgConnection,
connection: &mut SqliteConnection,
request: DeleteShareRequest,
) -> anyhow::Result<Share> {
let mut tx = connection.begin().await?;
@@ -209,7 +213,7 @@ pub(super) async fn delete_share(
}
pub(super) async fn verify_password(
connection: &mut PgConnection,
connection: &mut SqliteConnection,
request: VerifySharePasswordRequest,
) -> Result<Share, VerifySharePasswordError> {
let share_row: ShareRow = sqlx::query_as(
@@ -264,9 +268,9 @@ pub(super) async fn verify_password(
}
}
impl Postgres {
impl Sqlite {
pub(super) async fn delete_expired_shares(
connection: &mut PgConnection,
connection: &mut SqliteConnection,
) -> Result<u64, sqlx::Error> {
let delete_count = sqlx::query(
"

View File

@@ -1,5 +1,5 @@
use anyhow::{Context as _, anyhow};
use sqlx::{Acquire as _, PgConnection};
use sqlx::{Acquire as _, SqliteConnection};
use uuid::Uuid;
use crate::domain::warren::{
@@ -21,9 +21,9 @@ use crate::domain::warren::{
ports::WarrenRepository,
};
use super::{Postgres, is_not_found_error};
use super::{Sqlite, is_not_found_error};
impl WarrenRepository for Postgres {
impl WarrenRepository for Sqlite {
async fn create_warren(
&self,
request: CreateWarrenRequest,
@@ -32,7 +32,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let warren = self
.create_warren(&mut connection, request.name(), request.path())
@@ -47,7 +47,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let warren = self
.edit_warren(
@@ -70,7 +70,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let warren = self
.delete_warren(&mut connection, request.id())
@@ -88,7 +88,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let warrens = self
.fetch_warrens(&mut connection, request.ids())
@@ -106,7 +106,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let warrens = self
.fetch_all_warrens(&mut connection)
@@ -121,7 +121,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let warren = self
.get_warren(&mut connection, request.id())
@@ -144,7 +144,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
super::share::get_share(&mut connection, request)
.await
@@ -159,7 +159,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
super::share::create_share(&mut connection, request)
.await
@@ -177,7 +177,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
let path = request.path().clone();
@@ -195,7 +195,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
super::share::delete_share(&mut connection, request)
.await
@@ -211,7 +211,7 @@ impl WarrenRepository for Postgres {
.pool
.acquire()
.await
.context("Failed to get a PostgreSQL connection")?;
.context("Failed to get a Sqlite connection")?;
super::share::verify_password(&mut connection, request)
.await
@@ -220,10 +220,10 @@ impl WarrenRepository for Postgres {
}
}
impl Postgres {
impl Sqlite {
async fn create_warren(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
name: &WarrenName,
path: &AbsoluteFilePath,
) -> Result<Warren, sqlx::Error> {
@@ -232,16 +232,19 @@ impl Postgres {
let warren: Warren = sqlx::query_as(
"
INSERT INTO warrens (
id,
name,
path
) VALUES (
$1,
$2
$2,
$3
)
RETURNING
*
",
)
.bind(Uuid::new_v4())
.bind(name)
.bind(path)
.fetch_one(&mut *tx)
@@ -254,7 +257,7 @@ impl Postgres {
async fn edit_warren(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
id: &Uuid,
name: &WarrenName,
path: &AbsoluteFilePath,
@@ -287,7 +290,7 @@ impl Postgres {
async fn delete_warren(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
id: &Uuid,
) -> Result<Warren, sqlx::Error> {
let mut tx = connection.begin().await?;
@@ -313,7 +316,7 @@ impl Postgres {
async fn get_warren(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
id: &Uuid,
) -> Result<Warren, sqlx::Error> {
let warren: Warren = sqlx::query_as(
@@ -335,20 +338,28 @@ impl Postgres {
async fn fetch_warrens(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
ids: &[Uuid],
) -> Result<Vec<Warren>, sqlx::Error> {
let warrens: Vec<Warren> = sqlx::query_as::<sqlx::Postgres, Warren>(
let mut ids_as_string = ids.into_iter().fold(String::new(), |mut acc, id| {
let encoded = hex::encode(id.as_bytes());
acc.push_str("x'");
acc.push_str(encoded.as_str());
acc.push_str("',");
acc
});
ids_as_string.pop();
let warrens: Vec<Warren> = sqlx::query_as::<sqlx::Sqlite, Warren>(&format!(
"
SELECT
*
FROM
warrens
WHERE
id = ANY($1)
id IN ({ids_as_string})
",
)
.bind(ids)
))
.fetch_all(&mut *connection)
.await?;
@@ -357,9 +368,9 @@ impl Postgres {
async fn fetch_all_warrens(
&self,
connection: &mut PgConnection,
connection: &mut SqliteConnection,
) -> Result<Vec<Warren>, sqlx::Error> {
let warrens: Vec<Warren> = sqlx::query_as::<sqlx::Postgres, Warren>(
let warrens: Vec<Warren> = sqlx::query_as::<sqlx::Sqlite, Warren>(
"
SELECT
*

View File

@@ -1,7 +1,5 @@
services:
warren:
depends_on:
- 'postgres'
image: 'warren:latest'
container_name: 'warren'
build: '.'
@@ -13,26 +11,15 @@ services:
environment:
- 'SERVER_ADDRESS=0.0.0.0'
- 'SERVER_PORT=8080'
- 'DATABASE_URL=postgres://postgres:pg@warren-postgres:5432'
- 'DATABASE_NAME=warren'
- 'DATABASE_URL=sqlite:///var/lib/warren/data/warren.db'
- 'SERVE_DIRECTORY=/serve'
- 'CORS_ALLOW_ORIGIN=http://localhost:8081'
- 'LOG_LEVEL=debug'
- 'MAX_FILE_FETCH_BYTES=10737418240'
- 'ZIP_READ_BUFFER_BYTES=4096'
volumes:
- './backend/serve:/serve:rw'
postgres:
image: 'postgres:17'
container_name: 'warren-db'
hostname: 'warren-postgres'
networks:
- 'warren-net'
volumes:
- './postgres-data:/var/lib/postgresql/data'
environment:
- 'POSTGRES_PASSWORD=pg'
ports:
- '5432:5432/tcp'
- './backend/data:/var/lib/warren/data:rw'
networks:
warren-net:
name: 'warren-net'

View File

@@ -122,6 +122,7 @@ function onClearCopy() {
<ContextMenuContent>
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@pointerdown.stop
@select="openRenameDialog"
>
<Icon name="lucide:pencil" />
@@ -136,22 +137,24 @@ function onClearCopy() {
copyStore.file.path !== warrenStore.current.path ||
copyStore.file.name !== entry.name
"
@pointerdown.stop
@select="onCopy"
>
<Icon name="lucide:copy" />
Copy
</ContextMenuItem>
<ContextMenuItem v-else @select="onClearCopy">
<ContextMenuItem v-else @pointerdown.stop @select="onClearCopy">
<Icon name="lucide:copy-x" />
Clear clipboard
</ContextMenuItem>
</template>
<ContextMenuItem @select="onDownload">
<ContextMenuItem @pointerdown.stop @select="onDownload">
<Icon name="lucide:download" />
Download
</ContextMenuItem>
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@pointerdown.stop
@select="onShare"
>
<Icon name="lucide:share" />
@@ -164,6 +167,7 @@ function onClearCopy() {
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@pointerdown.stop
@select="() => onDelete(false)"
>
<Icon name="lucide:trash-2" />
@@ -171,6 +175,7 @@ function onClearCopy() {
</ContextMenuItem>
<ContextMenuItem
:class="[warrenStore.current == null && 'hidden']"
@pointerdown.stop
@select="() => onDelete(true)"
>
<Icon

View File

@@ -8,7 +8,7 @@ const width = computed(() => Math.abs(rect.a.x - rect.b.x));
const height = computed(() => Math.abs(rect.a.y - rect.b.y));
function onDocumentPointerDown(e: MouseEvent) {
if (e.button !== 0) {
if (e.button !== 0 || matchMedia('(pointer:coarse)').matches) {
return;
}
@@ -21,7 +21,7 @@ function onDocumentPointerDown(e: MouseEvent) {
}
function onDocumentPointerMove(e: MouseEvent) {
if (!rect.enabled) {
if (!rect.enabled || matchMedia('(pointer:coarse)').matches) {
return;
}
@@ -39,7 +39,11 @@ function onDocumentPointerMove(e: MouseEvent) {
}
function onDocumentPointerUp(e: MouseEvent) {
if (e.button !== 0 || !rect.enabled) {
if (
!rect.enabled ||
e.button !== 0 ||
matchMedia('(pointer:coarse)').matches
) {
return;
}

View File

@@ -51,6 +51,11 @@ const form = useForm({
canReadFiles: false,
canModifyFiles: false,
canDeleteFiles: false,
canListShares: false,
canCreateShares: false,
canModifyShares: false,
canDeleteShares: false,
},
});
@@ -231,6 +236,70 @@ const onSubmit = form.handleSubmit(async (values) => {
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ value, handleChange }"
name="canListShares"
>
<FormItem class="flex flex-row justify-between">
<FormLabel class="grow">List shares</FormLabel>
<FormControl>
<Switch
:model-value="value"
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ value, handleChange }"
name="canCreateShares"
>
<FormItem class="flex flex-row justify-between">
<FormLabel class="grow">Create shares</FormLabel>
<FormControl>
<Switch
:model-value="value"
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ value, handleChange }"
name="canModifyShares"
>
<FormItem class="flex flex-row justify-between">
<FormLabel class="grow">Modify shares</FormLabel>
<FormControl>
<Switch
:model-value="value"
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
<FormField
v-slot="{ value, handleChange }"
name="canDeleteShares"
>
<FormItem class="flex flex-row justify-between">
<FormLabel class="grow">Delete shares</FormLabel>
<FormControl>
<Switch
:model-value="value"
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</div>
</form>

View File

@@ -7,7 +7,7 @@ const textarea = ref<HTMLTextAreaElement>(
null as unknown as HTMLTextAreaElement
);
const currentLine = ref<number>(0);
const currentLine = ref<number>(1);
const totalLines = computed(
() => editor.data?.editedContent.split('\n').length ?? 0
);

View File

@@ -272,7 +272,7 @@ export async function renameWarrenEntry(
headers: getApiHeaders(),
body: JSON.stringify({
warrenId,
path,
paths: [path],
targetPath,
}),
});

View File

@@ -25,6 +25,11 @@ export const userWarrenSchema = object({
canReadFiles: boolean().required(),
canModifyFiles: boolean().required(),
canDeleteFiles: boolean().required(),
canListShares: boolean().required(),
canCreateShares: boolean().required(),
canModifyShares: boolean().required(),
canDeleteShares: boolean().required(),
});
export const createWarrenSchema = object({