Compare commits

...

8 Commits

18 changed files with 18238 additions and 34 deletions

View File

@ -1,8 +1,18 @@
.PHONY: server client-dev
.PHONY: server-dev server-test client-dev client-test
server:
cd servilo && make
test:
cd server && make test-oneshot
cd v-client && make test
server-dev:
cd server && make dev
server-test:
cd server && make test
client-dev:
cd v-client && make dev
client-test:
cd v-client && make test

60
common/Cargo.lock generated
View File

@ -2,15 +2,44 @@
# It is not intended for manual editing.
version = 3
[[package]]
name = "autocfg"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "common"
version = "0.1.0"
dependencies = [
"serde",
"serde_derive",
"serde_yaml",
"thiserror",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "indexmap"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e"
dependencies = [
"autocfg",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4217ad341ebadf8d8e724e264f13e593e0648f5b3e94b3896a5df283be015ecc"
[[package]]
name = "proc-macro2"
version = "1.0.34"
@ -30,10 +59,16 @@ dependencies = [
]
[[package]]
name = "serde"
version = "1.0.132"
name = "ryu"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b9875c23cf305cd1fd7eb77234cbb705f21ea6a72c637a5c6db5fe4b8e7f008"
checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09"
[[package]]
name = "serde"
version = "1.0.147"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965"
[[package]]
name = "serde_derive"
@ -46,6 +81,19 @@ dependencies = [
"syn",
]
[[package]]
name = "serde_yaml"
version = "0.9.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d232d893b10de3eb7258ff01974d6ee20663d8e833263c99409d4b13a0209da"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "syn"
version = "1.0.84"
@ -82,3 +130,9 @@ name = "unicode-xid"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
[[package]]
name = "unsafe-libyaml"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e5fa573d8ac5f1a856f8d7be41d390ee973daf97c806b2c1a465e4e1406e68"

1184
server/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

21
server/Cargo.toml Normal file
View File

@ -0,0 +1,21 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"
default-run = "server"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = { version = "1" }
rand = { version = "0.8" }
rusqlite = { version = "0.26" }
serde = { version = "1.0", features = ["derive"] }
sha2 = { version = "0.10" }
thiserror = { version = "1" }
tokio = { version = "1", features = ["full"] }
uuid = { version = "0.8", features = ["v4"] }
warp = { version = "0.3" }
[dev-dependencies]
tempfile = { version = "3" }

11
server/Makefile Normal file
View File

@ -0,0 +1,11 @@
.PHONY: dev
dev:
cargo watch -x run
test:
cargo watch -x test
test-oneshot:
cargo test

View File

@ -0,0 +1,286 @@
use crate::errors::{error, fatal, ok, AppResult};
use rusqlite::types::{FromSql, FromSqlError, FromSqlResult, ValueRef};
use std::collections::HashMap;
use std::{convert::Infallible, str::FromStr};
use thiserror::Error;
use uuid::{adapter::Hyphenated, Uuid};
#[cfg(test)]
use crate::errors::maybe_fail;
#[derive(Debug, Error)]
pub enum AuthenticationError {
#[error("username already exists")]
DuplicateUsername,
#[error("invitation is not valid")]
InvalidInvitation,
#[error("session token not found")]
InvalidSession,
#[error("user not found")]
UserNotFound,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SessionToken(String);
impl From<&str> for SessionToken {
fn from(s: &str) -> Self {
SessionToken(s.to_owned())
}
}
impl FromStr for SessionToken {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(SessionToken(s.to_owned()))
}
}
impl From<SessionToken> for String {
fn from(s: SessionToken) -> Self {
s.0.clone()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Invitation(String);
impl From<&str> for Invitation {
fn from(s: &str) -> Self {
Invitation(s.to_owned())
}
}
impl FromStr for Invitation {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Invitation(s.to_owned()))
}
}
impl From<Invitation> for String {
fn from(s: Invitation) -> Self {
s.0.clone()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct UserId(String);
impl From<&str> for UserId {
fn from(s: &str) -> Self {
UserId(s.to_owned())
}
}
impl FromStr for UserId {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(UserId(s.to_owned()))
}
}
impl From<UserId> for String {
fn from(s: UserId) -> Self {
s.0.clone()
}
}
impl FromSql for UserId {
fn column_result(val: ValueRef<'_>) -> FromSqlResult<Self> {
match val {
ValueRef::Text(t) => Ok(UserId::from(
String::from_utf8(Vec::from(t)).unwrap().as_ref(),
)),
_ => Err(FromSqlError::InvalidType),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Username(String);
impl From<&str> for Username {
fn from(s: &str) -> Self {
Username(s.to_owned())
}
}
impl FromStr for Username {
type Err = Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Username(s.to_owned()))
}
}
impl From<&Username> for String {
fn from(s: &Username) -> Self {
s.0.clone()
}
}
impl From<Username> for String {
fn from(s: Username) -> Self {
s.0.clone()
}
}
impl FromSql for Username {
fn column_result(val: ValueRef<'_>) -> FromSqlResult<Self> {
match val {
ValueRef::Text(t) => Ok(Username::from(
String::from_utf8(Vec::from(t)).unwrap().as_ref(),
)),
_ => Err(FromSqlError::InvalidType),
}
}
}
pub trait AuthenticationDB: Send + Sync {
fn create_user(&mut self, username: Username) -> AppResult<UserId, AuthenticationError>;
fn create_invitation(&mut self, user: UserId) -> AppResult<Invitation, AuthenticationError>;
fn authenticate(
&mut self,
invitation: Invitation,
) -> AppResult<SessionToken, AuthenticationError>;
fn delete_session(&mut self, session: SessionToken) -> AppResult<(), AuthenticationError>;
fn delete_invitation(&mut self, invitation: Invitation) -> AppResult<(), AuthenticationError>;
fn delete_user(&mut self, user: UserId) -> AppResult<(), AuthenticationError>;
fn validate_session(&self, session: SessionToken) -> AppResult<(), AuthenticationError>;
fn get_user_id(&self, username: Username) -> AppResult<UserId, AuthenticationError>;
fn list_users(&self) -> AppResult<Vec<Username>, AuthenticationError>;
}
#[derive(Debug, Default)]
pub struct MemoryAuth {
users: HashMap<Username, UserId>,
inverse_users: HashMap<UserId, Username>,
invitations: HashMap<Invitation, UserId>,
sessions: HashMap<SessionToken, UserId>,
}
impl AuthenticationDB for MemoryAuth {
fn create_user(&mut self, username: Username) -> AppResult<UserId, AuthenticationError> {
#[cfg(test)]
let _ = maybe_fail::<AuthenticationError>(vec![])?;
let userid = UserId::from(format!("{}", Hyphenated::from_uuid(Uuid::new_v4())).as_str());
self.users.insert(username.clone(), userid.clone());
self.inverse_users.insert(userid.clone(), username);
ok(userid)
}
fn create_invitation(&mut self, user: UserId) -> AppResult<Invitation, AuthenticationError> {
#[cfg(test)]
let _ = maybe_fail::<AuthenticationError>(vec![])?;
if !self.inverse_users.contains_key(&user) {
return error::<Invitation, AuthenticationError>(AuthenticationError::UserNotFound);
}
let invitation =
Invitation::from(format!("{}", Hyphenated::from_uuid(Uuid::new_v4())).as_str());
self.invitations.insert(invitation.clone(), user);
ok(invitation)
}
fn authenticate(
&mut self,
invitation: Invitation,
) -> AppResult<SessionToken, AuthenticationError> {
#[cfg(test)]
let _ = maybe_fail::<AuthenticationError>(vec![])?;
if let Some(user) = self.invitations.get(&invitation) {
let session_token =
SessionToken::from(format!("{}", Hyphenated::from_uuid(Uuid::new_v4())).as_str());
self.sessions.insert(session_token.clone(), user.clone());
let _ = self.invitations.remove(&invitation);
ok(session_token)
} else {
error::<SessionToken, AuthenticationError>(AuthenticationError::InvalidInvitation)
}
}
fn delete_session(&mut self, session: SessionToken) -> AppResult<(), AuthenticationError> {
#[cfg(test)]
let _ = maybe_fail::<AuthenticationError>(vec![])?;
if let Some(_) = self.sessions.remove(&session) {
ok(())
} else {
error::<(), AuthenticationError>(AuthenticationError::InvalidSession)
}
}
fn delete_invitation(&mut self, invitation: Invitation) -> AppResult<(), AuthenticationError> {
#[cfg(test)]
let _ = maybe_fail::<AuthenticationError>(vec![])?;
if let Some(_) = self.invitations.remove(&invitation) {
ok(())
} else {
error::<(), AuthenticationError>(AuthenticationError::InvalidInvitation)
}
}
fn delete_user(&mut self, user: UserId) -> AppResult<(), AuthenticationError> {
#[cfg(test)]
let _ = maybe_fail::<AuthenticationError>(vec![])?;
if let Some(username) = self.inverse_users.remove(&user) {
let _ = self.users.remove(&username);
self.invitations = self
.invitations
.iter()
.filter(|(_, value)| **value != user)
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<HashMap<Invitation, UserId>>();
self.sessions = self
.sessions
.iter()
.filter(|(_, value)| **value != user)
.map(|(key, value)| (key.clone(), value.clone()))
.collect::<HashMap<SessionToken, UserId>>();
ok(())
} else {
error::<(), AuthenticationError>(AuthenticationError::UserNotFound)
}
}
fn validate_session(&self, session: SessionToken) -> AppResult<(), AuthenticationError> {
if self.sessions.contains_key(&session) {
ok(())
} else {
error::<(), AuthenticationError>(AuthenticationError::InvalidSession)
}
}
fn get_user_id(&self, username: Username) -> AppResult<UserId, AuthenticationError> {
Ok(self
.users
.get(&username)
.map(|u| u.clone())
.ok_or(AuthenticationError::UserNotFound))
}
fn list_users(&self) -> AppResult<Vec<Username>, AuthenticationError> {
ok(self.users.keys().cloned().collect::<Vec<Username>>())
}
}
#[cfg(test)]
mod test {}

View File

@ -0,0 +1,3 @@
fn main() {
println!("There is a tool");
}

111
server/src/database.rs Normal file
View File

@ -0,0 +1,111 @@
use rusqlite::{params, Connection};
use std::{
ops::{Deref, DerefMut},
path::PathBuf,
sync::{Arc, Mutex},
};
pub struct ManagedConnection<'a> {
pool: &'a Database,
connection: Option<Connection>,
}
pub struct Database {
file_path: PathBuf,
pool: Arc<Mutex<Vec<Connection>>>,
}
impl Database {
pub fn new(file_path: PathBuf) -> Result<Database, anyhow::Error> {
let mut connection = Connection::open(file_path.clone())?;
let tx = connection.transaction()?;
let version: i32 = tx.pragma_query_value(None, "user_version", |r| r.get(0))?;
if version == 0 {
tx.execute_batch(
"CREATE TABLE users (id STRING PRIMARY KEY, name TEXT);
CREATE TABLE invitations (token STRING PRIMARY KEY, user_id STRING, FOREIGN KEY(user_id) REFERENCES users(id));
CREATE TABLE sessions (token STRING PRIMARY KEY, user_id STRING, FOREIGN KEY(user_id) REFERENCES users(id));
PRAGMA user_version = 1;",
)?;
}
tx.commit()?;
Ok(Database {
file_path,
pool: Arc::new(Mutex::new(vec![connection])),
})
}
pub fn connect<'a>(&'a self) -> Result<ManagedConnection<'a>, anyhow::Error> {
let mut pool = self.pool.lock().unwrap();
match pool.pop() {
Some(connection) => Ok(ManagedConnection {
pool: &self,
connection: Some(connection),
}),
None => {
let connection = Connection::open(self.file_path.clone())?;
Ok(ManagedConnection {
pool: &self,
connection: Some(connection),
})
}
}
}
pub fn release(&self, connection: Connection) {
let mut pool = self.pool.lock().unwrap();
pool.push(connection);
}
}
impl Deref for ManagedConnection<'_> {
type Target = Connection;
fn deref(&self) -> &Connection {
self.connection.as_ref().unwrap()
}
}
impl DerefMut for ManagedConnection<'_> {
fn deref_mut(&mut self) -> &mut Connection {
self.connection.as_mut().unwrap()
}
}
impl Drop for ManagedConnection<'_> {
fn drop(&mut self) {
self.pool.release(self.connection.take().unwrap());
}
}
#[cfg(test)]
mod test {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn it_can_create_users() {
let path = NamedTempFile::new().unwrap().into_temp_path();
let database = Database::new(path.to_path_buf()).unwrap();
let mut connection = database.connect().unwrap();
let tr = connection.transaction().unwrap();
tr.execute(
"INSERT INTO users VALUES(?, ?)",
params!["abcdefg", "mercer"],
)
.unwrap();
tr.commit().unwrap();
let connection = database.connect().unwrap();
let id: Option<String> = connection
.query_row(
"SELECT id FROM users WHERE name = ?",
[String::from("mercer")],
|row| row.get("id"),
)
.unwrap();
assert_eq!(id, Some(String::from("abcdefg")));
}
}

