Merge pull request 'feature/tagging' (#16) from feature/tagging into master
Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
1002
Cargo.lock
generated
1002
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,8 @@ futures = "0.3.5"
|
|||||||
jsonwebtoken = "7.2.0"
|
jsonwebtoken = "7.2.0"
|
||||||
serde = "1"
|
serde = "1"
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
diesel = { version = "1.4.5", features = ["sqlite"] }
|
diesel = { version = "2.0.2", features = ["sqlite"] }
|
||||||
|
diesel_migrations = "2.0.0"
|
||||||
hmac = "0.11"
|
hmac = "0.11"
|
||||||
sha2 = "0.9"
|
sha2 = "0.9"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
@@ -27,9 +28,9 @@ dotenv = "0.15"
|
|||||||
bcrypt = "0.9"
|
bcrypt = "0.9"
|
||||||
image = { version = "0.23", 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.5"
|
||||||
notify = "4.0"
|
notify = "4.0"
|
||||||
path-absolutize = "3.0.6"
|
path-absolutize = "3.0"
|
||||||
log="0.4"
|
log="0.4"
|
||||||
env_logger="0.8"
|
env_logger="0.8"
|
||||||
actix-web-prom = "0.6"
|
actix-web-prom = "0.6"
|
||||||
|
|||||||
3
migrations/2021-09-02-000740_create_tags/down.sql
Normal file
3
migrations/2021-09-02-000740_create_tags/down.sql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE tags;
|
||||||
|
DROP TABLE tagged_photo;
|
||||||
|
|
||||||
13
migrations/2021-09-02-000740_create_tags/up.sql
Normal file
13
migrations/2021-09-02-000740_create_tags/up.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE tags (
|
||||||
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_time BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tagged_photo (
|
||||||
|
id INTEGER PRIMARY KEY NOT NULL,
|
||||||
|
photo_name TEXT NOT NULL,
|
||||||
|
tag_id INTEGER NOT NULL,
|
||||||
|
created_time BIGINT NOT NULL,
|
||||||
|
CONSTRAINT tagid FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
31
src/auth.rs
31
src/auth.rs
@@ -5,7 +5,8 @@ use actix_web::{
|
|||||||
};
|
};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
use log::{debug, error};
|
use log::{error, info};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
data::{secret_key, Claims, CreateAccountRequest, LoginRequest, Token},
|
data::{secret_key, Claims, CreateAccountRequest, LoginRequest, Token},
|
||||||
@@ -15,12 +16,13 @@ use crate::{
|
|||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
async fn register<D: UserDao>(
|
async fn register<D: UserDao>(
|
||||||
user: Json<CreateAccountRequest>,
|
user: Json<CreateAccountRequest>,
|
||||||
user_dao: web::Data<D>,
|
user_dao: web::Data<Mutex<D>>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
if !user.username.is_empty() && user.password.len() > 5 && user.password == user.confirmation {
|
if !user.username.is_empty() && user.password.len() > 5 && user.password == user.confirmation {
|
||||||
if user_dao.user_exists(&user.username) {
|
let mut dao = user_dao.lock().expect("Unable to get UserDao");
|
||||||
|
if dao.user_exists(&user.username) {
|
||||||
HttpResponse::BadRequest()
|
HttpResponse::BadRequest()
|
||||||
} else if let Some(_user) = user_dao.create_user(&user.username, &user.password) {
|
} else if let Some(_user) = dao.create_user(&user.username, &user.password) {
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
} else {
|
} else {
|
||||||
HttpResponse::InternalServerError()
|
HttpResponse::InternalServerError()
|
||||||
@@ -30,8 +32,13 @@ async fn register<D: UserDao>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn login<D: UserDao>(creds: Json<LoginRequest>, user_dao: web::Data<D>) -> HttpResponse {
|
pub async fn login<D: UserDao>(
|
||||||
debug!("Logging in: {}", creds.username);
|
creds: Json<LoginRequest>,
|
||||||
|
user_dao: web::Data<Mutex<D>>,
|
||||||
|
) -> HttpResponse {
|
||||||
|
info!("Logging in: {}", creds.username);
|
||||||
|
|
||||||
|
let mut user_dao = user_dao.lock().expect("Unable to get UserDao");
|
||||||
|
|
||||||
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 {
|
||||||
@@ -63,7 +70,7 @@ mod tests {
|
|||||||
|
|
||||||
#[actix_rt::test]
|
#[actix_rt::test]
|
||||||
async fn test_login_reports_200_when_user_exists() {
|
async fn test_login_reports_200_when_user_exists() {
|
||||||
let dao = TestUserDao::new();
|
let mut dao = TestUserDao::new();
|
||||||
dao.create_user("user", "pass");
|
dao.create_user("user", "pass");
|
||||||
|
|
||||||
let j = Json(LoginRequest {
|
let j = Json(LoginRequest {
|
||||||
@@ -71,14 +78,14 @@ mod tests {
|
|||||||
password: "pass".to_string(),
|
password: "pass".to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = login::<TestUserDao>(j, web::Data::new(dao)).await;
|
let response = login::<TestUserDao>(j, web::Data::new(Mutex::new(dao))).await;
|
||||||
|
|
||||||
assert_eq!(response.status(), 200);
|
assert_eq!(response.status(), 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_rt::test]
|
#[actix_rt::test]
|
||||||
async fn test_login_returns_token_on_success() {
|
async fn test_login_returns_token_on_success() {
|
||||||
let dao = TestUserDao::new();
|
let mut dao = TestUserDao::new();
|
||||||
dao.create_user("user", "password");
|
dao.create_user("user", "password");
|
||||||
|
|
||||||
let j = Json(LoginRequest {
|
let j = Json(LoginRequest {
|
||||||
@@ -86,7 +93,7 @@ mod tests {
|
|||||||
password: "password".to_string(),
|
password: "password".to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = login::<TestUserDao>(j, web::Data::new(dao)).await;
|
let response = login::<TestUserDao>(j, web::Data::new(Mutex::new(dao))).await;
|
||||||
|
|
||||||
assert_eq!(response.status(), 200);
|
assert_eq!(response.status(), 200);
|
||||||
let response_text: String = response.read_to_str();
|
let response_text: String = response.read_to_str();
|
||||||
@@ -96,7 +103,7 @@ mod tests {
|
|||||||
|
|
||||||
#[actix_rt::test]
|
#[actix_rt::test]
|
||||||
async fn test_login_reports_404_when_user_does_not_exist() {
|
async fn test_login_reports_404_when_user_does_not_exist() {
|
||||||
let dao = TestUserDao::new();
|
let mut dao = TestUserDao::new();
|
||||||
dao.create_user("user", "password");
|
dao.create_user("user", "password");
|
||||||
|
|
||||||
let j = Json(LoginRequest {
|
let j = Json(LoginRequest {
|
||||||
@@ -104,7 +111,7 @@ mod tests {
|
|||||||
password: "password".to_string(),
|
password: "password".to_string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = login::<TestUserDao>(j, web::Data::new(dao)).await;
|
let response = login::<TestUserDao>(j, web::Data::new(Mutex::new(dao))).await;
|
||||||
|
|
||||||
assert_eq!(response.status(), 404);
|
assert_eq!(response.status(), 404);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,12 +16,27 @@ pub struct Token<'a> {
|
|||||||
pub token: &'a str,
|
pub token: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: String,
|
pub sub: String,
|
||||||
pub exp: i64,
|
pub exp: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod helper {
|
||||||
|
use super::Claims;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
|
||||||
|
impl Claims {
|
||||||
|
pub fn valid_user(user_id: String) -> Self {
|
||||||
|
Claims {
|
||||||
|
sub: user_id,
|
||||||
|
exp: (Utc::now() + Duration::minutes(1)).timestamp(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn secret_key() -> String {
|
pub fn secret_key() -> String {
|
||||||
if cfg!(test) {
|
if cfg!(test) {
|
||||||
String::from("test_key")
|
String::from("test_key")
|
||||||
@@ -85,6 +100,19 @@ pub struct PhotosResponse {
|
|||||||
pub dirs: Vec<String>,
|
pub dirs: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct FilesRequest {
|
||||||
|
pub path: String,
|
||||||
|
pub tag_ids: Option<String>, // comma separated numbers
|
||||||
|
pub tag_filter_mode: Option<FilterMode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Deserialize, PartialEq)]
|
||||||
|
pub enum FilterMode {
|
||||||
|
Any,
|
||||||
|
All,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ThumbnailRequest {
|
pub struct ThumbnailRequest {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
@@ -132,6 +160,12 @@ impl From<fs::Metadata> for MetadataResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AddTagRequest {
|
||||||
|
pub file_name: String,
|
||||||
|
pub tag_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::Claims;
|
use super::Claims;
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
use bcrypt::{hash, verify, DEFAULT_COST};
|
use bcrypt::{hash, verify, DEFAULT_COST};
|
||||||
use diesel::prelude::*;
|
use diesel::prelude::*;
|
||||||
use diesel::sqlite::SqliteConnection;
|
use diesel::sqlite::SqliteConnection;
|
||||||
use std::{
|
use std::ops::DerefMut;
|
||||||
ops::Deref,
|
use std::sync::{Arc, Mutex};
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::database::models::{Favorite, InsertFavorite, InsertUser, User};
|
use crate::database::models::{Favorite, InsertFavorite, InsertUser, User};
|
||||||
|
|
||||||
pub mod models;
|
pub mod models;
|
||||||
mod schema;
|
pub mod schema;
|
||||||
|
|
||||||
pub trait UserDao {
|
pub trait UserDao {
|
||||||
fn create_user(&self, user: &str, password: &str) -> Option<User>;
|
fn create_user(&mut self, user: &str, password: &str) -> Option<User>;
|
||||||
fn get_user(&self, user: &str, password: &str) -> Option<User>;
|
fn get_user(&mut self, user: &str, password: &str) -> Option<User>;
|
||||||
fn user_exists(&self, user: &str) -> bool;
|
fn user_exists(&mut self, user: &str) -> bool;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SqliteUserDao {
|
pub struct SqliteUserDao {
|
||||||
@@ -29,9 +27,27 @@ impl SqliteUserDao {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub mod test {
|
||||||
|
use diesel::{Connection, SqliteConnection};
|
||||||
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
|
|
||||||
|
const DB_MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
||||||
|
|
||||||
|
pub fn in_memory_db_connection() -> SqliteConnection {
|
||||||
|
let mut connection = SqliteConnection::establish(":memory:")
|
||||||
|
.expect("Unable to create in-memory db connection");
|
||||||
|
connection
|
||||||
|
.run_pending_migrations(DB_MIGRATIONS)
|
||||||
|
.expect("Failure running DB migrations");
|
||||||
|
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl UserDao for SqliteUserDao {
|
impl UserDao for SqliteUserDao {
|
||||||
// TODO: Should probably use Result here
|
// TODO: Should probably use Result here
|
||||||
fn create_user(&self, user: &str, pass: &str) -> std::option::Option<User> {
|
fn create_user(&mut self, user: &str, pass: &str) -> Option<User> {
|
||||||
use schema::users::dsl::*;
|
use schema::users::dsl::*;
|
||||||
|
|
||||||
let hashed = hash(pass, DEFAULT_COST);
|
let hashed = hash(pass, DEFAULT_COST);
|
||||||
@@ -41,12 +57,12 @@ impl UserDao for SqliteUserDao {
|
|||||||
username: user,
|
username: user,
|
||||||
password: &hash,
|
password: &hash,
|
||||||
})
|
})
|
||||||
.execute(&self.connection)
|
.execute(&mut self.connection)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
users
|
users
|
||||||
.filter(username.eq(username))
|
.filter(username.eq(username))
|
||||||
.load::<User>(&self.connection)
|
.load::<User>(&mut self.connection)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.first()
|
.first()
|
||||||
.cloned()
|
.cloned()
|
||||||
@@ -55,12 +71,12 @@ impl UserDao for SqliteUserDao {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_user(&self, user: &str, pass: &str) -> Option<User> {
|
fn get_user(&mut self, user: &str, pass: &str) -> Option<User> {
|
||||||
use schema::users::dsl::*;
|
use schema::users::dsl::*;
|
||||||
|
|
||||||
match users
|
match users
|
||||||
.filter(username.eq(user))
|
.filter(username.eq(user))
|
||||||
.load::<User>(&self.connection)
|
.load::<User>(&mut self.connection)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.first()
|
.first()
|
||||||
{
|
{
|
||||||
@@ -69,19 +85,19 @@ impl UserDao for SqliteUserDao {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn user_exists(&self, user: &str) -> bool {
|
fn user_exists(&mut self, user: &str) -> bool {
|
||||||
use schema::users::dsl::*;
|
use schema::users::dsl::*;
|
||||||
|
|
||||||
users
|
users
|
||||||
.filter(username.eq(user))
|
.filter(username.eq(user))
|
||||||
.load::<User>(&self.connection)
|
.load::<User>(&mut self.connection)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.first()
|
.first()
|
||||||
.is_some()
|
.is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn connect() -> SqliteConnection {
|
pub fn connect() -> SqliteConnection {
|
||||||
let db_url = dotenv::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let db_url = dotenv::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
SqliteConnection::establish(&db_url).expect("Error connecting to DB")
|
SqliteConnection::establish(&db_url).expect("Error connecting to DB")
|
||||||
}
|
}
|
||||||
@@ -109,9 +125,9 @@ pub enum DbErrorKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub trait FavoriteDao: Sync + Send {
|
pub trait FavoriteDao: Sync + Send {
|
||||||
fn add_favorite(&self, user_id: i32, favorite_path: &str) -> Result<usize, DbError>;
|
fn add_favorite(&mut self, user_id: i32, favorite_path: &str) -> Result<usize, DbError>;
|
||||||
fn remove_favorite(&self, user_id: i32, favorite_path: String);
|
fn remove_favorite(&mut self, user_id: i32, favorite_path: String);
|
||||||
fn get_favorites(&self, user_id: i32) -> Result<Vec<Favorite>, DbError>;
|
fn get_favorites(&mut self, user_id: i32) -> Result<Vec<Favorite>, DbError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct SqliteFavoriteDao {
|
pub struct SqliteFavoriteDao {
|
||||||
@@ -127,15 +143,14 @@ impl SqliteFavoriteDao {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl FavoriteDao for SqliteFavoriteDao {
|
impl FavoriteDao for SqliteFavoriteDao {
|
||||||
fn add_favorite(&self, user_id: i32, favorite_path: &str) -> Result<usize, DbError> {
|
fn add_favorite(&mut self, user_id: i32, favorite_path: &str) -> Result<usize, DbError> {
|
||||||
use schema::favorites::dsl::*;
|
use schema::favorites::dsl::*;
|
||||||
|
|
||||||
let connection = self.connection.lock().unwrap();
|
let mut connection = self.connection.lock().expect("Unable to get FavoriteDao");
|
||||||
let connection = connection.deref();
|
|
||||||
|
|
||||||
if favorites
|
if favorites
|
||||||
.filter(userid.eq(user_id).and(path.eq(&favorite_path)))
|
.filter(userid.eq(user_id).and(path.eq(&favorite_path)))
|
||||||
.first::<Favorite>(connection)
|
.first::<Favorite>(connection.deref_mut())
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
diesel::insert_into(favorites)
|
diesel::insert_into(favorites)
|
||||||
@@ -143,28 +158,28 @@ impl FavoriteDao for SqliteFavoriteDao {
|
|||||||
userid: &user_id,
|
userid: &user_id,
|
||||||
path: favorite_path,
|
path: favorite_path,
|
||||||
})
|
})
|
||||||
.execute(connection)
|
.execute(connection.deref_mut())
|
||||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
||||||
} else {
|
} else {
|
||||||
Err(DbError::exists())
|
Err(DbError::exists())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_favorite(&self, user_id: i32, favorite_path: String) {
|
fn remove_favorite(&mut self, user_id: i32, favorite_path: String) {
|
||||||
use schema::favorites::dsl::*;
|
use schema::favorites::dsl::*;
|
||||||
|
|
||||||
diesel::delete(favorites)
|
diesel::delete(favorites)
|
||||||
.filter(userid.eq(user_id).and(path.eq(favorite_path)))
|
.filter(userid.eq(user_id).and(path.eq(favorite_path)))
|
||||||
.execute(self.connection.lock().unwrap().deref())
|
.execute(self.connection.lock().unwrap().deref_mut())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_favorites(&self, user_id: i32) -> Result<Vec<Favorite>, DbError> {
|
fn get_favorites(&mut self, user_id: i32) -> Result<Vec<Favorite>, DbError> {
|
||||||
use schema::favorites::dsl::*;
|
use schema::favorites::dsl::*;
|
||||||
|
|
||||||
favorites
|
favorites
|
||||||
.filter(userid.eq(user_id))
|
.filter(userid.eq(user_id))
|
||||||
.load::<Favorite>(self.connection.lock().unwrap().deref())
|
.load::<Favorite>(self.connection.lock().unwrap().deref_mut())
|
||||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::database::schema::{favorites, users};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Insertable)]
|
#[derive(Insertable)]
|
||||||
#[table_name = "users"]
|
#[diesel(table_name = users)]
|
||||||
pub struct InsertUser<'a> {
|
pub struct InsertUser<'a> {
|
||||||
pub username: &'a str,
|
pub username: &'a str,
|
||||||
pub password: &'a str,
|
pub password: &'a str,
|
||||||
@@ -17,7 +17,7 @@ pub struct User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Insertable)]
|
#[derive(Insertable)]
|
||||||
#[table_name = "favorites"]
|
#[diesel(table_name = favorites)]
|
||||||
pub struct InsertFavorite<'a> {
|
pub struct InsertFavorite<'a> {
|
||||||
pub userid: &'a i32,
|
pub userid: &'a i32,
|
||||||
pub path: &'a str,
|
pub path: &'a str,
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
table! {
|
||||||
|
tagged_photo (id) {
|
||||||
|
id -> Integer,
|
||||||
|
photo_name -> Text,
|
||||||
|
tag_id -> Integer,
|
||||||
|
created_time -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table! {
|
||||||
|
tags (id) {
|
||||||
|
id -> Integer,
|
||||||
|
name -> Text,
|
||||||
|
created_time -> BigInt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
table! {
|
table! {
|
||||||
users (id) {
|
users (id) {
|
||||||
id -> Integer,
|
id -> Integer,
|
||||||
@@ -14,4 +31,6 @@ table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allow_tables_to_appear_in_same_query!(favorites, users,);
|
joinable!(tagged_photo -> tags (tag_id));
|
||||||
|
|
||||||
|
allow_tables_to_appear_in_same_query!(favorites, tagged_photo, tags, users,);
|
||||||
|
|||||||
14
src/error.rs
Normal file
14
src/error.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
use actix_web::{error::InternalError, http::StatusCode};
|
||||||
|
|
||||||
|
pub trait IntoHttpError<T> {
|
||||||
|
fn into_http_internal_err(self) -> Result<T, actix_web::Error>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> IntoHttpError<T> for Result<T, anyhow::Error> {
|
||||||
|
fn into_http_internal_err(self) -> Result<T, actix_web::Error> {
|
||||||
|
self.map_err(|e| {
|
||||||
|
log::error!("Map to err: {:?}", e);
|
||||||
|
InternalError::new(e, StatusCode::INTERNAL_SERVER_ERROR).into()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
304
src/files.rs
304
src/files.rs
@@ -2,6 +2,7 @@ use std::fmt::Debug;
|
|||||||
use std::fs::read_dir;
|
use std::fs::read_dir;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use ::anyhow;
|
use ::anyhow;
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
@@ -10,23 +11,48 @@ use actix_web::{
|
|||||||
web::{self, Query},
|
web::{self, Query},
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use log::{debug, error};
|
use log::{debug, error};
|
||||||
|
|
||||||
use crate::data::{Claims, PhotosResponse, ThumbnailRequest};
|
use crate::data::{Claims, FilesRequest, FilterMode, PhotosResponse};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
use crate::error::IntoHttpError;
|
||||||
|
use crate::tags::TagDao;
|
||||||
use path_absolutize::*;
|
use path_absolutize::*;
|
||||||
|
|
||||||
pub async fn list_photos(
|
pub async fn list_photos<TagD: TagDao, FS: FileSystemAccess>(
|
||||||
_: Claims,
|
_: Claims,
|
||||||
req: Query<ThumbnailRequest>,
|
req: Query<FilesRequest>,
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
|
file_system: web::Data<FS>,
|
||||||
|
tag_dao: web::Data<Mutex<TagD>>,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let path = &req.path;
|
let path = &req.path;
|
||||||
if let Some(path) = is_valid_full_path(&PathBuf::from(&app_state.base_path), path) {
|
|
||||||
|
if let (Some(tag_ids), Some(filter_mode)) = (&req.tag_ids, &req.tag_filter_mode) {
|
||||||
|
if *filter_mode == FilterMode::All {
|
||||||
|
let mut dao = tag_dao.lock().expect("Unable to get TagDao");
|
||||||
|
let tag_ids = tag_ids
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|t| t.parse().ok())
|
||||||
|
.collect::<Vec<i32>>();
|
||||||
|
|
||||||
|
return dao
|
||||||
|
.get_files_with_tag_ids(tag_ids.clone())
|
||||||
|
.context(format!("Failed to files with tag_ids: {:?}", tag_ids))
|
||||||
|
.map(|tagged_files| {
|
||||||
|
HttpResponse::Ok().json(PhotosResponse {
|
||||||
|
photos: tagged_files,
|
||||||
|
dirs: vec![],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.into_http_internal_err()
|
||||||
|
.unwrap_or_else(|e| e.error_response());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(files) = file_system.get_files_for_path(path) {
|
||||||
debug!("Valid path: {:?}", path);
|
debug!("Valid path: {:?}", path);
|
||||||
let files = list_files(&path).unwrap_or_default();
|
|
||||||
|
|
||||||
let photos = files
|
let photos = files
|
||||||
.iter()
|
.iter()
|
||||||
@@ -34,7 +60,7 @@ pub async fn list_photos(
|
|||||||
f.metadata().map_or_else(
|
f.metadata().map_or_else(
|
||||||
|e| {
|
|e| {
|
||||||
error!("Failed getting file metadata: {:?}", e);
|
error!("Failed getting file metadata: {:?}", e);
|
||||||
false
|
f.extension().is_some()
|
||||||
},
|
},
|
||||||
|md| md.is_file(),
|
|md| md.is_file(),
|
||||||
)
|
)
|
||||||
@@ -44,6 +70,27 @@ pub async fn list_photos(
|
|||||||
relative.to_path_buf()
|
relative.to_path_buf()
|
||||||
})
|
})
|
||||||
.map(|f| f.to_str().unwrap().to_string())
|
.map(|f| f.to_str().unwrap().to_string())
|
||||||
|
.filter(|file_path| {
|
||||||
|
if let (Some(tag_ids), Ok(mut tag_dao)) = (&req.tag_ids, tag_dao.lock()) {
|
||||||
|
let tag_ids = tag_ids
|
||||||
|
.split(',')
|
||||||
|
.filter_map(|t| t.parse().ok())
|
||||||
|
.collect::<Vec<i32>>();
|
||||||
|
|
||||||
|
let filter_mode = &req.tag_filter_mode.unwrap_or(FilterMode::Any);
|
||||||
|
|
||||||
|
let file_tags = tag_dao.get_tags_for_path(file_path).unwrap_or_default();
|
||||||
|
|
||||||
|
return match filter_mode {
|
||||||
|
FilterMode::Any => file_tags.iter().any(|t| tag_ids.contains(&t.id)),
|
||||||
|
FilterMode::All => tag_ids
|
||||||
|
.iter()
|
||||||
|
.all(|id| file_tags.iter().any(|tag| &tag.id == id)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
})
|
||||||
.collect::<Vec<String>>();
|
.collect::<Vec<String>>();
|
||||||
|
|
||||||
let dirs = files
|
let dirs = files
|
||||||
@@ -87,10 +134,14 @@ pub fn is_image_or_video(path: &Path) -> bool {
|
|||||||
|| extension == "nef"
|
|| extension == "nef"
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_valid_full_path<P: AsRef<Path> + Debug>(base: &P, path: &str) -> Option<PathBuf> {
|
pub fn is_valid_full_path<P: AsRef<Path> + Debug + AsRef<std::ffi::OsStr>>(
|
||||||
debug!("Base: {:?}. Path: {}", base, path);
|
base: &P,
|
||||||
|
path: &P,
|
||||||
|
new_file: bool,
|
||||||
|
) -> Option<PathBuf> {
|
||||||
|
debug!("Base: {:?}. Path: {:?}", base, path);
|
||||||
|
|
||||||
let path = PathBuf::from(path);
|
let path = PathBuf::from(&path);
|
||||||
let mut path = if path.is_relative() {
|
let mut path = if path.is_relative() {
|
||||||
let mut full_path = PathBuf::new();
|
let mut full_path = PathBuf::new();
|
||||||
full_path.push(base);
|
full_path.push(base);
|
||||||
@@ -100,7 +151,7 @@ pub fn is_valid_full_path<P: AsRef<Path> + Debug>(base: &P, path: &str) -> Optio
|
|||||||
path
|
path
|
||||||
};
|
};
|
||||||
|
|
||||||
match is_path_above_base_dir(base, &mut path) {
|
match is_path_above_base_dir(base, &mut path, new_file) {
|
||||||
Ok(path) => Some(path),
|
Ok(path) => Some(path),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
@@ -112,6 +163,7 @@ pub fn is_valid_full_path<P: AsRef<Path> + Debug>(base: &P, path: &str) -> Optio
|
|||||||
fn is_path_above_base_dir<P: AsRef<Path> + Debug>(
|
fn is_path_above_base_dir<P: AsRef<Path> + Debug>(
|
||||||
base: P,
|
base: P,
|
||||||
full_path: &mut PathBuf,
|
full_path: &mut PathBuf,
|
||||||
|
new_file: bool,
|
||||||
) -> anyhow::Result<PathBuf> {
|
) -> anyhow::Result<PathBuf> {
|
||||||
full_path
|
full_path
|
||||||
.absolutize()
|
.absolutize()
|
||||||
@@ -119,7 +171,7 @@ fn is_path_above_base_dir<P: AsRef<Path> + Debug>(
|
|||||||
.map_or_else(
|
.map_or_else(
|
||||||
|e| Err(anyhow!(e)),
|
|e| Err(anyhow!(e)),
|
||||||
|p| {
|
|p| {
|
||||||
if p.starts_with(base) && p.exists() {
|
if p.starts_with(base) && (new_file || p.exists()) {
|
||||||
Ok(p.into_owned())
|
Ok(p.into_owned())
|
||||||
} else if !p.exists() {
|
} else if !p.exists() {
|
||||||
Err(anyhow!("Path does not exist: {:?}", p))
|
Err(anyhow!("Path does not exist: {:?}", p))
|
||||||
@@ -130,28 +182,89 @@ fn is_path_above_base_dir<P: AsRef<Path> + Debug>(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub trait FileSystemAccess {
|
||||||
|
fn get_files_for_path(&self, path: &str) -> anyhow::Result<Vec<PathBuf>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RealFileSystem {
|
||||||
|
base_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RealFileSystem {
|
||||||
|
pub(crate) fn new(base_path: String) -> RealFileSystem {
|
||||||
|
RealFileSystem { base_path }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystemAccess for RealFileSystem {
|
||||||
|
fn get_files_for_path(&self, path: &str) -> anyhow::Result<Vec<PathBuf>> {
|
||||||
|
is_valid_full_path(&PathBuf::from(&self.base_path), &PathBuf::from(path), false)
|
||||||
|
.map(|path| {
|
||||||
|
debug!("Valid path: {:?}", path);
|
||||||
|
list_files(&path).unwrap_or_default()
|
||||||
|
})
|
||||||
|
.context("Invalid path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
struct FakeFileSystem {
|
||||||
|
files: HashMap<String, Vec<String>>,
|
||||||
|
err: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeFileSystem {
|
||||||
|
fn with_error() -> FakeFileSystem {
|
||||||
|
FakeFileSystem {
|
||||||
|
files: HashMap::new(),
|
||||||
|
err: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(files: HashMap<String, Vec<String>>) -> FakeFileSystem {
|
||||||
|
FakeFileSystem { files, err: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileSystemAccess for FakeFileSystem {
|
||||||
|
fn get_files_for_path(&self, path: &str) -> anyhow::Result<Vec<PathBuf>> {
|
||||||
|
if self.err {
|
||||||
|
Err(anyhow!("Error for test"))
|
||||||
|
} else {
|
||||||
|
if let Some(files) = self.files.get(path) {
|
||||||
|
Ok(files
|
||||||
|
.iter()
|
||||||
|
.map(|p| PathBuf::from(p))
|
||||||
|
.collect::<Vec<PathBuf>>())
|
||||||
|
} else {
|
||||||
|
Ok(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mod api {
|
mod api {
|
||||||
use super::*;
|
use super::*;
|
||||||
use actix::Actor;
|
use actix::Actor;
|
||||||
use actix_web::{
|
use actix_web::{web::Query, HttpResponse};
|
||||||
web::{self, Query},
|
|
||||||
HttpResponse,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
data::{Claims, PhotosResponse, ThumbnailRequest},
|
data::{Claims, PhotosResponse},
|
||||||
testhelpers::BodyReader,
|
testhelpers::BodyReader,
|
||||||
video::StreamActor,
|
video::StreamActor,
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use crate::database::test::in_memory_db_connection;
|
||||||
|
use crate::tags::SqliteTagDao;
|
||||||
|
use actix_web::web::Data;
|
||||||
use std::{fs, sync::Arc};
|
use std::{fs, sync::Arc};
|
||||||
|
|
||||||
fn setup() {
|
fn setup() {
|
||||||
@@ -167,9 +280,9 @@ mod tests {
|
|||||||
exp: 12345,
|
exp: 12345,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request: Query<ThumbnailRequest> = Query::from_query("path=").unwrap();
|
let request: Query<FilesRequest> = Query::from_query("path=").unwrap();
|
||||||
|
|
||||||
let mut temp_photo = std::env::temp_dir();
|
let mut temp_photo = env::temp_dir();
|
||||||
let mut tmp = temp_photo.clone();
|
let mut tmp = temp_photo.clone();
|
||||||
|
|
||||||
tmp.push("test-dir");
|
tmp.push("test-dir");
|
||||||
@@ -177,23 +290,25 @@ mod tests {
|
|||||||
|
|
||||||
temp_photo.push("photo.jpg");
|
temp_photo.push("photo.jpg");
|
||||||
|
|
||||||
fs::File::create(temp_photo.clone()).unwrap();
|
File::create(temp_photo.clone()).unwrap();
|
||||||
|
|
||||||
let response: HttpResponse = list_photos(
|
let response: HttpResponse = list_photos(
|
||||||
claims,
|
claims,
|
||||||
request,
|
request,
|
||||||
web::Data::new(AppState::new(
|
Data::new(AppState::new(
|
||||||
Arc::new(StreamActor {}.start()),
|
Arc::new(StreamActor {}.start()),
|
||||||
String::from("/tmp"),
|
String::from("/tmp"),
|
||||||
String::from("/tmp/thumbs"),
|
String::from("/tmp/thumbs"),
|
||||||
)),
|
)),
|
||||||
|
Data::new(RealFileSystem::new(String::from("/tmp"))),
|
||||||
|
Data::new(Mutex::new(SqliteTagDao::default())),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
let body: PhotosResponse = serde_json::from_str(&response.read_to_str()).unwrap();
|
let body: PhotosResponse = serde_json::from_str(&response.read_to_str()).unwrap();
|
||||||
|
|
||||||
assert_eq!(status, 200);
|
|
||||||
assert!(body.photos.contains(&String::from("photo.jpg")));
|
assert!(body.photos.contains(&String::from("photo.jpg")));
|
||||||
assert!(body.dirs.contains(&String::from("test-dir")));
|
assert!(body.dirs.contains(&String::from("test-dir")));
|
||||||
assert!(body
|
assert!(body
|
||||||
@@ -215,32 +330,153 @@ mod tests {
|
|||||||
exp: 12345,
|
exp: 12345,
|
||||||
};
|
};
|
||||||
|
|
||||||
let request: Query<ThumbnailRequest> = Query::from_query("path=..").unwrap();
|
let request: Query<FilesRequest> = Query::from_query("path=..").unwrap();
|
||||||
|
|
||||||
let response = list_photos(
|
let response = list_photos(
|
||||||
claims,
|
claims,
|
||||||
request,
|
request,
|
||||||
web::Data::new(AppState::new(
|
Data::new(AppState::new(
|
||||||
Arc::new(StreamActor {}.start()),
|
Arc::new(StreamActor {}.start()),
|
||||||
String::from("/tmp"),
|
String::from("/tmp"),
|
||||||
String::from("/tmp/thumbs"),
|
String::from("/tmp/thumbs"),
|
||||||
)),
|
)),
|
||||||
|
Data::new(RealFileSystem::new(String::from("./"))),
|
||||||
|
Data::new(Mutex::new(SqliteTagDao::default())),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
assert_eq!(response.status(), 400);
|
assert_eq!(response.status(), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn get_files_with_tag_any_filter() {
|
||||||
|
setup();
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: String::from("1"),
|
||||||
|
exp: 12345,
|
||||||
|
};
|
||||||
|
|
||||||
|
let request: Query<FilesRequest> = Query::from_query("path=&tag_ids=1,3").unwrap();
|
||||||
|
|
||||||
|
let mut tag_dao = SqliteTagDao::new(in_memory_db_connection());
|
||||||
|
|
||||||
|
let tag1 = tag_dao.create_tag("tag1").unwrap();
|
||||||
|
let _tag2 = tag_dao.create_tag("tag2").unwrap();
|
||||||
|
let tag3 = tag_dao.create_tag("tag3").unwrap();
|
||||||
|
|
||||||
|
let _ = &tag_dao.tag_file("test.jpg", tag1.id).unwrap();
|
||||||
|
let _ = &tag_dao.tag_file("test.jpg", tag3.id).unwrap();
|
||||||
|
|
||||||
|
let mut files = HashMap::new();
|
||||||
|
files.insert(
|
||||||
|
String::from(""),
|
||||||
|
vec![
|
||||||
|
String::from("file1.txt"),
|
||||||
|
String::from("test.jpg"),
|
||||||
|
String::from("some-other.jpg"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let response: HttpResponse = list_photos(
|
||||||
|
claims,
|
||||||
|
request,
|
||||||
|
Data::new(AppState::new(
|
||||||
|
Arc::new(StreamActor {}.start()),
|
||||||
|
String::from(""),
|
||||||
|
String::from("/tmp/thumbs"),
|
||||||
|
)),
|
||||||
|
Data::new(FakeFileSystem::new(files)),
|
||||||
|
Data::new(Mutex::new(tag_dao)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(200, response.status());
|
||||||
|
|
||||||
|
let body: PhotosResponse = serde_json::from_str(&response.read_to_str()).unwrap();
|
||||||
|
assert_eq!(1, body.photos.len());
|
||||||
|
assert!(body.photos.contains(&String::from("test.jpg")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn get_files_with_tag_all_filter() {
|
||||||
|
setup();
|
||||||
|
|
||||||
|
let claims = Claims {
|
||||||
|
sub: String::from("1"),
|
||||||
|
exp: 12345,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut tag_dao = SqliteTagDao::new(in_memory_db_connection());
|
||||||
|
|
||||||
|
let tag1 = tag_dao.create_tag("tag1").unwrap();
|
||||||
|
let _tag2 = tag_dao.create_tag("tag2").unwrap();
|
||||||
|
let tag3 = tag_dao.create_tag("tag3").unwrap();
|
||||||
|
|
||||||
|
let _ = &tag_dao.tag_file("test.jpg", tag1.id).unwrap();
|
||||||
|
let _ = &tag_dao.tag_file("test.jpg", tag3.id).unwrap();
|
||||||
|
|
||||||
|
// Should get filtered since it doesn't have tag3
|
||||||
|
tag_dao.tag_file("some-other.jpg", tag1.id).unwrap();
|
||||||
|
|
||||||
|
let mut files = HashMap::new();
|
||||||
|
files.insert(
|
||||||
|
String::from(""),
|
||||||
|
vec![
|
||||||
|
String::from("file1.txt"),
|
||||||
|
String::from("test.jpg"),
|
||||||
|
String::from("some-other.jpg"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let request: Query<FilesRequest> = Query::from_query(&*format!(
|
||||||
|
"path=&tag_ids={},{}&tag_filter_mode=All",
|
||||||
|
tag1.id, tag3.id
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let response: HttpResponse = list_photos(
|
||||||
|
claims,
|
||||||
|
request,
|
||||||
|
Data::new(AppState::new(
|
||||||
|
Arc::new(StreamActor {}.start()),
|
||||||
|
String::from(""),
|
||||||
|
String::from("/tmp/thumbs"),
|
||||||
|
)),
|
||||||
|
Data::new(FakeFileSystem::new(files)),
|
||||||
|
Data::new(Mutex::new(tag_dao)),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(200, response.status());
|
||||||
|
|
||||||
|
let body: PhotosResponse = serde_json::from_str(&response.read_to_str()).unwrap();
|
||||||
|
assert_eq!(1, body.photos.len());
|
||||||
|
assert!(body.photos.contains(&String::from("test.jpg")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn directory_traversal_test() {
|
fn directory_traversal_test() {
|
||||||
let base = env::temp_dir();
|
let base = env::temp_dir();
|
||||||
assert_eq!(None, is_valid_full_path(&base, "../"));
|
assert_eq!(None, is_valid_full_path(&base, &PathBuf::from("../"), false));
|
||||||
assert_eq!(None, is_valid_full_path(&base, ".."));
|
assert_eq!(None, is_valid_full_path(&base, &PathBuf::from(".."), false));
|
||||||
assert_eq!(None, is_valid_full_path(&base, "fake/../../../"));
|
assert_eq!(
|
||||||
assert_eq!(None, is_valid_full_path(&base, "../../../etc/passwd"));
|
None,
|
||||||
assert_eq!(None, is_valid_full_path(&base, "..//etc/passwd"));
|
is_valid_full_path(&base, &PathBuf::from("fake/../../../"), false)
|
||||||
assert_eq!(None, is_valid_full_path(&base, "../../etc/passwd"));
|
);
|
||||||
|
assert_eq!(
|
||||||
|
None,
|
||||||
|
is_valid_full_path(&base, &PathBuf::from("../../../etc/passwd"), false)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
None,
|
||||||
|
is_valid_full_path(&base, &PathBuf::from("..//etc/passwd"), false)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
None,
|
||||||
|
is_valid_full_path(&base, &PathBuf::from("../../etc/passwd"), false)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -250,7 +486,7 @@ mod tests {
|
|||||||
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, &PathBuf::from("test.png"), false).is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -261,7 +497,7 @@ mod tests {
|
|||||||
let mut test_file = PathBuf::from(&base);
|
let mut test_file = PathBuf::from(&base);
|
||||||
test_file.push(path);
|
test_file.push(path);
|
||||||
|
|
||||||
assert_eq!(None, is_valid_full_path(&base, path));
|
assert_eq!(None, is_valid_full_path(&base, &test_file, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -271,11 +507,11 @@ mod tests {
|
|||||||
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_file.to_str().unwrap()).is_some());
|
assert!(is_valid_full_path(&base, &test_file, false).is_some());
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Some(PathBuf::from("/tmp/test.png")),
|
Some(PathBuf::from("/tmp/test.png")),
|
||||||
is_valid_full_path(&base, "/tmp/test.png")
|
is_valid_full_path(&base, &PathBuf::from("/tmp/test.png"), false)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
131
src/main.rs
131
src/main.rs
@@ -4,10 +4,13 @@ extern crate rayon;
|
|||||||
|
|
||||||
use actix_web::web::Data;
|
use actix_web::web::Data;
|
||||||
use actix_web_prom::PrometheusMetricsBuilder;
|
use actix_web_prom::PrometheusMetricsBuilder;
|
||||||
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
use futures::stream::StreamExt;
|
use futures::stream::StreamExt;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use prometheus::{self, IntGauge};
|
use prometheus::{self, IntGauge};
|
||||||
|
use std::error::Error;
|
||||||
use std::sync::mpsc::channel;
|
use std::sync::mpsc::channel;
|
||||||
|
use std::sync::Mutex;
|
||||||
use std::{collections::HashMap, io::prelude::*};
|
use std::{collections::HashMap, io::prelude::*};
|
||||||
use std::{env, fs::File};
|
use std::{env, fs::File};
|
||||||
use std::{
|
use std::{
|
||||||
@@ -23,6 +26,7 @@ use actix_web::{
|
|||||||
web::{self, BufMut, BytesMut},
|
web::{self, BufMut, BytesMut},
|
||||||
App, HttpRequest, HttpResponse, HttpServer, Responder,
|
App, HttpRequest, HttpResponse, HttpServer, Responder,
|
||||||
};
|
};
|
||||||
|
use diesel::sqlite::Sqlite;
|
||||||
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
|
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
|
||||||
@@ -31,17 +35,22 @@ use log::{debug, error, info};
|
|||||||
use crate::auth::login;
|
use crate::auth::login;
|
||||||
use crate::data::*;
|
use crate::data::*;
|
||||||
use crate::database::*;
|
use crate::database::*;
|
||||||
use crate::files::{is_image_or_video, is_valid_full_path};
|
use crate::files::{is_image_or_video, is_valid_full_path, RealFileSystem};
|
||||||
|
use crate::service::ServiceBuilder;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
use crate::tags::*;
|
||||||
use crate::video::*;
|
use crate::video::*;
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod data;
|
mod data;
|
||||||
mod database;
|
mod database;
|
||||||
|
mod error;
|
||||||
mod files;
|
mod files;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod tags;
|
||||||
mod video;
|
mod video;
|
||||||
|
|
||||||
|
mod service;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod testhelpers;
|
mod testhelpers;
|
||||||
|
|
||||||
@@ -58,14 +67,16 @@ lazy_static! {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!();
|
||||||
|
|
||||||
#[get("/image")]
|
#[get("/image")]
|
||||||
async fn get_image(
|
async fn get_image(
|
||||||
_claims: Claims,
|
_claims: Claims,
|
||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
req: web::Query<ThumbnailRequest>,
|
req: web::Query<ThumbnailRequest>,
|
||||||
app_state: web::Data<AppState>,
|
app_state: Data<AppState>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
if let Some(path) = is_valid_full_path(&app_state.base_path, &req.path) {
|
if let Some(path) = is_valid_full_path(&app_state.base_path, &req.path, false) {
|
||||||
if req.size.is_some() {
|
if req.size.is_some() {
|
||||||
let relative_path = path
|
let relative_path = path
|
||||||
.strip_prefix(&app_state.base_path)
|
.strip_prefix(&app_state.base_path)
|
||||||
@@ -95,9 +106,9 @@ async fn get_image(
|
|||||||
async fn get_file_metadata(
|
async fn get_file_metadata(
|
||||||
_: Claims,
|
_: Claims,
|
||||||
path: web::Query<ThumbnailRequest>,
|
path: web::Query<ThumbnailRequest>,
|
||||||
app_state: web::Data<AppState>,
|
app_state: Data<AppState>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
match is_valid_full_path(&app_state.base_path, &path.path)
|
match is_valid_full_path(&app_state.base_path, &path.path, false)
|
||||||
.ok_or_else(|| ErrorKind::InvalidData.into())
|
.ok_or_else(|| ErrorKind::InvalidData.into())
|
||||||
.and_then(File::open)
|
.and_then(File::open)
|
||||||
.and_then(|file| file.metadata())
|
.and_then(|file| file.metadata())
|
||||||
@@ -117,7 +128,7 @@ async fn get_file_metadata(
|
|||||||
async fn upload_image(
|
async fn upload_image(
|
||||||
_: Claims,
|
_: Claims,
|
||||||
mut payload: mp::Multipart,
|
mut payload: mp::Multipart,
|
||||||
app_state: web::Data<AppState>,
|
app_state: Data<AppState>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let mut file_content: BytesMut = BytesMut::new();
|
let mut file_content: BytesMut = BytesMut::new();
|
||||||
let mut file_name: Option<String> = None;
|
let mut file_name: Option<String> = None;
|
||||||
@@ -145,9 +156,11 @@ async fn upload_image(
|
|||||||
let path = file_path.unwrap_or_else(|| app_state.base_path.clone());
|
let path = file_path.unwrap_or_else(|| app_state.base_path.clone());
|
||||||
if !file_content.is_empty() {
|
if !file_content.is_empty() {
|
||||||
let full_path = PathBuf::from(&path).join(file_name.unwrap());
|
let full_path = PathBuf::from(&path).join(file_name.unwrap());
|
||||||
if let Some(full_path) =
|
if let Some(full_path) = is_valid_full_path(
|
||||||
is_valid_full_path(&app_state.base_path, full_path.to_str().unwrap_or(""))
|
&app_state.base_path,
|
||||||
{
|
&full_path.to_str().unwrap().to_string(),
|
||||||
|
true,
|
||||||
|
) {
|
||||||
if !full_path.is_file() && is_image_or_video(&full_path) {
|
if !full_path.is_file() && is_image_or_video(&full_path) {
|
||||||
let mut file = File::create(full_path).unwrap();
|
let mut file = File::create(full_path).unwrap();
|
||||||
file.write_all(&file_content).unwrap();
|
file.write_all(&file_content).unwrap();
|
||||||
@@ -168,7 +181,7 @@ async fn upload_image(
|
|||||||
#[post("/video/generate")]
|
#[post("/video/generate")]
|
||||||
async fn generate_video(
|
async fn generate_video(
|
||||||
_claims: Claims,
|
_claims: Claims,
|
||||||
app_state: web::Data<AppState>,
|
app_state: Data<AppState>,
|
||||||
body: web::Json<ThumbnailRequest>,
|
body: web::Json<ThumbnailRequest>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let filename = PathBuf::from(&body.path);
|
let filename = PathBuf::from(&body.path);
|
||||||
@@ -176,7 +189,7 @@ async fn generate_video(
|
|||||||
if let Some(name) = filename.file_stem() {
|
if let Some(name) = filename.file_stem() {
|
||||||
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_full_path(&app_state.base_path, &body.path) {
|
if let Some(path) = is_valid_full_path(&app_state.base_path, &body.path, false) {
|
||||||
if let Ok(child) = create_playlist(path.to_str().unwrap(), &playlist).await {
|
if let Ok(child) = create_playlist(path.to_str().unwrap(), &playlist).await {
|
||||||
app_state
|
app_state
|
||||||
.stream_manager
|
.stream_manager
|
||||||
@@ -198,13 +211,14 @@ async fn stream_video(
|
|||||||
request: HttpRequest,
|
request: HttpRequest,
|
||||||
_: Claims,
|
_: Claims,
|
||||||
path: web::Query<ThumbnailRequest>,
|
path: web::Query<ThumbnailRequest>,
|
||||||
app_state: web::Data<AppState>,
|
app_state: Data<AppState>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let playlist = &path.path;
|
let playlist = &path.path;
|
||||||
debug!("Playlist: {}", playlist);
|
debug!("Playlist: {}", playlist);
|
||||||
|
|
||||||
// Extract video playlist dir to dotenv
|
// Extract video playlist dir to dotenv
|
||||||
if !playlist.starts_with("tmp") && is_valid_full_path(&app_state.base_path, playlist) != None {
|
if !playlist.starts_with("tmp") && is_valid_full_path(&app_state.base_path, playlist, false).is_some()
|
||||||
|
{
|
||||||
HttpResponse::BadRequest().finish()
|
HttpResponse::BadRequest().finish()
|
||||||
} else if let Ok(file) = NamedFile::open(playlist) {
|
} else if let Ok(file) = NamedFile::open(playlist) {
|
||||||
file.into_response(&request)
|
file.into_response(&request)
|
||||||
@@ -233,9 +247,15 @@ async fn get_video_part(
|
|||||||
#[get("image/favorites")]
|
#[get("image/favorites")]
|
||||||
async fn favorites(
|
async fn favorites(
|
||||||
claims: Claims,
|
claims: Claims,
|
||||||
favorites_dao: web::Data<Box<dyn FavoriteDao>>,
|
favorites_dao: Data<Mutex<Box<dyn FavoriteDao>>>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
match web::block(move || favorites_dao.get_favorites(claims.sub.parse::<i32>().unwrap())).await
|
match web::block(move || {
|
||||||
|
favorites_dao
|
||||||
|
.lock()
|
||||||
|
.expect("Unable to get FavoritesDao")
|
||||||
|
.get_favorites(claims.sub.parse::<i32>().unwrap())
|
||||||
|
})
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(favorites)) => {
|
Ok(Ok(favorites)) => {
|
||||||
let favorites = favorites
|
let favorites = favorites
|
||||||
@@ -260,12 +280,15 @@ async fn favorites(
|
|||||||
async fn put_add_favorite(
|
async fn put_add_favorite(
|
||||||
claims: Claims,
|
claims: Claims,
|
||||||
body: web::Json<AddFavoriteRequest>,
|
body: web::Json<AddFavoriteRequest>,
|
||||||
favorites_dao: web::Data<Box<dyn FavoriteDao>>,
|
favorites_dao: Data<Mutex<Box<dyn FavoriteDao>>>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
if let Ok(user_id) = claims.sub.parse::<i32>() {
|
if let Ok(user_id) = claims.sub.parse::<i32>() {
|
||||||
let path = body.path.clone();
|
let path = body.path.clone();
|
||||||
match web::block::<_, Result<usize, DbError>>(move || {
|
match web::block::<_, Result<usize, DbError>>(move || {
|
||||||
favorites_dao.add_favorite(user_id, &path)
|
favorites_dao
|
||||||
|
.lock()
|
||||||
|
.expect("Unable to get FavoritesDao")
|
||||||
|
.add_favorite(user_id, &path)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -296,17 +319,20 @@ async fn put_add_favorite(
|
|||||||
async fn delete_favorite(
|
async fn delete_favorite(
|
||||||
claims: Claims,
|
claims: Claims,
|
||||||
body: web::Query<AddFavoriteRequest>,
|
body: web::Query<AddFavoriteRequest>,
|
||||||
favorites_dao: web::Data<Box<dyn FavoriteDao>>,
|
favorites_dao: Data<Mutex<Box<dyn FavoriteDao>>>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
if let Ok(user_id) = claims.sub.parse::<i32>() {
|
if let Ok(user_id) = claims.sub.parse::<i32>() {
|
||||||
let path = body.path.clone();
|
let path = body.path.clone();
|
||||||
web::block(move || {
|
web::block(move || {
|
||||||
favorites_dao.remove_favorite(user_id, path);
|
favorites_dao
|
||||||
|
.lock()
|
||||||
|
.expect("Unable to get favorites dao")
|
||||||
|
.remove_favorite(user_id, path);
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
debug!(
|
info!(
|
||||||
"Removing favorite \"{}\" for userid: {}",
|
"Removing favorite \"{}\" for userid: {}",
|
||||||
body.path, user_id
|
body.path, user_id
|
||||||
);
|
);
|
||||||
@@ -323,34 +349,28 @@ fn create_thumbnails() {
|
|||||||
|
|
||||||
let images = PathBuf::from(dotenv::var("BASE_PATH").unwrap());
|
let images = PathBuf::from(dotenv::var("BASE_PATH").unwrap());
|
||||||
|
|
||||||
walkdir::WalkDir::new(&images)
|
WalkDir::new(&images)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect::<Vec<Result<_, _>>>()
|
.collect::<Vec<Result<_, _>>>()
|
||||||
.into_par_iter()
|
.into_par_iter()
|
||||||
.filter_map(|entry| entry.ok())
|
.filter_map(|entry| entry.ok())
|
||||||
.filter(|entry| entry.file_type().is_file())
|
.filter(|entry| entry.file_type().is_file())
|
||||||
.filter(|entry| {
|
.filter(|entry| {
|
||||||
debug!("{:?}", entry.path());
|
if is_video(entry) {
|
||||||
if let Some(ext) = entry
|
let relative_path = &entry.path().strip_prefix(&images).unwrap();
|
||||||
.path()
|
let thumb_path = Path::new(thumbnail_directory).join(relative_path);
|
||||||
.extension()
|
std::fs::create_dir_all(
|
||||||
.and_then(|ext| ext.to_str().map(|ext| ext.to_lowercase()))
|
thumb_path
|
||||||
{
|
.parent()
|
||||||
if ext == "mp4" || ext == "mov" {
|
.unwrap_or_else(|| panic!("Thumbnail {:?} has no parent?", thumb_path)),
|
||||||
let relative_path = &entry.path().strip_prefix(&images).unwrap();
|
)
|
||||||
let thumb_path = Path::new(thumbnail_directory).join(relative_path);
|
.expect("Error creating directory");
|
||||||
std::fs::create_dir_all(&thumb_path.parent().unwrap())
|
|
||||||
.expect("Error creating directory");
|
|
||||||
|
|
||||||
debug!("Generating video thumbnail: {:?}", thumb_path);
|
debug!("Generating video thumbnail: {:?}", thumb_path);
|
||||||
generate_video_thumbnail(entry.path(), &thumb_path);
|
generate_video_thumbnail(entry.path(), &thumb_path);
|
||||||
false
|
|
||||||
} else {
|
|
||||||
is_image(entry)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
error!("Unable to get extension for file: {:?}", entry.path());
|
|
||||||
false
|
false
|
||||||
|
} else {
|
||||||
|
is_image(entry)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(|entry| {
|
.filter(|entry| {
|
||||||
@@ -371,7 +391,7 @@ fn create_thumbnails() {
|
|||||||
.map(|(image, path)| {
|
.map(|(image, path)| {
|
||||||
let relative_path = &path.strip_prefix(&images).unwrap();
|
let relative_path = &path.strip_prefix(&images).unwrap();
|
||||||
let thumb_path = Path::new(thumbnail_directory).join(relative_path);
|
let thumb_path = Path::new(thumbnail_directory).join(relative_path);
|
||||||
std::fs::create_dir_all(&thumb_path.parent().unwrap())
|
std::fs::create_dir_all(thumb_path.parent().unwrap())
|
||||||
.expect("There was an issue creating directory");
|
.expect("There was an issue creating directory");
|
||||||
debug!("Saving thumbnail: {:?}", thumb_path);
|
debug!("Saving thumbnail: {:?}", thumb_path);
|
||||||
image.save(thumb_path).expect("Failure saving thumbnail");
|
image.save(thumb_path).expect("Failure saving thumbnail");
|
||||||
@@ -420,12 +440,14 @@ fn main() -> std::io::Result<()> {
|
|||||||
dotenv::dotenv().ok();
|
dotenv::dotenv().ok();
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
|
run_migrations(&mut connect()).expect("Failed to run migrations");
|
||||||
|
|
||||||
create_thumbnails();
|
create_thumbnails();
|
||||||
watch_files();
|
watch_files();
|
||||||
|
|
||||||
let system = actix::System::new();
|
let system = actix::System::new();
|
||||||
system.block_on(async {
|
system.block_on(async {
|
||||||
let app_data = web::Data::new(AppState::default());
|
let app_data = Data::new(AppState::default());
|
||||||
|
|
||||||
let labels = HashMap::new();
|
let labels = HashMap::new();
|
||||||
let prometheus = PrometheusMetricsBuilder::new("api")
|
let prometheus = PrometheusMetricsBuilder::new("api")
|
||||||
@@ -445,10 +467,14 @@ fn main() -> std::io::Result<()> {
|
|||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
let user_dao = SqliteUserDao::new();
|
let user_dao = SqliteUserDao::new();
|
||||||
let favorites_dao = SqliteFavoriteDao::new();
|
let favorites_dao = SqliteFavoriteDao::new();
|
||||||
|
let tag_dao = SqliteTagDao::default();
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(middleware::Logger::default())
|
.wrap(middleware::Logger::default())
|
||||||
.service(web::resource("/login").route(web::post().to(login::<SqliteUserDao>)))
|
.service(web::resource("/login").route(web::post().to(login::<SqliteUserDao>)))
|
||||||
.service(web::resource("/photos").route(web::get().to(files::list_photos)))
|
.service(
|
||||||
|
web::resource("/photos")
|
||||||
|
.route(web::get().to(files::list_photos::<SqliteTagDao, RealFileSystem>)),
|
||||||
|
)
|
||||||
.service(get_image)
|
.service(get_image)
|
||||||
.service(upload_image)
|
.service(upload_image)
|
||||||
.service(generate_video)
|
.service(generate_video)
|
||||||
@@ -458,9 +484,16 @@ fn main() -> std::io::Result<()> {
|
|||||||
.service(put_add_favorite)
|
.service(put_add_favorite)
|
||||||
.service(delete_favorite)
|
.service(delete_favorite)
|
||||||
.service(get_file_metadata)
|
.service(get_file_metadata)
|
||||||
|
.add_feature(add_tag_services::<_, SqliteTagDao>)
|
||||||
.app_data(app_data.clone())
|
.app_data(app_data.clone())
|
||||||
.app_data::<Data<SqliteUserDao>>(Data::new(user_dao))
|
.app_data::<Data<RealFileSystem>>(Data::new(RealFileSystem::new(
|
||||||
.app_data::<Data<Box<dyn FavoriteDao>>>(Data::new(Box::new(favorites_dao)))
|
app_data.base_path.clone(),
|
||||||
|
)))
|
||||||
|
.app_data::<Data<Mutex<SqliteUserDao>>>(Data::new(Mutex::new(user_dao)))
|
||||||
|
.app_data::<Data<Mutex<Box<dyn FavoriteDao>>>>(Data::new(Mutex::new(Box::new(
|
||||||
|
favorites_dao,
|
||||||
|
))))
|
||||||
|
.app_data::<Data<Mutex<SqliteTagDao>>>(Data::new(Mutex::new(tag_dao)))
|
||||||
.wrap(prometheus.clone())
|
.wrap(prometheus.clone())
|
||||||
})
|
})
|
||||||
.bind(dotenv::var("BIND_URL").unwrap())?
|
.bind(dotenv::var("BIND_URL").unwrap())?
|
||||||
@@ -470,6 +503,14 @@ fn main() -> std::io::Result<()> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_migrations(
|
||||||
|
connection: &mut impl MigrationHarness<Sqlite>,
|
||||||
|
) -> Result<(), Box<dyn Error + Send + Sync + 'static>> {
|
||||||
|
connection.run_pending_migrations(MIGRATIONS)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn watch_files() {
|
fn watch_files() {
|
||||||
std::thread::spawn(|| {
|
std::thread::spawn(|| {
|
||||||
let (wtx, wrx) = channel();
|
let (wtx, wrx) = channel();
|
||||||
|
|||||||
16
src/service.rs
Normal file
16
src/service.rs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
use actix_web::App;
|
||||||
|
|
||||||
|
pub trait ServiceBuilder<T> {
|
||||||
|
fn add_feature<F>(self, f: F) -> App<T>
|
||||||
|
where
|
||||||
|
F: Fn(App<T>) -> App<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> ServiceBuilder<T> for App<T> {
|
||||||
|
fn add_feature<F>(self, create_feature: F) -> App<T>
|
||||||
|
where
|
||||||
|
F: Fn(App<T>) -> App<T>,
|
||||||
|
{
|
||||||
|
create_feature(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
506
src/tags.rs
Normal file
506
src/tags.rs
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
use crate::{connect, data::AddTagRequest, error::IntoHttpError, schema, Claims, ThumbnailRequest};
|
||||||
|
use actix_web::dev::{ServiceFactory, ServiceRequest};
|
||||||
|
use actix_web::{web, App, HttpResponse, Responder};
|
||||||
|
use anyhow::Context;
|
||||||
|
use chrono::Utc;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use log::{debug, info};
|
||||||
|
use schema::{tagged_photo, tags};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::borrow::BorrowMut;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
pub fn add_tag_services<T, TagD: TagDao + 'static>(app: App<T>) -> App<T>
|
||||||
|
where
|
||||||
|
T: ServiceFactory<ServiceRequest, Config = (), Error = actix_web::Error, InitError = ()>,
|
||||||
|
{
|
||||||
|
app.service(
|
||||||
|
web::resource("image/tags")
|
||||||
|
.route(web::post().to(add_tag::<TagD>))
|
||||||
|
.route(web::get().to(get_tags::<TagD>))
|
||||||
|
.route(web::delete().to(remove_tagged_photo::<TagD>)),
|
||||||
|
)
|
||||||
|
.service(web::resource("image/tags/all").route(web::get().to(get_all_tags::<TagD>)))
|
||||||
|
.service(web::resource("image/tags/batch").route(web::post().to(update_tags::<TagD>)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_tag<D: TagDao>(
|
||||||
|
_: Claims,
|
||||||
|
body: web::Json<AddTagRequest>,
|
||||||
|
tag_dao: web::Data<Mutex<D>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let tag_name = body.tag_name.clone();
|
||||||
|
|
||||||
|
let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao");
|
||||||
|
|
||||||
|
tag_dao
|
||||||
|
.get_all_tags()
|
||||||
|
.and_then(|tags| {
|
||||||
|
if let Some(tag) = tags.iter().find(|t| t.name == tag_name) {
|
||||||
|
Ok(tag.clone())
|
||||||
|
} else {
|
||||||
|
tag_dao.create_tag(&tag_name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.and_then(|tag| tag_dao.tag_file(&body.file_name, tag.id))
|
||||||
|
.map(|_| HttpResponse::Ok())
|
||||||
|
.into_http_internal_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_tags<D: TagDao>(
|
||||||
|
_: Claims,
|
||||||
|
request: web::Query<ThumbnailRequest>,
|
||||||
|
tag_dao: web::Data<Mutex<D>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao");
|
||||||
|
tag_dao
|
||||||
|
.get_tags_for_path(&request.path)
|
||||||
|
.map(|tags| HttpResponse::Ok().json(tags))
|
||||||
|
.into_http_internal_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_all_tags<D: TagDao>(_: Claims, tag_dao: web::Data<Mutex<D>>) -> impl Responder {
|
||||||
|
let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao");
|
||||||
|
tag_dao
|
||||||
|
.get_all_tags()
|
||||||
|
.map(|tags| HttpResponse::Ok().json(tags))
|
||||||
|
.into_http_internal_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn remove_tagged_photo<D: TagDao>(
|
||||||
|
_: Claims,
|
||||||
|
request: web::Json<AddTagRequest>,
|
||||||
|
tag_dao: web::Data<Mutex<D>>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let mut tag_dao = tag_dao.lock().expect("Unable to get TagDao");
|
||||||
|
tag_dao
|
||||||
|
.remove_tag(&request.tag_name, &request.file_name)
|
||||||
|
.map(|result| {
|
||||||
|
if result.is_some() {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
} else {
|
||||||
|
HttpResponse::NotFound()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.into_http_internal_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_tags<D: TagDao>(
|
||||||
|
_: Claims,
|
||||||
|
tag_dao: web::Data<Mutex<D>>,
|
||||||
|
request: web::Json<AddTagsRequest>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let mut dao = tag_dao.lock().expect("Unable to get TagDao");
|
||||||
|
|
||||||
|
dao.get_tags_for_path(&request.file_name)
|
||||||
|
.and_then(|existing_tags| dao.get_all_tags().map(|all| (existing_tags, all)))
|
||||||
|
.map(|(existing_tags, all_tags)| {
|
||||||
|
let tags_to_remove = existing_tags
|
||||||
|
.iter()
|
||||||
|
.filter(|&t| !request.tag_ids.contains(&t.id))
|
||||||
|
.collect::<Vec<&Tag>>();
|
||||||
|
|
||||||
|
for tag in tags_to_remove {
|
||||||
|
info!(
|
||||||
|
"Removing tag {:?} from file: {:?}",
|
||||||
|
tag.name, request.file_name
|
||||||
|
);
|
||||||
|
dao.remove_tag(&tag.name, &request.file_name)
|
||||||
|
.unwrap_or_else(|err| panic!("{:?} Unable to remove tag {:?}", err, &tag.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_tags = all_tags
|
||||||
|
.iter()
|
||||||
|
.filter(|&t| !existing_tags.contains(t) && request.tag_ids.contains(&t.id))
|
||||||
|
.collect::<Vec<&Tag>>();
|
||||||
|
|
||||||
|
for new_tag in new_tags {
|
||||||
|
info!(
|
||||||
|
"Adding tag {:?} to file: {:?}",
|
||||||
|
new_tag.name, request.file_name
|
||||||
|
);
|
||||||
|
|
||||||
|
dao.tag_file(&request.file_name, new_tag.id)
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Unable to tag file {:?} with tag: {:?}",
|
||||||
|
request.file_name, new_tag.name
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse::Ok()
|
||||||
|
})
|
||||||
|
.into_http_internal_err()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Queryable, Clone, Debug, PartialEq)]
|
||||||
|
pub struct Tag {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub created_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Insertable, Clone, Debug)]
|
||||||
|
#[diesel(table_name = tags)]
|
||||||
|
pub struct InsertTag {
|
||||||
|
pub name: String,
|
||||||
|
pub created_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Insertable, Clone, Debug)]
|
||||||
|
#[diesel(table_name = tagged_photo)]
|
||||||
|
pub struct InsertTaggedPhoto {
|
||||||
|
pub tag_id: i32,
|
||||||
|
pub photo_name: String,
|
||||||
|
pub created_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Queryable, Clone, Debug)]
|
||||||
|
pub struct TaggedPhoto {
|
||||||
|
pub id: i32,
|
||||||
|
pub photo_name: String,
|
||||||
|
pub tag_id: i32,
|
||||||
|
pub created_time: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AddTagsRequest {
|
||||||
|
pub file_name: String,
|
||||||
|
pub tag_ids: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait TagDao {
|
||||||
|
fn get_all_tags(&mut self) -> anyhow::Result<Vec<Tag>>;
|
||||||
|
fn get_tags_for_path(&mut self, path: &str) -> anyhow::Result<Vec<Tag>>;
|
||||||
|
fn create_tag(&mut self, name: &str) -> anyhow::Result<Tag>;
|
||||||
|
fn remove_tag(&mut self, tag_name: &str, path: &str) -> anyhow::Result<Option<()>>;
|
||||||
|
fn tag_file(&mut self, path: &str, tag_id: i32) -> anyhow::Result<TaggedPhoto>;
|
||||||
|
fn get_files_with_tag_ids(&mut self, tag_ids: Vec<i32>) -> anyhow::Result<Vec<String>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SqliteTagDao {
|
||||||
|
connection: SqliteConnection,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SqliteTagDao {
|
||||||
|
pub(crate) fn new(connection: SqliteConnection) -> Self {
|
||||||
|
SqliteTagDao { connection }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SqliteTagDao {
|
||||||
|
fn default() -> Self {
|
||||||
|
SqliteTagDao::new(connect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TagDao for SqliteTagDao {
|
||||||
|
fn get_all_tags(&mut self) -> anyhow::Result<Vec<Tag>> {
|
||||||
|
tags::table
|
||||||
|
.get_results(&mut self.connection)
|
||||||
|
.with_context(|| "Unable to get all tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_tags_for_path(&mut self, path: &str) -> anyhow::Result<Vec<Tag>> {
|
||||||
|
debug!("Getting Tags for path: {:?}", path);
|
||||||
|
tags::table
|
||||||
|
.left_join(tagged_photo::table)
|
||||||
|
.filter(tagged_photo::photo_name.eq(&path))
|
||||||
|
.select((tags::id, tags::name, tags::created_time))
|
||||||
|
.get_results::<Tag>(self.connection.borrow_mut())
|
||||||
|
.with_context(|| "Unable to get tags from Sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_tag(&mut self, name: &str) -> anyhow::Result<Tag> {
|
||||||
|
diesel::insert_into(tags::table)
|
||||||
|
.values(InsertTag {
|
||||||
|
name: name.to_string(),
|
||||||
|
created_time: Utc::now().timestamp(),
|
||||||
|
})
|
||||||
|
.execute(&mut self.connection)
|
||||||
|
.with_context(|| format!("Unable to insert tag {:?} in Sqlite", name))
|
||||||
|
.and_then(|_| {
|
||||||
|
info!("Inserted tag: {:?}", name);
|
||||||
|
sql_function! {
|
||||||
|
fn last_insert_rowid() -> diesel::sql_types::Integer;
|
||||||
|
}
|
||||||
|
diesel::select(last_insert_rowid())
|
||||||
|
.get_result::<i32>(&mut self.connection)
|
||||||
|
.with_context(|| "Unable to get last inserted tag from Sqlite")
|
||||||
|
})
|
||||||
|
.and_then(|id| {
|
||||||
|
debug!("Got id: {:?} for inserted tag: {:?}", id, name);
|
||||||
|
tags::table
|
||||||
|
.filter(tags::id.eq(id))
|
||||||
|
.select((tags::id, tags::name, tags::created_time))
|
||||||
|
.get_result::<Tag>(self.connection.borrow_mut())
|
||||||
|
.with_context(|| {
|
||||||
|
format!("Unable to get tagged photo with id: {:?} from Sqlite", id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_tag(&mut self, tag_name: &str, path: &str) -> anyhow::Result<Option<()>> {
|
||||||
|
tags::table
|
||||||
|
.filter(tags::name.eq(tag_name))
|
||||||
|
.get_result::<Tag>(self.connection.borrow_mut())
|
||||||
|
.optional()
|
||||||
|
.with_context(|| format!("Unable to get tag '{}'", tag_name))
|
||||||
|
.and_then(|tag| {
|
||||||
|
if let Some(tag) = tag {
|
||||||
|
diesel::delete(
|
||||||
|
tagged_photo::table
|
||||||
|
.filter(tagged_photo::tag_id.eq(tag.id))
|
||||||
|
.filter(tagged_photo::photo_name.eq(path)),
|
||||||
|
)
|
||||||
|
.execute(&mut self.connection)
|
||||||
|
.with_context(|| format!("Unable to delete tag: '{}'", &tag.name))
|
||||||
|
.map(|_| Some(()))
|
||||||
|
} else {
|
||||||
|
info!("No tag found with name '{}'", tag_name);
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_file(&mut self, path: &str, tag_id: i32) -> anyhow::Result<TaggedPhoto> {
|
||||||
|
diesel::insert_into(tagged_photo::table)
|
||||||
|
.values(InsertTaggedPhoto {
|
||||||
|
tag_id,
|
||||||
|
photo_name: path.to_string(),
|
||||||
|
created_time: Utc::now().timestamp(),
|
||||||
|
})
|
||||||
|
.execute(self.connection.borrow_mut())
|
||||||
|
.with_context(|| format!("Unable to tag file {:?} in sqlite", path))
|
||||||
|
.and_then(|_| {
|
||||||
|
info!("Inserted tagged photo: {:#} -> {:?}", tag_id, path);
|
||||||
|
sql_function! {
|
||||||
|
fn last_insert_rowid() -> diesel::sql_types::Integer;
|
||||||
|
}
|
||||||
|
diesel::select(last_insert_rowid())
|
||||||
|
.get_result::<i32>(&mut self.connection)
|
||||||
|
.with_context(|| "Unable to get last inserted tag from Sqlite")
|
||||||
|
})
|
||||||
|
.and_then(|tagged_id| {
|
||||||
|
tagged_photo::table
|
||||||
|
.find(tagged_id)
|
||||||
|
.first(self.connection.borrow_mut())
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"Error getting inserted tagged photo with id: {:?}",
|
||||||
|
tagged_id
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_files_with_tag_ids(&mut self, tag_ids: Vec<i32>) -> anyhow::Result<Vec<String>> {
|
||||||
|
use diesel::dsl::*;
|
||||||
|
|
||||||
|
tagged_photo::table
|
||||||
|
.filter(tagged_photo::tag_id.eq_any(tag_ids.clone()))
|
||||||
|
.group_by(tagged_photo::photo_name)
|
||||||
|
.select((tagged_photo::photo_name, count(tagged_photo::tag_id)))
|
||||||
|
.having(count(tagged_photo::tag_id).eq(tag_ids.len() as i64))
|
||||||
|
.select(tagged_photo::photo_name)
|
||||||
|
.get_results::<String>(&mut self.connection)
|
||||||
|
.with_context(|| format!("Unable to get Tagged photos with ids: {:?}", tag_ids))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use actix_web::web::{Data, Json};
|
||||||
|
use std::{cell::RefCell, collections::HashMap};
|
||||||
|
|
||||||
|
use diesel::result::Error::NotFound;
|
||||||
|
use log::warn;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct TestTagDao {
|
||||||
|
tags: RefCell<Vec<Tag>>,
|
||||||
|
tagged_photos: RefCell<HashMap<String, Vec<Tag>>>,
|
||||||
|
tag_count: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestTagDao {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
tags: RefCell::new(vec![]),
|
||||||
|
tagged_photos: RefCell::new(HashMap::new()),
|
||||||
|
tag_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TagDao for TestTagDao {
|
||||||
|
fn get_all_tags(&mut self) -> anyhow::Result<Vec<Tag>> {
|
||||||
|
Ok(self.tags.borrow().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_tags_for_path(&mut self, path: &str) -> anyhow::Result<Vec<Tag>> {
|
||||||
|
info!("Getting test tags for: {:?}", path);
|
||||||
|
warn!("Tags for path: {:?}", self.tagged_photos);
|
||||||
|
|
||||||
|
Ok(self
|
||||||
|
.tagged_photos
|
||||||
|
.borrow()
|
||||||
|
.get(path)
|
||||||
|
.unwrap_or(&vec![])
|
||||||
|
.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_tag(&mut self, name: &str) -> anyhow::Result<Tag> {
|
||||||
|
self.tag_count += 1;
|
||||||
|
let tag_id = self.tag_count;
|
||||||
|
|
||||||
|
let tag = Tag {
|
||||||
|
id: tag_id as i32,
|
||||||
|
name: name.to_string(),
|
||||||
|
created_time: Utc::now().timestamp(),
|
||||||
|
};
|
||||||
|
self.tags.borrow_mut().push(tag.clone());
|
||||||
|
|
||||||
|
debug!("Created tag: {:?}", tag);
|
||||||
|
|
||||||
|
Ok(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_tag(&mut self, tag_name: &str, path: &str) -> anyhow::Result<Option<()>> {
|
||||||
|
let mut clone = {
|
||||||
|
let photo_tags = &self.tagged_photos.borrow()[path];
|
||||||
|
photo_tags.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
clone.retain(|t| t.name != tag_name);
|
||||||
|
self.tagged_photos
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(path.to_string(), clone);
|
||||||
|
|
||||||
|
let index = self.tags.borrow().iter().position(|t| t.name == tag_name);
|
||||||
|
if let Some(index) = index {
|
||||||
|
self.tags.borrow_mut().remove(index);
|
||||||
|
Ok(Some(()))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tag_file(&mut self, path: &str, tag_id: i32) -> anyhow::Result<TaggedPhoto> {
|
||||||
|
debug!("Tagging file: {:?} with tag_id: {:?}", path, tag_id);
|
||||||
|
|
||||||
|
if let Some(tag) = self.tags.borrow().iter().find(|t| t.id == tag_id) {
|
||||||
|
debug!("Found tag: {:?}", tag);
|
||||||
|
|
||||||
|
let tagged_photo = TaggedPhoto {
|
||||||
|
id: self.tagged_photos.borrow().len() as i32,
|
||||||
|
tag_id: tag.id,
|
||||||
|
created_time: Utc::now().timestamp(),
|
||||||
|
photo_name: path.to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if self.tagged_photos.borrow().contains_key(path) {
|
||||||
|
let mut photo_tags = self.tagged_photos.borrow()[path].clone();
|
||||||
|
photo_tags.push(tag.clone());
|
||||||
|
|
||||||
|
self.tagged_photos
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(path.to_string(), photo_tags);
|
||||||
|
} else {
|
||||||
|
//TODO: Add to existing tags (? huh)
|
||||||
|
self.tagged_photos
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(path.to_string(), vec![tag.clone()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tagged_photo)
|
||||||
|
} else {
|
||||||
|
Err(NotFound.into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_files_with_tag_ids(&mut self, _tag_ids: Vec<i32>) -> anyhow::Result<Vec<String>> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn add_new_tag_test() {
|
||||||
|
let tag_dao = TestTagDao::new();
|
||||||
|
let claims = Claims::valid_user(String::from("1"));
|
||||||
|
let body = AddTagRequest {
|
||||||
|
file_name: String::from("test.png"),
|
||||||
|
tag_name: String::from("test-tag"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let tag_data = Data::new(Mutex::new(tag_dao));
|
||||||
|
add_tag(claims, web::Json(body), tag_data.clone()).await;
|
||||||
|
|
||||||
|
let mut tag_dao = tag_data.lock().unwrap();
|
||||||
|
let tags = tag_dao.get_all_tags().unwrap();
|
||||||
|
assert_eq!(tags.len(), 1);
|
||||||
|
assert_eq!(tags.first().unwrap().name, "test-tag");
|
||||||
|
let tagged_photos = tag_dao.tagged_photos.borrow();
|
||||||
|
assert_eq!(tagged_photos["test.png"].len(), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn remove_tag_test() {
|
||||||
|
let tag_dao = TestTagDao::new();
|
||||||
|
let claims = Claims::valid_user(String::from("1"));
|
||||||
|
let add_request = AddTagRequest {
|
||||||
|
file_name: String::from("test.png"),
|
||||||
|
tag_name: String::from("test-tag"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let remove_request = AddTagRequest {
|
||||||
|
file_name: String::from("test.png"),
|
||||||
|
tag_name: String::from("test-tag"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let tag_data = Data::new(Mutex::new(tag_dao));
|
||||||
|
add_tag(claims.clone(), web::Json(add_request), tag_data.clone()).await;
|
||||||
|
remove_tagged_photo(claims, web::Json(remove_request), tag_data.clone()).await;
|
||||||
|
|
||||||
|
let mut tag_dao = tag_data.lock().unwrap();
|
||||||
|
let tags = tag_dao.get_all_tags().unwrap();
|
||||||
|
assert!(tags.is_empty());
|
||||||
|
let tagged_photos = tag_dao.tagged_photos.borrow();
|
||||||
|
let previously_added_tagged_photo = tagged_photos.get("test.png").unwrap();
|
||||||
|
assert_eq!(previously_added_tagged_photo.len(), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn replace_tags_keeps_existing_tags_removes_extras_adds_missing_test() {
|
||||||
|
let mut tag_dao = TestTagDao::new();
|
||||||
|
let new_tag = tag_dao.create_tag("Test").unwrap();
|
||||||
|
let new_tag2 = tag_dao.create_tag("Test2").unwrap();
|
||||||
|
let _ = tag_dao.create_tag("Test3").unwrap();
|
||||||
|
|
||||||
|
tag_dao.tag_file("test.jpg", new_tag.id).unwrap();
|
||||||
|
tag_dao.tag_file("test.jpg", new_tag2.id).unwrap();
|
||||||
|
|
||||||
|
let claims = Claims::valid_user(String::from("1"));
|
||||||
|
let tag_data = Data::new(Mutex::new(tag_dao));
|
||||||
|
|
||||||
|
let add_tags_request = AddTagsRequest {
|
||||||
|
tag_ids: vec![1, 3],
|
||||||
|
file_name: String::from("test.jpg"),
|
||||||
|
};
|
||||||
|
|
||||||
|
update_tags(claims, tag_data.clone(), Json(add_tags_request)).await;
|
||||||
|
|
||||||
|
let tag_dao = tag_data.lock().unwrap();
|
||||||
|
let tags_for_test_photo = &tag_dao.tagged_photos.borrow()["test.jpg"];
|
||||||
|
|
||||||
|
assert_eq!(tags_for_test_photo.len(), 2);
|
||||||
|
// ID of 2 was removed and 3 was added
|
||||||
|
assert_eq!(
|
||||||
|
tags_for_test_photo.iter().find(|&t| t.name == "Test2"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ impl TestUserDao {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UserDao for TestUserDao {
|
impl UserDao for TestUserDao {
|
||||||
fn create_user(&self, username: &str, password: &str) -> Option<User> {
|
fn create_user(&mut self, username: &str, password: &str) -> Option<User> {
|
||||||
let u = User {
|
let u = User {
|
||||||
id: (self.user_map.borrow().len() + 1) as i32,
|
id: (self.user_map.borrow().len() + 1) as i32,
|
||||||
username: username.to_string(),
|
username: username.to_string(),
|
||||||
@@ -32,7 +32,7 @@ impl UserDao for TestUserDao {
|
|||||||
Some(u)
|
Some(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_user(&self, user: &str, pass: &str) -> Option<User> {
|
fn get_user(&mut self, user: &str, pass: &str) -> Option<User> {
|
||||||
match self
|
match self
|
||||||
.user_map
|
.user_map
|
||||||
.borrow()
|
.borrow()
|
||||||
@@ -47,7 +47,7 @@ impl UserDao for TestUserDao {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn user_exists(&self, user: &str) -> bool {
|
fn user_exists(&mut self, user: &str) -> bool {
|
||||||
self.user_map
|
self.user_map
|
||||||
.borrow()
|
.borrow()
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
Reference in New Issue
Block a user