Compare commits
4 Commits
feature/im
...
c482912fd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c482912fd8 | ||
|
|
fcc520af1a | ||
|
|
19dea67e3f | ||
|
|
55725e2b3c |
9
.gitignore
vendored
9
.gitignore
vendored
@@ -2,12 +2,3 @@
|
|||||||
database/target
|
database/target
|
||||||
*.db
|
*.db
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Default ignored files
|
|
||||||
.idea/shelf/
|
|
||||||
.idea/workspace.xml
|
|
||||||
# Datasource local storage ignored files
|
|
||||||
.idea/dataSources*
|
|
||||||
.idea/dataSources.local.xml
|
|
||||||
# Editor-based HTTP Client requests
|
|
||||||
.idea/httpRequests/
|
|
||||||
|
|||||||
11
.idea/image-api.iml
generated
11
.idea/image-api.iml
generated
@@ -1,11 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="CPP_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
6
.idea/misc.xml
generated
6
.idea/misc.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="JavaScriptSettings">
|
|
||||||
<option name="languageLevel" value="ES6" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
8
.idea/modules.xml
generated
8
.idea/modules.xml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/image-api.iml" filepath="$PROJECT_DIR$/.idea/image-api.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
6
.idea/vcs.xml
generated
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
||||||
1023
Cargo.lock
generated
1023
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
24
Cargo.toml
@@ -6,33 +6,25 @@ edition = "2018"
|
|||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
lto = true
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
actix = "0.10"
|
|
||||||
actix-web = "3"
|
actix-web = "3"
|
||||||
actix-rt = "1"
|
actix-rt = "1"
|
||||||
actix-files = "0.5"
|
actix-files = "0.4"
|
||||||
actix-multipart = "0.3.0"
|
actix-multipart = "0.3.0"
|
||||||
|
actix-cors="0.5"
|
||||||
futures = "0.3.5"
|
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 = "1.4.5", features = ["sqlite"] }
|
||||||
hmac = "0.11"
|
hmac = "0.7.1"
|
||||||
sha2 = "0.9"
|
sha2 = "0.8.2"
|
||||||
chrono = "0.4"
|
chrono = "0.4.11"
|
||||||
dotenv = "0.15"
|
dotenv = "0.15"
|
||||||
bcrypt = "0.9"
|
bcrypt = "0.8.1"
|
||||||
image = { version = "0.23", default-features = false, features = ["jpeg", "png", "jpeg_rayon"] }
|
image = "0.23.7"
|
||||||
walkdir = "2"
|
walkdir = "2"
|
||||||
rayon = "1.3"
|
rayon = "1.3"
|
||||||
notify = "4.0"
|
notify = "4.0"
|
||||||
|
tokio = "0.2"
|
||||||
path-absolutize = "3.0.6"
|
path-absolutize = "3.0.6"
|
||||||
log="0.4"
|
|
||||||
env_logger="0.8"
|
|
||||||
actix-web-prom = "0.5.1"
|
|
||||||
prometheus = "0.11"
|
|
||||||
lazy_static = "1.1"
|
|
||||||
anyhow = "1.0"
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
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/
|
|
||||||
21
Jenkinsfile
vendored
21
Jenkinsfile
vendored
@@ -1,30 +1,25 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent {
|
agent {
|
||||||
dockerfile {
|
docker {
|
||||||
filename 'Dockerfile.ci'
|
image 'rust:1.48'
|
||||||
args '-v "$PWD:/usr/src/image-api'
|
args "-v '$PWD':/usr/src/image-api"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
stage('build') {
|
stage('build') {
|
||||||
steps {
|
steps {
|
||||||
sh 'cargo build --release'
|
echo $PWD
|
||||||
archiveArtifacts artifacts: '**/target/release/image-api', fingerprint: true
|
sh 'cargo build --release'
|
||||||
|
archiveArtifacts artifacts: '**/target/release/**', fingerprint: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('test') {
|
stage('test') {
|
||||||
steps {
|
steps {
|
||||||
sh 'echo "BASE_PATH=$PWD" > .env'
|
sh 'cargo test'
|
||||||
sh 'cargo test'
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
always {
|
|
||||||
sh 'rm -f .env'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,5 +11,4 @@ They should be defined where the binary is located or above it in an `.env` file
|
|||||||
- `THUMBNAILS` is a path where generated thumbnails should be stored
|
- `THUMBNAILS` is a path where generated thumbnails should be stored
|
||||||
- `BIND_URL` is the url and port to bind to (typically your own IP address)
|
- `BIND_URL` is the url and port to bind to (typically your own IP address)
|
||||||
- `SECRET_KEY` is the *hopefully* random string to sign Tokens with
|
- `SECRET_KEY` is the *hopefully* random string to sign Tokens with
|
||||||
- `RUST_LOG` is one of `off, error, warn, info, debug, trace`, from least to most noisy [error is default]
|
|
||||||
|
|
||||||
|
|||||||
108
src/auth.rs
108
src/auth.rs
@@ -1,108 +0,0 @@
|
|||||||
use actix_web::web::{self, HttpResponse, Json};
|
|
||||||
use actix_web::{post, Responder};
|
|
||||||
use chrono::{Duration, Utc};
|
|
||||||
use jsonwebtoken::{encode, EncodingKey, Header};
|
|
||||||
use log::{debug, error};
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
data::{secret_key, Claims, CreateAccountRequest, LoginRequest, Token},
|
|
||||||
database::UserDao,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[post("/register")]
|
|
||||||
async fn register(
|
|
||||||
user: Json<CreateAccountRequest>,
|
|
||||||
user_dao: web::Data<Box<dyn UserDao>>,
|
|
||||||
) -> impl Responder {
|
|
||||||
if !user.username.is_empty() && user.password.len() > 5 && user.password == user.confirmation {
|
|
||||||
if user_dao.user_exists(&user.username) {
|
|
||||||
HttpResponse::BadRequest()
|
|
||||||
} else if let Some(_user) = user_dao.create_user(&user.username, &user.password) {
|
|
||||||
HttpResponse::Ok()
|
|
||||||
} else {
|
|
||||||
HttpResponse::InternalServerError()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HttpResponse::BadRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn login(
|
|
||||||
creds: Json<LoginRequest>,
|
|
||||||
user_dao: web::Data<Box<dyn UserDao>>,
|
|
||||||
) -> HttpResponse {
|
|
||||||
debug!("Logging in: {}", creds.username);
|
|
||||||
|
|
||||||
if let Some(user) = user_dao.get_user(&creds.username, &creds.password) {
|
|
||||||
let claims = Claims {
|
|
||||||
sub: user.id.to_string(),
|
|
||||||
exp: (Utc::now() + Duration::minutes(1)).timestamp(),
|
|
||||||
};
|
|
||||||
let token = encode(
|
|
||||||
&Header::default(),
|
|
||||||
&claims,
|
|
||||||
&EncodingKey::from_secret(secret_key().as_bytes()),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
HttpResponse::Ok().json(Token { token: &token })
|
|
||||||
} else {
|
|
||||||
error!(
|
|
||||||
"User not found during login or incorrect password: '{}'",
|
|
||||||
creds.username
|
|
||||||
);
|
|
||||||
HttpResponse::NotFound().finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::testhelpers::{BodyReader, TestUserDao};
|
|
||||||
|
|
||||||
#[actix_rt::test]
|
|
||||||
async fn test_login_reports_200_when_user_exists() {
|
|
||||||
let dao = TestUserDao::new();
|
|
||||||
dao.create_user("user", "pass");
|
|
||||||
|
|
||||||
let j = Json(LoginRequest {
|
|
||||||
username: "user".to_string(),
|
|
||||||
password: "pass".to_string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = login(j, web::Data::new(Box::new(dao))).await;
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[actix_rt::test]
|
|
||||||
async fn test_login_returns_token_on_success() {
|
|
||||||
let dao = TestUserDao::new();
|
|
||||||
dao.create_user("user", "password");
|
|
||||||
|
|
||||||
let j = Json(LoginRequest {
|
|
||||||
username: "user".to_string(),
|
|
||||||
password: "password".to_string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = login(j, web::Data::new(Box::new(dao))).await;
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 200);
|
|
||||||
assert!(response.body().read_to_str().contains("\"token\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[actix_rt::test]
|
|
||||||
async fn test_login_reports_404_when_user_does_not_exist() {
|
|
||||||
let dao = TestUserDao::new();
|
|
||||||
dao.create_user("user", "password");
|
|
||||||
|
|
||||||
let j = Json(LoginRequest {
|
|
||||||
username: "doesnotexist".to_string(),
|
|
||||||
password: "password".to_string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
let response = login(j, web::Data::new(Box::new(dao))).await;
|
|
||||||
|
|
||||||
assert_eq!(response.status(), 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
134
src/data/mod.rs
134
src/data/mod.rs
@@ -1,14 +1,9 @@
|
|||||||
use std::{fs, str::FromStr};
|
use std::str::FromStr;
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use log::error;
|
|
||||||
|
|
||||||
|
use actix_web::{dev, Error, FromRequest, http::header, HttpRequest};
|
||||||
use actix_web::error::ErrorUnauthorized;
|
use actix_web::error::ErrorUnauthorized;
|
||||||
use actix_web::{dev, http::header, Error, FromRequest, HttpRequest};
|
|
||||||
use futures::future::{err, ok, Ready};
|
use futures::future::{err, ok, Ready};
|
||||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
use jsonwebtoken::{Algorithm, decode, DecodingKey, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -16,18 +11,14 @@ pub struct Token<'a> {
|
|||||||
pub token: &'a str,
|
pub token: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize)]
|
#[derive(Deserialize, Serialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: String,
|
pub sub: String,
|
||||||
pub exp: i64,
|
pub exp: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn secret_key() -> String {
|
pub fn secret_key() -> String {
|
||||||
if cfg!(test) {
|
dotenv::var("SECRET_KEY").expect("SECRET_KEY env not set!")
|
||||||
String::from("test_key")
|
|
||||||
} else {
|
|
||||||
dotenv::var("SECRET_KEY").expect("SECRET_KEY env not set!")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FromStr for Claims {
|
impl FromStr for Claims {
|
||||||
@@ -37,13 +28,13 @@ 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),
|
||||||
) {
|
) {
|
||||||
Ok(data) => Ok(data.claims),
|
Ok(data) => Ok(data.claims),
|
||||||
Err(other) => {
|
Err(other) => {
|
||||||
error!("DecodeError: {}", other);
|
println!("DecodeError: {}", other);
|
||||||
Err(other)
|
Err(other)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,34 +47,19 @@ 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 {
|
||||||
req.headers()
|
let claims = match req.headers().get(header::AUTHORIZATION) {
|
||||||
.get(header::AUTHORIZATION)
|
Some(header) => Claims::from_str(header.to_str().unwrap_or_else(|_| "")),
|
||||||
.map_or_else(
|
None => Err(jsonwebtoken::errors::Error::from(
|
||||||
|| Err(anyhow!("No authorization header")),
|
jsonwebtoken::errors::ErrorKind::InvalidToken,
|
||||||
|header| {
|
)),
|
||||||
header
|
};
|
||||||
.to_str()
|
|
||||||
.context("Unable to read Authorization header to string")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.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)]
|
if let Ok(claims) = claims {
|
||||||
pub struct PhotosResponse {
|
ok(claims)
|
||||||
pub photos: Vec<String>,
|
} else {
|
||||||
pub dirs: Vec<String>,
|
err(ErrorUnauthorized("Bad token"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -109,75 +85,3 @@ pub struct CreateAccountRequest {
|
|||||||
pub struct AddFavoriteRequest {
|
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)]
|
|
||||||
mod tests {
|
|
||||||
use super::Claims;
|
|
||||||
use jsonwebtoken::errors::ErrorKind;
|
|
||||||
use std::str::FromStr;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_token_from_claims() {
|
|
||||||
let claims = Claims {
|
|
||||||
exp: 16136164790, // 2481-ish
|
|
||||||
sub: String::from("9"),
|
|
||||||
};
|
|
||||||
|
|
||||||
let c = Claims::from_str(
|
|
||||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5IiwiZXhwIjoxNjEzNjE2NDc5MH0.9wwK4l8vhvq55YoueEljMbN_5uVTaAsGLLRPr0AuymE")
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(claims.sub, c.sub);
|
|
||||||
assert_eq!(claims.exp, c.exp);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_expired_token() {
|
|
||||||
let err = Claims::from_str(
|
|
||||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5IiwiZXhwIjoxNn0.eZnfaNfiD54VMbphIqeBICeG9SzAtwNXntLwtTBihjY");
|
|
||||||
|
|
||||||
match err.unwrap_err().into_kind() {
|
|
||||||
ErrorKind::ExpiredSignature => assert!(true),
|
|
||||||
kind => {
|
|
||||||
println!("Unexpected error: {:?}", kind);
|
|
||||||
assert!(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,170 +1,92 @@
|
|||||||
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 dotenv::dotenv;
|
||||||
ops::Deref,
|
|
||||||
sync::{Arc, Mutex},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::database::models::{Favorite, InsertFavorite, InsertUser, User};
|
use crate::database::models::{Favorite, InsertFavorite, InsertUser, User};
|
||||||
|
|
||||||
pub mod models;
|
mod models;
|
||||||
mod schema;
|
mod schema;
|
||||||
|
|
||||||
pub trait UserDao {
|
|
||||||
fn create_user(&self, user: &str, password: &str) -> Option<User>;
|
|
||||||
fn get_user(&self, user: &str, password: &str) -> Option<User>;
|
|
||||||
fn user_exists(&self, user: &str) -> bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SqliteUserDao {
|
|
||||||
connection: SqliteConnection,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SqliteUserDao {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
connection: connect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl UserDao for SqliteUserDao {
|
|
||||||
// TODO: Should probably use Result here
|
|
||||||
fn create_user(&self, user: &str, pass: &str) -> std::option::Option<User> {
|
|
||||||
use schema::users::dsl::*;
|
|
||||||
|
|
||||||
let hashed = hash(pass, DEFAULT_COST);
|
|
||||||
if let Ok(hash) = hashed {
|
|
||||||
diesel::insert_into(users)
|
|
||||||
.values(InsertUser {
|
|
||||||
username: user,
|
|
||||||
password: &hash,
|
|
||||||
})
|
|
||||||
.execute(&self.connection)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
users
|
|
||||||
.filter(username.eq(username))
|
|
||||||
.load::<User>(&self.connection)
|
|
||||||
.unwrap()
|
|
||||||
.first()
|
|
||||||
.cloned()
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_user(&self, user: &str, pass: &str) -> Option<User> {
|
|
||||||
use schema::users::dsl::*;
|
|
||||||
|
|
||||||
match users
|
|
||||||
.filter(username.eq(user))
|
|
||||||
.load::<User>(&self.connection)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.first()
|
|
||||||
{
|
|
||||||
Some(u) if verify(pass, &u.password).unwrap_or(false) => Some(u.clone()),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user_exists(&self, user: &str) -> bool {
|
|
||||||
use schema::users::dsl::*;
|
|
||||||
|
|
||||||
users
|
|
||||||
.filter(username.eq(user))
|
|
||||||
.load::<User>(&self.connection)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.first()
|
|
||||||
.is_some()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn connect() -> SqliteConnection {
|
fn connect() -> SqliteConnection {
|
||||||
|
dotenv().ok();
|
||||||
|
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
// TODO: Should probably use Result here
|
||||||
pub struct DbError {
|
pub fn create_user(user: &str, pass: &str) -> Option<User> {
|
||||||
pub kind: DbErrorKind,
|
use schema::users::dsl::*;
|
||||||
}
|
|
||||||
|
|
||||||
impl DbError {
|
let hashed = hash(pass, DEFAULT_COST);
|
||||||
fn new(kind: DbErrorKind) -> Self {
|
if let Ok(hash) = hashed {
|
||||||
DbError { kind }
|
let connection = connect();
|
||||||
}
|
diesel::insert_into(users)
|
||||||
|
.values(InsertUser {
|
||||||
fn exists() -> Self {
|
username: user,
|
||||||
DbError::new(DbErrorKind::AlreadyExists)
|
password: &hash,
|
||||||
}
|
})
|
||||||
}
|
.execute(&connection)
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
|
||||||
pub enum DbErrorKind {
|
|
||||||
AlreadyExists,
|
|
||||||
InsertError,
|
|
||||||
QueryError,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait FavoriteDao: Sync + Send {
|
|
||||||
fn add_favorite(&self, user_id: i32, favorite_path: &str) -> Result<usize, DbError>;
|
|
||||||
fn remove_favorite(&self, user_id: i32, favorite_path: String);
|
|
||||||
fn get_favorites(&self, user_id: i32) -> Result<Vec<Favorite>, DbError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct SqliteFavoriteDao {
|
|
||||||
connection: Arc<Mutex<SqliteConnection>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SqliteFavoriteDao {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
SqliteFavoriteDao {
|
|
||||||
connection: Arc::new(Mutex::new(connect())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FavoriteDao for SqliteFavoriteDao {
|
|
||||||
fn add_favorite(&self, user_id: i32, favorite_path: &str) -> Result<usize, DbError> {
|
|
||||||
use schema::favorites::dsl::*;
|
|
||||||
|
|
||||||
let connection = self.connection.lock().unwrap();
|
|
||||||
let connection = connection.deref();
|
|
||||||
|
|
||||||
if favorites
|
|
||||||
.filter(userid.eq(user_id).and(path.eq(&favorite_path)))
|
|
||||||
.first::<Favorite>(connection)
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
diesel::insert_into(favorites)
|
|
||||||
.values(InsertFavorite {
|
|
||||||
userid: &user_id,
|
|
||||||
path: favorite_path,
|
|
||||||
})
|
|
||||||
.execute(connection)
|
|
||||||
.map_err(|_| DbError::new(DbErrorKind::InsertError))
|
|
||||||
} else {
|
|
||||||
Err(DbError::exists())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn remove_favorite(&self, user_id: i32, favorite_path: String) {
|
|
||||||
use schema::favorites::dsl::*;
|
|
||||||
|
|
||||||
diesel::delete(favorites)
|
|
||||||
.filter(userid.eq(user_id).and(path.eq(favorite_path)))
|
|
||||||
.execute(self.connection.lock().unwrap().deref())
|
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
|
||||||
|
|
||||||
fn get_favorites(&self, user_id: i32) -> Result<Vec<Favorite>, DbError> {
|
match users
|
||||||
use schema::favorites::dsl::*;
|
.filter(username.eq(user))
|
||||||
|
.load::<User>(&connection)
|
||||||
favorites
|
.unwrap()
|
||||||
.filter(userid.eq(user_id))
|
.first()
|
||||||
.load::<Favorite>(self.connection.lock().unwrap().deref())
|
{
|
||||||
.map_err(|_| DbError::new(DbErrorKind::QueryError))
|
Some(u) => Some(u.clone()),
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_user(user: &str, pass: &str) -> Option<User> {
|
||||||
|
use schema::users::dsl::*;
|
||||||
|
|
||||||
|
match users
|
||||||
|
.filter(username.eq(user))
|
||||||
|
.load::<User>(&connect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.first()
|
||||||
|
{
|
||||||
|
Some(u) if verify(pass, &u.password).unwrap_or(false) => Some(u.clone()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn user_exists(name: &str) -> bool {
|
||||||
|
use schema::users::dsl::*;
|
||||||
|
|
||||||
|
users
|
||||||
|
.filter(username.eq(name))
|
||||||
|
.load::<User>(&connect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.first()
|
||||||
|
.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_favorite(user_id: i32, favorite_path: String) {
|
||||||
|
use schema::favorites::dsl::*;
|
||||||
|
|
||||||
|
let connection = connect();
|
||||||
|
diesel::insert_into(favorites)
|
||||||
|
.values(InsertFavorite {
|
||||||
|
userid: &user_id,
|
||||||
|
path: &favorite_path,
|
||||||
|
})
|
||||||
|
.execute(&connection)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_favorites(user_id: i32) -> Vec<Favorite> {
|
||||||
|
use schema::favorites::dsl::*;
|
||||||
|
|
||||||
|
favorites
|
||||||
|
.filter(userid.eq(user_id))
|
||||||
|
.load::<Favorite>(&connect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|||||||
263
src/files.rs
263
src/files.rs
@@ -1,78 +1,34 @@
|
|||||||
|
use std::ffi::OsStr;
|
||||||
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 async fn list_photos(_: Claims, req: Query<ThumbnailRequest>) -> HttpResponse {
|
pub fn list_files(dir: PathBuf) -> io::Result<Vec<PathBuf>> {
|
||||||
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)?
|
||||||
.filter_map(|res| res.ok())
|
.map(|res| res.unwrap())
|
||||||
.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_image_or_video(path: &Path) -> bool {
|
fn is_image_or_video(path: &Path) -> bool {
|
||||||
let extension = path
|
let extension = &path
|
||||||
.extension()
|
.extension()
|
||||||
.and_then(|p| p.to_str())
|
.unwrap_or_else(|| OsStr::new(""))
|
||||||
.map_or(String::from(""), |p| p.to_lowercase());
|
.to_str()
|
||||||
|
.unwrap_or_else(|| "")
|
||||||
|
.to_lowercase();
|
||||||
|
|
||||||
extension == "png"
|
extension == "png"
|
||||||
|| extension == "jpg"
|
|| extension == "jpg"
|
||||||
@@ -89,42 +45,29 @@ 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> {
|
||||||
debug!("Base: {:?}. Path: {}", base, path);
|
let mut path = PathBuf::from(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);
|
||||||
full_path
|
is_path_above_base_dir(base, &mut full_path).ok()
|
||||||
|
} else if let Ok(path) = is_path_above_base_dir(base, &mut path) {
|
||||||
|
Some(path)
|
||||||
} else {
|
} else {
|
||||||
path
|
None
|
||||||
};
|
|
||||||
|
|
||||||
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) -> anyhow::Result<PathBuf> {
|
fn is_path_above_base_dir(base: &Path, full_path: &mut PathBuf) -> Result<PathBuf, Error> {
|
||||||
full_path
|
full_path.absolutize().and_then(|p| {
|
||||||
.absolutize()
|
if p.starts_with(base) {
|
||||||
.with_context(|| format!("Unable to resolve absolute path: {:?}", full_path))
|
Ok(p.into_owned())
|
||||||
.map_or_else(
|
} else {
|
||||||
|e| Err(anyhow!(e)),
|
Err(io::Error::new(
|
||||||
|p| {
|
io::ErrorKind::Other,
|
||||||
if p.starts_with(base) && p.exists() {
|
"Path below base directory",
|
||||||
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)]
|
||||||
@@ -134,77 +77,6 @@ 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("../"));
|
||||||
@@ -216,24 +88,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_from_path_relative_to_base_test() {
|
fn build_from_relative_path_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!(None, is_valid_full_path(&base, path));
|
assert_eq!(
|
||||||
|
Some(PathBuf::from("/tmp/relative/path/test.png")),
|
||||||
|
is_valid_full_path(&base, path)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -245,41 +115,44 @@ 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/test.png")),
|
Some(PathBuf::from("/tmp/relative/path/test.png")),
|
||||||
is_valid_full_path(&base, "/tmp/test.png")
|
is_valid_full_path(&base, path)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! extension_test {
|
#[test]
|
||||||
($name:ident, $filename:literal) => {
|
fn png_valid_extension_test() {
|
||||||
#[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)));
|
assert!(is_image_or_video(Path::new("image.pNg")));
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension_test!(valid_png, "image.png");
|
#[test]
|
||||||
extension_test!(valid_png_mixed_case, "image.pNg");
|
fn jpg_valid_extension_test() {
|
||||||
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.JPEG")));
|
||||||
|
assert!(is_image_or_video(Path::new("image.jpg")));
|
||||||
|
assert!(is_image_or_video(Path::new("image.JPG")));
|
||||||
|
}
|
||||||
|
|
||||||
extension_test!(valid_jpeg, "image.jpeg");
|
#[test]
|
||||||
extension_test!(valid_jpeg_upper_case, "image.JPEG");
|
fn mp4_valid_extension_test() {
|
||||||
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")));
|
||||||
|
assert!(is_image_or_video(Path::new("image.MP4")));
|
||||||
|
}
|
||||||
|
|
||||||
extension_test!(valid_mp4, "image.mp4");
|
#[test]
|
||||||
extension_test!(valid_mp4_mixed_case, "image.mP4");
|
fn mov_valid_extension_test() {
|
||||||
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");
|
assert!(is_image_or_video(Path::new("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() {
|
||||||
|
|||||||
400
src/main.rs
400
src/main.rs
@@ -2,60 +2,97 @@
|
|||||||
extern crate diesel;
|
extern crate diesel;
|
||||||
extern crate rayon;
|
extern crate rayon;
|
||||||
|
|
||||||
use actix_web_prom::PrometheusMetrics;
|
|
||||||
use futures::stream::StreamExt;
|
|
||||||
use lazy_static::lazy_static;
|
|
||||||
use prometheus::{self, IntGauge};
|
|
||||||
use std::sync::{mpsc::channel, Arc};
|
|
||||||
use std::{collections::HashMap, io::prelude::*};
|
|
||||||
use std::{env, fs::File};
|
|
||||||
use std::{
|
|
||||||
io::ErrorKind,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
use walkdir::{DirEntry, WalkDir};
|
|
||||||
|
|
||||||
use actix::prelude::*;
|
|
||||||
use actix_files::NamedFile;
|
use actix_files::NamedFile;
|
||||||
use actix_multipart as mp;
|
use actix_multipart as mp;
|
||||||
use actix_web::{
|
use actix_web::web::{HttpRequest, HttpResponse, Json};
|
||||||
delete,
|
use actix_web::{get, post, web, App, HttpServer, Responder};
|
||||||
error::BlockingError,
|
use chrono::{Duration, Utc};
|
||||||
get, middleware, post, put,
|
use data::{AddFavoriteRequest, LoginRequest, ThumbnailRequest};
|
||||||
web::{self, BufMut, BytesMut, HttpRequest, HttpResponse},
|
use futures::stream::StreamExt;
|
||||||
App, HttpServer, Responder,
|
use jsonwebtoken::{encode, EncodingKey, Header};
|
||||||
};
|
|
||||||
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
|
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::prelude::*;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::channel;
|
||||||
|
|
||||||
use log::{debug, error, info};
|
use crate::data::{secret_key, Claims, CreateAccountRequest, Token};
|
||||||
|
use crate::database::{add_favorite, create_user, get_favorites, get_user, user_exists};
|
||||||
use crate::auth::login;
|
use crate::files::{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 data;
|
mod data;
|
||||||
mod database;
|
mod database;
|
||||||
mod files;
|
mod files;
|
||||||
mod video;
|
mod video;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[post("/register")]
|
||||||
mod testhelpers;
|
async fn register(user: Json<CreateAccountRequest>) -> impl Responder {
|
||||||
|
if !user.username.is_empty() && user.password.len() > 5 && user.password == user.confirmation {
|
||||||
|
if user_exists(&user.username) {
|
||||||
|
HttpResponse::BadRequest()
|
||||||
|
} else if let Some(_user) = create_user(&user.username, &user.password) {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
} else {
|
||||||
|
HttpResponse::InternalServerError()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
HttpResponse::BadRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lazy_static! {
|
#[post("/login")]
|
||||||
static ref IMAGE_GAUGE: IntGauge = IntGauge::new(
|
async fn login(creds: Json<LoginRequest>) -> impl Responder {
|
||||||
"imageserver_image_total",
|
println!("Logging in: {}", creds.username);
|
||||||
"Count of the images on the server"
|
if let Some(user) = get_user(&creds.username, &creds.password) {
|
||||||
)
|
let claims = Claims {
|
||||||
.unwrap();
|
sub: user.id.to_string(),
|
||||||
static ref VIDEO_GAUGE: IntGauge = IntGauge::new(
|
exp: (Utc::now() + Duration::days(5)).timestamp(),
|
||||||
"imageserver_video_total",
|
};
|
||||||
"Count of the videos on the server"
|
let token = encode(
|
||||||
)
|
&Header::default(),
|
||||||
.unwrap();
|
&claims,
|
||||||
|
&EncodingKey::from_secret(secret_key().as_bytes()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
HttpResponse::Ok().json(Token { token: &token })
|
||||||
|
} else {
|
||||||
|
HttpResponse::NotFound().finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/photos")]
|
||||||
|
async fn list_photos(_claims: Claims, req: Json<ThumbnailRequest>) -> impl Responder {
|
||||||
|
println!("{}", 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 {
|
||||||
|
HttpResponse::BadRequest().finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PhotosResponse<'a> {
|
||||||
|
photos: &'a [String],
|
||||||
|
dirs: &'a [String],
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/image")]
|
#[get("/image")]
|
||||||
@@ -72,7 +109,7 @@ async fn get_image(
|
|||||||
.expect("Error stripping prefix");
|
.expect("Error stripping prefix");
|
||||||
let thumb_path = Path::new(&thumbs).join(relative_path);
|
let thumb_path = Path::new(&thumbs).join(relative_path);
|
||||||
|
|
||||||
debug!("{:?}", thumb_path);
|
println!("{:?}", thumb_path);
|
||||||
if let Ok(file) = NamedFile::open(&thumb_path) {
|
if let Ok(file) = NamedFile::open(&thumb_path) {
|
||||||
file.into_response(&request).unwrap()
|
file.into_response(&request).unwrap()
|
||||||
} else {
|
} else {
|
||||||
@@ -84,44 +121,25 @@ async fn get_image(
|
|||||||
HttpResponse::NotFound().finish()
|
HttpResponse::NotFound().finish()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Bad photos request: {}", req.path);
|
|
||||||
HttpResponse::BadRequest().finish()
|
HttpResponse::BadRequest().finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[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: Vec<_> = Vec::new();
|
||||||
let mut file_name: Option<String> = None;
|
let mut file_name: Option<String> = None;
|
||||||
let mut file_path: Option<String> = None;
|
let mut file_path: Option<String> = None;
|
||||||
|
|
||||||
while let Some(Ok(mut part)) = payload.next().await {
|
while let Some(Ok(mut part)) = payload.next().await {
|
||||||
if let Some(content_type) = part.content_disposition() {
|
if let Some(content_type) = part.content_disposition() {
|
||||||
debug!("{:?}", content_type);
|
println!("{:?}", content_type);
|
||||||
if let Some(filename) = content_type.get_filename() {
|
if let Some(filename) = content_type.get_filename() {
|
||||||
debug!("Name: {:?}", filename);
|
println!("Name: {:?}", filename);
|
||||||
file_name = Some(filename.to_string());
|
file_name = Some(filename.to_string());
|
||||||
|
|
||||||
while let Some(Ok(data)) = part.next().await {
|
while let Some(Ok(data)) = part.next().await {
|
||||||
file_content.put(data);
|
file_content.extend_from_slice(data.as_ref());
|
||||||
}
|
}
|
||||||
} else if content_type.get_name().map_or(false, |name| name == "path") {
|
} else if content_type.get_name().map_or(false, |name| name == "path") {
|
||||||
while let Some(Ok(data)) = part.next().await {
|
while let Some(Ok(data)) = part.next().await {
|
||||||
@@ -137,15 +155,13 @@ async fn upload_image(_: Claims, mut payload: mp::Multipart) -> impl Responder {
|
|||||||
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) = is_valid_path(full_path.to_str().unwrap_or("")) {
|
if let Some(full_path) = is_valid_path(full_path.to_str().unwrap_or("")) {
|
||||||
if !full_path.is_file() && is_image_or_video(&full_path) {
|
if !full_path.is_file() {
|
||||||
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();
|
||||||
} else {
|
} else {
|
||||||
error!("File already exists: {:?}", full_path);
|
|
||||||
return HttpResponse::BadRequest().body("File already exists");
|
return HttpResponse::BadRequest().body("File already exists");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Invalid path for upload: {:?}", full_path);
|
|
||||||
return HttpResponse::BadRequest().body("Path was not valid");
|
return HttpResponse::BadRequest().body("Path was not valid");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -155,28 +171,20 @@ async fn upload_image(_: Claims, mut payload: mp::Multipart) -> impl Responder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[post("/video/generate")]
|
#[post("/video/generate")]
|
||||||
async fn generate_video(
|
async fn generate_video(_claims: Claims, body: web::Json<ThumbnailRequest>) -> impl Responder {
|
||||||
_claims: Claims,
|
|
||||||
data: web::Data<AppState>,
|
|
||||||
body: web::Json<ThumbnailRequest>,
|
|
||||||
) -> impl Responder {
|
|
||||||
let filename = PathBuf::from(&body.path);
|
let filename = PathBuf::from(&body.path);
|
||||||
|
|
||||||
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_path(&body.path) {
|
if let Some(path) = is_valid_path(&body.path) {
|
||||||
if let Ok(child) = create_playlist(path.to_str().unwrap(), &playlist).await {
|
create_playlist(&path.to_str().unwrap(), &playlist);
|
||||||
data.stream_manager
|
|
||||||
.do_send(ProcessMessage(playlist.clone(), child));
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
return HttpResponse::BadRequest().finish();
|
return HttpResponse::BadRequest().finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
HttpResponse::Ok().json(playlist)
|
HttpResponse::Ok().json(playlist)
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to get file name: {:?}", filename);
|
|
||||||
HttpResponse::BadRequest().finish()
|
HttpResponse::BadRequest().finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,11 +196,11 @@ async fn stream_video(
|
|||||||
path: web::Query<ThumbnailRequest>,
|
path: web::Query<ThumbnailRequest>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let playlist = &path.path;
|
let playlist = &path.path;
|
||||||
debug!("Playlist: {}", playlist);
|
println!("Playlist: {}", playlist);
|
||||||
|
|
||||||
// Extract video playlist dir to dotenv
|
// Extract video playlist dir to dotenv
|
||||||
if !playlist.starts_with("tmp") && is_valid_path(playlist) != None {
|
if !playlist.starts_with("tmp") || playlist.contains("..") {
|
||||||
HttpResponse::BadRequest().finish()
|
HttpResponse::NotFound().finish()
|
||||||
} else if let Ok(file) = NamedFile::open(playlist) {
|
} else if let Ok(file) = NamedFile::open(playlist) {
|
||||||
file.into_response(&request).unwrap()
|
file.into_response(&request).unwrap()
|
||||||
} else {
|
} else {
|
||||||
@@ -207,92 +215,38 @@ async fn get_video_part(
|
|||||||
path: web::Path<ThumbnailRequest>,
|
path: web::Path<ThumbnailRequest>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let part = &path.path;
|
let part = &path.path;
|
||||||
debug!("Video part: {}", part);
|
println!("Video part: {}", part);
|
||||||
|
|
||||||
if let Ok(file) = NamedFile::open(String::from("tmp/") + part) {
|
if let Ok(file) = NamedFile::open(String::from("tmp/") + part) {
|
||||||
file.into_response(&request).unwrap()
|
file.into_response(&request).unwrap()
|
||||||
} else {
|
} else {
|
||||||
error!("Video part not found: tmp/{}", part);
|
|
||||||
HttpResponse::NotFound().finish()
|
HttpResponse::NotFound().finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("image/favorites")]
|
#[get("image/favorites")]
|
||||||
async fn favorites(
|
async fn favorites(claims: Claims) -> impl Responder {
|
||||||
claims: Claims,
|
let favorites = get_favorites(claims.sub.parse::<i32>().unwrap())
|
||||||
favorites_dao: web::Data<Box<dyn FavoriteDao>>,
|
.into_iter()
|
||||||
) -> impl Responder {
|
.map(|favorite| favorite.path)
|
||||||
let favorites =
|
.collect::<Vec<String>>();
|
||||||
web::block(move || favorites_dao.get_favorites(claims.sub.parse::<i32>().unwrap()))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.into_iter()
|
|
||||||
.map(|favorite| favorite.path)
|
|
||||||
.collect::<Vec<String>>();
|
|
||||||
|
|
||||||
HttpResponse::Ok().json(PhotosResponse {
|
HttpResponse::Ok().json(PhotosResponse {
|
||||||
photos: favorites,
|
photos: &favorites,
|
||||||
dirs: Vec::new(),
|
dirs: &Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[put("image/favorites")]
|
#[post("image/favorites")]
|
||||||
async fn put_add_favorite(
|
async fn post_add_favorite(claims: Claims, body: web::Json<AddFavoriteRequest>) -> impl Responder {
|
||||||
claims: Claims,
|
|
||||||
body: web::Json<AddFavoriteRequest>,
|
|
||||||
favorites_dao: web::Data<Box<dyn FavoriteDao>>,
|
|
||||||
) -> 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();
|
add_favorite(user_id, body.path.clone());
|
||||||
match web::block::<_, usize, DbError>(move || favorites_dao.add_favorite(user_id, &path))
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Err(BlockingError::Error(e)) if e.kind == DbErrorKind::AlreadyExists => {
|
|
||||||
debug!("Favorite: {} exists for user: {}", &body.path, user_id);
|
|
||||||
HttpResponse::Ok()
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
info!("{:?} {}. for user: {}", e, body.path, user_id);
|
|
||||||
HttpResponse::BadRequest()
|
|
||||||
}
|
|
||||||
Ok(_) => {
|
|
||||||
debug!("Adding favorite \"{}\" for userid: {}", body.path, user_id);
|
|
||||||
HttpResponse::Created()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
error!("Unable to parse sub as i32: {}", claims.sub);
|
|
||||||
HttpResponse::BadRequest()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[delete("image/favorites")]
|
|
||||||
async fn delete_favorite(
|
|
||||||
claims: Claims,
|
|
||||||
body: web::Query<AddFavoriteRequest>,
|
|
||||||
favorites_dao: web::Data<Box<dyn FavoriteDao>>,
|
|
||||||
) -> impl Responder {
|
|
||||||
if let Ok(user_id) = claims.sub.parse::<i32>() {
|
|
||||||
let path = body.path.clone();
|
|
||||||
web::block::<_, _, String>(move || {
|
|
||||||
favorites_dao.remove_favorite(user_id, path);
|
|
||||||
Ok(())
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Removing favorite \"{}\" for userid: {}",
|
|
||||||
body.path, user_id
|
|
||||||
);
|
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to parse sub as i32: {}", claims.sub);
|
|
||||||
HttpResponse::BadRequest()
|
HttpResponse::BadRequest()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_thumbnails() {
|
async fn create_thumbnails() {
|
||||||
let thumbs = &dotenv::var("THUMBNAILS").expect("THUMBNAILS not defined");
|
let thumbs = &dotenv::var("THUMBNAILS").expect("THUMBNAILS not defined");
|
||||||
let thumbnail_directory: &Path = Path::new(thumbs);
|
let thumbnail_directory: &Path = Path::new(thumbs);
|
||||||
|
|
||||||
@@ -303,9 +257,8 @@ fn create_thumbnails() {
|
|||||||
.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| {
|
.filter(|entry| {
|
||||||
debug!("{:?}", entry.path());
|
println!("{:?}", entry.path());
|
||||||
if let Some(ext) = entry
|
if let Some(ext) = entry
|
||||||
.path()
|
.path()
|
||||||
.extension()
|
.extension()
|
||||||
@@ -316,15 +269,12 @@ fn create_thumbnails() {
|
|||||||
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("Error creating directory");
|
.expect("Error creating directory");
|
||||||
|
|
||||||
debug!("Generating video thumbnail: {:?}", thumb_path);
|
|
||||||
generate_video_thumbnail(entry.path(), &thumb_path);
|
generate_video_thumbnail(entry.path(), &thumb_path);
|
||||||
false
|
false
|
||||||
} else {
|
} else {
|
||||||
is_image(entry)
|
ext == "jpg" || ext == "jpeg" || ext == "png" || ext == "nef"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Unable to get extension for file: {:?}", entry.path());
|
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -335,12 +285,7 @@ fn create_thumbnails() {
|
|||||||
!thumb_path.exists()
|
!thumb_path.exists()
|
||||||
})
|
})
|
||||||
.map(|entry| (image::open(entry.path()), entry.path().to_path_buf()))
|
.map(|entry| (image::open(entry.path()), entry.path().to_path_buf()))
|
||||||
.filter(|(img, path)| {
|
.filter(|(img, _)| img.is_ok())
|
||||||
if let Err(e) = img {
|
|
||||||
error!("Unable to open image: {:?}. {}", path, e);
|
|
||||||
}
|
|
||||||
img.is_ok()
|
|
||||||
})
|
|
||||||
.map(|(img, path)| (img.unwrap(), path))
|
.map(|(img, path)| (img.unwrap(), path))
|
||||||
.map(|(image, path)| (image.thumbnail(200, u32::MAX), path))
|
.map(|(image, path)| (image.thumbnail(200, u32::MAX), path))
|
||||||
.map(|(image, path)| {
|
.map(|(image, path)| {
|
||||||
@@ -348,56 +293,19 @@ fn create_thumbnails() {
|
|||||||
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);
|
println!("{:?}", thumb_path);
|
||||||
image.save(thumb_path).expect("Failure saving thumbnail");
|
image.save(thumb_path).expect("Failure saving thumbnail");
|
||||||
})
|
})
|
||||||
.for_each(drop);
|
.for_each(drop);
|
||||||
|
|
||||||
debug!("Finished making thumbnails");
|
println!("Finished");
|
||||||
|
|
||||||
update_media_counts(&images);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_media_counts(media_dir: &Path) {
|
#[actix_rt::main]
|
||||||
let mut image_count = 0;
|
async fn main() -> std::io::Result<()> {
|
||||||
let mut video_count = 0;
|
create_thumbnails().await;
|
||||||
for ref entry in WalkDir::new(media_dir).into_iter().filter_map(|e| e.ok()) {
|
|
||||||
if is_image(entry) {
|
|
||||||
image_count += 1;
|
|
||||||
} else if is_video(entry) {
|
|
||||||
video_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IMAGE_GAUGE.set(image_count);
|
tokio::spawn(async {
|
||||||
VIDEO_GAUGE.set(video_count);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_image(entry: &DirEntry) -> bool {
|
|
||||||
entry
|
|
||||||
.path()
|
|
||||||
.extension()
|
|
||||||
.and_then(|ext| ext.to_str())
|
|
||||||
.map(|ext| ext == "jpg" || ext == "jpeg" || ext == "png" || ext == "nef")
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_video(entry: &DirEntry) -> bool {
|
|
||||||
entry
|
|
||||||
.path()
|
|
||||||
.extension()
|
|
||||||
.and_then(|ext| ext.to_str())
|
|
||||||
.map(|ext| ext == "mp4" || ext == "mov")
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main() -> std::io::Result<()> {
|
|
||||||
dotenv::dotenv().ok();
|
|
||||||
env_logger::init();
|
|
||||||
|
|
||||||
create_thumbnails();
|
|
||||||
|
|
||||||
std::thread::spawn(|| {
|
|
||||||
let (wtx, wrx) = channel();
|
let (wtx, wrx) = channel();
|
||||||
let mut watcher = watcher(wtx, std::time::Duration::from_secs(10)).unwrap();
|
let mut watcher = watcher(wtx, std::time::Duration::from_secs(10)).unwrap();
|
||||||
watcher
|
watcher
|
||||||
@@ -405,87 +313,37 @@ fn main() -> std::io::Result<()> {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let ev = wrx.recv();
|
let ev = wrx.recv_timeout(std::time::Duration::from_secs(5));
|
||||||
if let Ok(event) = ev {
|
match ev {
|
||||||
match event {
|
Ok(event) => {
|
||||||
DebouncedEvent::Create(_) => create_thumbnails(),
|
match event {
|
||||||
DebouncedEvent::Rename(orig, _) | DebouncedEvent::Write(orig) => {
|
DebouncedEvent::Create(_) => create_thumbnails().await,
|
||||||
let image_base_path = PathBuf::from(env::var("BASE_PATH").unwrap());
|
DebouncedEvent::Rename(_, _) => create_thumbnails().await,
|
||||||
let image_relative = orig.strip_prefix(&image_base_path).unwrap();
|
_ => continue,
|
||||||
if let Ok(old_thumbnail) =
|
};
|
||||||
env::var("THUMBNAILS").map(PathBuf::from).map(|mut base| {
|
}
|
||||||
base.push(image_relative);
|
Err(e) => {
|
||||||
base
|
println!("Event: {:?}", e);
|
||||||
})
|
// break;
|
||||||
{
|
}
|
||||||
if let Err(e) = std::fs::remove_file(&old_thumbnail) {
|
|
||||||
error!(
|
|
||||||
"Error removing thumbnail: {}\n{}",
|
|
||||||
old_thumbnail.display(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
info!("Deleted moved thumbnail: {}", old_thumbnail.display());
|
|
||||||
|
|
||||||
create_thumbnails();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DebouncedEvent::Remove(_) => {
|
|
||||||
update_media_counts(&PathBuf::from(env::var("BASE_PATH").unwrap()))
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let system = actix::System::new("image-api");
|
HttpServer::new(|| {
|
||||||
let act = StreamActor {}.start();
|
|
||||||
|
|
||||||
let app_data = web::Data::new(AppState {
|
|
||||||
stream_manager: Arc::new(act),
|
|
||||||
});
|
|
||||||
|
|
||||||
let labels = HashMap::new();
|
|
||||||
let prometheus = PrometheusMetrics::new("", Some("/metrics"), Some(labels));
|
|
||||||
prometheus
|
|
||||||
.registry
|
|
||||||
.register(Box::new(IMAGE_GAUGE.clone()))
|
|
||||||
.unwrap();
|
|
||||||
prometheus
|
|
||||||
.registry
|
|
||||||
.register(Box::new(VIDEO_GAUGE.clone()))
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
HttpServer::new(move || {
|
|
||||||
let user_dao = SqliteUserDao::new();
|
|
||||||
let favorites_dao = SqliteFavoriteDao::new();
|
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(middleware::Logger::default())
|
.service(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)
|
||||||
.service(stream_video)
|
.service(stream_video)
|
||||||
.service(get_video_part)
|
.service(get_video_part)
|
||||||
.service(favorites)
|
.service(favorites)
|
||||||
.service(put_add_favorite)
|
.service(post_add_favorite)
|
||||||
.service(delete_favorite)
|
|
||||||
.service(get_file_metadata)
|
|
||||||
.app_data(app_data.clone())
|
|
||||||
.data::<Box<dyn UserDao>>(Box::new(user_dao))
|
|
||||||
.data::<Box<dyn FavoriteDao>>(Box::new(favorites_dao))
|
|
||||||
.wrap(prometheus.clone())
|
|
||||||
})
|
})
|
||||||
.bind(dotenv::var("BIND_URL").unwrap())?
|
.bind(dotenv::var("BIND_URL").unwrap())?
|
||||||
.bind("localhost:8088")?
|
.bind("localhost:8088")?
|
||||||
.run();
|
.run()
|
||||||
|
.await
|
||||||
system.run()
|
|
||||||
}
|
|
||||||
|
|
||||||
struct AppState {
|
|
||||||
stream_manager: Arc<Addr<StreamActor>>,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
64
src/video.rs
64
src/video.rs
@@ -1,48 +1,13 @@
|
|||||||
use std::io::Result;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::{Child, Command, ExitStatus, Stdio};
|
use std::process::Command;
|
||||||
|
|
||||||
use actix::prelude::*;
|
|
||||||
use log::{debug, trace};
|
|
||||||
|
|
||||||
// ffmpeg -i test.mp4 -c:v h264 -flags +cgop -g 30 -hls_time 3 out.m3u8
|
// ffmpeg -i test.mp4 -c:v h264 -flags +cgop -g 30 -hls_time 3 out.m3u8
|
||||||
// ffmpeg -i "filename.mp4" -preset veryfast -c:v libx264 -f hls -hls_list_size 100 -hls_time 2 -crf 24 -vf scale=1080:-2,setsar=1:1 attempt/vid_out.m3u8
|
// ffmpeg -i "filename.mp4" -preset veryfast -c:v libx264 -f hls -hls_list_size 100 -hls_time 2 -crf 24 -vf scale=1080:-2,setsar=1:1 attempt/vid_out.m3u8
|
||||||
|
|
||||||
pub struct StreamActor;
|
pub fn create_playlist(video_path: &str, playlist_file: &str) {
|
||||||
|
|
||||||
impl Actor for StreamActor {
|
|
||||||
type Context = Context<Self>;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ProcessMessage(pub String, pub Child);
|
|
||||||
|
|
||||||
impl Message for ProcessMessage {
|
|
||||||
type Result = Result<ExitStatus>;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Handler<ProcessMessage> for StreamActor {
|
|
||||||
type Result = Result<ExitStatus>;
|
|
||||||
|
|
||||||
fn handle(&mut self, msg: ProcessMessage, _ctx: &mut Self::Context) -> Self::Result {
|
|
||||||
trace!("Message received");
|
|
||||||
let mut process = msg.1;
|
|
||||||
let result = process.wait();
|
|
||||||
|
|
||||||
debug!(
|
|
||||||
"Finished waiting for: {:?}. Code: {:?}",
|
|
||||||
msg.0,
|
|
||||||
result
|
|
||||||
.as_ref()
|
|
||||||
.map_or(-1, |status| status.code().unwrap_or(-1))
|
|
||||||
);
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn create_playlist(video_path: &str, playlist_file: &str) -> Result<Child> {
|
|
||||||
if Path::new(playlist_file).exists() {
|
if Path::new(playlist_file).exists() {
|
||||||
debug!("Playlist already exists: {}", playlist_file);
|
println!("Playlist already exists: {}", playlist_file);
|
||||||
return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = Command::new("ffmpeg")
|
let result = Command::new("ffmpeg")
|
||||||
@@ -51,7 +16,7 @@ pub async fn create_playlist(video_path: &str, playlist_file: &str) -> Result<Ch
|
|||||||
.arg("-c:v")
|
.arg("-c:v")
|
||||||
.arg("h264")
|
.arg("h264")
|
||||||
.arg("-crf")
|
.arg("-crf")
|
||||||
.arg("21")
|
.arg("23")
|
||||||
.arg("-preset")
|
.arg("-preset")
|
||||||
.arg("veryfast")
|
.arg("veryfast")
|
||||||
.arg("-hls_time")
|
.arg("-hls_time")
|
||||||
@@ -61,22 +26,11 @@ pub async fn create_playlist(video_path: &str, playlist_file: &str) -> Result<Ch
|
|||||||
.arg("-vf")
|
.arg("-vf")
|
||||||
.arg("scale=1080:-2,setsar=1:1")
|
.arg("scale=1080:-2,setsar=1:1")
|
||||||
.arg(playlist_file)
|
.arg(playlist_file)
|
||||||
.stdout(Stdio::null())
|
.output()
|
||||||
.stderr(Stdio::null())
|
.expect("Expected this to work..");
|
||||||
.spawn();
|
|
||||||
|
|
||||||
let start_time = std::time::Instant::now();
|
println!("{:?}", result);
|
||||||
loop {
|
println!("Status: {}", String::from_utf8(result.stdout).unwrap())
|
||||||
actix::clock::delay_for(std::time::Duration::from_secs(1)).await;
|
|
||||||
|
|
||||||
if Path::new(playlist_file).exists()
|
|
||||||
|| std::time::Instant::now() - start_time > std::time::Duration::from_secs(5)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_video_thumbnail(path: &Path, destination: &Path) {
|
pub fn generate_video_thumbnail(path: &Path, destination: &Path) {
|
||||||
|
|||||||
Reference in New Issue
Block a user