Compare commits
35 Commits
2f6be84a43
...
6fb872569d
Author | SHA1 | Date |
---|---|---|
Savanni D'Gerinel | 6fb872569d | |
Savanni D'Gerinel | a187582f16 | |
Savanni D'Gerinel | 5491521f95 | |
Savanni D'Gerinel | fee2fe607d | |
Savanni D'Gerinel | 9bc785722b | |
Savanni D'Gerinel | b044dbaed8 | |
Savanni D'Gerinel | 48f8c4aaf5 | |
Savanni D'Gerinel | 17ad927187 | |
Savanni D'Gerinel | 73293fd932 | |
Savanni D'Gerinel | 2ae0d9cfe8 | |
Savanni D'Gerinel | 2ad3874724 | |
Savanni D'Gerinel | 5c80fb3591 | |
Savanni D'Gerinel | 5417eecdad | |
Savanni D'Gerinel | 5cc7c3ac5e | |
Savanni D'Gerinel | 40b9c41ed1 | |
Savanni D'Gerinel | d4fb5601c0 | |
Savanni D'Gerinel | 5479c136fd | |
Savanni D'Gerinel | f204920216 | |
Savanni D'Gerinel | 5c23427fdb | |
Savanni D'Gerinel | a3add82294 | |
Savanni D'Gerinel | 3b05e31374 | |
Savanni D'Gerinel | 756120c9e6 | |
Savanni D'Gerinel | b7ffdfac61 | |
Savanni D'Gerinel | 8afbe1ddc1 | |
Savanni D'Gerinel | 89594d3169 | |
Savanni D'Gerinel | e957865d2a | |
Savanni D'Gerinel | 334cd42e10 | |
Savanni D'Gerinel | 5ef0260ce2 | |
Savanni D'Gerinel | 10a0c483a1 | |
Savanni D'Gerinel | 0d0cc8c495 | |
Savanni D'Gerinel | f451df3a79 | |
Savanni D'Gerinel | d0c5e0a59f | |
Savanni D'Gerinel | de034d53c1 | |
Savanni D'Gerinel | 343e8e8817 | |
Savanni D'Gerinel | 404ccd1854 |
|
@ -4,3 +4,7 @@ node_modules
|
|||
dist
|
||||
result
|
||||
*.tgz
|
||||
file-service/*.sqlite
|
||||
file-service/*.sqlite-shm
|
||||
file-service/*.sqlite-wal
|
||||
file-service/var
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1 @@
|
|||
fixtures
|
|
@ -0,0 +1,2 @@
|
|||
fixtures
|
||||
var
|
File diff suppressed because it is too large
Load Diff
|
@ -6,20 +6,50 @@ 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"
|
||||
iron = "0.6.1"
|
||||
logger = "*"
|
||||
log = { version = "0.4" }
|
||||
mime = "0.3.16"
|
||||
mime_guess = "2.0.3"
|
||||
mustache = "0.9.0"
|
||||
orizentic = { path = "../orizentic" }
|
||||
params = "*"
|
||||
router = "*"
|
||||
pretty_env_logger = { version = "0.5" }
|
||||
serde_json = "*"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
sha2 = "0.8.2"
|
||||
sha2 = "0.10"
|
||||
sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite" ] }
|
||||
thiserror = "1.0.20"
|
||||
uuid = { version = "0.4", features = ["serde", "v4"] }
|
||||
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" }
|
||||
|
||||
|
|
|
@ -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)
|
||||
);
|
|
@ -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 => {}
|
||||
}
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
use iron::headers;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cookie {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl From<&str> for Cookie {
|
||||
fn from(s: &str) -> Cookie {
|
||||
let parts: Vec<&str> = s.split("=").collect();
|
||||
Cookie {
|
||||
name: String::from(parts[0]),
|
||||
value: String::from(parts[1]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&String> for Cookie {
|
||||
fn from(s: &String) -> Cookie {
|
||||
Cookie::from(s.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Cookie {
|
||||
fn from(s: String) -> Cookie {
|
||||
Cookie::from(s.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CookieJar(HashMap<String, Cookie>);
|
||||
|
||||
impl CookieJar {
|
||||
pub fn new() -> CookieJar {
|
||||
CookieJar(HashMap::new())
|
||||
}
|
||||
|
||||
pub fn add_cookie(&mut self, name: String, value: Cookie) {
|
||||
self.0.insert(name, value);
|
||||
}
|
||||
|
||||
pub fn lookup(&self, name: &str) -> Option<&Cookie> {
|
||||
self.0.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
// Some(Cookie(["auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJhYzNhNDZjNi0zZmExLTRkMGEtYWYxMi1lN2QzZmVmZGM4NzgiLCJhdWQiOiJzYXZhbm5pIiwiZXhwIjoxNjIxMzUxNDM2LCJpc3MiOiJzYXZhbm5pIiwiaWF0IjoxNTg5NzI5MDM2LCJzdWIiOiJodHRwczovL3NhdmFubmkubHVtaW5lc2NlbnQtZHJlYW1zLmNvbS9maWxlLXNlcnZpY2UvIiwicGVybXMiOlsiYWRtaW4iXX0.8zjAbZ7Ut0d6EcDeyik39GKhXvH4qkMDdaiQVNKWiuM"]))
|
||||
impl From<&headers::Cookie> for CookieJar {
|
||||
fn from(c: &headers::Cookie) -> CookieJar {
|
||||
let jar = CookieJar::new();
|
||||
|
||||
let headers::Cookie(cs) = c;
|
||||
cs.iter().fold(jar, |mut jar, c_| {
|
||||
let cookie = Cookie::from(c_);
|
||||
jar.add_cookie(cookie.name.clone(), cookie);
|
||||
jar
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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(())
|
||||
}
|
|
@ -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,)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
mod store;
|
||||
|
||||
pub use store::{
|
||||
AuthDB, AuthError, AuthToken, FileHandle, FileId, FileInfo, ReadFileError, SessionToken, Store,
|
||||
Username, WriteFileError,
|
||||
};
|
|
@ -1,31 +0,0 @@
|
|||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum Error {
|
||||
#[error("not implemented")]
|
||||
NotImplemented,
|
||||
|
||||
#[error("file not found: `{0}`")]
|
||||
FileNotFound(PathBuf),
|
||||
|
||||
#[error("file is not an image: `{0}`")]
|
||||
NotAnImage(PathBuf),
|
||||
|
||||
#[error("path is not a file: `{0}`")]
|
||||
NotAFile(PathBuf),
|
||||
|
||||
#[error("Image loading error")]
|
||||
ImageError(#[from] image::ImageError),
|
||||
|
||||
#[error("IO error")]
|
||||
IOError(#[from] std::io::Error),
|
||||
|
||||
#[error("JSON error")]
|
||||
JSONError(#[from] serde_json::error::Error),
|
||||
|
||||
#[error("UTF8 Error")]
|
||||
UTF8Error(#[from] std::str::Utf8Error),
|
||||
}
|
||||
|
||||
pub type Result<A> = std::result::Result<A, Error>;
|
|
@ -1,151 +0,0 @@
|
|||
use super::error::{Error, Result};
|
||||
use super::fileinfo::FileInfo;
|
||||
use super::thumbnail::Thumbnail;
|
||||
use std::fs::{copy, read_dir, remove_file};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct File {
|
||||
info: FileInfo,
|
||||
tn: Thumbnail,
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl File {
|
||||
pub fn new(
|
||||
id: &str,
|
||||
root: &Path,
|
||||
temp_path: &PathBuf,
|
||||
filename: &Option<PathBuf>,
|
||||
) -> Result<File> {
|
||||
let mut dest_path = PathBuf::from(root);
|
||||
dest_path.push(id);
|
||||
match filename {
|
||||
Some(fname) => match fname.extension() {
|
||||
Some(ext) => {
|
||||
dest_path.set_extension(ext);
|
||||
()
|
||||
}
|
||||
None => (),
|
||||
},
|
||||
None => (),
|
||||
};
|
||||
copy(temp_path, dest_path.clone())?;
|
||||
let info = FileInfo::from_path(&dest_path)?;
|
||||
let tn = Thumbnail::from_path(&dest_path)?;
|
||||
Ok(File {
|
||||
info,
|
||||
tn,
|
||||
root: PathBuf::from(root),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open(id: &str, root: &Path) -> Result<File> {
|
||||
let mut file_path = PathBuf::from(root);
|
||||
file_path.push(id.clone());
|
||||
|
||||
if !file_path.exists() {
|
||||
return Err(Error::FileNotFound(file_path));
|
||||
}
|
||||
if !file_path.is_file() {
|
||||
return Err(Error::NotAFile(file_path));
|
||||
}
|
||||
|
||||
let info = match FileInfo::open(id, root) {
|
||||
Ok(i) => Ok(i),
|
||||
Err(Error::FileNotFound(_)) => {
|
||||
let info = FileInfo::from_path(&file_path)?;
|
||||
info.save(&root)?;
|
||||
Ok(info)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}?;
|
||||
|
||||
let tn = Thumbnail::open(id, root)?;
|
||||
|
||||
Ok(File {
|
||||
info,
|
||||
tn,
|
||||
root: PathBuf::from(root),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list(root: &Path) -> Vec<Result<File>> {
|
||||
let dir_iter = read_dir(&root).unwrap();
|
||||
dir_iter
|
||||
.filter(|entry| {
|
||||
let entry_ = entry.as_ref().unwrap();
|
||||
let filename = entry_.file_name();
|
||||
!(filename.to_string_lossy().starts_with("."))
|
||||
})
|
||||
.map(|entry| {
|
||||
let entry_ = entry.unwrap();
|
||||
let id = entry_.file_name().into_string().unwrap();
|
||||
File::open(&id, root)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn info(&self) -> FileInfo {
|
||||
self.info.clone()
|
||||
}
|
||||
|
||||
pub fn thumbnail(&self) -> Thumbnail {
|
||||
self.tn.clone()
|
||||
}
|
||||
|
||||
pub fn stream(&self) -> Result<std::fs::File> {
|
||||
let mut path = self.root.clone();
|
||||
path.push(self.info.id.clone());
|
||||
std::fs::File::open(path).map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn delete(&self) -> Result<()> {
|
||||
let mut path = self.root.clone();
|
||||
path.push(self.info.id.clone());
|
||||
remove_file(path)?;
|
||||
self.tn.delete()?;
|
||||
self.info.delete()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::lib::utils::FileCleanup;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn it_opens_a_file() {
|
||||
let _md = FileCleanup(PathBuf::from("fixtures/.metadata/rawr.png.json"));
|
||||
let _tn = FileCleanup(PathBuf::from("fixtures/.thumbnails/rawr.png"));
|
||||
|
||||
File::open("rawr.png", Path::new("fixtures/")).expect("to succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_can_return_a_thumbnail() {
|
||||
let f = File::open("rawr.png", Path::new("fixtures/")).expect("to succeed");
|
||||
assert_eq!(
|
||||
f.thumbnail(),
|
||||
Thumbnail {
|
||||
id: String::from("rawr.png"),
|
||||
root: PathBuf::from("fixtures/"),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_can_return_a_file_stream() {
|
||||
let f = File::open("rawr.png", Path::new("fixtures/")).expect("to succeed");
|
||||
f.stream().expect("to succeed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_raises_an_error_when_file_not_found() {
|
||||
match File::open("garbage", Path::new("fixtures/")) {
|
||||
Err(Error::FileNotFound(_)) => assert!(true),
|
||||
_ => assert!(false),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,160 +0,0 @@
|
|||
use chrono::prelude::*;
|
||||
use hex_string::HexString;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs::remove_file;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::{Error, Result};
|
||||
use super::utils::append_extension;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct FileInfo {
|
||||
pub id: String,
|
||||
pub size: u64,
|
||||
pub created: DateTime<Utc>,
|
||||
pub file_type: String,
|
||||
pub hash: String,
|
||||
|
||||
#[serde(skip)]
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl FileInfo {
|
||||
pub fn save(&self, root: &Path) -> Result<()> {
|
||||
let ser = serde_json::to_string(self).unwrap();
|
||||
std::fs::File::create(FileInfo::metadata_path(&self.id, root))
|
||||
.and_then(|mut stream| stream.write(ser.as_bytes()).map(|_| (())))
|
||||
.map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn open(id: &str, root: &Path) -> Result<FileInfo> {
|
||||
let mut buf = Vec::new();
|
||||
|
||||
let md_path = FileInfo::metadata_path(id, root);
|
||||
std::fs::File::open(md_path.clone())
|
||||
.and_then(|mut stream| stream.read_to_end(&mut buf))
|
||||
.map_err(move |err| match err.kind() {
|
||||
std::io::ErrorKind::NotFound => Error::FileNotFound(md_path),
|
||||
_ => Error::IOError(err),
|
||||
})?;
|
||||
|
||||
let str_repr = std::str::from_utf8(&buf)?;
|
||||
|
||||
serde_json::from_str(&str_repr).map_err(Error::from)
|
||||
}
|
||||
|
||||
pub fn from_path(path: &Path) -> Result<FileInfo> {
|
||||
match (path.is_file(), path.is_dir()) {
|
||||
(false, false) => Err(Error::FileNotFound(PathBuf::from(path))),
|
||||
(false, true) => Err(Error::NotAFile(PathBuf::from(path))),
|
||||
(true, _) => Ok(()),
|
||||
}?;
|
||||
|
||||
let metadata = path.metadata().map_err(Error::IOError)?;
|
||||
let id = path
|
||||
.file_name()
|
||||
.map(|s| String::from(s.to_string_lossy()))
|
||||
.ok_or(Error::NotAFile(PathBuf::from(path)))?;
|
||||
let created = metadata
|
||||
.created()
|
||||
.map(|m| DateTime::from(m))
|
||||
.map_err(|err| Error::IOError(err))?;
|
||||
let file_type = String::from(
|
||||
mime_guess::from_path(path)
|
||||
.first_or_octet_stream()
|
||||
.essence_str(),
|
||||
);
|
||||
let hash = FileInfo::hash_file(path)?;
|
||||
Ok(FileInfo {
|
||||
id,
|
||||
size: metadata.len(),
|
||||
created: created,
|
||||
file_type,
|
||||
hash: hash.as_string(),
|
||||
root: PathBuf::from(path.parent().unwrap()),
|
||||
})
|
||||
}
|
||||
|
||||
fn hash_file(path: &Path) -> Result<HexString> {
|
||||
let mut buf = Vec::new();
|
||||
let mut file = std::fs::File::open(path).map_err(Error::from)?;
|
||||
|
||||
file.read_to_end(&mut buf).map_err(Error::from)?;
|
||||
let mut vec = Vec::new();
|
||||
vec.extend_from_slice(Sha256::digest(&buf).as_slice());
|
||||
Ok(HexString::from_bytes(&vec))
|
||||
}
|
||||
|
||||
fn metadata_path(id: &str, root: &Path) -> PathBuf {
|
||||
let mut path = PathBuf::from(root);
|
||||
path.push(".metadata");
|
||||
path.push(id.clone());
|
||||
append_extension(&path, "json")
|
||||
}
|
||||
|
||||
pub fn delete(&self) -> Result<()> {
|
||||
let path = FileInfo::metadata_path(&self.id, &self.root);
|
||||
remove_file(path).map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
use crate::lib::utils::FileCleanup;
|
||||
|
||||
#[test]
|
||||
fn it_generates_information_from_file_path() {
|
||||
let path = Path::new("fixtures/rawr.png");
|
||||
match FileInfo::from_path(&path) {
|
||||
Ok(FileInfo {
|
||||
id,
|
||||
size,
|
||||
file_type,
|
||||
hash,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(id, "rawr.png");
|
||||
assert_eq!(size, 23777);
|
||||
assert_eq!(file_type, "image/png");
|
||||
assert_eq!(
|
||||
hash,
|
||||
"b6cd35e113b95d62f53d9cbd27ccefef47d3e324aef01a2db6c0c6d3a43c89ee"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
println!("error loading file path: {}", err);
|
||||
assert!(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_saves_and_loads_metadata() {
|
||||
let path = Path::new("fixtures/rawr.png");
|
||||
let _ = FileCleanup(append_extension(path, "json"));
|
||||
let info = FileInfo::from_path(&path).unwrap();
|
||||
info.save(Path::new("fixtures")).unwrap();
|
||||
|
||||
assert!(Path::new("fixtures/.metadata/rawr.png.json").is_file());
|
||||
|
||||
let info_ = FileInfo::open("rawr.png", Path::new("fixtures")).unwrap();
|
||||
assert_eq!(info_.id, "rawr.png");
|
||||
assert_eq!(info_.size, 23777);
|
||||
assert_eq!(info_.created, info.created);
|
||||
assert_eq!(info_.file_type, "image/png");
|
||||
assert_eq!(info_.hash, info.hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_extends_a_file_extension() {
|
||||
assert_eq!(
|
||||
append_extension(Path::new("fixtures/rawr.png"), "json"),
|
||||
Path::new("fixtures/rawr.png.json")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
use uuid::Uuid;
|
||||
|
||||
mod error;
|
||||
mod file;
|
||||
mod fileinfo;
|
||||
mod thumbnail;
|
||||
mod utils;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use file::File;
|
||||
pub use fileinfo::FileInfo;
|
||||
pub use thumbnail::Thumbnail;
|
||||
|
||||
pub struct App {
|
||||
files_root: PathBuf,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(files_root: &Path) -> App {
|
||||
App {
|
||||
files_root: PathBuf::from(files_root),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_files(&self) -> Vec<Result<File>> {
|
||||
File::list(&self.files_root)
|
||||
}
|
||||
|
||||
pub fn add_file(&mut self, temp_path: &PathBuf, filename: &Option<PathBuf>) -> Result<File> {
|
||||
let id = Uuid::new_v4().hyphenated().to_string();
|
||||
File::new(&id, &self.files_root, temp_path, filename)
|
||||
}
|
||||
|
||||
pub fn delete_file(&mut self, id: String) -> Result<()> {
|
||||
let f = File::open(&id, &self.files_root)?;
|
||||
f.delete()
|
||||
}
|
||||
|
||||
pub fn get_metadata(&self, id: String) -> Result<FileInfo> {
|
||||
FileInfo::open(&id, &self.files_root)
|
||||
}
|
||||
|
||||
pub fn get_file(&self, id: String) -> Result<(FileInfo, std::fs::File)> {
|
||||
let f = File::open(&id, &self.files_root)?;
|
||||
let info = f.info();
|
||||
let stream = f.stream()?;
|
||||
Ok((info, stream))
|
||||
}
|
||||
|
||||
pub fn get_thumbnail(&self, id: &str) -> Result<(FileInfo, std::fs::File)> {
|
||||
let f = File::open(id, &self.files_root)?;
|
||||
let stream = f.thumbnail().stream()?;
|
||||
Ok((f.info(), stream))
|
||||
}
|
||||
}
|
|
@ -1,82 +0,0 @@
|
|||
use image::imageops::FilterType;
|
||||
use std::fs::remove_file;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::error::{Error, Result};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Thumbnail {
|
||||
pub id: String,
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl Thumbnail {
|
||||
pub fn open(id: &str, root: &Path) -> Result<Thumbnail> {
|
||||
let mut source_path = PathBuf::from(root);
|
||||
source_path.push(id);
|
||||
|
||||
let self_ = Thumbnail {
|
||||
id: String::from(id),
|
||||
root: PathBuf::from(root),
|
||||
};
|
||||
|
||||
let thumbnail_path = Thumbnail::thumbnail_path(id, root);
|
||||
if !thumbnail_path.exists() {
|
||||
let img = image::open(source_path)?;
|
||||
let tn = img.resize(640, 640, FilterType::Nearest);
|
||||
tn.save(thumbnail_path)?;
|
||||
}
|
||||
|
||||
Ok(self_)
|
||||
}
|
||||
|
||||
pub fn from_path(path: &Path) -> Result<Thumbnail> {
|
||||
let id = path
|
||||
.file_name()
|
||||
.map(|s| String::from(s.to_string_lossy()))
|
||||
.ok_or(Error::NotAnImage(PathBuf::from(path)))?;
|
||||
|
||||
let root = path
|
||||
.parent()
|
||||
.ok_or(Error::FileNotFound(PathBuf::from(path)))?;
|
||||
|
||||
Thumbnail::open(&id, root)
|
||||
}
|
||||
|
||||
fn thumbnail_path(id: &str, root: &Path) -> PathBuf {
|
||||
let mut path = PathBuf::from(root);
|
||||
path.push(".thumbnails");
|
||||
path.push(id.clone());
|
||||
path
|
||||
}
|
||||
|
||||
pub fn stream(&self) -> Result<std::fs::File> {
|
||||
let thumbnail_path = Thumbnail::thumbnail_path(&self.id, &self.root);
|
||||
std::fs::File::open(thumbnail_path.clone()).map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
Error::FileNotFound(thumbnail_path)
|
||||
} else {
|
||||
Error::from(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete(&self) -> Result<()> {
|
||||
let path = Thumbnail::thumbnail_path(&self.id, &self.root);
|
||||
remove_file(path).map_err(Error::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::lib::utils::FileCleanup;
|
||||
|
||||
#[test]
|
||||
fn it_creates_a_thumbnail_if_one_does_not_exist() {
|
||||
let _ = FileCleanup(PathBuf::from("fixtures/.thumbnails/rawr.png"));
|
||||
let _ =
|
||||
Thumbnail::open("rawr.png", Path::new("fixtures")).expect("thumbnail open must work");
|
||||
assert!(Path::new("fixtures/.thumbnails/rawr.png").is_file());
|
||||
}
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub struct FileCleanup(pub PathBuf);
|
||||
|
||||
impl Drop for FileCleanup {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn append_extension(path: &Path, extra_ext: &str) -> PathBuf {
|
||||
let ext_ = match path.extension() {
|
||||
None => String::from(extra_ext),
|
||||
Some(ext) => [ext.to_string_lossy(), std::borrow::Cow::from(extra_ext)].join("."),
|
||||
};
|
||||
path.with_extension(ext_)
|
||||
}
|
|
@ -1,341 +1,166 @@
|
|||
use iron::headers;
|
||||
use iron::middleware::Handler;
|
||||
use iron::modifiers::{Header, Redirect};
|
||||
use iron::prelude::*;
|
||||
use iron::response::BodyReader;
|
||||
use iron::status;
|
||||
use mustache::{compile_path, Template};
|
||||
use orizentic::{Permissions, ResourceName, Secret};
|
||||
use params::{Params, Value};
|
||||
use router::Router;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, RwLock};
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
|
||||
mod cookies;
|
||||
mod lib;
|
||||
mod middleware;
|
||||
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};
|
||||
|
||||
use lib::{App, FileInfo};
|
||||
use middleware::{Authentication, RestForm};
|
||||
mod handlers;
|
||||
mod html;
|
||||
mod pages;
|
||||
|
||||
fn is_admin(resource: &ResourceName, permissions: &Permissions) -> bool {
|
||||
let Permissions(perms) = permissions;
|
||||
ResourceName(String::from(
|
||||
"https://savanni.luminescent-dreams.com/file-service/",
|
||||
)) == *resource
|
||||
&& perms.contains(&String::from("admin"))
|
||||
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>>,
|
||||
}
|
||||
|
||||
pub fn compare_etags(info: FileInfo, etag_list: &headers::IfNoneMatch) -> bool {
|
||||
let current_etag = headers::EntityTag::new(false, info.hash);
|
||||
match etag_list {
|
||||
headers::IfNoneMatch::Any => false,
|
||||
headers::IfNoneMatch::Items(lst) => lst.iter().any(|etag| etag.weak_eq(¤t_etag)),
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
mod files {
|
||||
use super::*;
|
||||
fn with_app(app: App) -> impl Filter<Extract = (App,), Error = Infallible> + Clone {
|
||||
warp::any().map(move || app.clone())
|
||||
}
|
||||
|
||||
pub struct IndexHandler {
|
||||
pub app: Arc<RwLock<App>>,
|
||||
pub template: Template,
|
||||
}
|
||||
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>>()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub enum TemplateFile {
|
||||
#[serde(rename = "error")]
|
||||
Error { error: String },
|
||||
#[serde(rename = "file")]
|
||||
File {
|
||||
id: String,
|
||||
size: u64,
|
||||
date: String,
|
||||
type_: String,
|
||||
},
|
||||
}
|
||||
fn get_session_token(cookies: HashMap<String, String>) -> Option<SessionToken> {
|
||||
cookies
|
||||
.get("session")
|
||||
.cloned()
|
||||
.and_then(|session| Some(SessionToken::from(session)))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct IndexTemplateParams {
|
||||
files: Vec<TemplateFile>,
|
||||
}
|
||||
|
||||
impl Handler for IndexHandler {
|
||||
fn handle(&self, req: &mut Request) -> IronResult<Response> {
|
||||
let app = self.app.read().unwrap();
|
||||
let m_token = req.extensions.get::<Authentication>();
|
||||
match m_token {
|
||||
Some(token) => {
|
||||
if token.check_authorizations(is_admin) {
|
||||
let files: Vec<TemplateFile> = app
|
||||
.list_files()
|
||||
.into_iter()
|
||||
.map(|entry| match entry {
|
||||
Ok(file) => TemplateFile::File {
|
||||
id: file.info().id,
|
||||
size: file.info().size,
|
||||
date: format!(
|
||||
"{}",
|
||||
file.info().created.format("%Y-%m-%d %H:%M:%S")
|
||||
),
|
||||
type_: file.info().file_type,
|
||||
},
|
||||
Err(err) => TemplateFile::Error {
|
||||
error: format!("{}", err),
|
||||
},
|
||||
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,
|
||||
})
|
||||
.collect();
|
||||
Ok(Response::with((
|
||||
status::Ok,
|
||||
Header(headers::ContentType::html()),
|
||||
Header(headers::SetCookie(vec![format!("auth={}", token.text)])),
|
||||
self.template
|
||||
.render_to_string(&IndexTemplateParams { files })
|
||||
.expect("the template to render"),
|
||||
)))
|
||||
} else {
|
||||
Ok(Response::with(status::Forbidden))
|
||||
}
|
||||
}
|
||||
None => Ok(Response::with(status::Forbidden)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetHandler {
|
||||
pub app: Arc<RwLock<App>>,
|
||||
}
|
||||
|
||||
impl Handler for GetHandler {
|
||||
fn handle(&self, req: &mut Request) -> IronResult<Response> {
|
||||
let app = self.app.read().unwrap();
|
||||
let capture = req.extensions.get::<Router>().unwrap().clone();
|
||||
let old_etags = req.headers.get::<headers::IfNoneMatch>();
|
||||
match capture.find("id") {
|
||||
Some(id) => {
|
||||
let info = app.get_metadata(String::from(id));
|
||||
match (info, old_etags) {
|
||||
(Ok(info_), Some(if_none_match)) => {
|
||||
if compare_etags(info_, if_none_match) {
|
||||
return Ok(Response::with(status::NotModified));
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
match app.get_file(String::from(id)) {
|
||||
Ok((info, stream)) => Ok(Response::with((
|
||||
status::Ok,
|
||||
Header(headers::ContentType(
|
||||
info.file_type.parse::<iron::mime::Mime>().unwrap(),
|
||||
)),
|
||||
Header(headers::ETag(headers::EntityTag::new(false, info.hash))),
|
||||
BodyReader(stream),
|
||||
))),
|
||||
Err(_err) => Ok(Response::with(status::NotFound)),
|
||||
}
|
||||
}
|
||||
_ => Ok(Response::with(status::BadRequest)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct GetThumbnailHandler {
|
||||
pub app: Arc<RwLock<App>>,
|
||||
}
|
||||
|
||||
impl Handler for GetThumbnailHandler {
|
||||
fn handle(&self, req: &mut Request) -> IronResult<Response> {
|
||||
let app = self.app.read().unwrap();
|
||||
let capture = req.extensions.get::<Router>().unwrap().clone();
|
||||
let old_etags = req.headers.get::<headers::IfNoneMatch>();
|
||||
match capture.find("id") {
|
||||
Some(id) => {
|
||||
let info = app.get_metadata(String::from(id));
|
||||
match (info, old_etags) {
|
||||
(Ok(info_), Some(if_none_match)) => {
|
||||
if compare_etags(info_, if_none_match) {
|
||||
return Ok(Response::with(status::NotModified));
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
match app.get_thumbnail(id) {
|
||||
Ok((info, stream)) => Ok(Response::with((
|
||||
status::Ok,
|
||||
Header(headers::ContentType(
|
||||
info.file_type.parse::<iron::mime::Mime>().unwrap(),
|
||||
)),
|
||||
Header(headers::ETag(headers::EntityTag::new(false, info.hash))),
|
||||
BodyReader(stream),
|
||||
))),
|
||||
Err(_err) => Ok(Response::with(status::NotFound)),
|
||||
}
|
||||
}
|
||||
_ => Ok(Response::with(status::BadRequest)),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub struct PostHandler {
|
||||
pub app: Arc<RwLock<App>>,
|
||||
}
|
||||
|
||||
impl Handler for PostHandler {
|
||||
fn handle(&self, req: &mut Request) -> IronResult<Response> {
|
||||
let mut app = self.app.write().unwrap();
|
||||
let m_token = req.extensions.get::<Authentication>();
|
||||
match m_token {
|
||||
Some(token) => {
|
||||
if token.check_authorizations(is_admin) {
|
||||
let params = req.get_ref::<Params>().unwrap();
|
||||
if let Value::File(f_info) = params.get("file").unwrap() {
|
||||
match app.add_file(
|
||||
&f_info.path,
|
||||
&f_info.filename.clone().map(|fname| PathBuf::from(fname)),
|
||||
) {
|
||||
Ok(_) => Ok(Response::with((
|
||||
status::MovedPermanently,
|
||||
Redirect(router::url_for(req, "index", HashMap::new())),
|
||||
))),
|
||||
Err(_) => Ok(Response::with(status::InternalServerError)),
|
||||
}
|
||||
} else {
|
||||
Ok(Response::with(status::BadRequest))
|
||||
}
|
||||
} else {
|
||||
Ok(Response::with(status::Forbidden))
|
||||
}
|
||||
}
|
||||
None => Ok(Response::with(status::Forbidden)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DeleteHandler {
|
||||
pub app: Arc<RwLock<App>>,
|
||||
}
|
||||
|
||||
impl Handler for DeleteHandler {
|
||||
fn handle(&self, req: &mut Request) -> IronResult<Response> {
|
||||
let mut app = self.app.write().unwrap();
|
||||
let capture = req.extensions.get::<Router>().unwrap().clone();
|
||||
let m_token = req.extensions.get::<Authentication>();
|
||||
match m_token {
|
||||
Some(token) => {
|
||||
if token.check_authorizations(is_admin) {
|
||||
match capture.find("id") {
|
||||
Some(id) => match app.delete_file(String::from(id)) {
|
||||
Ok(()) => Ok(Response::with((
|
||||
status::MovedPermanently,
|
||||
Redirect(router::url_for(req, "index", HashMap::new())),
|
||||
))),
|
||||
Err(_) => Ok(Response::with(status::InternalServerError)),
|
||||
},
|
||||
None => Ok(Response::with(status::BadRequest)),
|
||||
}
|
||||
} else {
|
||||
Ok(Response::with(status::Forbidden))
|
||||
}
|
||||
}
|
||||
None => Ok(Response::with(status::Forbidden)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn css(_: &mut Request) -> IronResult<Response> {
|
||||
let mut css: String = String::from("");
|
||||
File::open("templates/style.css")
|
||||
.unwrap()
|
||||
.read_to_string(&mut css)
|
||||
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();
|
||||
Ok(Response::with((
|
||||
status::Ok,
|
||||
Header(headers::ContentType(iron::mime::Mime(
|
||||
iron::mime::TopLevel::Text,
|
||||
iron::mime::SubLevel::Css,
|
||||
vec![],
|
||||
))),
|
||||
css,
|
||||
)))
|
||||
}
|
||||
|
||||
fn script(_: &mut Request) -> IronResult<Response> {
|
||||
let mut js: String = String::from("");
|
||||
File::open("templates/script.js")
|
||||
.unwrap()
|
||||
.read_to_string(&mut js)
|
||||
.unwrap();
|
||||
Ok(Response::with((
|
||||
status::Ok,
|
||||
Header(headers::ContentType(iron::mime::Mime(
|
||||
iron::mime::TopLevel::Text,
|
||||
iron::mime::SubLevel::Javascript,
|
||||
vec![],
|
||||
))),
|
||||
js,
|
||||
)))
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let auth_db_path = std::env::var("ORIZENTIC_DB").unwrap();
|
||||
let secret = Secret(Vec::from(
|
||||
std::env::var("ORIZENTIC_SECRET").unwrap().as_bytes(),
|
||||
));
|
||||
let auth_middleware = Authentication::new(secret, auth_db_path);
|
||||
|
||||
let app = Arc::new(RwLock::new(App::new(Path::new(
|
||||
&std::env::var("FILE_SHARE_DIR").unwrap(),
|
||||
))));
|
||||
|
||||
let mut router = Router::new();
|
||||
router.get(
|
||||
"/",
|
||||
files::IndexHandler {
|
||||
app: app.clone(),
|
||||
template: compile_path("templates/index.html").expect("the template to compile"),
|
||||
},
|
||||
"index",
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:id",
|
||||
files::GetFileHandler {
|
||||
app: app.clone(),
|
||||
template: compile_path("templates/file.html").expect("the template to compile"),
|
||||
},
|
||||
"get-file-page",
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:id/raw",
|
||||
files::GetHandler { app: app.clone() },
|
||||
"get-file",
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:id/tn",
|
||||
files::GetThumbnailHandler { app: app.clone() },
|
||||
"get-thumbnail",
|
||||
);
|
||||
|
||||
router.post("/", files::PostHandler { app: app.clone() }, "upload-file");
|
||||
|
||||
router.delete(
|
||||
"/:id",
|
||||
files::DeleteHandler { app: app.clone() },
|
||||
"delete-file",
|
||||
);
|
||||
router.get("/css", css, "styles");
|
||||
router.get("/script", script, "script");
|
||||
|
||||
let mut chain = Chain::new(router);
|
||||
chain.link_before(auth_middleware);
|
||||
chain.link_before(RestForm {});
|
||||
|
||||
Iron::new(chain).http("0.0.0.0:3000").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;
|
||||
}
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
use iron::headers;
|
||||
use iron::middleware::BeforeMiddleware;
|
||||
use iron::prelude::*;
|
||||
use iron::typemap::Key;
|
||||
use orizentic::{filedb, OrizenticCtx, Secret};
|
||||
use params::{FromValue, Params};
|
||||
|
||||
use crate::cookies::{Cookie, CookieJar};
|
||||
|
||||
pub struct Authentication {
|
||||
pub auth: OrizenticCtx,
|
||||
}
|
||||
|
||||
impl Key for Authentication {
|
||||
type Value = orizentic::VerifiedToken;
|
||||
}
|
||||
|
||||
impl Authentication {
|
||||
pub fn new(secret: Secret, auth_db_path: String) -> Authentication {
|
||||
let claims = filedb::load_claims_from_file(&auth_db_path).expect("claims did not load");
|
||||
let orizentic = OrizenticCtx::new(secret, claims);
|
||||
Authentication { auth: orizentic }
|
||||
}
|
||||
|
||||
fn authenticate_user(
|
||||
&self,
|
||||
token_str: String,
|
||||
) -> Result<orizentic::VerifiedToken, orizentic::Error> {
|
||||
self.auth.decode_and_validate_text(token_str)
|
||||
}
|
||||
}
|
||||
|
||||
impl BeforeMiddleware for Authentication {
|
||||
fn before(&self, req: &mut Request) -> IronResult<()> {
|
||||
let params = req.get_ref::<Params>().unwrap();
|
||||
let token = match params.get("auth").and_then(|v| String::from_value(v)) {
|
||||
Some(token_str) => self.authenticate_user(token_str).ok(),
|
||||
None => {
|
||||
let m_jar = req
|
||||
.headers
|
||||
.get::<headers::Cookie>()
|
||||
.map(|cookies| CookieJar::from(cookies));
|
||||
m_jar
|
||||
.and_then(|jar| jar.lookup("auth").cloned())
|
||||
.and_then(|Cookie { value, .. }| self.authenticate_user(value.clone()).ok())
|
||||
}
|
||||
};
|
||||
token.map(|t| req.extensions.insert::<Authentication>(t));
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
use iron::middleware::{AfterMiddleware, BeforeMiddleware};
|
||||
use iron::prelude::*;
|
||||
|
||||
pub struct Logging {}
|
||||
|
||||
impl BeforeMiddleware for Logging {
|
||||
fn before(&self, _: &mut Request) -> IronResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl AfterMiddleware for Logging {
|
||||
fn after(&self, _: &mut Request, res: Response) -> IronResult<Response> {
|
||||
Ok(res)
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
mod authentication;
|
||||
mod logging;
|
||||
mod restform;
|
||||
|
||||
pub use authentication::Authentication;
|
||||
pub use restform::RestForm;
|
|
@ -1,34 +0,0 @@
|
|||
use iron::method::Method;
|
||||
use iron::middleware::BeforeMiddleware;
|
||||
use iron::prelude::*;
|
||||
use params::{Params, Value};
|
||||
|
||||
pub struct RestForm {}
|
||||
|
||||
impl RestForm {
|
||||
fn method(&self, v: &Value) -> Option<Method> {
|
||||
match v {
|
||||
Value::String(method_str) => match method_str.as_str() {
|
||||
"delete" => Some(Method::Delete),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BeforeMiddleware for RestForm {
|
||||
fn before(&self, req: &mut Request) -> IronResult<()> {
|
||||
if req.method == Method::Post {
|
||||
let method = {
|
||||
let params = req.get_ref::<Params>().unwrap();
|
||||
params
|
||||
.get("_method")
|
||||
.and_then(|m| self.method(m))
|
||||
.unwrap_or(Method::Post)
|
||||
};
|
||||
req.method = method;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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.authenticate(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.authenticate(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
|
||||
.authenticate(token)
|
||||
.await
|
||||
.expect("token authentication should succeed")
|
||||
.expect("session token should be found");
|
||||
|
||||
assert_matches!(
|
||||
db.validate_session(session).await,
|
||||
Ok(Some(username)) => {
|
||||
assert_eq!(username, Username::from("savanni"));
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue