Compare commits

...

38 Commits

Author SHA1 Message Date
Savanni D'Gerinel 05c9e26c0f Remove the Iron middleware files 2023-10-03 17:57:11 -04:00
Savanni D'Gerinel 2aa21d304b Remove dead comments 2023-10-03 17:56:38 -04:00
Savanni D'Gerinel 50ef8a77be Remove a legacy file 2023-10-03 17:56:17 -04:00
Savanni D'Gerinel 0187969571 Remove an excess comment 2023-10-03 17:55:05 -04:00
Savanni D'Gerinel cab0fc92df Remove old placeholder directories 2023-10-03 17:54:37 -04:00
Savanni D'Gerinel 5b7faf556f Handle file uploads with a validated session 2023-10-03 17:47:54 -04:00
Savanni D'Gerinel f2bbb4e720 Validate the session token with file uploads
File uploads now check the session token before continuing.

Resolves: https://www.pivotaltracker.com/story/show/186174680
2023-10-03 17:30:43 -04:00
Savanni D'Gerinel 09df915296 Validate the session token
A previous commit added authentication token checks. Auth tokens are replaced with session tokens, which can (and should) expire. This commit validates sessions, which now allows access to gated operations.
2023-10-03 17:04:35 -04:00
Savanni D'Gerinel 529f22f49e Add a CLI application for user management 2023-10-03 16:32:00 -04:00
Savanni D'Gerinel 1b161435df Split out a support library 2023-10-03 16:18:19 -04:00
Savanni D'Gerinel f3940111c4 Add session checks 2023-10-03 13:56:55 -04:00
Savanni D'Gerinel 0209f72a9b Add the ability to create and list users 2023-10-03 13:31:58 -04:00
Savanni D'Gerinel 2b1d010540 Create the initial database migration 2023-10-03 13:31:43 -04:00
Savanni D'Gerinel 00f76d4f4b Finish the auth handler and create app auth stubs 2023-10-03 11:28:09 -04:00
Savanni D'Gerinel 791adbb085 Set up authentication routes 2023-10-03 11:10:37 -04:00
Savanni D'Gerinel 2cabcc46aa Complete upload 2023-09-26 22:43:33 -04:00
Savanni D'Gerinel 3294262d8d Set up temperory working directories 2023-09-25 00:58:35 -04:00
Savanni D'Gerinel afa8134644 Correctly set up file ids from list_files 2023-09-25 00:17:34 -04:00
Savanni D'Gerinel f7012ee11a Refactor PathResolver so it cannot fail 2023-09-25 00:05:45 -04:00
Savanni D'Gerinel 759a4b6a1a Remove old test files 2023-09-24 23:52:44 -04:00
Savanni D'Gerinel 4fa3a89e08 Lots more refactoring :( 2023-09-24 23:52:29 -04:00
Savanni D'Gerinel 6c1fa7cc2f Clean up the filehandle logic 2023-09-24 12:08:09 -04:00
Savanni D'Gerinel b0641bf5e9 Add cool_asserts 2023-09-23 20:20:57 -04:00
Savanni D'Gerinel 98a07ce03e Provide a unified interface for the File and Thumbnail 2023-09-23 20:20:35 -04:00
Savanni D'Gerinel 809861a38e Load file by ID 2023-09-23 19:15:56 -04:00
Savanni D'Gerinel bda0caff95 Get thumbnail creation working again 2023-09-23 15:17:49 -04:00
Savanni D'Gerinel 90f9b80dd9 Add some tests to verify that a file can be added to the system
Still gutting a lot of the old code, but this MR focuses more on ensuring that a file can be added and that the metadata gets saved.
2023-09-22 23:43:45 -04:00
Savanni D'Gerinel 163e1e1de1 Add some testing for the PathResolver 2023-09-22 21:56:43 -04:00
Savanni D'Gerinel d658747202 Start ripping out lots of infrastructure
Much of the infrastructure is old and seems to be based on some assumptions about how Iron handled multipart posts. I don't understand how much of this works, so I'm slowly ripping parts out and rebuilding how the separation of concerns works.
2023-09-22 20:03:58 -04:00
Savanni D'Gerinel 8c099d0586 Set up the delete route
Sets up the delete route, including post-delete redirect back to the root.
Also adds logging.

Delete does not actually delete things yet.
2023-09-22 00:38:26 -04:00
Savanni D'Gerinel f440e6f7ad Refactor file and thumbnail serving to common code 2023-09-21 22:15:58 -04:00
Savanni D'Gerinel 1bb6bd78e6 Attempt to add etag caching 2023-09-21 00:00:09 -04:00
Savanni D'Gerinel f4b3e811fc Render thumbnails 2023-09-20 23:31:52 -04:00
Savanni D'Gerinel 56d7fae43c Update cargo.lock 2023-09-20 23:07:01 -04:00
Savanni D'Gerinel a02e335492 Swap from iron to warp and start rebuilding the app 2023-09-20 23:06:34 -04:00
Savanni D'Gerinel 75bfae02c4 Add orizentic and file-service to the build 2023-09-19 19:01:09 -04:00
Savanni D'Gerinel 81aa7410de Import a questionably refactored version of file-service 2023-09-19 18:55:53 -04:00
Savanni D'Gerinel 356395a503 Import orizentic 2023-09-19 18:31:30 -04:00
38 changed files with 5553 additions and 75 deletions

4
.gitignore vendored
View File

@ -4,3 +4,7 @@ node_modules
dist
result
*.tgz
file-service/*.sqlite
file-service/*.sqlite-shm
file-service/*.sqlite-wal
file-service/var

1911
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,7 @@ members = [
"cyberpunk-splash",
"dashboard",
"emseries",
"file-service",
"flow",
"fluent-ergonomics",
"geo-types",
@ -16,6 +17,7 @@ members = [
"kifu/core",
"kifu/gtk",
"memorycache",
"orizentic",
"screenplay",
"sgf",
]

View File

@ -10,6 +10,7 @@ RUST_ALL_TARGETS=(
"cyberpunk-splash"
"dashboard"
"emseries"
"file-service"
"flow"
"fluent-ergonomics"
"geo-types"
@ -19,6 +20,7 @@ RUST_ALL_TARGETS=(
"kifu-core"
"kifu-gtk"
"memorycache"
"orizentic"
"screenplay"
"sgf"
)

1
file-service/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
fixtures

2
file-service/.ignore Normal file
View File

@ -0,0 +1,2 @@
fixtures
var

55
file-service/Cargo.toml Normal file
View File

@ -0,0 +1,55 @@
[package]
name = "file-service"
version = "0.1.0"
authors = ["savanni@luminescent-dreams.com"]
edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
auth-cli = [ "clap" ]
[lib]
name = "file_service"
path = "src/lib.rs"
[[bin]]
name = "file-service"
path = "src/main.rs"
[[bin]]
name = "auth-cli"
path = "src/bin/cli.rs"
required-features = [ "auth-cli" ]
[target.auth-cli.dependencies]
[dependencies]
base64ct = { version = "1", features = [ "alloc" ] }
build_html = { version = "2" }
bytes = { version = "1" }
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4", features = [ "derive" ], optional = true }
cookie = { version = "0.17" }
futures-util = { version = "0.3" }
hex-string = "0.1.0"
http = { version = "0.2" }
image = "0.23.5"
logger = "*"
log = { version = "0.4" }
mime = "0.3.16"
mime_guess = "2.0.3"
pretty_env_logger = { version = "0.5" }
serde_json = "*"
serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10"
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] }
thiserror = "1.0.20"
tokio = { version = "1", features = [ "full" ] }
uuid = { version = "0.4", features = [ "serde", "v4" ] }
warp = { version = "0.3" }
[dev-dependencies]
cool_asserts = { version = "2" }
tempdir = { version = "0.3" }

1
file-service/authdb.json Normal file
View File

@ -0,0 +1 @@
[{"jti":"ac3a46c6-3fa1-4d0a-af12-e7d3fefdc878","aud":"savanni","exp":1621351436,"iss":"savanni","iat":1589729036,"sub":"https://savanni.luminescent-dreams.com/file-service/","perms":["admin"]}]

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY NOT NULL,
username TEXT NOT NULL,
token TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT NOT NULL,
user_id INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id)
);

View File

@ -0,0 +1,44 @@
use clap::{Parser, Subcommand};
use file_service::{AuthDB, Username};
use std::path::PathBuf;
#[derive(Subcommand, Debug)]
enum Commands {
AddUser { username: String },
DeleteUser { username: String },
ListUsers,
}
#[derive(Parser, Debug)]
struct Args {
#[command(subcommand)]
command: Commands,
}
#[tokio::main]
pub async fn main() {
let args = Args::parse();
let authdb = AuthDB::new(PathBuf::from(&std::env::var("AUTHDB").unwrap()))
.await
.expect("to be able to open the database");
match args.command {
Commands::AddUser { username } => {
match authdb.add_user(Username::from(username.clone())).await {
Ok(token) => {
println!(
"User {} created. Auth token: {}",
username,
token.to_string()
);
}
Err(err) => {
println!("Could not create user {}", username);
println!("\tError: {:?}", err);
}
}
}
Commands::DeleteUser { username } => {}
Commands::ListUsers => {}
}
}

View File

@ -0,0 +1,255 @@
use build_html::Html;
use bytes::Buf;
use cookie::time::error::Format;
use file_service::WriteFileError;
use futures_util::StreamExt;
use http::{Error, StatusCode};
use std::collections::HashMap;
use std::io::Read;
use warp::{filters::multipart::FormData, http::Response, multipart::Part};
use crate::{pages, App, AuthToken, FileId, FileInfo, ReadFileError, SessionToken};
pub async fn handle_index(
app: App,
token: Option<SessionToken>,
) -> Result<Response<String>, Error> {
match token {
Some(token) => match app.validate_session(token).await {
Ok(_) => render_gallery_page(app).await,
Err(err) => render_auth_page(Some(format!("session expired: {:?}", err))),
},
None => render_auth_page(None),
}
}
pub fn render_auth_page(message: Option<String>) -> Result<Response<String>, Error> {
Response::builder()
.status(StatusCode::OK)
.body(pages::auth(message).to_html_string())
}
pub async fn render_gallery_page(app: App) -> Result<Response<String>, Error> {
match app.list_files().await {
Ok(ids) => {
let mut files = vec![];
for id in ids.into_iter() {
let file = app.get_file(&id).await;
files.push(file);
}
Response::builder()
.header("content-type", "text/html")
.status(StatusCode::OK)
.body(pages::gallery(files).to_html_string())
}
Err(_) => Response::builder()
.header("content-type", "text/html")
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body("".to_owned()),
}
}
pub async fn thumbnail(
app: App,
id: String,
old_etags: Option<String>,
) -> Result<Response<Vec<u8>>, Error> {
match app.get_file(&FileId::from(id)).await {
Ok(file) => serve_file(file.info.clone(), || file.thumbnail(), old_etags),
Err(_err) => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(vec![]),
}
}
pub async fn file(
app: App,
id: String,
old_etags: Option<String>,
) -> Result<Response<Vec<u8>>, Error> {
match app.get_file(&FileId::from(id)).await {
Ok(file) => serve_file(file.info.clone(), || file.thumbnail(), old_etags),
Err(_err) => Response::builder()
.status(StatusCode::NOT_FOUND)
.body(vec![]),
}
}
pub async fn handle_auth(
app: App,
form: HashMap<String, String>,
) -> Result<http::Response<String>, Error> {
match form.get("token") {
Some(token) => match app
.authenticate(AuthToken::from(AuthToken::from(token.clone())))
.await
{
Ok(Some(session_token)) => Response::builder()
.header("location", "/")
.header(
"set-cookie",
format!(
"session={}; Secure; HttpOnly; SameSite=Strict",
session_token.to_string()
),
)
.status(StatusCode::SEE_OTHER)
.body("".to_owned()),
Ok(None) => render_auth_page(Some(format!("no user found"))),
Err(_) => render_auth_page(Some(format!("invalid auth token"))),
},
None => render_auth_page(Some(format!("no token available"))),
}
}
pub async fn handle_upload(
app: App,
token: SessionToken,
form: FormData,
) -> Result<http::Response<String>, Error> {
match app.validate_session(token).await {
Ok(Some(_)) => match process_file_upload(app, form).await {
Ok(_) => Response::builder()
.header("location", "/")
.status(StatusCode::SEE_OTHER)
.body("".to_owned()),
Err(UploadError::FilenameMissing) => Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("filename is required for all files".to_owned()),
Err(UploadError::WriteFileError(err)) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(format!("could not write to the file system: {:?}", err)),
Err(UploadError::WarpError(err)) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(format!("error with the app framework: {:?}", err)),
},
_ => Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("".to_owned()),
}
}
fn serve_file<F>(
info: FileInfo,
file: F,
old_etags: Option<String>,
) -> http::Result<http::Response<Vec<u8>>>
where
F: FnOnce() -> Result<Vec<u8>, ReadFileError>,
{
match old_etags {
Some(old_etags) if old_etags != info.hash => Response::builder()
.header("content-type", info.file_type)
.status(StatusCode::NOT_MODIFIED)
.body(vec![]),
_ => match file() {
Ok(content) => Response::builder()
.header("content-type", info.file_type)
.header("etag", info.hash)
.status(StatusCode::OK)
.body(content),
Err(_) => Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(vec![]),
},
}
}
async fn collect_multipart(
mut stream: warp::filters::multipart::FormData,
) -> Result<Vec<(Option<String>, Option<String>, Vec<u8>)>, warp::Error> {
let mut content: Vec<(Option<String>, Option<String>, Vec<u8>)> = Vec::new();
while let Some(part) = stream.next().await {
match part {
Ok(part) => content.push(collect_content(part).await.unwrap()),
Err(err) => return Err(err),
}
}
Ok(content)
}
async fn collect_content(
mut part: Part,
) -> Result<(Option<String>, Option<String>, Vec<u8>), String> {
let mut content: Vec<u8> = Vec::new();
while let Some(Ok(data)) = part.data().await {
let mut reader = data.reader();
reader.read_to_end(&mut content).unwrap();
}
Ok((
part.content_type().map(|s| s.to_owned()),
part.filename().map(|s| s.to_owned()),
content,
))
}
/*
async fn handle_upload(
form: warp::filters::multipart::FormData,
app: App,
) -> warp::http::Result<warp::http::Response<String>> {
let files = collect_multipart(form).await;
match files {
Ok(files) => {
for (_, filename, content) in files {
match filename {
Some(filename) => {
app.add_file(filename, content).unwrap();
}
None => {
return warp::http::Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("".to_owned())
}
}
}
}
Err(_err) => {
return warp::http::Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("".to_owned())
}
}
// println!("file length: {:?}", files.map(|f| f.len()));
warp::http::Response::builder()
.header("location", "/")
.status(StatusCode::SEE_OTHER)
.body("".to_owned())
}
*/
enum UploadError {
FilenameMissing,
WriteFileError(WriteFileError),
WarpError(warp::Error),
}
impl From<WriteFileError> for UploadError {
fn from(err: WriteFileError) -> Self {
Self::WriteFileError(err)
}
}
impl From<warp::Error> for UploadError {
fn from(err: warp::Error) -> Self {
Self::WarpError(err)
}
}
async fn process_file_upload(app: App, form: FormData) -> Result<(), UploadError> {
let files = collect_multipart(form).await?;
for (_, filename, content) in files {
match filename {
Some(filename) => {
app.add_file(filename, content).await?;
}
None => return Err(UploadError::FilenameMissing),
}
}
Ok(())
}