31
server/src/errors.rs Normal file
View File

@ -0,0 +1,31 @@
use thiserror::Error;
/// This struct covers *fatal* errors for the application. Cross-functional things like full disks,
/// failing disks, database deadlocks, and so forth, which require that the whole application shut
/// down and that the administrator fix a problem.
#[derive(Debug, Error)]
pub enum FatalError {
#[error("disk is full")]
DiskFull,
#[error("io error: {0}")]
Io(std::io::Error),
}
pub type AppResult<A, E> = Result<Result<A, E>, FatalError>;
pub fn ok<A, E>(val: A) -> AppResult<A, E> {
Ok(Ok(val))
}
pub fn error<A, E>(err: E) -> AppResult<A, E> {
Ok(Err(err))
}
pub fn fatal<A, E>(err: FatalError) -> AppResult<A, E> {
Err(err)
}
#[cfg(test)]
pub fn maybe_fail<E>(possible_errors: Vec<E>) -> AppResult<(), E> {
ok(())
}

140
server/src/main.rs Normal file
View File

@ -0,0 +1,140 @@
use serde::{Deserialize, Serialize};
use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
sync::{Arc, RwLock},
};
use warp::Filter;
mod authentication;
use authentication::{AuthenticationDB, AuthenticationError, MemoryAuth, UserId, Username};
mod database;
mod errors;
#[derive(Debug)]
struct AuthenticationRefused;
impl warp::reject::Reject for AuthenticationRefused {}
fn with_authentication(
auth_ctx: Arc<RwLock<impl AuthenticationDB>>,
) -> impl Filter<Extract = ((Username, UserId),), Error = warp::Rejection> + Clone {
let auth_ctx = auth_ctx.clone();
warp::header("authentication").and_then({
let auth_ctx = auth_ctx.clone();
move |auth_header: String| {
let auth_ctx = auth_ctx.clone();
async move {
if auth_header.starts_with("Basic ") {
let username = auth_header.split(" ").skip(1).collect::<String>();
match auth_ctx
.read()
.unwrap()
.get_user_id(Username::from(username.as_str()))
{
Ok(Ok(userid)) => Ok((Username::from(username.as_str()), userid)),
Ok(Err(_)) => Err(warp::reject::custom(AuthenticationRefused)),
Err(err) => panic!("{}", err),
}
} else {
Err(warp::reject::custom(AuthenticationRefused))
}
}
}
})
}
#[derive(Serialize)]
struct ErrorResponse {
error: String,
}
#[derive(Deserialize)]
struct MakeUserParameters {
username: String,
}
#[derive(Serialize)]
struct MakeUserResponse {
userid: String,
}
fn make_user(
auth_ctx: Arc<RwLock<impl AuthenticationDB>>,
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "v1" / "users")
.and(warp::put())
.and(warp::body::json())
.map(move |params: MakeUserParameters| {
let mut auth_ctx = auth_ctx.write().unwrap();
match (*auth_ctx).create_user(Username::from(params.username.as_str())) {
Ok(Ok(userid)) => warp::reply::json(&MakeUserResponse {
userid: String::from(userid),
}),
Ok(auth_error) => warp::reply::json(&ErrorResponse {
error: format!("{:?}", auth_error),
}),
Err(err) => panic!("{}", err),
}
})
}
fn list_users(
auth_ctx: Arc<RwLock<impl AuthenticationDB>>,
) -> impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone {
warp::path!("api" / "v1" / "users")
.and(warp::get())
.map(move || {
let auth_ctx = auth_ctx.read().unwrap();
match (*auth_ctx).list_users() {
Ok(Ok(users)) => warp::reply::json(
&users
.iter()
.map(|u| String::from(u))
.collect::<Vec<String>>(),
),
Ok(auth_error) => warp::reply::json(&ErrorResponse {
error: format!("{:?}", auth_error),
}),
Err(err) => panic!("{}", err),
}
})
}
#[tokio::main]
pub async fn main() {
let auth_ctx: Arc<RwLock<MemoryAuth>> = Arc::new(RwLock::new(Default::default()));
let echo_unauthenticated = warp::path!("api" / "v1" / "echo" / String).map(|param: String| {
println!("param: {}", param);
warp::reply::json(&vec!["unauthenticated", param.as_str()])
});
/*
let authenticate = warp::path!("api" / "v1" / "auth" / String).map(|param: String| {
println!("param: {}", param);
warp::reply::json(&param)
});
*/
let echo_authenticated = warp::path!("api" / "v1" / "echo" / String)
.and(with_authentication(auth_ctx.clone()))
.map(|param: String, (username, userid)| {
println!("param: {:?}", username);
println!("param: {:?}", userid);
println!("param: {}", param);
warp::reply::json(&vec!["authed", param.as_str()])
});
let filter = list_users(auth_ctx.clone())
.or(make_user(auth_ctx.clone()))
.or(echo_authenticated)
.or(echo_unauthenticated);
let server = warp::serve(filter);
server
.run(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
8001,
))
.await;
}

