Compare commits
17 Commits
f1a7cbc0f0
...
feature/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99dbc6577e | ||
|
|
5a965d766b | ||
|
|
44f9bcedbd | ||
|
|
90625e099e | ||
|
|
d20a7e9c4d | ||
|
|
6e39f8c58e | ||
| 1e3f33c2d3 | |||
|
|
f0e96071be | ||
| 652c2d03d5 | |||
|
|
d6e4a01c88 | ||
|
|
2c50b4ae2f | ||
| e4dac64776 | |||
|
|
0e972509aa | ||
|
|
8622500a2f | ||
|
|
9d823fdc51 | ||
| 9a40614d1e | |||
|
|
c5a7675986 |
420
Cargo.lock
generated
420
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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
7
Dockerfile.ci
Normal 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
6
Jenkinsfile
vendored
@@ -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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
252
src/files.rs
252
src/files.rs
@@ -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() {
|
||||||
|
|||||||
95
src/main.rs
95
src/main.rs
@@ -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
86
src/testhelpers.rs
Normal 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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user