199
file-service/src/html.rs Normal file
View File

@ -0,0 +1,199 @@
use build_html::{self, Html, HtmlContainer};
#[derive(Clone, Debug)]
pub struct Form {
path: String,
method: String,
encoding: Option<String>,
elements: String,
}
impl Form {
pub fn new() -> Self {
Self {
path: "/".to_owned(),
method: "get".to_owned(),
encoding: None,
elements: "".to_owned(),
}
}
pub fn with_path(mut self, path: &str) -> Self {
self.path = path.to_owned();
self
}
pub fn with_method(mut self, method: &str) -> Self {
self.method = method.to_owned();
self
}
pub fn with_encoding(mut self, encoding: &str) -> Self {
self.encoding = Some(encoding.to_owned());
self
}
}
impl Html for Form {
fn to_html_string(&self) -> String {
let encoding = match self.encoding {
Some(ref encoding) => format!("enctype=\"{encoding}\"", encoding = encoding),
None => format!(""),
};
format!(
"<form action=\"{path}\" method=\"{method}\" {encoding}>\n{elements}\n</form>\n",
path = self.path,
method = self.method,
encoding = encoding,
elements = self.elements.to_html_string()
)
}
}
impl HtmlContainer for Form {
fn add_html<H: Html>(&mut self, html: H) {
self.elements.push_str(&html.to_html_string());
}
}
#[derive(Clone, Debug)]
pub struct Input {
ty: String,
name: String,
id: Option<String>,
value: Option<String>,
content: Option<String>,
}
impl Html for Input {
fn to_html_string(&self) -> String {
let id = match self.id {
Some(ref id) => format!("id=\"{}\"", id),
None => "".to_owned(),
};
let value = match self.value {
Some(ref value) => format!("value=\"{}\"", value),
None => "".to_owned(),
};
format!(
"<input type=\"{ty}\" name=\"{name}\" {id} {value}>{content}</input>\n",
ty = self.ty,
name = self.name,
id = id,
value = value,
content = self.content.clone().unwrap_or("".to_owned()),
)
}
}
impl Input {
pub fn new(ty: &str, name: &str) -> Self {
Self {
ty: ty.to_owned(),
name: name.to_owned(),
id: None,
value: None,
content: None,
}
}
pub fn with_id(mut self, val: &str) -> Self {
self.id = Some(val.to_owned());
self
}
pub fn with_value(mut self, val: &str) -> Self {
self.value = Some(val.to_owned());
self
}
pub fn with_content(mut self, val: &str) -> Self {
self.content = Some(val.to_owned());
self
}
}
#[derive(Clone, Debug)]
pub struct Label {
target: String,
text: String,
}
impl Label {
pub fn new(target: &str, text: &str) -> Self {
Self {
target: target.to_owned(),
text: text.to_owned(),
}
}
}
impl Html for Label {
fn to_html_string(&self) -> String {
format!(
"<label for=\"{target}\">{text}</label>",
target = self.target,
text = self.text
)
}
}
#[derive(Clone, Debug)]
pub struct Button {
ty: Option<String>,
name: Option<String>,
label: String,
}
impl Button {
pub fn new(label: &str) -> Self {
Self {
ty: None,
name: None,
label: label.to_owned(),
}
}
pub fn with_type(mut self, ty: &str) -> Self {
self.ty = Some(ty.to_owned());
self
}
}
impl Html for Button {
fn to_html_string(&self) -> String {
let ty = match self.ty {
Some(ref ty) => format!("type={}", ty),
None => "".to_owned(),
};
let name = match self.name {
Some(ref name) => format!("name={}", name),
None => "".to_owned(),
};
format!(
"<button {ty} {name}>{label}</button>",
name = name,
label = self.label
)
}
}
#[derive(Clone, Debug)]
pub struct Image {
path: String,
}
impl Image {
pub fn new(path: &str) -> Self {
Self {
path: path.to_owned(),
}
}
}
impl Html for Image {
fn to_html_string(&self) -> String {
format!("<img src={path} />", path = self.path,)
}
}

6
file-service/src/lib.rs Normal file
View File

@ -0,0 +1,6 @@
mod store;
pub use store::{
AuthDB, AuthError, AuthToken, FileHandle, FileId, FileInfo, ReadFileError, SessionToken, Store,
Username, WriteFileError,
};

166
file-service/src/main.rs Normal file
View File

@ -0,0 +1,166 @@
#[macro_use]
extern crate log;
use handlers::{file, handle_auth, handle_upload, thumbnail};
use http::status::StatusCode;
// use mustache::{compile_path, Template};
// use orizentic::{Permissions, ResourceName, Secret};
use bytes::Buf;
use cookie::Cookie;
use futures_util::StreamExt;
use std::{
collections::{HashMap, HashSet},
convert::Infallible,
io::Read,
net::{IpAddr, Ipv4Addr, SocketAddr},
path::PathBuf,
sync::Arc,
};
use tokio::sync::RwLock;
use warp::{filters::multipart::Part, Filter, Rejection};
mod handlers;
mod html;
mod pages;
pub use file_service::{
AuthDB, AuthError, AuthToken, FileHandle, FileId, FileInfo, ReadFileError, SessionToken, Store,
Username, WriteFileError,
};
pub use handlers::handle_index;
#[derive(Clone)]
pub struct App {
authdb: Arc<RwLock<AuthDB>>,
store: Arc<RwLock<Store>>,
}
impl App {
pub fn new(authdb: AuthDB, store: Store) -> Self {
Self {
authdb: Arc::new(RwLock::new(authdb)),
store: Arc::new(RwLock::new(store)),
}
}
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
self.authdb.read().await.authenticate(token).await
}
pub async fn validate_session(
&self,
token: SessionToken,
) -> Result<Option<Username>, AuthError> {
self.authdb.read().await.validate_session(token).await
}
pub async fn list_files(&self) -> Result<HashSet<FileId>, ReadFileError> {
self.store.read().await.list_files()
}
pub async fn get_file(&self, id: &FileId) -> Result<FileHandle, ReadFileError> {
self.store.read().await.get_file(id)
}
pub async fn add_file(
&self,
filename: String,
content: Vec<u8>,
) -> Result<FileHandle, WriteFileError> {
self.store.write().await.add_file(filename, content)
}
}
fn with_app(app: App) -> impl Filter<Extract = (App,), Error = Infallible> + Clone {
warp::any().map(move || app.clone())
}
fn parse_cookies(cookie_str: &str) -> Result<HashMap<String, String>, cookie::ParseError> {
Cookie::split_parse(cookie_str)
.map(|c| c.map(|c| (c.name().to_owned(), c.value().to_owned())))
.collect::<Result<HashMap<String, String>, cookie::ParseError>>()
}
fn get_session_token(cookies: HashMap<String, String>) -> Option<SessionToken> {
cookies
.get("session")
.cloned()
.and_then(|session| Some(SessionToken::from(session)))
}
fn maybe_with_session() -> impl Filter<Extract = (Option<SessionToken>,), Error = Rejection> + Copy
{
warp::any()
.and(warp::header::optional::<String>("cookie"))
.map(|cookie_str: Option<String>| match cookie_str {
Some(cookie_str) => parse_cookies(&cookie_str).ok().and_then(get_session_token),
None => None,
})
}
fn with_session() -> impl Filter<Extract = (SessionToken,), Error = Rejection> + Copy {
warp::any()
.and(warp::header::<String>("cookie"))
.and_then(|cookie_str: String| async move {
match parse_cookies(&cookie_str).ok().and_then(get_session_token) {
Some(session_token) => Ok(session_token),
None => Err(warp::reject()),
}
})
}
#[tokio::main]
pub async fn main() {
pretty_env_logger::init();
let authdb = AuthDB::new(PathBuf::from(&std::env::var("AUTHDB").unwrap()))
.await
.unwrap();
let store = Store::new(PathBuf::from(&std::env::var("FILE_SHARE_DIR").unwrap()));
let app = App::new(authdb, store);
let log = warp::log("file_service");
let root = warp::path!()
.and(warp::get())
.and(with_app(app.clone()))
.and(maybe_with_session())
.then(handle_index);
let auth = warp::path!("auth")
.and(warp::post())
.and(with_app(app.clone()))
.and(warp::filters::body::form())
.then(handle_auth);
let upload_via_form = warp::path!("upload")
.and(warp::post())
.and(with_app(app.clone()))
.and(with_session())
.and(warp::multipart::form())
.then(handle_upload);
let thumbnail = warp::path!(String / "tn")
.and(warp::get())
.and(warp::header::optional::<String>("if-none-match"))
.and(with_app(app.clone()))
.then(move |id, old_etags, app: App| thumbnail(app, id, old_etags));
let file = warp::path!(String)
.and(warp::get())
.and(warp::header::optional::<String>("if-none-match"))
.and(with_app(app.clone()))
.then(move |id, old_etags, app: App| file(app, id, old_etags));
let server = warp::serve(
root.or(auth)
.or(upload_via_form)
.or(thumbnail)
.or(file)
.with(log),
);
server
.run(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 8002))
.await;
}