View File

@ -3,3 +3,6 @@
dev:
npx tauri dev
test:
npm run test

5
v-client/jest.config.cjs Normal file
View File

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};

16325
v-client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "jest"
},
"dependencies": {
"@tauri-apps/api": "^1.2.0",
@ -15,10 +16,27 @@
},
"devDependencies": {
"@tauri-apps/cli": "^1.2.0",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^14.4.3",
"@types/jest": "^29.2.3",
"@types/react": "^18.0.24",
"@types/react-dom": "^18.0.8",
"@vitejs/plugin-react": "^2.2.0",
"eslint": "^8.27.0",
"eslint-config-react-app": "^7.0.1",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
"prettier": "^2.7.1",
"sass": "^1.56.1",
"ts-jest": "^29.0.3",
"typescript": "^4.6.4",
"vite": "^3.2.3"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
}
}

View File

@ -1,34 +1,12 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import './App.css'
import Authentication from "./pages/Authentication/Authentication";
import "./App.css";
function App() {
const [count, setCount] = useState(0)
return (
<div className="App">
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" className="logo" alt="Vite logo" />
</a>
<a href="https://reactjs.org" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
<Authentication />
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</div>
)
);
}
export default App
export default App;

View File

@ -0,0 +1,14 @@
/**
* @jest-environment jsdom
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import "@testing-library/jest-dom";
import Authentication from "./Authentication";
test("prompts the user for the invitation URL", () => {
render(<Authentication />);
expect(screen.getByText("Please enter your invitation URL")).toBeDefined();
});

View File

@ -0,0 +1,10 @@
import "./Authentication.scss";
const Authentication = () => (
<div>
<p>Please enter your invitation URL</p>
<input type="text" placeholder="Invitation URL" />
</div>
);
export default Authentication;