Compare commits

...

17 Commits

Author SHA1 Message Date
Cameron Cordes
99dbc6577e Fix build directory permissions
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-12-24 13:20:43 -05:00
Cameron Cordes
5a965d766b Update working directory
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-12-24 11:05:06 -05:00
Cameron Cordes
44f9bcedbd Set User to fix permission issue
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-12-24 11:02:34 -05:00
Cameron Cordes
90625e099e Create src directory
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-12-24 10:45:35 -05:00
Cameron Cordes
d20a7e9c4d Make temporary main.rs for fetch
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-12-24 10:44:18 -05:00
Cameron Cordes
6e39f8c58e Build from Dockerfile to improve caching
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
This should improve build times with changes that don't change the
dependencies in Cargo.toml.
2021-12-24 10:33:26 -05:00
1e3f33c2d3 Merge pull request 'Update Rust image to 1.55' (#17) from feature/update-ci-rust-155 into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #17
2021-10-13 16:41:15 +00:00
Cameron Cordes
f0e96071be Update Rust image to 1.55
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-10-13 12:12:25 -04:00
652c2d03d5 Merge pull request 'feature/improve-files-endpoint' (#15) from feature/improve-files-endpoint into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #15
2021-10-08 01:01:00 +00:00
Cameron Cordes
d6e4a01c88 Move list_photos to files module
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
Added some tests, refactored the error handling/logging, and refactored
the extension tests.
2021-10-07 20:32:36 -04:00
Cameron Cordes
2c50b4ae2f Add anyhow, Improve Auth token code
Moved test helper code to its own module.
2021-10-07 20:32:36 -04:00
e4dac64776 Merge pull request 'Create file metadata endpoint' (#14) from feature/file-info-api into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #14
2021-07-08 21:28:15 +00:00
Cameron Cordes
0e972509aa Update dependencies
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-07-08 16:53:50 -04:00
Cameron Cordes
8622500a2f Use file metadata to sort files and directories 2021-06-02 08:32:20 -04:00
Cameron Cordes
9d823fdc51 Create file metadata endpoint
This allows retrieving create/modify date as well as file size for any
file in the BASE_PATH.
2021-05-19 08:53:20 -04:00
9a40614d1e Merge pull request 'feature/prometheus-metrics' (#13) from feature/prometheus-metrics into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #13
2021-05-01 05:02:27 +00:00
Cameron Cordes
c5a7675986 Use IntGauge for media counts
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-05-01 00:16:55 -04:00
10 changed files with 645 additions and 396 deletions

420
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,12 +20,12 @@ jsonwebtoken = "7.2.0"
serde = "1" serde = "1"
serde_json = "1" serde_json = "1"
diesel = { version = "1.4.5", features = ["sqlite"] } diesel = { version = "1.4.5", features = ["sqlite"] }
hmac = "0.10" hmac = "0.11"
sha2 = "0.9" sha2 = "0.9"
chrono = "0.4" chrono = "0.4"
dotenv = "0.15" dotenv = "0.15"
bcrypt = "0.9" bcrypt = "0.9"
image = { version = "0.23.7", default-features = false, features = ["jpeg", "png", "jpeg_rayon"] } image = { version = "0.23", default-features = false, features = ["jpeg", "png", "jpeg_rayon"] }
walkdir = "2" walkdir = "2"
rayon = "1.3" rayon = "1.3"
notify = "4.0" notify = "4.0"
@@ -35,3 +35,4 @@ env_logger="0.8"
actix-web-prom = "0.5.1" actix-web-prom = "0.5.1"
prometheus = "0.11" prometheus = "0.11"
lazy_static = "1.1" lazy_static = "1.1"
anyhow = "1.0"

7
Dockerfile.ci Normal file
View File

@@ -0,0 +1,7 @@
FROM rust:1.55
RUN mkdir /usr/src/image-api && chown -R 1000:999 /usr/src/image-api
USER 1000:999
WORKDIR /usr/src/image-api
COPY Cargo.toml .
RUN mkdir ./src && echo "fn main() {}" > ./src/main.rs && cargo fetch
COPY src/ ./src/

6
Jenkinsfile vendored
View File

@@ -1,8 +1,8 @@
pipeline { pipeline {
agent { agent {
docker { dockerfile {
image 'rust:1.51' filename 'Dockerfile.ci'
args '-v "$PWD":/usr/src/image-api' args '-v "$PWD:/usr/src/image-api'
} }
} }

View File

@@ -32,10 +32,11 @@ pub async fn login(
user_dao: web::Data<Box<dyn UserDao>>, user_dao: web::Data<Box<dyn UserDao>>,
) -> HttpResponse { ) -> HttpResponse {
debug!("Logging in: {}", creds.username); debug!("Logging in: {}", creds.username);
if let Some(user) = user_dao.get_user(&creds.username, &creds.password) { if let Some(user) = user_dao.get_user(&creds.username, &creds.password) {
let claims = Claims { let claims = Claims {
sub: user.id.to_string(), sub: user.id.to_string(),
exp: (Utc::now() + Duration::days(5)).timestamp(), exp: (Utc::now() + Duration::minutes(1)).timestamp(),
}; };
let token = encode( let token = encode(
&Header::default(), &Header::default(),
@@ -43,6 +44,7 @@ pub async fn login(
&EncodingKey::from_secret(secret_key().as_bytes()), &EncodingKey::from_secret(secret_key().as_bytes()),
) )
.unwrap(); .unwrap();
HttpResponse::Ok().json(Token { token: &token }) HttpResponse::Ok().json(Token { token: &token })
} else { } else {
error!( error!(
@@ -56,7 +58,7 @@ pub async fn login(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::database::testhelpers::{BodyReader, TestUserDao}; use crate::testhelpers::{BodyReader, TestUserDao};
#[actix_rt::test] #[actix_rt::test]
async fn test_login_reports_200_when_user_exists() { async fn test_login_reports_200_when_user_exists() {

View File

@@ -1,5 +1,8 @@
use std::str::FromStr; use std::{fs, str::FromStr};
use anyhow::{anyhow, Context};
use chrono::{DateTime, Utc};
use log::error; use log::error;
use actix_web::error::ErrorUnauthorized; use actix_web::error::ErrorUnauthorized;
@@ -34,7 +37,7 @@ impl FromStr for Claims {
let token = *(s.split("Bearer ").collect::<Vec<_>>().last().unwrap_or(&"")); let token = *(s.split("Bearer ").collect::<Vec<_>>().last().unwrap_or(&""));
match decode::<Claims>( match decode::<Claims>(
&token, token,
&DecodingKey::from_secret(secret_key().as_bytes()), &DecodingKey::from_secret(secret_key().as_bytes()),
&Validation::new(Algorithm::HS256), &Validation::new(Algorithm::HS256),
) { ) {
@@ -53,21 +56,36 @@ impl FromRequest for Claims {
type Config = (); type Config = ();
fn from_request(req: &HttpRequest, _payload: &mut dev::Payload) -> Self::Future { fn from_request(req: &HttpRequest, _payload: &mut dev::Payload) -> Self::Future {
let claims = match req.headers().get(header::AUTHORIZATION) { req.headers()
Some(header) => Claims::from_str(header.to_str().unwrap_or("")), .get(header::AUTHORIZATION)
None => Err(jsonwebtoken::errors::Error::from( .map_or_else(
jsonwebtoken::errors::ErrorKind::InvalidToken, || Err(anyhow!("No authorization header")),
)), |header| {
}; header
.to_str()
if let Ok(claims) = claims { .context("Unable to read Authorization header to string")
ok(claims) },
} else { )
err(ErrorUnauthorized("Bad token")) .and_then(|header| {
} Claims::from_str(header)
.with_context(|| format!("Unable to decode token from: {}", header))
})
.map_or_else(
|e| {
error!("{}", e);
err(ErrorUnauthorized("Bad token"))
},
ok,
)
} }
} }
#[derive(Serialize, Deserialize, Debug)]
pub struct PhotosResponse {
pub photos: Vec<String>,
pub dirs: Vec<String>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ThumbnailRequest { pub struct ThumbnailRequest {
pub path: String, pub path: String,
@@ -92,6 +110,29 @@ pub struct AddFavoriteRequest {
pub path: String, pub path: String,
} }
#[derive(Debug, Serialize)]
pub struct MetadataResponse {
pub created: Option<i64>,
pub modified: Option<i64>,
pub size: u64,
}
impl From<fs::Metadata> for MetadataResponse {
fn from(metadata: fs::Metadata) -> Self {
MetadataResponse {
created: metadata.created().ok().map(|created| {
let utc: DateTime<Utc> = created.into();
utc.timestamp()
}),
modified: metadata.modified().ok().map(|modified| {
let utc: DateTime<Utc> = modified.into();
utc.timestamp()
}),
size: metadata.len(),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::Claims; use super::Claims;
@@ -126,4 +167,17 @@ mod tests {
} }
} }
} }
#[test]
fn test_junk_token_is_invalid() {
let err = Claims::from_str("uni-֍ՓՓՓՓՓՓՓՓՓՓՓՓՓՓՓ");
match err.unwrap_err().into_kind() {
ErrorKind::InvalidToken => assert!(true),
kind => {
println!("Unexpected error: {:?}", kind);
assert!(false)
}
}
}
} }

View File

@@ -8,7 +8,7 @@ use std::{
use crate::database::models::{Favorite, InsertFavorite, InsertUser, User}; use crate::database::models::{Favorite, InsertFavorite, InsertUser, User};
mod models; pub mod models;
mod schema; mod schema;
pub trait UserDao { pub trait UserDao {
@@ -44,15 +44,12 @@ impl UserDao for SqliteUserDao {
.execute(&self.connection) .execute(&self.connection)
.unwrap(); .unwrap();
match users users
.filter(username.eq(username)) .filter(username.eq(username))
.load::<User>(&self.connection) .load::<User>(&self.connection)
.unwrap() .unwrap()
.first() .first()
{ .cloned()
Some(u) => Some(u.clone()),
None => None,
}
} else { } else {
None None
} }
@@ -144,7 +141,7 @@ impl FavoriteDao for SqliteFavoriteDao {
diesel::insert_into(favorites) diesel::insert_into(favorites)
.values(InsertFavorite { .values(InsertFavorite {
userid: &user_id, userid: &user_id,
path: &favorite_path, path: favorite_path,
}) })
.execute(connection) .execute(connection)
.map_err(|_| DbError::new(DbErrorKind::InsertError)) .map_err(|_| DbError::new(DbErrorKind::InsertError))
@@ -171,74 +168,3 @@ impl FavoriteDao for SqliteFavoriteDao {
.map_err(|_| DbError::new(DbErrorKind::QueryError)) .map_err(|_| DbError::new(DbErrorKind::QueryError))
} }
} }
#[cfg(test)]
pub mod testhelpers {
use actix_web::dev::{Body, ResponseBody};
use super::{models::User, UserDao};
use std::cell::RefCell;
use std::option::Option;
pub struct TestUserDao {
pub user_map: RefCell<Vec<User>>,
}
impl TestUserDao {
pub fn new() -> Self {
Self {
user_map: RefCell::new(Vec::new()),
}
}
}
impl UserDao for TestUserDao {
fn create_user(&self, username: &str, password: &str) -> Option<User> {
let u = User {
id: (self.user_map.borrow().len() + 1) as i32,
username: username.to_string(),
password: password.to_string(),
};
self.user_map.borrow_mut().push(u.clone());
Some(u)
}
fn get_user(&self, user: &str, pass: &str) -> Option<User> {
match self
.user_map
.borrow()
.iter()
.find(|&u| u.username == user && u.password == pass)
{
Some(u) => {
let copy = (*u).clone();
Some(copy)
}
None => None,
}
}
fn user_exists(&self, user: &str) -> bool {
self.user_map
.borrow()
.iter()
.find(|&u| u.username == user)
.is_some()
}
}
pub trait BodyReader {
fn read_to_str(&self) -> &str;
}
impl BodyReader for ResponseBody<Body> {
fn read_to_str(&self) -> &str {
match self {
ResponseBody::Body(Body::Bytes(ref b)) => std::str::from_utf8(b).unwrap(),
_ => panic!("Unknown response body"),
}
}
}
}

View File

@@ -1,21 +1,68 @@
use std::fs::read_dir; use std::fs::read_dir;
use std::io; use std::io;
use std::io::Error;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use ::anyhow;
use anyhow::{anyhow, Context};
use actix_web::web::{HttpResponse, Query};
use log::{debug, error};
use crate::data::{Claims, PhotosResponse, ThumbnailRequest};
use path_absolutize::*; use path_absolutize::*;
pub fn list_files(dir: PathBuf) -> io::Result<Vec<PathBuf>> { pub async fn list_photos(_: Claims, req: Query<ThumbnailRequest>) -> HttpResponse {
let path = &req.path;
if let Some(path) = is_valid_path(path) {
debug!("Valid path: {:?}", path);
let files = list_files(&path).unwrap_or_default();
let photos = files
.iter()
.filter(|&f| {
f.metadata().map_or_else(
|e| {
error!("Failed getting file metadata: {:?}", e);
false
},
|md| md.is_file(),
)
})
.map(|path: &PathBuf| {
let relative = path
.strip_prefix(dotenv::var("BASE_PATH").unwrap())
.unwrap();
relative.to_path_buf()
})
.map(|f| f.to_str().unwrap().to_string())
.collect::<Vec<String>>();
let dirs = files
.iter()
.filter(|&f| f.metadata().map_or(false, |md| md.is_dir()))
.map(|path: &PathBuf| {
let relative = path
.strip_prefix(dotenv::var("BASE_PATH").unwrap())
.unwrap();
relative.to_path_buf()
})
.map(|f| f.to_str().unwrap().to_string())
.collect::<Vec<String>>();
HttpResponse::Ok().json(PhotosResponse { photos, dirs })
} else {
error!("Bad photos request: {}", req.path);
HttpResponse::BadRequest().finish()
}
}
pub fn list_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
let files = read_dir(dir)? let files = read_dir(dir)?
.map(|res| res.unwrap()) .filter_map(|res| res.ok())
.filter(|entry| is_image_or_video(&entry.path()) || entry.file_type().unwrap().is_dir()) .filter(|entry| is_image_or_video(&entry.path()) || entry.file_type().unwrap().is_dir())
.map(|entry| entry.path()) .map(|entry| entry.path())
.map(|path: PathBuf| {
let relative = path
.strip_prefix(dotenv::var("BASE_PATH").unwrap())
.unwrap();
relative.to_path_buf()
})
.collect::<Vec<PathBuf>>(); .collect::<Vec<PathBuf>>();
Ok(files) Ok(files)
@@ -42,29 +89,42 @@ pub fn is_valid_path(path: &str) -> Option<PathBuf> {
} }
fn is_valid_full_path(base: &Path, path: &str) -> Option<PathBuf> { fn is_valid_full_path(base: &Path, path: &str) -> Option<PathBuf> {
let mut path = PathBuf::from(path); debug!("Base: {:?}. Path: {}", base, path);
if path.is_relative() {
let path = PathBuf::from(path);
let mut path = if path.is_relative() {
let mut full_path = PathBuf::from(base); let mut full_path = PathBuf::from(base);
full_path.push(&path); full_path.push(&path);
is_path_above_base_dir(base, &mut full_path).ok() full_path
} else if let Ok(path) = is_path_above_base_dir(base, &mut path) {
Some(path)
} else { } else {
None path
};
match is_path_above_base_dir(base, &mut path) {
Ok(path) => Some(path),
Err(e) => {
error!("{}", e);
None
}
} }
} }
fn is_path_above_base_dir(base: &Path, full_path: &mut PathBuf) -> Result<PathBuf, Error> { fn is_path_above_base_dir(base: &Path, full_path: &mut PathBuf) -> anyhow::Result<PathBuf> {
full_path.absolutize().and_then(|p| { full_path
if p.starts_with(base) { .absolutize()
Ok(p.into_owned()) .with_context(|| format!("Unable to resolve absolute path: {:?}", full_path))
} else { .map_or_else(
Err(io::Error::new( |e| Err(anyhow!(e)),
io::ErrorKind::Other, |p| {
"Path below base directory", if p.starts_with(base) && p.exists() {
)) Ok(p.into_owned())
} } else if !p.exists() {
}) Err(anyhow!("Path does not exist: {:?}", p))
} else {
Err(anyhow!("Path above base directory"))
}
},
)
} }
#[cfg(test)] #[cfg(test)]
@@ -74,6 +134,77 @@ mod tests {
use super::*; use super::*;
mod api {
use actix_web::{web::Query, HttpResponse};
use super::list_photos;
use crate::{
data::{Claims, PhotosResponse, ThumbnailRequest},
testhelpers::TypedBodyReader,
};
use std::fs;
fn setup() {
let _ = env_logger::builder().is_test(true).try_init();
}
#[actix_rt::test]
async fn test_list_photos() {
setup();
let claims = Claims {
sub: String::from("1"),
exp: 12345,
};
let request: Query<ThumbnailRequest> = Query::from_query("path=").unwrap();
std::env::set_var("BASE_PATH", "/tmp");
let mut temp_photo = std::env::temp_dir();
let mut tmp = temp_photo.clone();
tmp.push("test-dir");
fs::create_dir_all(tmp).unwrap();
temp_photo.push("photo.jpg");
fs::File::create(temp_photo).unwrap();
let response: HttpResponse = list_photos(claims, request).await;
let body: PhotosResponse = response.body().read_body();
assert_eq!(response.status(), 200);
assert!(body.photos.contains(&String::from("photo.jpg")));
assert!(body.dirs.contains(&String::from("test-dir")));
assert!(body
.photos
.iter()
.filter(|filename| !filename.ends_with(".png")
&& !filename.ends_with(".jpg")
&& !filename.ends_with(".jpeg"))
.collect::<Vec<&String>>()
.is_empty());
}
#[actix_rt::test]
async fn test_list_below_base_fails_400() {
setup();
let claims = Claims {
sub: String::from("1"),
exp: 12345,
};
let request: Query<ThumbnailRequest> = Query::from_query("path=..").unwrap();
let response = list_photos(claims, request).await;
assert_eq!(response.status(), 400);
}
}
#[test] #[test]
fn directory_traversal_test() { fn directory_traversal_test() {
assert_eq!(None, is_valid_path("../")); assert_eq!(None, is_valid_path("../"));
@@ -85,22 +216,24 @@ mod tests {
} }
#[test] #[test]
fn build_from_relative_path_test() { fn build_from_path_relative_to_base_test() {
let base = env::temp_dir(); let base = env::temp_dir();
let mut test_file = PathBuf::from(&base); let mut test_file = PathBuf::from(&base);
test_file.push("test.png"); test_file.push("test.png");
File::create(test_file).unwrap(); File::create(test_file).unwrap();
assert!(is_valid_full_path(&base, "test.png").is_some()); assert!(is_valid_full_path(&base, "test.png").is_some());
}
#[test]
fn build_from_relative_returns_none_if_directory_does_not_exist_test() {
let base = env::temp_dir();
let path = "relative/path/test.png"; let path = "relative/path/test.png";
let mut test_file = PathBuf::from(&base); let mut test_file = PathBuf::from(&base);
test_file.push(path); test_file.push(path);
assert_eq!( assert_eq!(None, is_valid_full_path(&base, path));
Some(PathBuf::from("/tmp/relative/path/test.png")),
is_valid_full_path(&base, path)
);
} }
#[test] #[test]
@@ -112,44 +245,41 @@ mod tests {
assert!(is_valid_full_path(&base, test_file.to_str().unwrap()).is_some()); assert!(is_valid_full_path(&base, test_file.to_str().unwrap()).is_some());
let path = "relative/path/test.png";
let mut test_file = PathBuf::from(&base);
test_file.push(path);
assert_eq!( assert_eq!(
Some(PathBuf::from("/tmp/relative/path/test.png")), Some(PathBuf::from("/tmp/test.png")),
is_valid_full_path(&base, path) is_valid_full_path(&base, "/tmp/test.png")
); );
} }
#[test] macro_rules! extension_test {
fn png_valid_extension_test() { ($name:ident, $filename:literal) => {
assert!(is_image_or_video(Path::new("image.png"))); #[test]
assert!(is_image_or_video(Path::new("image.PNG"))); fn $name() {
assert!(is_image_or_video(Path::new("image.pNg"))); assert!(is_image_or_video(Path::new($filename)));
}
};
} }
#[test] extension_test!(valid_png, "image.png");
fn jpg_valid_extension_test() { extension_test!(valid_png_mixed_case, "image.pNg");
assert!(is_image_or_video(Path::new("image.jpeg"))); extension_test!(valid_png_upper_case, "image.PNG");
assert!(is_image_or_video(Path::new("image.JPEG")));
assert!(is_image_or_video(Path::new("image.jpg")));
assert!(is_image_or_video(Path::new("image.JPG")));
}
#[test] extension_test!(valid_jpeg, "image.jpeg");
fn mp4_valid_extension_test() { extension_test!(valid_jpeg_upper_case, "image.JPEG");
assert!(is_image_or_video(Path::new("image.mp4"))); extension_test!(valid_jpg, "image.jpg");
assert!(is_image_or_video(Path::new("image.mP4"))); extension_test!(valid_jpg_upper_case, "image.JPG");
assert!(is_image_or_video(Path::new("image.MP4")));
}
#[test] extension_test!(valid_mp4, "image.mp4");
fn mov_valid_extension_test() { extension_test!(valid_mp4_mixed_case, "image.mP4");
assert!(is_image_or_video(Path::new("image.mov"))); extension_test!(valid_mp4_upper_case, "image.MP4");
assert!(is_image_or_video(Path::new("image.MOV")));
assert!(is_image_or_video(Path::new("image.MoV"))); extension_test!(valid_mov, "image.mov");
} extension_test!(valid_mov_mixed_case, "image.mOV");
extension_test!(valid_mov_upper_case, "image.MOV");
extension_test!(valid_nef, "image.nef");
extension_test!(valid_nef_mixed_case, "image.nEF");
extension_test!(valid_nef_upper_case, "image.NEF");
#[test] #[test]
fn hidden_file_not_valid_test() { fn hidden_file_not_valid_test() {

View File

@@ -2,17 +2,17 @@
extern crate diesel; extern crate diesel;
extern crate rayon; extern crate rayon;
use crate::auth::login;
use actix_web_prom::PrometheusMetrics; use actix_web_prom::PrometheusMetrics;
use database::{DbError, DbErrorKind, FavoriteDao, SqliteFavoriteDao, SqliteUserDao, UserDao};
use futures::stream::StreamExt; use futures::stream::StreamExt;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use prometheus::{self, Gauge}; use prometheus::{self, IntGauge};
use std::path::{Path, PathBuf}; use std::sync::{mpsc::channel, Arc};
use std::sync::mpsc::channel;
use std::sync::Arc;
use std::{collections::HashMap, io::prelude::*}; use std::{collections::HashMap, io::prelude::*};
use std::{env, fs::File}; use std::{env, fs::File};
use std::{
io::ErrorKind,
path::{Path, PathBuf},
};
use walkdir::{DirEntry, WalkDir}; use walkdir::{DirEntry, WalkDir};
use actix::prelude::*; use actix::prelude::*;
@@ -22,18 +22,18 @@ use actix_web::{
delete, delete,
error::BlockingError, error::BlockingError,
get, middleware, post, put, get, middleware, post, put,
web::{self, BufMut, BytesMut, HttpRequest, HttpResponse, Query}, web::{self, BufMut, BytesMut, HttpRequest, HttpResponse},
App, HttpServer, Responder, App, HttpServer, Responder,
}; };
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher}; use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
use rayon::prelude::*; use rayon::prelude::*;
use serde::Serialize;
use data::{AddFavoriteRequest, ThumbnailRequest};
use log::{debug, error, info}; use log::{debug, error, info};
use crate::data::Claims; use crate::auth::login;
use crate::files::{is_image_or_video, is_valid_path, list_files}; use crate::data::*;
use crate::database::*;
use crate::files::{is_image_or_video, is_valid_path};
use crate::video::*; use crate::video::*;
mod auth; mod auth;
@@ -42,52 +42,22 @@ mod database;
mod files; mod files;
mod video; mod video;
#[cfg(test)]
mod testhelpers;
lazy_static! { lazy_static! {
static ref IMAGE_GAUGE: Gauge = Gauge::new( static ref IMAGE_GAUGE: IntGauge = IntGauge::new(
"imageserver_image_total", "imageserver_image_total",
"Count of the images on the server" "Count of the images on the server"
) )
.unwrap(); .unwrap();
static ref VIDEO_GAUGE: Gauge = Gauge::new( static ref VIDEO_GAUGE: IntGauge = IntGauge::new(
"imageserver_video_total", "imageserver_video_total",
"Count of the videos on the server" "Count of the videos on the server"
) )
.unwrap(); .unwrap();
} }
#[get("/photos")]
async fn list_photos(_claims: Claims, req: Query<ThumbnailRequest>) -> impl Responder {
info!("{}", req.path);
let path = &req.path;
if let Some(path) = is_valid_path(path) {
let files = list_files(path).unwrap_or_default();
let photos = &files
.iter()
.filter(|f| !f.extension().unwrap_or_default().is_empty())
.map(|f| f.to_str().unwrap().to_string())
.collect::<Vec<String>>();
let dirs = &files
.iter()
.filter(|f| f.extension().unwrap_or_default().is_empty())
.map(|f| f.to_str().unwrap().to_string())
.collect::<Vec<String>>();
HttpResponse::Ok().json(PhotosResponse { photos, dirs })
} else {
error!("Bad photos request: {}", req.path);
HttpResponse::BadRequest().finish()
}
}
#[derive(Serialize)]
struct PhotosResponse<'a> {
photos: &'a [String],
dirs: &'a [String],
}
#[get("/image")] #[get("/image")]
async fn get_image( async fn get_image(
_claims: Claims, _claims: Claims,
@@ -119,6 +89,24 @@ async fn get_image(
} }
} }
#[get("/image/metadata")]
async fn get_file_metadata(_: Claims, path: web::Query<ThumbnailRequest>) -> impl Responder {
match is_valid_path(&path.path)
.ok_or_else(|| ErrorKind::InvalidData.into())
.and_then(File::open)
.and_then(|file| file.metadata())
{
Ok(metadata) => {
let response: MetadataResponse = metadata.into();
HttpResponse::Ok().json(response)
}
Err(e) => {
error!("Error getting metadata for file '{}': {:?}", path.path, e);
HttpResponse::InternalServerError().finish()
}
}
}
#[post("/image")] #[post("/image")]
async fn upload_image(_: Claims, mut payload: mp::Multipart) -> impl Responder { async fn upload_image(_: Claims, mut payload: mp::Multipart) -> impl Responder {
let mut file_content: BytesMut = BytesMut::new(); let mut file_content: BytesMut = BytesMut::new();
@@ -178,7 +166,7 @@ async fn generate_video(
let filename = name.to_str().expect("Filename should convert to string"); let filename = name.to_str().expect("Filename should convert to string");
let playlist = format!("tmp/{}.m3u8", filename); let playlist = format!("tmp/{}.m3u8", filename);
if let Some(path) = is_valid_path(&body.path) { if let Some(path) = is_valid_path(&body.path) {
if let Ok(child) = create_playlist(&path.to_str().unwrap(), &playlist).await { if let Ok(child) = create_playlist(path.to_str().unwrap(), &playlist).await {
data.stream_manager data.stream_manager
.do_send(ProcessMessage(playlist.clone(), child)); .do_send(ProcessMessage(playlist.clone(), child));
} }
@@ -243,8 +231,8 @@ async fn favorites(
.collect::<Vec<String>>(); .collect::<Vec<String>>();
HttpResponse::Ok().json(PhotosResponse { HttpResponse::Ok().json(PhotosResponse {
photos: &favorites, photos: favorites,
dirs: &Vec::new(), dirs: Vec::new(),
}) })
} }
@@ -370,7 +358,7 @@ fn create_thumbnails() {
update_media_counts(&images); update_media_counts(&images);
} }
fn update_media_counts(media_dir: &PathBuf) { fn update_media_counts(media_dir: &Path) {
let mut image_count = 0; let mut image_count = 0;
let mut video_count = 0; let mut video_count = 0;
for ref entry in WalkDir::new(media_dir).into_iter().filter_map(|e| e.ok()) { for ref entry in WalkDir::new(media_dir).into_iter().filter_map(|e| e.ok()) {
@@ -381,8 +369,8 @@ fn update_media_counts(media_dir: &PathBuf) {
} }
} }
IMAGE_GAUGE.set(image_count as f64); IMAGE_GAUGE.set(image_count);
VIDEO_GAUGE.set(video_count as f64); VIDEO_GAUGE.set(video_count);
} }
fn is_image(entry: &DirEntry) -> bool { fn is_image(entry: &DirEntry) -> bool {
@@ -476,7 +464,7 @@ fn main() -> std::io::Result<()> {
App::new() App::new()
.wrap(middleware::Logger::default()) .wrap(middleware::Logger::default())
.service(web::resource("/login").route(web::post().to(login))) .service(web::resource("/login").route(web::post().to(login)))
.service(list_photos) .service(web::resource("/photos").route(web::get().to(files::list_photos)))
.service(get_image) .service(get_image)
.service(upload_image) .service(upload_image)
.service(generate_video) .service(generate_video)
@@ -485,6 +473,7 @@ fn main() -> std::io::Result<()> {
.service(favorites) .service(favorites)
.service(put_add_favorite) .service(put_add_favorite)
.service(delete_favorite) .service(delete_favorite)
.service(get_file_metadata)
.app_data(app_data.clone()) .app_data(app_data.clone())
.data::<Box<dyn UserDao>>(Box::new(user_dao)) .data::<Box<dyn UserDao>>(Box::new(user_dao))
.data::<Box<dyn FavoriteDao>>(Box::new(favorites_dao)) .data::<Box<dyn FavoriteDao>>(Box::new(favorites_dao))

86
src/testhelpers.rs Normal file
View File

@@ -0,0 +1,86 @@
use actix_web::dev::{Body, ResponseBody};
use serde::Deserialize;
use crate::database::{models::User, UserDao};
use std::cell::RefCell;
use std::option::Option;
pub struct TestUserDao {
pub user_map: RefCell<Vec<User>>,
}
impl TestUserDao {
pub fn new() -> Self {
Self {
user_map: RefCell::new(Vec::new()),
}
}
}
impl UserDao for TestUserDao {
fn create_user(&self, username: &str, password: &str) -> Option<User> {
let u = User {
id: (self.user_map.borrow().len() + 1) as i32,
username: username.to_string(),
password: password.to_string(),
};
self.user_map.borrow_mut().push(u.clone());
Some(u)
}
fn get_user(&self, user: &str, pass: &str) -> Option<User> {
match self
.user_map
.borrow()
.iter()
.find(|&u| u.username == user && u.password == pass)
{
Some(u) => {
let copy = (*u).clone();
Some(copy)
}
None => None,
}
}
fn user_exists(&self, user: &str) -> bool {
self.user_map
.borrow()
.iter()
.find(|&u| u.username == user)
.is_some()
}
}
pub trait BodyReader {
fn read_to_str(&self) -> &str;
}
impl BodyReader for ResponseBody<Body> {
fn read_to_str(&self) -> &str {
match self {
ResponseBody::Body(Body::Bytes(ref b)) => std::str::from_utf8(b).unwrap(),
_ => panic!("Unknown response body"),
}
}
}
pub trait TypedBodyReader<'a, T>
where
T: Deserialize<'a>,
{
fn read_body(&'a self) -> T;
}
impl<'a, T: Deserialize<'a>> TypedBodyReader<'a, T> for ResponseBody<Body> {
fn read_body(&'a self) -> T {
match self {
ResponseBody::Body(Body::Bytes(ref b)) => {
serde_json::from_str(std::str::from_utf8(b).unwrap()).unwrap()
}
_ => panic!("Unknown response body"),
}
}
}