66
file-service/src/pages.rs Normal file
View File

@ -0,0 +1,66 @@
use crate::html::*;
use build_html::{self, Container, ContainerType, Html, HtmlContainer};
use file_service::{FileHandle, FileId, ReadFileError};
pub fn auth(_message: Option<String>) -> build_html::HtmlPage {
build_html::HtmlPage::new()
.with_title("Authentication")
.with_html(
Form::new()
.with_path("/auth")
.with_method("post")
.with_container(
Container::new(ContainerType::Div)
.with_html(Input::new("token", "token").with_id("for-token-input"))
.with_html(Label::new("for-token-input", "Authentication Token")),
),
)
}
pub fn gallery(handles: Vec<Result<FileHandle, ReadFileError>>) -> build_html::HtmlPage {
let mut page = build_html::HtmlPage::new()
.with_title("Admin list of files")
.with_header(1, "Admin list of files")
.with_html(
Form::new()
.with_path("/upload")
.with_method("post")
.with_encoding("multipart/form-data")
.with_container(
Container::new(ContainerType::Div)
.with_html(Input::new("file", "file").with_id("for-selector-input"))
.with_html(Label::new("for-selector-input", "Select a file")),
)
.with_html(Button::new("Upload file").with_type("submit")),
);
for handle in handles {
let container = match handle {
Ok(ref handle) => thumbnail(&handle.id).with_html(
Form::new()
.with_path(&format!("/{}", *handle.id))
.with_method("post")
.with_html(Input::new("hidden", "_method").with_value("delete"))
.with_html(Button::new("Delete")),
),
Err(err) => Container::new(ContainerType::Div)
.with_attributes(vec![("class", "file")])
.with_paragraph(format!("{:?}", err)),
};
page.add_container(container)
}
page
}
pub fn thumbnail(id: &FileId) -> Container {
let mut container = Container::new(ContainerType::Div).with_attributes(vec![("class", "file")]);
let tn = Container::new(ContainerType::Div)
.with_attributes(vec![("class", "thumbnail")])
.with_link(
format!("/{}", **id),
Image::new(&format!("{}/tn", **id)).to_html_string(),
);
container.add_html(tn);
container
}

View File

@ -0,0 +1,282 @@
use super::{fileinfo::FileInfo, FileId, ReadFileError, WriteFileError};
use chrono::prelude::*;
use hex_string::HexString;
use image::imageops::FilterType;
use sha2::{Digest, Sha256};
use std::{
convert::TryFrom,
io::{Read, Write},
path::{Path, PathBuf},
};
use thiserror::Error;
use uuid::Uuid;
#[derive(Debug, Error)]
pub enum PathError {
#[error("path cannot be derived from input")]
InvalidPath,
}
#[derive(Clone, Debug)]
pub struct PathResolver {
base: PathBuf,
id: FileId,
extension: String,
}
impl PathResolver {
pub fn new(base: &Path, id: FileId, extension: String) -> Self {
Self {
base: base.to_owned(),
id,
extension,
}
}
pub fn metadata_path_by_id(base: &Path, id: FileId) -> PathBuf {
let mut path = base.to_path_buf();
path.push(PathBuf::from(id.clone()));
path.set_extension("json");
path
}
pub fn id(&self) -> FileId {
self.id.clone()
}
pub fn file_path(&self) -> PathBuf {
let mut path = self.base.clone();
path.push(PathBuf::from(self.id.clone()));
path.set_extension(self.extension.clone());
path
}
pub fn metadata_path(&self) -> PathBuf {
let mut path = self.base.clone();
path.push(PathBuf::from(self.id.clone()));
path.set_extension("json");
path
}
pub fn thumbnail_path(&self) -> PathBuf {
let mut path = self.base.clone();
path.push(PathBuf::from(self.id.clone()));
path.set_extension(format!("tn.{}", self.extension));
path
}
}
impl TryFrom<String> for PathResolver {
type Error = PathError;
fn try_from(s: String) -> Result<Self, Self::Error> {
PathResolver::try_from(s.as_str())
}
}
impl TryFrom<&str> for PathResolver {
type Error = PathError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
PathResolver::try_from(Path::new(s))
}
}
impl TryFrom<PathBuf> for PathResolver {
type Error = PathError;
fn try_from(path: PathBuf) -> Result<Self, Self::Error> {
PathResolver::try_from(path.as_path())
}
}
impl TryFrom<&Path> for PathResolver {
type Error = PathError;
fn try_from(path: &Path) -> Result<Self, Self::Error> {
Ok(Self {
base: path
.parent()
.map(|s| s.to_owned())
.ok_or(PathError::InvalidPath)?,
id: path
.file_stem()
.and_then(|s| s.to_str().map(|s| FileId::from(s)))
.ok_or(PathError::InvalidPath)?,
extension: path
.extension()
.and_then(|s| s.to_str().map(|s| s.to_owned()))
.ok_or(PathError::InvalidPath)?,
})
}
}
/// One file in the database, complete with the path of the file and information about the
/// thumbnail of the file.
#[derive(Debug)]
pub struct FileHandle {
pub id: FileId,
pub path: PathResolver,
pub info: FileInfo,
}
impl FileHandle {
/// Create a new entry in the database
pub fn new(filename: String, root: PathBuf) -> Result<Self, WriteFileError> {
let id = FileId::from(Uuid::new_v4().hyphenated().to_string());
let extension = PathBuf::from(filename)
.extension()
.and_then(|s| s.to_str().map(|s| s.to_owned()))
.ok_or(WriteFileError::InvalidPath)?;
let path = PathResolver {
base: root.clone(),
id: id.clone(),
extension: extension.clone(),
};
let file_type = mime_guess::from_ext(&extension)
.first_or_text_plain()
.essence_str()
.to_owned();
let info = FileInfo {
id: id.clone(),
size: 0,
created: Utc::now(),
file_type,
hash: "".to_owned(),
extension,
};
let mut md_file = std::fs::File::create(path.metadata_path())?;
md_file.write(&serde_json::to_vec(&info)?)?;
Ok(Self { id, path, info })
}
pub fn load(id: &FileId, root: &Path) -> Result<Self, ReadFileError> {
let info = FileInfo::load(PathResolver::metadata_path_by_id(root, id.clone()))?;
let resolver = PathResolver::new(root, id.clone(), info.extension.clone());
Ok(Self {
id: info.id.clone(),
path: resolver,
info,
})
}
pub fn set_content(&mut self, content: Vec<u8>) -> Result<(), WriteFileError> {
let mut content_file = std::fs::File::create(self.path.file_path())?;
let byte_count = content_file.write(&content)?;
self.info.size = byte_count;
self.info.hash = self.hash_content(&content).as_string();
let mut md_file = std::fs::File::create(self.path.metadata_path())?;
md_file.write(&serde_json::to_vec(&self.info)?)?;
self.write_thumbnail()?;
Ok(())
}
pub fn content(&self) -> Result<Vec<u8>, ReadFileError> {
load_content(&self.path.file_path())
}
pub fn thumbnail(&self) -> Result<Vec<u8>, ReadFileError> {
load_content(&self.path.thumbnail_path())
}
fn hash_content(&self, data: &Vec<u8>) -> HexString {
HexString::from_bytes(&Sha256::digest(data).to_vec())
}
fn write_thumbnail(&self) -> Result<(), WriteFileError> {
let img = image::open(&self.path.file_path())?;
let tn = img.resize(640, 640, FilterType::Nearest);
tn.save(&self.path.thumbnail_path())?;
Ok(())
}
pub fn delete(self) {
let _ = std::fs::remove_file(self.path.thumbnail_path());
let _ = std::fs::remove_file(self.path.file_path());
let _ = std::fs::remove_file(self.path.metadata_path());
}
}
fn load_content(path: &Path) -> Result<Vec<u8>, ReadFileError> {
let mut buf = Vec::new();
let mut file = std::fs::File::open(&path)?;
file.read_to_end(&mut buf)?;
Ok(buf)
}
#[cfg(test)]
mod test {
use super::*;
use std::{convert::TryFrom, path::PathBuf};
use tempdir::TempDir;
#[test]
fn paths() {
let resolver = PathResolver::try_from("path/82420255-d3c8-4d90-a582-f94be588c70c.png")
.expect("to have a valid path");
assert_eq!(
resolver.file_path(),
PathBuf::from("path/82420255-d3c8-4d90-a582-f94be588c70c.png")
);
assert_eq!(
resolver.metadata_path(),
PathBuf::from("path/82420255-d3c8-4d90-a582-f94be588c70c.json")
);
assert_eq!(
resolver.thumbnail_path(),
PathBuf::from("path/82420255-d3c8-4d90-a582-f94be588c70c.tn.png")
);
}
#[test]
fn it_opens_a_file() {
let tmp = TempDir::new("var").unwrap();
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
}
#[test]
fn it_deletes_a_file() {
let tmp = TempDir::new("var").unwrap();
let f =
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
f.delete();
}
#[test]
fn it_can_return_a_thumbnail() {
let tmp = TempDir::new("var").unwrap();
let _ =
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
/*
assert_eq!(
f.thumbnail(),
Thumbnail {
id: String::from("rawr.png"),
root: PathBuf::from("var/"),
},
);
*/
}
#[test]
fn it_can_return_a_file_stream() {
let tmp = TempDir::new("var").unwrap();
let _ =
FileHandle::new("rawr.png".to_owned(), PathBuf::from(tmp.path())).expect("to succeed");
// f.stream().expect("to succeed");
}
#[test]
fn it_raises_an_error_when_file_not_found() {
let tmp = TempDir::new("var").unwrap();
match FileHandle::load(&FileId::from("rawr"), tmp.path()) {
Err(ReadFileError::FileNotFound(_)) => assert!(true),
_ => assert!(false),
}
}
}

View File

@ -0,0 +1,70 @@
use crate::FileId;
use super::{ReadFileError, WriteFileError};
use chrono::prelude::*;
use serde::{Deserialize, Serialize};
use serde_json;
use std::{
io::{Read, Write},
path::PathBuf,
};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileInfo {
pub id: FileId,
pub size: usize,
pub created: DateTime<Utc>,
pub file_type: String,
pub hash: String,
pub extension: String,
}
impl FileInfo {
pub fn load(path: PathBuf) -> Result<Self, ReadFileError> {
let mut content: Vec<u8> = Vec::new();
let mut file =
std::fs::File::open(path.clone()).map_err(|_| ReadFileError::FileNotFound(path))?;
file.read_to_end(&mut content)?;
let js = serde_json::from_slice(&content)?;
Ok(js)
}
pub fn save(&self, path: PathBuf) -> Result<(), WriteFileError> {
let ser = serde_json::to_string(self).unwrap();
let mut file = std::fs::File::create(path)?;
file.write(ser.as_bytes())?;
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::store::FileId;
use tempdir::TempDir;
#[test]
fn it_saves_and_loads_metadata() {
let tmp = TempDir::new("var").unwrap();
let created = Utc::now();
let info = FileInfo {
id: FileId("temp-id".to_owned()),
size: 23777,
created,
file_type: "image/png".to_owned(),
hash: "abcdefg".to_owned(),
extension: "png".to_owned(),
};
let mut path = tmp.path().to_owned();
path.push(&PathBuf::from(info.id.clone()));
info.save(path.clone()).unwrap();
let info_ = FileInfo::load(path).unwrap();
assert_eq!(info_.size, 23777);
assert_eq!(info_.created, info.created);
assert_eq!(info_.file_type, "image/png");
assert_eq!(info_.hash, info.hash);
}
}

View File

@ -0,0 +1,541 @@
use base64ct::{Base64, Encoding};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use sqlx::{
sqlite::{SqlitePool, SqliteRow},
Executor, Row,
};
use std::collections::HashSet;
use std::{ops::Deref, path::PathBuf, sync::Arc};
use thiserror::Error;
use tokio::sync::RwLock;
use uuid::Uuid;
mod filehandle;
mod fileinfo;
pub use filehandle::FileHandle;
pub use fileinfo::FileInfo;
#[derive(Debug, Error)]
pub enum WriteFileError {
#[error("root file path does not exist")]
RootNotFound,
#[error("permission denied")]
PermissionDenied,
#[error("invalid path")]
InvalidPath,
#[error("no metadata available")]
NoMetadata,
#[error("file could not be loaded")]
LoadError(#[from] ReadFileError),
#[error("image conversion failed")]
ImageError(#[from] image::ImageError),
#[error("JSON error")]
JSONError(#[from] serde_json::error::Error),
#[error("IO error")]
IOError(#[from] std::io::Error),
}
#[derive(Debug, Error)]
pub enum ReadFileError {
#[error("file not found")]
FileNotFound(PathBuf),
#[error("path is not a file")]
NotAFile,
#[error("permission denied")]
PermissionDenied,
#[error("invalid path")]
InvalidPath,
#[error("JSON error")]
JSONError(#[from] serde_json::error::Error),
#[error("IO error")]
IOError(#[from] std::io::Error),
}
#[derive(Debug, Error)]
pub enum AuthError {
#[error("authentication token is duplicated")]
DuplicateAuthToken,
#[error("session token is duplicated")]
DuplicateSessionToken,
#[error("database failed")]
SqlError(sqlx::Error),
}
impl From<sqlx::Error> for AuthError {
fn from(err: sqlx::Error) -> AuthError {
AuthError::SqlError(err)
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct Username(String);
impl From<String> for Username {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for Username {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<Username> for String {
fn from(s: Username) -> Self {
Self::from(&s)
}
}
impl From<&Username> for String {
fn from(s: &Username) -> Self {
let Username(s) = s;
Self::from(s)
}
}
impl Deref for Username {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl sqlx::FromRow<'_, SqliteRow> for Username {
fn from_row(row: &SqliteRow) -> sqlx::Result<Self> {
let name: String = row.try_get("username")?;
Ok(Username::from(name))
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct AuthToken(String);
impl From<String> for AuthToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for AuthToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<AuthToken> for PathBuf {
fn from(s: AuthToken) -> Self {
Self::from(&s)
}
}
impl From<&AuthToken> for PathBuf {
fn from(s: &AuthToken) -> Self {
let AuthToken(s) = s;
Self::from(s)
}
}
impl Deref for AuthToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct SessionToken(String);
impl From<String> for SessionToken {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for SessionToken {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<SessionToken> for PathBuf {
fn from(s: SessionToken) -> Self {
Self::from(&s)
}
}
impl From<&SessionToken> for PathBuf {
fn from(s: &SessionToken) -> Self {
let SessionToken(s) = s;
Self::from(s)
}
}
impl Deref for SessionToken {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[derive(Clone, Debug, Serialize, Deserialize, Hash, PartialEq, Eq)]
pub struct FileId(String);
impl From<String> for FileId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for FileId {
fn from(s: &str) -> Self {
Self(s.to_owned())
}
}
impl From<FileId> for PathBuf {
fn from(s: FileId) -> Self {
Self::from(&s)
}
}
impl From<&FileId> for PathBuf {
fn from(s: &FileId) -> Self {
let FileId(s) = s;
Self::from(s)
}
}
impl Deref for FileId {
type Target = String;
fn deref(&self) -> &Self::Target {
&self.0
}
}
pub trait FileRoot {
fn root(&self) -> PathBuf;
}
pub struct Context(PathBuf);
impl FileRoot for Context {
fn root(&self) -> PathBuf {
self.0.clone()
}
}
#[derive(Clone)]
pub struct AuthDB {
pool: SqlitePool,
}
impl AuthDB {
pub async fn new(path: PathBuf) -> Result<Self, sqlx::Error> {
let migrator = sqlx::migrate!("./migrations");
let pool = SqlitePool::connect(&format!("sqlite://{}", path.to_str().unwrap())).await?;
migrator.run(&pool).await?;
Ok(Self { pool })
}
pub async fn add_user(&self, username: Username) -> Result<AuthToken, AuthError> {
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(username.to_string());
let auth_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO users (username, token) VALUES ($1, $2)")
.bind(username.to_string())
.bind(auth_token.clone())
.execute(&self.pool)
.await?;
Ok(AuthToken::from(auth_token))
}
pub async fn list_users(&self) -> Result<Vec<Username>, AuthError> {
let usernames = sqlx::query_as::<_, Username>("SELECT (username) FROM users")
.fetch_all(&self.pool)
.await?;
Ok(usernames)
}
pub async fn authenticate(&self, token: AuthToken) -> Result<Option<SessionToken>, AuthError> {
let results = sqlx::query("SELECT * FROM users WHERE token = $1")
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if results.len() > 1 {
return Err(AuthError::DuplicateAuthToken);
}
if results.len() == 0 {
return Ok(None);
}
let user_id: i64 = results[0].try_get("id")?;
let mut hasher = Sha256::new();
hasher.update(Uuid::new_v4().hyphenated().to_string());
hasher.update(token.to_string());
let session_token = Base64::encode_string(&hasher.finalize());
let _ = sqlx::query("INSERT INTO sessions (token, user_id) VALUES ($1, $2)")
.bind(session_token.clone())
.bind(user_id)
.execute(&self.pool)
.await?;
Ok(Some(SessionToken::from(session_token)))
}
pub async fn validate_session(
&self,
token: SessionToken,
) -> Result<Option<Username>, AuthError> {
let rows = sqlx::query(
"SELECT users.username FROM sessions INNER JOIN users ON sessions.user_id = users.id WHERE sessions.token = $1",
)
.bind(token.to_string())
.fetch_all(&self.pool)
.await?;
if rows.len() > 1 {
return Err(AuthError::DuplicateSessionToken);
}
if rows.len() == 0 {
return Ok(None);
}
let username: String = rows[0].try_get("username")?;
Ok(Some(Username::from(username)))
}
}
pub struct Store {
files_root: PathBuf,
}
impl Store {
pub fn new(files_root: PathBuf) -> Self {
Self { files_root }
}
pub fn list_files(&self) -> Result<HashSet<FileId>, ReadFileError> {
let paths = std::fs::read_dir(&self.files_root)?;
let info_files = paths
.into_iter()
.filter_map(|path| {
let path_ = path.unwrap().path();
if path_.extension().and_then(|s| s.to_str()) == Some("json") {
let stem = path_.file_stem().and_then(|s| s.to_str()).unwrap();
Some(FileId::from(FileId::from(stem)))
} else {
None
}
})
.collect::<HashSet<FileId>>();
Ok(info_files)
}
pub fn add_file(
&mut self,
filename: String,
content: Vec<u8>,
) -> Result<FileHandle, WriteFileError> {
let mut file = FileHandle::new(filename, self.files_root.clone())?;
file.set_content(content)?;
Ok(file)
}
pub fn get_file(&self, id: &FileId) -> Result<FileHandle, ReadFileError> {
FileHandle::load(id, &self.files_root)
}
pub fn delete_file(&mut self, id: &FileId) -> Result<(), WriteFileError> {
let handle = FileHandle::load(id, &self.files_root)?;
handle.delete();
Ok(())
}
pub fn get_metadata(&self, id: &FileId) -> Result<FileInfo, ReadFileError> {
let mut path = self.files_root.clone();
path.push(PathBuf::from(id));
path.set_extension("json");
FileInfo::load(path)
}
}
#[cfg(test)]
mod test {
use super::*;
use cool_asserts::assert_matches;
use std::{collections::HashSet, io::Read};
use tempdir::TempDir;
fn with_file<F>(test_fn: F)
where
F: FnOnce(Store, FileId, TempDir),
{
let tmp = TempDir::new("var").unwrap();
let mut buf = Vec::new();
let mut file = std::fs::File::open("fixtures/rawr.png").unwrap();
file.read_to_end(&mut buf).unwrap();
let mut store = Store::new(PathBuf::from(tmp.path()));
let file_record = store.add_file("rawr.png".to_owned(), buf).unwrap();
test_fn(store, file_record.id, tmp);
}
#[test]
fn adds_files() {
with_file(|store, id, tmp| {
let file = store.get_file(&id).expect("to retrieve the file");
assert_eq!(file.content().map(|file| file.len()).unwrap(), 23777);
assert!(tmp.path().join(&(*id)).with_extension("png").exists());
assert!(tmp.path().join(&(*id)).with_extension("json").exists());
assert!(tmp.path().join(&(*id)).with_extension("tn.png").exists());
});
}
#[test]
fn sets_up_metadata_for_file() {
with_file(|store, id, tmp| {
assert!(tmp.path().join(&(*id)).with_extension("png").exists());
let info = store.get_metadata(&id).expect("to retrieve the metadata");
assert_matches!(info, FileInfo { size, file_type, hash, extension, .. } => {
assert_eq!(size, 23777);
assert_eq!(file_type, "image/png");
assert_eq!(hash, "b6cd35e113b95d62f53d9cbd27ccefef47d3e324aef01a2db6c0c6d3a43c89ee".to_owned());
assert_eq!(extension, "png".to_owned());
});
});
}
/*
#[test]
fn sets_up_thumbnail_for_file() {
with_file(|store, id| {
let (_, thumbnail) = store.get_thumbnail(&id).expect("to retrieve the thumbnail");
assert_eq!(thumbnail.content().map(|file| file.len()).unwrap(), 48869);
});
}
*/
#[test]
fn deletes_associated_files() {
with_file(|mut store, id, tmp| {
store.delete_file(&id).expect("file to be deleted");
assert!(!tmp.path().join(&(*id)).with_extension("png").exists());
assert!(!tmp.path().join(&(*id)).with_extension("json").exists());
assert!(!tmp.path().join(&(*id)).with_extension("tn.png").exists());
});
}
#[test]
fn lists_files_in_the_db() {
with_file(|store, id, _| {
let resolvers = store.list_files().expect("file listing to succeed");
let ids = resolvers.into_iter().collect::<HashSet<FileId>>();
assert_eq!(ids.len(), 1);
assert!(ids.contains(&id));
});
}
}
#[cfg(test)]
mod authdb_test {
use super::*;
use cool_asserts::assert_matches;
#[tokio::test]
async fn can_create_and_list_users() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.list_users().await, Ok(names) => {
let names = names.into_iter().collect::<HashSet<Username>>();
assert!(names.contains(&Username::from("savanni")));
})
}
#[tokio::test]
async fn unknown_auth_token_returns_nothing() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let _ = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let token = AuthToken::from("0000000000");
assert_matches!(db.auth_token(token).await, Ok(None));
}
#[tokio::test]
async fn auth_token_becomes_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
assert_matches!(db.auth_token(token).await, Ok(_));
}
#[tokio::test]
async fn can_validate_session_token() {
let db = AuthDB::new(PathBuf::from(":memory:"))
.await
.expect("a memory-only database will be created");
let token = db
.add_user(Username::from("savanni"))
.await
.expect("user to be created");
let session = db
.auth_token(token)
.await
.expect("token authentication should succeed")
.expect("session token should be found");
assert_matches!(
db.auth_session(session).await,
Ok(Some(username)) => {
assert_eq!(username, Username::from("savanni"));
});
}
}

View File

@ -0,0 +1,91 @@
use super::{ReadFileError, WriteFileError};
use image::imageops::FilterType;
use std::{
fs::remove_file,
io::Read,
path::{Path, PathBuf},
};
#[derive(Clone, Debug, PartialEq)]
pub struct Thumbnail {
pub path: PathBuf,
}
impl Thumbnail {
pub fn open(
origin_path: PathBuf,
thumbnail_path: PathBuf,
) -> Result<Thumbnail, WriteFileError> {
let s = Thumbnail {
path: PathBuf::from(thumbnail_path),
};
if !s.path.exists() {
let img = image::open(&origin_path)?;
let tn = img.resize(640, 640, FilterType::Nearest);
tn.save(&s.path)?;
}
Ok(s)
}
pub fn load(path: PathBuf) -> Result<Thumbnail, ReadFileError> {
let s = Thumbnail { path: path.clone() };
if !s.path.exists() {
return Err(ReadFileError::FileNotFound(path));
}
Ok(s)
}
/*
pub fn from_path(path: &Path) -> Result<Thumbnail, ReadFileError> {
let id = path
.file_name()
.map(|s| String::from(s.to_string_lossy()))
.ok_or(ReadFileError::NotAnImage(PathBuf::from(path)))?;
let path = path
.parent()
.ok_or(ReadFileError::FileNotFound(PathBuf::from(path)))?;
Thumbnail::open(&id, root)
}
*/
/*
pub fn stream(&self) -> Result<std::fs::File, ReadFileError> {
std::fs::File::open(self.path.clone()).map_err(|err| {
if err.kind() == std::io::ErrorKind::NotFound {
ReadFileError::FileNotFound
} else {
ReadFileError::from(err)
}
})
}
*/
/*
pub fn delete(self) -> Result<(), WriteFileError> {
remove_file(self.path).map_err(WriteFileError::from)
}
*/
}
#[cfg(test)]
mod test {
use super::*;
use crate::store::utils::FileCleanup;
#[test]
fn it_creates_a_thumbnail_if_one_does_not_exist() {
let _ = FileCleanup(PathBuf::from("var/rawr.tn.png"));
let _ = Thumbnail::open(
PathBuf::from("fixtures/rawr.png"),
PathBuf::from("var/rawr.tn.png"),
)
.expect("thumbnail open must work");
assert!(Path::new("var/rawr.tn.png").is_file());
}
}

View File

@ -0,0 +1,15 @@
<html>
<head>
<title> {{title}} </title>
<link href="/css" rel="stylesheet" type="text/css" media="screen" />
<script src="/script"></script>
</head>
<body>
<a href="/file/{{id}}"><img src="/tn/{{id}}" /></a>
</body>
</html>

View File

@ -0,0 +1,54 @@
<html>
<head>
<title> Admin list of files </title>
<link href="/css" rel="stylesheet" type="text/css" media="screen" />
<script src="/script"></script>
</head>
<body>
<h1> Admin list of files </h1>
<div class="uploadform">
<form action="/" method="post" enctype="multipart/form-data">
<div id="file-selector">
<input type="file" name="file" id="file-selector-input" />
<label for="file-selector-input" onclick="selectFile('file-selector')">Select a file</label>
</div>
<input type="submit" value="Upload file" />
</form>
</div>
<div class="files">
{{#files}}
<div class="file">
{{#error}}
<div>
<p> {{error}} </p>
</div>
{{/error}}
{{#file}}
<div class="thumbnail">
<a href="/file/{{id}}"><img src="/tn/{{id}}" /></a>
</div>
<div>
<ul>
<li> {{date}} </li>
<li> {{type_}} </li>
<li> {{size}} </li>
</ul>
<div>
<form action="/{{id}}" method="post">
<input type="hidden" name="_method" value="delete" />
<input type="submit" value="Delete" />
</form>
</div>
</div>
{{/file}}
</div>
{{/files}}
</div>
</body>
</html>

View File

@ -0,0 +1,10 @@
const selectFile = (selectorId) => {
console.log("wide arrow functions work: " + selectorId);
const input = document.querySelector("#" + selectorId + " input[type='file']")
const label = document.querySelector("#" + selectorId + " label")
input.addEventListener("change", (e) => {
if (input.files.length > 0) {
label.innerHTML = input.files[0].name
}
})
}

View File

@ -0,0 +1,103 @@
body {
font-family: 'Ariel', sans-serif;
}
.files {
display: flex;
flex-wrap: wrap;
}
.file {
display: flex;
margin: 1em;
border: 1px solid #449dfc;
border-radius: 5px;
padding: 1em;
}
.thumbnail {
max-width: 320px;
margin: 1em;
}
img {
max-width: 100%;
}
[type="submit"] {
border-radius: 1em;
margin: 1em;
padding: 1em;
}
.uploadform {
display: flex;
margin: 1em;
background-color: #e5f0fc;
border: 1px solid #449dfc;
border-radius: 5px;
padding: 1em;
}
/* https://benmarshall.me/styling-file-inputs/ */
[type="file"] {
border: 0;
clip: rect(0, 0, 0, 0);
height: 1px;
overflow: hidden;
padding: 0;
position: absolute !important;
white-space: nowrap;
width: 1px;
}
[type="file"] + label {
background-color: rgb(0, 86, 112);
border-radius: 1em;
color: #fff;
cursor: pointer;
display: inline-block;
padding: 1em;
margin: 1em;
transition: background-color 0.3s;
}
[type="file"]:focus + label,
[type="file"] + label:hover {
background-color: #67b0ff;
}
[type="file"]:focus + label {
outline: 1px dotted #000;
outline: -webkit-focus-ring-color auto 5px;
}
@media screen and (max-width: 980px) { /* This is the screen width of a OnePlus 5t */
body {
font-size: xx-large;
}
[type="submit"] {
font-size: xx-large;
width: 100%;
}
.uploadform {
display: flex;
}
[type="file"] + label {
width: 100%;
}
.thumbnail {
max-width: 100%;
margin: 1em;
}
.file {
display: flex;
flex-direction: column;
width: 100%;
}
}

View File

View File

@ -43,6 +43,7 @@
pkgs.cargo-nextest
pkgs.crate2nix
pkgs.wasm-pack
pkgs.sqlx-cli
typeshare.packages."x86_64-linux".default
];
LIBCLANG_PATH="${pkgs.llvmPackages.libclang.lib}/lib";

View File

@ -0,0 +1,74 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
education, socio-economic status, nationality, personal appearance, race,
religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at savanni@luminescent-dreams.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org

4
orizentic/CONTRIBUTORS Normal file
View File

@ -0,0 +1,4 @@
* [Savanni D'Gerinel](http://github.com/savannidgerinel)
* [Daria Phoebe Brasea](http://github.com/dariaphoebe)
* [Aria Stewart](http://github.com/aredridel)

446
orizentic/Cargo.lock generated Normal file
View File

@ -0,0 +1,446 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "ansi_term"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
dependencies = [
"winapi",
]
[[package]]
name = "atty"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fc4a1aa4c24c0718a250f0681885c1af91419d242f29eb8f2ab28502d80dbd1"
dependencies = [
"libc",
"termion",
"winapi",
]
[[package]]
name = "base64"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85415d2594767338a74a30c1d370b2f3262ec1b4ed2d7bba5b3faf4de40467d9"
dependencies = [
"byteorder",
"safemem",
]
[[package]]
name = "bitflags"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0c54bb8f454c567f21197eefcdbf5679d0bd99f2ddbe52e84c77061952e6789"
[[package]]
name = "byteorder"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74c0b906e9446b0a2e4f760cdb3fa4b2c48cdc6db8766a845c54b6ff063fd2e9"
[[package]]
name = "cc"
version = "1.0.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9ce8bb087aacff865633f0bd5aeaed910fe2fe55b55f4739527f2e023a2e53d"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "chrono"
version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6962c635d530328acc53ac6a955e83093fedc91c5809dfac1fa60fa470830a37"
dependencies = [
"num-integer",
"num-traits",
"serde",
"time",
]
[[package]]
name = "clap"
version = "2.33.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002"
dependencies = [
"ansi_term",
"atty",
"bitflags",
"strsim",
"textwrap",
"unicode-width",
"vec_map",
]
[[package]]
name = "dtoa"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09c3753c3db574d215cba4ea76018483895d7bff25a31b49ba45db21c48e50ab"
[[package]]
name = "either"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3be565ca5c557d7f59e7cfcf1844f9e3033650c929c6566f511e8005f205c1d0"
[[package]]
name = "getrandom"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "itertools"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69ddb889f9d0d08a67338271fa9b62996bc788c7796a5c18cf057420aaed5eaf"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c069bbec61e1ca5a596166e55dfe4773ff745c3d16b700013bcaff9a6df2c682"
[[package]]
name = "jsonwebtoken"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d438ea707d465c230305963b67f8357a1d56fcfad9434797d7cb1c46c2e41df"
dependencies = [
"base64",
"chrono",
"ring",
"serde",
"serde_derive",
"serde_json",
"untrusted",
]
[[package]]
name = "lazy_static"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5729f27f159ddd61f4df6228e827e86643d4d3e7c32183cb30a1c08f604a14"
[[package]]
name = "libc"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8f7255a17a627354f321ef0055d63b898c6fb27eff628af4d1b66b7331edf6"
[[package]]
name = "linked-hash-map"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70fb39025bc7cdd76305867c4eccf2f2dcf6e9a57f5b21a93e1c2d86cd03ec9e"
[[package]]
name = "num-integer"
version = "0.1.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e83d528d2677f0518c570baf2b7abdcf0cd2d248860b68507bdcb3e91d4c0cea"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "630de1ef5cc79d0cdd78b7e33b81f083cbfe90de0f4b2b2f07f905867c70e9fe"
[[package]]
name = "orizentic"
version = "1.0.1"
dependencies = [
"chrono",
"clap",
"itertools",
"jsonwebtoken",
"serde",
"serde_derive",
"serde_json",
"thiserror",
"uuid",
"version_check",
"yaml-rust",
]
[[package]]
name = "proc-macro2"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "effdb53b25cdad54f8f48843d67398f7ef2e14f12c1b4cb4effc549a6462a4d6"
dependencies = [
"unicode-xid 0.1.0",
]
[[package]]
name = "proc-macro2"
version = "1.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9f5105d4fdaab20335ca9565e106a5d9b82b6219b5ba735731124ac6711d23d"
dependencies = [
"unicode-xid 0.2.2",
]
[[package]]
name = "quote"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e44651a0dc4cdd99f71c83b561e221f714912d11af1a4dff0631f923d53af035"
dependencies = [
"proc-macro2 0.4.6",
]
[[package]]
name = "quote"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
dependencies = [
"proc-macro2 1.0.29",
]
[[package]]
name = "redox_syscall"
version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c214e91d3ecf43e9a4e41e578973adeb14b474f2bee858742d127af75a0112b1"
[[package]]
name = "redox_termios"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e891cfe48e9100a70a3b6eb652fef28920c117d366339687bd5576160db0f76"
dependencies = [
"redox_syscall",
]
[[package]]
name = "ring"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c4db68a2e35f3497146b7e4563df7d4773a2433230c5e4b448328e31740458a"
dependencies = [
"cc",
"lazy_static",
"libc",
"untrusted",
]
[[package]]
name = "safemem"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e27a8b19b835f7aea908818e871f5cc3a5a186550c30773be987e155e8163d8f"
[[package]]
name = "serde"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "210e5a3b159c566d7527e9b22e44be73f2e0fcc330bb78fef4dbccb56d2e74c8"
[[package]]
name = "serde_derive"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd724d68017ae3a7e63600ee4b2fdb3cad2158ffd1821d44aff4580f63e2b593"
dependencies = [
"proc-macro2 0.4.6",
"quote 0.6.3",
"syn 0.14.4",
]
[[package]]
name = "serde_json"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84b8035cabe9b35878adec8ac5fe03d5f6bc97ff6edd7ccb96b44c1276ba390e"
dependencies = [
"dtoa",
"itoa",
"serde",
]
[[package]]
name = "strsim"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "syn"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2beff8ebc3658f07512a413866875adddd20f4fd47b2a4e6c9da65cd281baaea"
dependencies = [
"proc-macro2 0.4.6",
"quote 0.6.3",
"unicode-xid 0.1.0",
]
[[package]]
name = "syn"
version = "1.0.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5239bc68e0fef57495900cfea4e8dc75596d9a319d7e16b1e0a440d24e6fe0a0"
dependencies = [
"proc-macro2 1.0.29",
"quote 1.0.9",
"unicode-xid 0.2.2",
]
[[package]]
name = "termion"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "689a3bdfaab439fd92bc87df5c4c78417d3cbe537487274e9b0b2dce76e92096"
dependencies = [
"libc",
"redox_syscall",
"redox_termios",
]
[[package]]
name = "textwrap"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
dependencies = [
"unicode-width",
]
[[package]]
name = "thiserror"
version = "1.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "602eca064b2d83369e2b2f34b09c70b605402801927c65c11071ac911d299b88"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad553cc2c78e8de258400763a647e80e6d1b31ee237275d756f6836d204494c"
dependencies = [
"proc-macro2 1.0.29",
"quote 1.0.9",
"syn 1.0.77",
]
[[package]]
name = "time"
version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d825be0eb33fda1a7e68012d51e9c7f451dc1a69391e7fdc197060bb8c56667b"
dependencies = [
"libc",
"redox_syscall",
"winapi",
]
[[package]]
name = "unicode-width"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882386231c45df4700b275c7ff55b6f3698780a650026380e72dabe76fa46526"
[[package]]
name = "unicode-xid"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc"
[[package]]
name = "unicode-xid"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
[[package]]
name = "untrusted"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55cd1f4b4e96b46aeb8d4855db4a7a9bd96eeeb5c6a1ab54593328761642ce2f"
[[package]]
name = "uuid"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7"
dependencies = [
"getrandom",
"serde",
]
[[package]]
name = "vec_map"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c78687fb1a80548ae3250346c3db86a80a7cdd77bda190189f2d0a0987c81a"
[[package]]
name = "version_check"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "914b1a6776c4c929a602fafd8bc742e06365d4bcbe48c30f9cca5824f70dc9dd"
[[package]]
name = "wasi"
version = "0.10.2+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
[[package]]
name = "winapi"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773ef9dcc5f24b7d850d0ff101e542ff24c3b090a9768e03ff889fdef41f00fd"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "yaml-rust"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57ab38ee1a4a266ed033496cf9af1828d8d6e6c1cfa5f643a2809effcae4d628"
dependencies = [
"linked-hash-map",
]

39
orizentic/Cargo.toml Normal file
View File

@ -0,0 +1,39 @@
[package]
name = "orizentic"
version = "1.0.1"
authors = ["Savanni D'Gerinel <savanni@luminescent-dreams.com>"]
description = "A library for inerfacing with a JWT auth token database and a command line tool for managing it."
license = "GPL3"
documentation = "https://docs.rs/orizentic"
homepage = "https://github.com/luminescent-dreams/orizentic"
repository = "https://github.com/luminescent-dreams/orizentic"
categories = ["authentication", "command-line-utilities"]
include = [
"**/*.rs",
"Cargo.toml",
"build.rs",
]
[build-dependencies]
version_check = "0.1.5"
[dependencies]
chrono = { version = "0.4", features = ["serde"] }
clap = "2.33"
itertools = "0.10"
jsonwebtoken = "5"
serde = "1"
serde_derive = "1"
serde_json = "1"
thiserror = "1"
uuid = { version = "0.8", features = ["v4", "serde"] }
yaml-rust = "0.4"
[lib]
name = "orizentic"
path = "src/lib.rs"
[[bin]]
name = "orizentic"
path = "src/bin.rs"

30
orizentic/LICENSE Normal file
View File

@ -0,0 +1,30 @@
Copyright Savanni D'Gerinel (c) 2017 - 2019
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following
disclaimer in the documentation and/or other materials provided
with the distribution.
* Neither the name of Savanni D'Gerinel nor the names of other
contributors may be used to endorse or promote products derived
from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

73
orizentic/readme.md Normal file
View File

@ -0,0 +1,73 @@
# Orizentic
[![CircleCI](https://circleci.com/gh/luminescent-dreams/orizentic/tree/sol.svg?style=svg)](https://circleci.com/gh/luminescent-dreams/orizentic/tree/sol)
[Documentation](https://docs.rs/orizentic")
Orizentic provides a library that streamlines token-based authentication, and a CLI tool for maintaining a database of tokens.
## Credit
The name is a contraction of Auth(oriz)ation/Auth(entic)ation, and credit goes to [Daria Phoebe Brashear](https://github.com/dariaphoebe).
The original idea has been debated online for many years, but the push to make this useful comes from [Aria Stewart](https://github.com/aredridel).
## Tokens
Tokens are simple [JWTs](https://jwt.io/). This library simplifies the process by easily generating and checking JWTs that have only an issuer, an optional time-to-live, a resource name, a username, and a list of permissions. A typical resulting JWT would look like this:
{ iss = Savanni
, sub = health
, aud = "Savanni Desktop"
, exp = null
, nbf = null
, iat = 1499650083
, jti = 9d57a8d8-d11e-43b2-a4d6-7b82ad043994
, unregisteredClaims = { perms: [ "read", "write" ] }
}
The `issuer` and `audience` (or username) are almost entirely for human readability. In this instance, I issued a token that was intended to be used on my desktop system.
The `subject` in this case is synonymous with Resource and is a name for the resource for which access is being granted. Permissions are a simple list of freeform strings. Both of these are flexible within your application and your authorization checks will use them to verify that the token can be used for the specified purpose.
## CLI Usage
## Library Usage
[orizentic - Rust](https://docs.rs/orizentic/1.0.0/orizentic/)
There are multiple errata for the documentation:
* There are, in fact, now [two functions](https://docs.rs/orizentic/1.0.0/orizentic/filedb/index.html) for saving and loading a database.
* An example for how to use the library is currently here [for loading the database](https://github.com/luminescent-dreams/fitnesstrax/blob/8c9f3f418ff75675874f7a8e3928ad3f7d134eb4/server/src/web.rs#L64) and here [as part of the AuthMiddleware for an Iron server](https://github.com/luminescent-dreams/fitnesstrax/blob/8c9f3f418ff75675874f7a8e3928ad3f7d134eb4/server/src/server.rs#L156). I apologize for not writing this in more detail yet.
## Language support
This library and application is only supported for Rust. Haskell and Go support has been discontinued, but can be revived if I discover folks have an interest. The token database is compatible across tools. See readmes in the language directory for usage information.
Future Haskell, Go, and other language versions of the library will be done through language bindings against the Rust utilities instead of through my previous clean-room re-implementations.
## Nix installation
If you have Nix installed on your system, or you run NixOS, create this derivation:
orizentic.nix:
```
{ fetchFromGitHub }:
let src = fetchFromGitHub {
owner = "luminescent-dreams";
repo = "orizentic";
rev = "896140f594fe3c106662ffe2550f289bb68bc0cb";
sha256 = "05g7b0jiyy0pv74zf89yikf65vi3jrn1da0maj0k9fxnxb2vv7a4";
};
in import "${src}/default.nix" {}
```
At this time, you must have nixpkgs-19.03 defined (and preferably pointing to the 19.03 channel). I will parameterize this and update the instructions in the future.
I import this into my shell.nix `with import ./orizentic.nix { inherit (pkgs) fetchFromGitHub; };`.
For a complete example, see my [shell.nix](https://github.com/savannidgerinel/nix-shell/blob/sol/shell.nix) file.
I have not bundled this application for any other distribution, but you should nave no trouble just building with just cargo build --release with Rust-1.33 and Cargo.

20
orizentic/shell.nix Normal file
View File

@ -0,0 +1,20 @@
let
rust_overlay = import (builtins.fetchTarball "https://github.com/oxalica/rust-overlay/archive/master.tar.gz");
pkgs = import <nixpkgs> { overlays = [ rust_overlay ]; };
unstable = import <unstable> {};
rust = pkgs.rust-bin.stable."1.59.0".default.override {
extensions = [ "rust-src" ];
};
in pkgs.mkShell {
name = "datasphere";
nativeBuildInputs = [
rust
unstable.rust-analyzer
];
shellHook = ''
if [ -e ~/.nixpkgs/shellhook.sh ]; then . ~/.nixpkgs/shellhook.sh; fi
'';
}

251
orizentic/src/bin.rs Normal file
View File

@ -0,0 +1,251 @@
extern crate chrono;
extern crate clap;
extern crate orizentic;
use chrono::Duration;
use clap::{App, Arg, ArgMatches, SubCommand};
use std::env;
use orizentic::*;
#[derive(Debug)]
enum OrizenticErr {
ParseError(std::num::ParseIntError),
}
// ORIZENTIC_DB
// ORIZENTIC_SECRET
//
// list
// create
// revoke
// encode
pub fn main() {
let db_path = env::var_os("ORIZENTIC_DB").map(|str| {
str.into_string()
.expect("ORIZENTIC_DB contains invalid Unicode sequences")
});
let secret = env::var_os("ORIZENTIC_SECRET").map(|str| {
Secret(
str.into_string()
.map(|s| s.into_bytes())
.expect("ORIZENTIC_SECRET contains invalid Unicode sequences"),
)
});
let matches = App::new("orizentic cli")
.subcommand(SubCommand::with_name("list"))
.subcommand(
SubCommand::with_name("create")
.arg(
Arg::with_name("issuer")
.long("issuer")
.takes_value(true)
.required(true),
)
.arg(
Arg::with_name("ttl")
.long("ttl")
.takes_value(true)
.required(true),
)
.arg(
Arg::with_name("resource")
.long("resource")
.takes_value(true)
.required(true),
)
.arg(
Arg::with_name("username")
.long("username")
.takes_value(true)
.required(true),
)
.arg(
Arg::with_name("perms")
.long("perms")
.takes_value(true)
.required(true),
),
)
.subcommand(
SubCommand::with_name("revoke").arg(
Arg::with_name("id")
.long("id")
.takes_value(true)
.required(true),
),
)
.subcommand(
SubCommand::with_name("encode").arg(
Arg::with_name("id")
.long("id")
.takes_value(true)
.required(true),
),
)
.get_matches();
match matches.subcommand() {
("list", _) => list_tokens(db_path),
("create", Some(args)) => create_token(db_path, secret, args),
("revoke", Some(args)) => revoke_token(db_path, args),
("encode", Some(args)) => encode_token(db_path, secret, args),
(cmd, _) => {
println!("unknown subcommand: {}", cmd);
}
}
}
fn list_tokens(db_path: Option<String>) {
let db_path_ = db_path.expect("ORIZENTIC_DB is required for this operation");
let claimsets = orizentic::filedb::load_claims_from_file(&db_path_);
match claimsets {
Ok(claimsets_) => {
for claimset in claimsets_ {
println!("[{}]", claimset.id);
println!("Audience: {}", String::from(claimset.audience));
match claimset.expiration {
Some(expiration) => println!(
"Expiration: {}",
expiration.format("%Y-%m-%d %H:%M:%S")
),
None => println!("Expiration: None"),
}
println!("Issuer: {}", claimset.issuer.0);
println!(
"Issued At: {}",
claimset.issued_at.format("%Y-%m-%d %H:%M:%S")
);
println!("Resource Name: {}", claimset.resource.0);
let perm_val: String = itertools::Itertools::intersperse(
claimset.permissions.0.clone().into_iter(),
String::from(", "),
)
.collect();
println!("Permissions: {}", perm_val);
println!("")
}
}
Err(err) => {
println!("claimset failed to load: {}", err);
std::process::exit(1);
}
}
}
fn create_token(db_path: Option<String>, secret: Option<Secret>, args: &ArgMatches) {
let db_path_ = db_path.expect("ORIZENTIC_DB is required for this operation");
let secret_ = secret.expect("ORIZENTIC_SECRET is required for this operation");
let issuer = args
.value_of("issuer")
.map(|x| Issuer(String::from(x)))
.expect("--issuer is a required parameter");
let ttl: Option<TTL> = args.value_of("ttl").map(|x| {
x.parse()
.and_then(|d| Ok(TTL(Duration::seconds(d))))
.map_err(|err| OrizenticErr::ParseError(err))
.expect("Failed to parse TTL")
});
let resource_name = args
.value_of("resource")
.map(|x| ResourceName(String::from(x)))
.expect("--resource is a required parameter");
let username = args
.value_of("username")
.map(|x| Username::from(x))
.expect("--username is a required parameter");
let perms: Permissions = args
.value_of("perms")
.map(|str| Permissions(str.split(',').map(|s| String::from(s)).collect()))
.expect("--permissions is a required parameter");
let claimsets = orizentic::filedb::load_claims_from_file(&db_path_);
match claimsets {
Err(err) => {
println!("claimset failed to load: {}", err);
std::process::exit(1);
}
Ok(claimsets_) => {
let new_claimset = ClaimSet::new(issuer, ttl, resource_name, username, perms);
let mut ctx = orizentic::OrizenticCtx::new(secret_, claimsets_);
ctx.add_claimset(new_claimset.clone());
match orizentic::filedb::save_claims_to_file(&ctx.list_claimsets(), &db_path_) {
Err(err) => {
println!("Failed to write claimset to file: {:?}", err);
std::process::exit(1);
}
Ok(_) => match ctx.encode_claimset(&new_claimset) {
Ok(token) => println!("{}", token.text),
Err(err) => {
println!("token could not be encoded: {:?}", err);
std::process::exit(1);
}
},
}
}
}
}
fn revoke_token(db_path: Option<String>, args: &ArgMatches) {
let db_path_ = db_path.expect("ORIZENTIC_DB is required for this operation");
let claimsets = orizentic::filedb::load_claims_from_file(&db_path_);
match claimsets {
Err(err) => {
println!("claimset failed to load: {}", err);
std::process::exit(1);
}
Ok(claimsets_) => {
let id = args
.value_of("id")
.map(String::from)
.expect("--id is a required parameter");
let mut ctx =
orizentic::OrizenticCtx::new(Secret(String::from("").into_bytes()), claimsets_);
ctx.revoke_by_uuid(&id);
match orizentic::filedb::save_claims_to_file(&ctx.list_claimsets(), &db_path_) {
Err(err) => {
println!("Failed to write claimset to file: {:?}", err);
std::process::exit(1);
}
Ok(_) => {}
}
}
}
}
fn encode_token(db_path: Option<String>, secret: Option<Secret>, args: &ArgMatches) {
let db_path_ = db_path.expect("ORIZENTIC_DB is required for this operation");
let secret_ = secret.expect("ORIZENTIC_SECRET is required for this operation");
let id = args
.value_of("id")
.map(String::from)
.expect("--id is a required parameter");
let claimsets = orizentic::filedb::load_claims_from_file(&db_path_);
match claimsets {
Err(err) => {
println!("claimset failed to load: {}", err);
std::process::exit(1);
}
Ok(claimsets_) => {
let ctx = orizentic::OrizenticCtx::new(secret_, claimsets_);
let claimset = ctx.find_claimset(&id);
match claimset {
Some(claimset_) => match ctx.encode_claimset(&claimset_) {
Ok(token) => println!("{}", token.text),
Err(err) => {
println!("token could not be encoded: {:?}", err);
std::process::exit(1);
}
},
None => {
println!("No claimset found");
std::process::exit(1);
}
}
}
}
}

303
orizentic/src/core.rs Normal file
View File

@ -0,0 +1,303 @@
extern crate chrono;
extern crate jsonwebtoken as jwt;
extern crate serde;
extern crate serde_json;
extern crate uuid;
extern crate yaml_rust;
use core::chrono::prelude::*;
use core::uuid::Uuid;
use std::collections::HashMap;
use thiserror::Error;
/// Orizentic Errors
#[derive(Debug, Error)]
pub enum Error {
/// An underlying JWT decoding error. May be replaced with Orizentic semantic errors to better
/// encapsulate the JWT library.
#[error("JWT failed to decode: {0}")]
JWTError(jwt::errors::Error),
/// Token decoded and verified but was not present in the database.
#[error("Token not recognized")]
UnknownToken,
}
/// ResourceName is application-defined and names a resource to which access should be controlled
#[derive(Debug, PartialEq, Clone)]
pub struct ResourceName(pub String);
/// Permissions are application-defined descriptions of what can be done with the named resource
#[derive(Debug, PartialEq, Clone)]
pub struct Permissions(pub Vec<String>);
/// Issuers are typically informative, but should generally describe who or what created the token
#[derive(Debug, PartialEq, Clone)]
pub struct Issuer(pub String);
/// Time to live is the number of seconds until a token expires. This is used for creating tokens
/// but tokens store their actual expiration time.
#[derive(Debug, PartialEq, Clone)]
pub struct TTL(pub chrono::Duration);
/// Username, or Audience in JWT terms, should describe who or what is supposed to be using this
/// token
#[derive(Debug, PartialEq, Clone)]
pub struct Username(String);
impl From<Username> for String {
fn from(u: Username) -> String {
u.0.clone()
}
}
impl From<&str> for Username {
fn from(s: &str) -> Username {
Username(s.to_owned())
}
}
#[derive(Debug, PartialEq, Clone)]
pub struct Secret(pub Vec<u8>);
/// A ClaimSet represents one set of permissions and claims. It is a standardized way of specifying
/// the owner, issuer, expiration time, relevant resources, and specific permissions on that
/// resource. By itself, this is only an informative data structure and so should never be trusted
/// when passed over the wire. See `VerifiedToken` and `UnverifiedToken`.
#[derive(Debug, PartialEq, Clone)]
pub struct ClaimSet {
pub id: String,
pub audience: Username,
pub expiration: Option<DateTime<Utc>>,
pub issuer: Issuer,
pub issued_at: DateTime<Utc>,
pub resource: ResourceName,
pub permissions: Permissions,
}
impl ClaimSet {
/// Create a new `ClaimSet`. This will return a claimset with the expiration time calculated
/// from the TTL if the TTL is provided. No expiration will be set if no TTL is provided.
pub fn new(
issuer: Issuer,
ttl: Option<TTL>,
resource_name: ResourceName,
user_name: Username,
perms: Permissions,
) -> ClaimSet {
let issued_at: DateTime<Utc> = Utc::now().with_nanosecond(0).unwrap();
let expiration = match ttl {
Some(TTL(ttl_)) => issued_at.checked_add_signed(ttl_),
None => None,
};
ClaimSet {
id: String::from(Uuid::new_v4().to_hyphenated().to_string()),
audience: user_name,
expiration,
issuer,
issued_at,
resource: resource_name,
permissions: perms,
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(&(ClaimSetJS::from_claimset(self)))
}
pub fn from_json(text: &String) -> Result<ClaimSet, serde_json::Error> {
serde_json::from_str(&text).map(|x| ClaimSetJS::to_claimset(&x))
}
}
/// ClaimSetJS is an intermediary data structure between JWT serialization and a more usable
/// ClaimSet.
#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
pub struct ClaimSetJS {
jti: String,
aud: String,
exp: Option<i64>,
iss: String,
iat: i64,
sub: String,
perms: Vec<String>,
}
impl ClaimSetJS {
pub fn from_claimset(claims: &ClaimSet) -> ClaimSetJS {
ClaimSetJS {
jti: claims.id.clone(),
aud: claims.audience.0.clone(),
exp: claims.expiration.map(|t| t.timestamp()),
iss: claims.issuer.0.clone(),
iat: claims.issued_at.timestamp(),
sub: claims.resource.0.clone(),
perms: claims.permissions.0.clone(),
}
}
pub fn to_claimset(&self) -> ClaimSet {
ClaimSet {
id: self.jti.clone(),
audience: Username(self.aud.clone()),
expiration: self.exp.map(|t| Utc.timestamp(t, 0)),
issuer: Issuer(self.iss.clone()),
issued_at: Utc.timestamp(self.iat, 0),
resource: ResourceName(self.sub.clone()),
permissions: Permissions(self.perms.clone()),
}
}
}
/// The Orizentic Context encapsulates a set of claims and an associated secret. This provides the
/// overall convenience of easily creating and validating tokens. Generated claimsets are stored
/// here on the theory that, even with validation, only those claims actually stored in the
/// database should be considered valid.
pub struct OrizenticCtx(Secret, HashMap<String, ClaimSet>);
/// An UnverifiedToken is a combination of the JWT serialization and the decoded `ClaimSet`. As this
/// is unverified, this should only be used for information purposes, such as determining what a
/// user can do with a token even when the decoding key is absent.
#[derive(Debug)]
pub struct UnverifiedToken {
pub text: String,
pub claims: ClaimSet,
}
impl UnverifiedToken {
/// Decode a JWT text string without verification
pub fn decode_text(text: String) -> Result<UnverifiedToken, Error> {
let res = jwt::dangerous_unsafe_decode::<ClaimSetJS>(&text);
match res {
Ok(res_) => Ok(UnverifiedToken {
text,
claims: res_.claims.to_claimset(),
}),
Err(err) => Err(Error::JWTError(err)),
}
}
}
/// An VerifiedToken is a combination of the JWT serialization and the decoded `ClaimSet`. This will
/// only be created by the `validate_function`, and thus will represent a token which has been
/// validated via signature, expiration time, and presence in the database.
#[derive(Debug)]
pub struct VerifiedToken {
pub text: String,
pub claims: ClaimSet,
}
impl VerifiedToken {
/// Given a `VerifiedToken`, pass the resource name and permissions to a user-defined function. The
/// function should return true if the caller should be granted access to the resource and false,
/// otherwise. That result will be passed back to the caller.
pub fn check_authorizations<F: FnOnce(&ResourceName, &Permissions) -> bool>(
&self,
f: F,
) -> bool {
f(&self.claims.resource, &self.claims.permissions)
}
}
impl OrizenticCtx {
/// Create a new Orizentic Context with an initial set of claims.
pub fn new(secret: Secret, claims_lst: Vec<ClaimSet>) -> OrizenticCtx {
let mut hm = HashMap::new();
for claimset in claims_lst {
hm.insert(claimset.id.clone(), claimset);
}
OrizenticCtx(secret, hm)
}
/// Validate a token by checking its signature, that it is not expired, and that it is still
/// present in the database. Return an error if any check fails, but return a `VerifiedToken`
/// if it all succeeds.
pub fn validate_token(&self, token: &UnverifiedToken) -> Result<VerifiedToken, Error> {
let validator = match token.claims.expiration {
Some(_) => jwt::Validation::default(),
None => jwt::Validation {
validate_exp: false,
..jwt::Validation::default()
},
};
let res = jwt::decode::<ClaimSetJS>(&token.text, &(self.0).0, &validator);
match res {
Ok(res_) => {
let claims = res_.claims;
let in_db = self.1.get(&claims.jti);
if in_db.is_some() {
Ok(VerifiedToken {
text: token.text.clone(),
claims: claims.to_claimset(),
})
} else {
Err(Error::UnknownToken)
}
}
Err(err) => Err(Error::JWTError(err)),
}
}
/// Given a text string, as from a web application's `Authorization` header, decode the string
/// and then validate the token.
pub fn decode_and_validate_text(&self, text: String) -> Result<VerifiedToken, Error> {
// it is necessary to first decode the token because we need the validator to know whether
// to attempt to validate the expiration. Without that check, the validator will fail any
// expiration set to None.
match UnverifiedToken::decode_text(text) {
Ok(unverified) => self.validate_token(&unverified),
Err(err) => Err(err),
}
}
/// Add a claimset to the database.
pub fn add_claimset(&mut self, claimset: ClaimSet) {
self.1.insert(claimset.id.clone(), claimset);
}
/// Remove a claims set from the database so that all additional validation checks fail.
pub fn revoke_claimset(&mut self, claim: &ClaimSet) {
self.1.remove(&claim.id);
}
/// Revoke a ClaimsSet given its ID, which is set in the `jti` claim of a JWT or the `id` field
/// of a `ClaimSet`.
pub fn revoke_by_uuid(&mut self, claim_id: &String) {
self.1.remove(claim_id);
}
/// *NOT IMPLEMENTED*
pub fn replace_claimsets(&mut self, _claims_lst: Vec<ClaimSet>) {
unimplemented!()
}
/// List all of the `ClaimSet` IDs in the database.
pub fn list_claimsets(&self) -> Vec<&ClaimSet> {
self.1.values().map(|item| item).collect()
}
/// Find a `ClaimSet` by ID.
pub fn find_claimset(&self, claims_id: &String) -> Option<&ClaimSet> {
self.1.get(claims_id)
}
/// Encode and sign a claimset, returning the result as a `VerifiedToken`.
pub fn encode_claimset(&self, claims: &ClaimSet) -> Result<VerifiedToken, Error> {
let in_db = self.1.get(&claims.id);
if in_db.is_some() {
let text = jwt::encode(
&jwt::Header::default(),
&ClaimSetJS::from_claimset(&claims),
&(self.0).0,
);
match text {
Ok(text_) => Ok(VerifiedToken {
text: text_,
claims: claims.clone(),
}),
Err(err) => Err(Error::JWTError(err)),
}
} else {
Err(Error::UnknownToken)
}
}
}

37
orizentic/src/filedb.rs Normal file
View File

@ -0,0 +1,37 @@
extern crate serde_json;
use core;
use std::fs::File;
use std::path::Path;
use std::io::{Read, Error, Write};
pub fn save_claims_to_file(claimsets: &Vec<&core::ClaimSet>, path: &String) -> Result<(), Error> {
let path = Path::new(path);
let mut file = File::create(&path)?;
let claimsets_js: Vec<core::ClaimSetJS> = claimsets
.into_iter()
.map(|claims| core::ClaimSetJS::from_claimset(claims))
.collect();
let claimset_str = serde_json::to_string(&claimsets_js)?;
file.write_fmt(format_args!("{}", claimset_str))?;
Ok(())
}
pub fn load_claims_from_file(path: &String) -> Result<Vec<core::ClaimSet>, Error> {
let path = Path::new(path);
let mut file = File::open(&path)?;
let mut text = String::new();
file.read_to_string(&mut text)?;
let claimsets_js: Vec<core::ClaimSetJS> = serde_json::from_str(&text)?;
let claimsets = claimsets_js
.into_iter()
.map(|cl_js| core::ClaimSetJS::to_claimset(&cl_js))
.collect();
Ok(claimsets)
}

30
orizentic/src/lib.rs Normal file
View File

@ -0,0 +1,30 @@
//! The Orizentic token management library
//!
//! This library provides a high level interface for authentication token management. It wraps
//! around the [JWT](https://jwt.io/) standard using the
//! [`jsonwebtoken`](https://github.com/Keats/jsonwebtoken) library for serialization and
//! validation.
//!
//! Functionality revolves around the relationship between a [ClaimSet](struct.ClaimSet.html), a
//! [VerifiedToken](struct.VerifiedToken.html), and an
//! [UnverifiedToken](struct.UnverifiedToken.html). A [ClaimSet](struct.ClaimSet.html) is
//! considered informative and stores all of the information about the permissions and resources
//! that the token bearer should have access to. [VerifiedToken](struct.VerifiedToken.html) and
//! [UnverifiedToken](struct.UnverifiedToken.html) are the result of the process of decoding a
//! string JWT, and inherently specify whether the decoding process verified the signature,
//! expiration time, and presence in the database.
//!
//! This library does not currently contain database save and load features, but those are a likely
//! upcoming feature.
//!
//! No setup is necessary when using this library to decode JWT strings. Refer to the standalone
//! [decode_text](fn.decode_text.html) function.
#[macro_use]
extern crate serde_derive;
extern crate thiserror;
pub use core::*;
mod core;
pub mod filedb;

View File

@ -0,0 +1,429 @@
extern crate chrono;
extern crate orizentic;
use orizentic::filedb::*;
use orizentic::*;
use std::fs;
use std::ops;
use std::thread;
use std::time;
struct FileCleanup(String);
impl FileCleanup {
fn new(path: &str) -> FileCleanup {
FileCleanup(String::from(path))
}
}
impl ops::Drop for FileCleanup {
fn drop(&mut self) {
fs::remove_file(&self.0).expect("failed to remove time series file");
}
}
#[test]
fn can_create_a_new_claimset() {
let mut ctx = OrizenticCtx::new(Secret("abcdefg".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
assert_eq!(claims.audience, Username::from("Savanni"));
match claims.expiration {
Some(ttl) => assert_eq!(ttl - claims.issued_at, chrono::Duration::seconds(3600)),
None => panic!("ttl should not be None"),
}
assert_eq!(claims.issuer, Issuer(String::from("test")));
assert_eq!(claims.resource, ResourceName(String::from("resource-1")));
assert_eq!(
claims.permissions,
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
])
);
{
let tok_list = ctx.list_claimsets();
assert_eq!(tok_list.len(), 1);
assert!(tok_list.contains(&&claims));
}
let claims2 = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-2")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims2.clone());
assert_ne!(claims2.id, claims.id);
assert_eq!(claims2.resource, ResourceName(String::from("resource-2")));
let tok_list = ctx.list_claimsets();
assert_eq!(tok_list.len(), 2);
assert!(tok_list.contains(&&claims));
assert!(tok_list.contains(&&claims2));
}
#[test]
fn can_retrieve_claim_by_id() {
let mut ctx = OrizenticCtx::new(Secret("abcdefg".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
let claims2 = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-2")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
ctx.add_claimset(claims2.clone());
assert_eq!(ctx.find_claimset(&claims.id), Some(&claims));
assert_eq!(ctx.find_claimset(&claims2.id), Some(&claims2));
ctx.revoke_claimset(&claims);
assert_eq!(ctx.find_claimset(&claims.id), None);
assert_eq!(ctx.find_claimset(&claims2.id), Some(&claims2));
}
#[test]
fn can_revoke_claim_by_id() {
let mut ctx = OrizenticCtx::new(Secret("abcdefg".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
let claims2 = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-2")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
ctx.add_claimset(claims2.clone());
assert_eq!(ctx.find_claimset(&claims.id), Some(&claims));
assert_eq!(ctx.find_claimset(&claims2.id), Some(&claims2));
ctx.revoke_by_uuid(&claims.id);
assert_eq!(ctx.find_claimset(&claims.id), None);
assert_eq!(ctx.find_claimset(&claims2.id), Some(&claims2));
}
#[test]
fn can_revoke_a_token() {
let mut ctx = OrizenticCtx::new(Secret("abcdefg".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
let claims2 = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-2")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
ctx.add_claimset(claims2.clone());
ctx.revoke_claimset(&claims);
let tok_list = ctx.list_claimsets();
assert_eq!(tok_list.len(), 1);
assert!(!tok_list.contains(&&claims));
assert!(tok_list.contains(&&claims2));
}
#[test]
fn rejects_tokens_with_an_invalid_secret() {
let mut ctx1 = OrizenticCtx::new(Secret("ctx1".to_string().into_bytes()), Vec::new());
let ctx2 = OrizenticCtx::new(Secret("ctx2".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx1.add_claimset(claims.clone());
let encoded_token = ctx1.encode_claimset(&claims).ok().unwrap();
assert!(ctx2.decode_and_validate_text(encoded_token.text).is_err());
}
#[test]
fn rejects_tokens_that_are_absent_from_the_database() {
let mut ctx = OrizenticCtx::new(Secret("ctx".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
let encoded_token = ctx.encode_claimset(&claims).ok().unwrap();
ctx.revoke_claimset(&claims);
assert!(ctx.decode_and_validate_text(encoded_token.text).is_err());
}
#[test]
fn validates_present_tokens_with_a_valid_secret() {
let mut ctx = OrizenticCtx::new(Secret("ctx".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
let encoded_token = ctx.encode_claimset(&claims).ok().unwrap();
assert!(ctx.decode_and_validate_text(encoded_token.text).is_ok());
}
#[test]
fn rejects_expired_tokens() {
let mut ctx = OrizenticCtx::new(Secret("ctx".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(1))),
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
thread::sleep(time::Duration::from_secs(2));
let encoded_token = ctx.encode_claimset(&claims).ok().unwrap();
assert!(ctx.decode_and_validate_text(encoded_token.text).is_err());
}
#[test]
fn accepts_tokens_that_have_no_expiration() {
let mut ctx = OrizenticCtx::new(Secret("ctx".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
None,
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
let encoded_token = ctx.encode_claimset(&claims).ok().unwrap();
assert!(ctx.decode_and_validate_text(encoded_token.text).is_ok());
}
#[test]
fn authorizes_a_token_with_the_correct_resource_and_permissions() {
let mut ctx = OrizenticCtx::new(Secret("ctx".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
None,
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
let encoded_token = ctx.encode_claimset(&claims).ok().unwrap();
let token = ctx
.decode_and_validate_text(encoded_token.text)
.ok()
.unwrap();
let res = token.check_authorizations(|rn: &ResourceName, perms: &Permissions| {
*rn == ResourceName(String::from("resource-1")) && perms.0.contains(&String::from("grant"))
});
assert!(res);
}
#[test]
fn rejects_a_token_with_the_incorrect_permissions() {
let mut ctx = OrizenticCtx::new(Secret("ctx".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
None,
ResourceName(String::from("resource-1")),
Username::from("Savanni"),
Permissions(vec![String::from("read"), String::from("write")]),
);
ctx.add_claimset(claims.clone());
let encoded_token = ctx.encode_claimset(&claims).ok().unwrap();
let token = ctx
.decode_and_validate_text(encoded_token.text)
.ok()
.unwrap();
let res = token.check_authorizations(|rn: &ResourceName, perms: &Permissions| {
*rn == ResourceName(String::from("resource-1")) && perms.0.contains(&String::from("grant"))
});
assert!(!res);
}
#[test]
fn rejects_a_token_with_the_incorrect_resource_name() {
let mut ctx = OrizenticCtx::new(Secret("ctx".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
None,
ResourceName(String::from("resource-2")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
let encoded_token = ctx.encode_claimset(&claims).ok().unwrap();
let token = ctx
.decode_and_validate_text(encoded_token.text)
.ok()
.unwrap();
let res = token.check_authorizations(|rn: &ResourceName, perms: &Permissions| {
*rn == ResourceName(String::from("resource-1")) && perms.0.contains(&String::from("grant"))
});
assert!(!res);
}
#[test]
fn claims_serialize_to_json() {
let claims = ClaimSet::new(
Issuer(String::from("test")),
None,
ResourceName(String::from("resource-2")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
let expected_jti = format!("\"jti\":\"{}\"", claims.id);
let claim_str = claims.to_json().expect("to_json threw an error");
//.expect(assert!(false, format!("[claims_serilazie_to_json] {}", err)));
assert!(claim_str.contains(&expected_jti));
let claims_ = ClaimSet::from_json(&claim_str).expect("from_json threw an error");
assert_eq!(claims, claims_);
}
#[test]
fn save_and_load() {
let _file_cleanup = FileCleanup::new("var/claims.db");
let mut ctx = OrizenticCtx::new(Secret("ctx".to_string().into_bytes()), Vec::new());
let claims = ClaimSet::new(
Issuer(String::from("test")),
None,
ResourceName(String::from("resource-2")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims.clone());
let claims2 = ClaimSet::new(
Issuer(String::from("test")),
Some(TTL(chrono::Duration::seconds(3600))),
ResourceName(String::from("resource-2")),
Username::from("Savanni"),
Permissions(vec![
String::from("read"),
String::from("write"),
String::from("grant"),
]),
);
ctx.add_claimset(claims2.clone());
let res = save_claims_to_file(&ctx.list_claimsets(), &String::from("var/claims.db"));
assert!(res.is_ok());
let claimset = load_claims_from_file(&String::from("var/claims.db"));
match claimset {
Ok(claimset_) => {
assert!(claimset_.contains(&claims));
assert!(claimset_.contains(&claims2));
}
Err(err) => assert!(false, "{}", err),
}
}