Compare commits

..

57 Commits

Author SHA1 Message Date
Cameron Cordes
99dbc6577e Fix build directory permissions
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-12-24 13:20:43 -05:00
Cameron Cordes
5a965d766b Update working directory
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-12-24 11:05:06 -05:00
Cameron Cordes
44f9bcedbd Set User to fix permission issue
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-12-24 11:02:34 -05:00
Cameron Cordes
90625e099e Create src directory
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-12-24 10:45:35 -05:00
Cameron Cordes
d20a7e9c4d Make temporary main.rs for fetch
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-12-24 10:44:18 -05:00
Cameron Cordes
6e39f8c58e Build from Dockerfile to improve caching
Some checks failed
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
This should improve build times with changes that don't change the
dependencies in Cargo.toml.
2021-12-24 10:33:26 -05:00
1e3f33c2d3 Merge pull request 'Update Rust image to 1.55' (#17) from feature/update-ci-rust-155 into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #17
2021-10-13 16:41:15 +00:00
Cameron Cordes
f0e96071be Update Rust image to 1.55
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-10-13 12:12:25 -04:00
652c2d03d5 Merge pull request 'feature/improve-files-endpoint' (#15) from feature/improve-files-endpoint into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #15
2021-10-08 01:01:00 +00:00
Cameron Cordes
d6e4a01c88 Move list_photos to files module
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
Added some tests, refactored the error handling/logging, and refactored
the extension tests.
2021-10-07 20:32:36 -04:00
Cameron Cordes
2c50b4ae2f Add anyhow, Improve Auth token code
Moved test helper code to its own module.
2021-10-07 20:32:36 -04:00
e4dac64776 Merge pull request 'Create file metadata endpoint' (#14) from feature/file-info-api into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #14
2021-07-08 21:28:15 +00:00
Cameron Cordes
0e972509aa Update dependencies
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-07-08 16:53:50 -04:00
Cameron Cordes
8622500a2f Use file metadata to sort files and directories 2021-06-02 08:32:20 -04:00
Cameron Cordes
9d823fdc51 Create file metadata endpoint
This allows retrieving create/modify date as well as file size for any
file in the BASE_PATH.
2021-05-19 08:53:20 -04:00
9a40614d1e Merge pull request 'feature/prometheus-metrics' (#13) from feature/prometheus-metrics into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #13
2021-05-01 05:02:27 +00:00
Cameron Cordes
c5a7675986 Use IntGauge for media counts
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-05-01 00:16:55 -04:00
Cameron Cordes
a79179c5c3 Add Image and Video total gauges 2021-04-30 23:53:10 -04:00
Cameron Cordes
6abc99d9b6 Add PrometheusMetrics 2021-04-05 20:14:34 -04:00
6768140785 Merge pull request 'Make list photos endpoint a get resource' (#12) from feature/photos-as-get into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #12
2021-03-29 20:24:07 +00:00
Cameron Cordes
4249fd319c Make list photos endpoint a get resource
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
The request doesn't modify state and the path can be passed as a query
param so a GET makes more sense.
2021-03-29 15:42:59 -04:00
44a6b582ab Merge pull request 'Revert back to actix 0.10' (#11) from feature/fix-actix-issue into master
Some checks are pending
Core Repos/ImageApi/pipeline/head Build started...
Reviewed-on: #11
2021-03-29 19:35:01 +00:00
Cameron Cordes
1b2aad0f08 Revert back to actix 0.10
Some checks failed
Core Repos/ImageApi/pipeline/head Something is wrong with the build of this commit
Core Repos/ImageApi/pipeline/pr-master This commit looks good
Until Actix Web 4, we need to use an older version of actix so that the
runtime's match up to version 1.
2021-03-29 14:59:42 -04:00
2a07fd18b5 Merge pull request 'Update dependencies' (#10) from feature/update-dependencies into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #10
2021-03-27 22:20:03 +00:00
Cameron Cordes
7474c4d310 Update dependencies
Some checks failed
Core Repos/ImageApi/pipeline/head Something is wrong with the build of this commit
Core Repos/ImageApi/pipeline/pr-master This commit looks good
Update dependencies and remove some features from the image crate that
are unused.
2021-03-27 17:25:52 -04:00
79c79c3b24 Merge pull request 'FavoritesDao for querying, adding and removing favorites' (#9) from feature/favorites-api into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #9
2021-03-27 21:25:25 +00:00
Cameron Cordes
2e97086751 FavoritesDao for querying, adding and removing favorites
Some checks failed
Core Repos/ImageApi/pipeline/head Something is wrong with the build of this commit
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-03-27 16:33:45 -04:00
bc6ce94e5a Merge pull request 'Update build image to Rust 1.51' (#8) from feature/update-to-1.51 into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #8
2021-03-26 12:40:23 +00:00
Cameron Cordes
53cdbabae1 Fix favorites logging parameter order
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-03-25 13:39:16 -04:00
Cameron Cordes
7f534a18bf Bump Jenkins build image to 1.51
Some checks failed
Core Repos/ImageApi/pipeline/head There was a failure building this commit
Core Repos/ImageApi/pipeline/pr-master There was a failure building this commit
2021-03-25 13:18:16 -04:00
Cameron Cordes
31e95dc158 Fix some lint warning and simplify some code 2021-03-25 13:17:58 -04:00
Cameron Cordes
1539255ae0 Remove and replace deleted or moved thumbnails
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
2021-03-17 22:30:27 -04:00
Cameron Cordes
a2a9c27f12 Use Actix worker thread for database operations 2021-03-17 22:30:02 -04:00
Cameron Cordes
3c02bcc8fb Check upload name to make sure its an image or video
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
The upload code should be additionally refactored to probably do a more
comprehensive check of if the file is an image or video.
2021-03-07 22:00:12 -05:00
Cameron Cordes
3611f46004 Fix test name and simplify helper 2021-03-04 16:37:19 -05:00
8ba24baaf7 Merge pull request 'Improve test coverage and logging' (#7) from feature/improve-test-coverage into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #7
2021-03-01 13:18:22 +00:00
Cameron Cordes
e5eb2d9c1f Add info level request logging
Some checks failed
Core Repos/ImageApi/pipeline/head Something is wrong with the build of this commit
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-02-27 11:54:04 -05:00
Cameron Cordes
1c7e54d355 Make playlist generation async
This should allow other requests to be answered while we wait for ffmpeg
to do its thing.
2021-02-27 11:53:29 -05:00
Cameron Cordes
e5ad88abd6 Create UserDao and unit tests for login 2021-02-26 08:48:33 -05:00
Cameron Cordes
64bfb58734 Log when unknown user tries to log in 2021-02-25 17:42:16 -05:00
Cameron Cordes
72e41b99a1 Remove Actix CORS dependency 2021-02-25 14:42:23 -05:00
0ff7c1610a Merge pull request 'feature/update-dependencies' (#6) from feature/update-dependencies into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #6
2021-02-25 16:20:32 +00:00
Cameron Cordes
45aa260d7b Merge branch 'master' into feature/update-dependencies
Some checks failed
Core Repos/ImageApi/pipeline/head Something is wrong with the build of this commit
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-02-25 10:44:28 -05:00
2940bdf056 Merge pull request 'feature/logging' (#5) from feature/logging into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #5
2021-02-25 15:26:45 +00:00
Cameron Cordes
e0d2a14d0f Report path when an image fails to open
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-02-24 22:28:46 -05:00
Cameron Cordes
f9983240df Use log crate for logging instead of println
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-02-24 21:26:11 -05:00
Cameron Cordes
8b5ba9d48c Move auth related methods to their own module 2021-02-18 20:31:29 -05:00
Cameron Cordes
fae7b2a962 Add tests for JWT decoding 2021-02-18 20:31:03 -05:00
Cameron Cordes
b0a9cd6327 Update Actix, tokio and hashing libraries
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
2021-02-15 20:39:14 -05:00
1ad7abb69c Merge pull request 'feature/video-stream-manager' (#4) from feature/video-stream-manager into master
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Reviewed-on: #4
2021-02-13 18:18:12 +00:00
Cameron Cordes
11d1e9600a Use an Actor for the Stream watching
All checks were successful
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-02-11 20:39:07 -05:00
Cameron Cordes
45b4f0cd72 Track relevant .idea files
Some checks failed
Core Repos/ImageApi/pipeline/head Something is wrong with the build of this commit
Core Repos/ImageApi/pipeline/pr-master This commit looks good
2021-02-09 21:32:22 -05:00
Cameron Cordes
b595bdd642 Add VideoStreamManager for keeping track of active streams
The stream manager should help prevent zombie processes and can later be
used for stopping video streams if the user exits the video before
finishing for example.
2021-02-09 21:30:27 -05:00
Cameron Cordes
e4c23c0fe5 Improve video streaming speed
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Instead of waiting for an entire video to stream, we spawn a child
ffmpeg process to start generating the HLS playlist, and once it creates
the first part of the playlist we return the response so the client can
start streaming almost immediately. HTTP Live streaming can handle
playlist updates after the initial playlist is created, although I don't
think the user can skip to the end and skip streaming parts of the
video.
2021-02-08 19:46:38 -05:00
Cameron Cordes
ecd43f776a Do more proper path validation for playlist generation
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
2021-02-08 18:38:30 -05:00
Cameron Cordes
659bad02c9 Fix clippy warnings
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
2021-02-03 17:21:23 -05:00
e46abbf8ee Jenkins CI (#2)
All checks were successful
Core Repos/ImageApi/pipeline/head This commit looks good
Co-authored-by: Cameron Cordes <cameronc.dev@gmail.com>
Reviewed-on: #2
Co-authored-by: cameron <cameron.cordes@pm.me>
Co-committed-by: cameron <cameron.cordes@pm.me>
2021-02-02 21:24:14 +00:00
17 changed files with 1590 additions and 803 deletions

9
.gitignore vendored
View File

@@ -2,3 +2,12 @@
database/target
*.db
.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 Normal file
View File

@@ -0,0 +1,11 @@
<?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 Normal file
View File

@@ -0,0 +1,6 @@
<?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 Normal file
View File

@@ -0,0 +1,8 @@
<?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 Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1025
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,25 +6,33 @@ edition = "2018"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[profile.release]
lto = true
[dependencies]
actix = "0.10"
actix-web = "3"
actix-rt = "1"
actix-files = "0.4"
actix-files = "0.5"
actix-multipart = "0.3.0"
actix-cors="0.5"
futures = "0.3.5"
jsonwebtoken = "7.2.0"
serde = "1"
serde_json = "1"
diesel = { version = "1.4.5", features = ["sqlite"] }
hmac = "0.7.1"
sha2 = "0.8.2"
chrono = "0.4.11"
hmac = "0.11"
sha2 = "0.9"
chrono = "0.4"
dotenv = "0.15"
bcrypt = "0.8.1"
image = "0.23.7"
bcrypt = "0.9"
image = { version = "0.23", default-features = false, features = ["jpeg", "png", "jpeg_rayon"] }
walkdir = "2"
rayon = "1.3"
notify = "4.0"
tokio = "0.2"
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"

7
Dockerfile.ci Normal file
View File

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

17
Jenkinsfile vendored
View File

@@ -1,25 +1,30 @@
pipeline {
agent {
docker {
image 'rust:1.48'
args "-v '$PWD':/usr/src/image-api"
dockerfile {
filename 'Dockerfile.ci'
args '-v "$PWD:/usr/src/image-api'
}
}
stages {
stage('build') {
steps {
echo $PWD
sh 'cargo build --release'
archiveArtifacts artifacts: '**/target/release/**', fingerprint: true
archiveArtifacts artifacts: '**/target/release/image-api', fingerprint: true
}
}
stage('test') {
steps {
sh 'echo "BASE_PATH=$PWD" > .env'
sh 'cargo test'
}
post {
always {
sh 'rm -f .env'
}
}
}
}
}

View File

@@ -11,4 +11,5 @@ 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
- `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
- `RUST_LOG` is one of `off, error, warn, info, debug, trace`, from least to most noisy [error is default]

108
src/auth.rs Normal file
View File

@@ -0,0 +1,108 @@
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);
}
}

View File

@@ -1,9 +1,14 @@
use std::str::FromStr;
use std::{fs, 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::{dev, http::header, Error, FromRequest, HttpRequest};
use futures::future::{err, ok, Ready};
use jsonwebtoken::{Algorithm, decode, DecodingKey, Validation};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
@@ -11,14 +16,18 @@ pub struct Token<'a> {
pub token: &'a str,
}
#[derive(Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize)]
pub struct Claims {
pub sub: String,
pub exp: i64,
}
pub fn secret_key() -> String {
if cfg!(test) {
String::from("test_key")
} else {
dotenv::var("SECRET_KEY").expect("SECRET_KEY env not set!")
}
}
impl FromStr for Claims {
@@ -28,13 +37,13 @@ impl FromStr for Claims {
let token = *(s.split("Bearer ").collect::<Vec<_>>().last().unwrap_or(&""));
match decode::<Claims>(
&token,
token,
&DecodingKey::from_secret(secret_key().as_bytes()),
&Validation::new(Algorithm::HS256),
) {
Ok(data) => Ok(data.claims),
Err(other) => {
println!("DecodeError: {}", other);
error!("DecodeError: {}", other);
Err(other)
}
}
@@ -47,19 +56,34 @@ impl FromRequest for Claims {
type Config = ();
fn from_request(req: &HttpRequest, _payload: &mut dev::Payload) -> Self::Future {
let claims = match req.headers().get(header::AUTHORIZATION) {
Some(header) => Claims::from_str(header.to_str().unwrap_or_else(|_| "")),
None => Err(jsonwebtoken::errors::Error::from(
jsonwebtoken::errors::ErrorKind::InvalidToken,
)),
};
if let Ok(claims) = claims {
ok(claims)
} else {
req.headers()
.get(header::AUTHORIZATION)
.map_or_else(
|| Err(anyhow!("No authorization header")),
|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)]
pub struct PhotosResponse {
pub photos: Vec<String>,
pub dirs: Vec<String>,
}
#[derive(Deserialize)]
@@ -85,3 +109,75 @@ pub struct CreateAccountRequest {
pub struct AddFavoriteRequest {
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)
}
}
}
}

View File

@@ -1,92 +1,170 @@
use bcrypt::{hash, verify, DEFAULT_COST};
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use dotenv::dotenv;
use std::{
ops::Deref,
sync::{Arc, Mutex},
};
use crate::database::models::{Favorite, InsertFavorite, InsertUser, User};
mod models;
pub mod models;
mod schema;
fn connect() -> SqliteConnection {
dotenv().ok();
let db_url = dotenv::var("DATABASE_URL").expect("DATABASE_URL must be set");
SqliteConnection::establish(&db_url).expect("Error connecting to DB")
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;
}
// TODO: Should probably use Result here
pub fn create_user(user: &str, pass: &str) -> Option<User> {
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 {
let connection = connect();
diesel::insert_into(users)
.values(InsertUser {
username: user,
password: &hash,
})
.execute(&connection)
.execute(&self.connection)
.unwrap();
match users
.filter(username.eq(user))
.load::<User>(&connection)
users
.filter(username.eq(username))
.load::<User>(&self.connection)
.unwrap()
.first()
{
Some(u) => Some(u.clone()),
None => None,
}
.cloned()
} else {
None
}
}
}
pub fn get_user(user: &str, pass: &str) -> Option<User> {
fn get_user(&self, user: &str, pass: &str) -> Option<User> {
use schema::users::dsl::*;
match users
.filter(username.eq(user))
.load::<User>(&connect())
.load::<User>(&self.connection)
.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 {
fn user_exists(&self, user: &str) -> bool {
use schema::users::dsl::*;
users
.filter(username.eq(name))
.load::<User>(&connect())
.filter(username.eq(user))
.load::<User>(&self.connection)
.unwrap_or_default()
.first()
.is_some()
}
}
pub fn add_favorite(user_id: i32, favorite_path: String) {
fn connect() -> SqliteConnection {
let db_url = dotenv::var("DATABASE_URL").expect("DATABASE_URL must be set");
SqliteConnection::establish(&db_url).expect("Error connecting to DB")
}
#[derive(Debug)]
pub struct DbError {
pub kind: DbErrorKind,
}
impl DbError {
fn new(kind: DbErrorKind) -> Self {
DbError { kind }
}
fn exists() -> Self {
DbError::new(DbErrorKind::AlreadyExists)
}
}
#[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 = connect();
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,
path: favorite_path,
})
.execute(&connection)
.unwrap();
}
.execute(connection)
.map_err(|_| DbError::new(DbErrorKind::InsertError))
} else {
Err(DbError::exists())
}
}
pub fn get_favorites(user_id: i32) -> Vec<Favorite> {
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();
}
fn get_favorites(&self, user_id: i32) -> Result<Vec<Favorite>, DbError> {
use schema::favorites::dsl::*;
favorites
.filter(userid.eq(user_id))
.load::<Favorite>(&connect())
.unwrap_or_default()
.load::<Favorite>(self.connection.lock().unwrap().deref())
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
}

View File

@@ -1,34 +1,78 @@
use std::ffi::OsStr;
use std::fs::read_dir;
use std::io;
use std::io::Error;
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::*;
pub fn list_files(dir: PathBuf) -> io::Result<Vec<PathBuf>> {
let files = read_dir(dir)?
.map(|res| res.unwrap())
.filter(|entry| is_image_or_video(&entry.path()) || entry.file_type().unwrap().is_dir())
.map(|entry| entry.path())
.map(|path: PathBuf| {
pub async fn list_photos(_: Claims, req: Query<ThumbnailRequest>) -> HttpResponse {
let path = &req.path;
if let Some(path) = is_valid_path(path) {
debug!("Valid path: {:?}", path);
let files = list_files(&path).unwrap_or_default();
let photos = files
.iter()
.filter(|&f| {
f.metadata().map_or_else(
|e| {
error!("Failed getting file metadata: {:?}", e);
false
},
|md| md.is_file(),
)
})
.map(|path: &PathBuf| {
let relative = path
.strip_prefix(dotenv::var("BASE_PATH").unwrap())
.unwrap();
relative.to_path_buf()
})
.map(|f| f.to_str().unwrap().to_string())
.collect::<Vec<String>>();
let dirs = files
.iter()
.filter(|&f| f.metadata().map_or(false, |md| md.is_dir()))
.map(|path: &PathBuf| {
let relative = path
.strip_prefix(dotenv::var("BASE_PATH").unwrap())
.unwrap();
relative.to_path_buf()
})
.map(|f| f.to_str().unwrap().to_string())
.collect::<Vec<String>>();
HttpResponse::Ok().json(PhotosResponse { photos, dirs })
} else {
error!("Bad photos request: {}", req.path);
HttpResponse::BadRequest().finish()
}
}
pub fn list_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
let files = read_dir(dir)?
.filter_map(|res| res.ok())
.filter(|entry| is_image_or_video(&entry.path()) || entry.file_type().unwrap().is_dir())
.map(|entry| entry.path())
.collect::<Vec<PathBuf>>();
Ok(files)
}
fn is_image_or_video(path: &Path) -> bool {
let extension = &path
pub fn is_image_or_video(path: &Path) -> bool {
let extension = path
.extension()
.unwrap_or_else(|| OsStr::new(""))
.to_str()
.unwrap_or_else(|| "")
.to_lowercase();
.and_then(|p| p.to_str())
.map_or(String::from(""), |p| p.to_lowercase());
extension == "png"
|| extension == "jpg"
@@ -45,29 +89,42 @@ pub fn is_valid_path(path: &str) -> Option<PathBuf> {
}
fn is_valid_full_path(base: &Path, path: &str) -> Option<PathBuf> {
let mut path = PathBuf::from(path);
if path.is_relative() {
debug!("Base: {:?}. Path: {}", base, path);
let path = PathBuf::from(path);
let mut path = if path.is_relative() {
let mut full_path = PathBuf::from(base);
full_path.push(&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)
full_path
} else {
path
};
match is_path_above_base_dir(base, &mut path) {
Ok(path) => Some(path),
Err(e) => {
error!("{}", e);
None
}
}
}
fn is_path_above_base_dir(base: &Path, full_path: &mut PathBuf) -> Result<PathBuf, Error> {
full_path.absolutize().and_then(|p| {
if p.starts_with(base) {
fn is_path_above_base_dir(base: &Path, full_path: &mut PathBuf) -> anyhow::Result<PathBuf> {
full_path
.absolutize()
.with_context(|| format!("Unable to resolve absolute path: {:?}", full_path))
.map_or_else(
|e| Err(anyhow!(e)),
|p| {
if p.starts_with(base) && p.exists() {
Ok(p.into_owned())
} else if !p.exists() {
Err(anyhow!("Path does not exist: {:?}", p))
} else {
Err(io::Error::new(
io::ErrorKind::Other,
"Path below base directory",
))
Err(anyhow!("Path above base directory"))
}
})
},
)
}
#[cfg(test)]
@@ -77,6 +134,77 @@ mod tests {
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]
fn directory_traversal_test() {
assert_eq!(None, is_valid_path("../"));
@@ -88,22 +216,24 @@ mod tests {
}
#[test]
fn build_from_relative_path_test() {
fn build_from_path_relative_to_base_test() {
let base = env::temp_dir();
let mut test_file = PathBuf::from(&base);
test_file.push("test.png");
File::create(test_file).unwrap();
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 mut test_file = PathBuf::from(&base);
test_file.push(path);
assert_eq!(
Some(PathBuf::from("/tmp/relative/path/test.png")),
is_valid_full_path(&base, path)
);
assert_eq!(None, is_valid_full_path(&base, path));
}
#[test]
@@ -115,44 +245,41 @@ mod tests {
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!(
Some(PathBuf::from("/tmp/relative/path/test.png")),
is_valid_full_path(&base, path)
Some(PathBuf::from("/tmp/test.png")),
is_valid_full_path(&base, "/tmp/test.png")
);
}
macro_rules! extension_test {
($name:ident, $filename:literal) => {
#[test]
fn png_valid_extension_test() {
assert!(is_image_or_video(Path::new("image.png")));
assert!(is_image_or_video(Path::new("image.PNG")));
assert!(is_image_or_video(Path::new("image.pNg")));
fn $name() {
assert!(is_image_or_video(Path::new($filename)));
}
};
}
#[test]
fn jpg_valid_extension_test() {
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_png, "image.png");
extension_test!(valid_png_mixed_case, "image.pNg");
extension_test!(valid_png_upper_case, "image.PNG");
#[test]
fn mp4_valid_extension_test() {
assert!(is_image_or_video(Path::new("image.mp4")));
assert!(is_image_or_video(Path::new("image.mP4")));
assert!(is_image_or_video(Path::new("image.MP4")));
}
extension_test!(valid_jpeg, "image.jpeg");
extension_test!(valid_jpeg_upper_case, "image.JPEG");
extension_test!(valid_jpg, "image.jpg");
extension_test!(valid_jpg_upper_case, "image.JPG");
#[test]
fn mov_valid_extension_test() {
assert!(is_image_or_video(Path::new("image.mov")));
assert!(is_image_or_video(Path::new("image.MOV")));
assert!(is_image_or_video(Path::new("image.MoV")));
}
extension_test!(valid_mp4, "image.mp4");
extension_test!(valid_mp4_mixed_case, "image.mP4");
extension_test!(valid_mp4_upper_case, "image.MP4");
extension_test!(valid_mov, "image.mov");
extension_test!(valid_mov_mixed_case, "image.mOV");
extension_test!(valid_mov_upper_case, "image.MOV");
extension_test!(valid_nef, "image.nef");
extension_test!(valid_nef_mixed_case, "image.nEF");
extension_test!(valid_nef_upper_case, "image.NEF");
#[test]
fn hidden_file_not_valid_test() {

View File

@@ -2,97 +2,60 @@
extern crate diesel;
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_multipart as mp;
use actix_web::web::{HttpRequest, HttpResponse, Json};
use actix_web::{get, post, web, App, HttpServer, Responder};
use chrono::{Duration, Utc};
use data::{AddFavoriteRequest, LoginRequest, ThumbnailRequest};
use futures::stream::StreamExt;
use jsonwebtoken::{encode, EncodingKey, Header};
use actix_web::{
delete,
error::BlockingError,
get, middleware, post, put,
web::{self, BufMut, BytesMut, HttpRequest, HttpResponse},
App, HttpServer, Responder,
};
use notify::{watcher, DebouncedEvent, RecursiveMode, Watcher};
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 crate::data::{secret_key, Claims, CreateAccountRequest, Token};
use crate::database::{add_favorite, create_user, get_favorites, get_user, user_exists};
use crate::files::{is_valid_path, list_files};
use log::{debug, error, info};
use crate::auth::login;
use crate::data::*;
use crate::database::*;
use crate::files::{is_image_or_video, is_valid_path};
use crate::video::*;
mod auth;
mod data;
mod database;
mod files;
mod video;
#[post("/register")]
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()
}
}
#[cfg(test)]
mod testhelpers;
#[post("/login")]
async fn login(creds: Json<LoginRequest>) -> impl Responder {
println!("Logging in: {}", creds.username);
if let Some(user) = get_user(&creds.username, &creds.password) {
let claims = Claims {
sub: user.id.to_string(),
exp: (Utc::now() + Duration::days(5)).timestamp(),
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret_key().as_bytes()),
lazy_static! {
static ref IMAGE_GAUGE: IntGauge = IntGauge::new(
"imageserver_image_total",
"Count of the images on the server"
)
.unwrap();
static ref VIDEO_GAUGE: IntGauge = IntGauge::new(
"imageserver_video_total",
"Count of the videos on the server"
)
.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")]
@@ -109,7 +72,7 @@ async fn get_image(
.expect("Error stripping prefix");
let thumb_path = Path::new(&thumbs).join(relative_path);
println!("{:?}", thumb_path);
debug!("{:?}", thumb_path);
if let Ok(file) = NamedFile::open(&thumb_path) {
file.into_response(&request).unwrap()
} else {
@@ -121,25 +84,44 @@ async fn get_image(
HttpResponse::NotFound().finish()
}
} else {
error!("Bad photos request: {}", req.path);
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")]
async fn upload_image(_: Claims, mut payload: mp::Multipart) -> impl Responder {
let mut file_content: Vec<_> = Vec::new();
let mut file_content: BytesMut = BytesMut::new();
let mut file_name: Option<String> = None;
let mut file_path: Option<String> = None;
while let Some(Ok(mut part)) = payload.next().await {
if let Some(content_type) = part.content_disposition() {
println!("{:?}", content_type);
debug!("{:?}", content_type);
if let Some(filename) = content_type.get_filename() {
println!("Name: {:?}", filename);
debug!("Name: {:?}", filename);
file_name = Some(filename.to_string());
while let Some(Ok(data)) = part.next().await {
file_content.extend_from_slice(data.as_ref());
file_content.put(data);
}
} else if content_type.get_name().map_or(false, |name| name == "path") {
while let Some(Ok(data)) = part.next().await {
@@ -155,13 +137,15 @@ async fn upload_image(_: Claims, mut payload: mp::Multipart) -> impl Responder {
if !file_content.is_empty() {
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 !full_path.is_file() {
if !full_path.is_file() && is_image_or_video(&full_path) {
let mut file = File::create(full_path).unwrap();
file.write_all(&file_content).unwrap();
} else {
error!("File already exists: {:?}", full_path);
return HttpResponse::BadRequest().body("File already exists");
}
} else {
error!("Invalid path for upload: {:?}", full_path);
return HttpResponse::BadRequest().body("Path was not valid");
}
} else {
@@ -171,20 +155,28 @@ async fn upload_image(_: Claims, mut payload: mp::Multipart) -> impl Responder {
}
#[post("/video/generate")]
async fn generate_video(_claims: Claims, body: web::Json<ThumbnailRequest>) -> impl Responder {
async fn generate_video(
_claims: Claims,
data: web::Data<AppState>,
body: web::Json<ThumbnailRequest>,
) -> impl Responder {
let filename = PathBuf::from(&body.path);
if let Some(name) = filename.file_stem() {
let filename = name.to_str().expect("Filename should convert to string");
let playlist = format!("tmp/{}.m3u8", filename);
if let Some(path) = is_valid_path(&body.path) {
create_playlist(&path.to_str().unwrap(), &playlist);
if let Ok(child) = create_playlist(path.to_str().unwrap(), &playlist).await {
data.stream_manager
.do_send(ProcessMessage(playlist.clone(), child));
}
} else {
return HttpResponse::BadRequest().finish();
}
HttpResponse::Ok().json(playlist)
} else {
error!("Unable to get file name: {:?}", filename);
HttpResponse::BadRequest().finish()
}
}
@@ -196,11 +188,11 @@ async fn stream_video(
path: web::Query<ThumbnailRequest>,
) -> impl Responder {
let playlist = &path.path;
println!("Playlist: {}", playlist);
debug!("Playlist: {}", playlist);
// Extract video playlist dir to dotenv
if !playlist.starts_with("tmp") || playlist.contains("..") {
HttpResponse::NotFound().finish()
if !playlist.starts_with("tmp") && is_valid_path(playlist) != None {
HttpResponse::BadRequest().finish()
} else if let Ok(file) = NamedFile::open(playlist) {
file.into_response(&request).unwrap()
} else {
@@ -215,38 +207,92 @@ async fn get_video_part(
path: web::Path<ThumbnailRequest>,
) -> impl Responder {
let part = &path.path;
println!("Video part: {}", part);
debug!("Video part: {}", part);
if let Ok(file) = NamedFile::open(String::from("tmp/") + part) {
file.into_response(&request).unwrap()
} else {
error!("Video part not found: tmp/{}", part);
HttpResponse::NotFound().finish()
}
}
#[get("image/favorites")]
async fn favorites(claims: Claims) -> impl Responder {
let favorites = get_favorites(claims.sub.parse::<i32>().unwrap())
async fn favorites(
claims: Claims,
favorites_dao: web::Data<Box<dyn FavoriteDao>>,
) -> impl Responder {
let favorites =
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 {
photos: &favorites,
dirs: &Vec::new(),
photos: favorites,
dirs: Vec::new(),
})
}
#[post("image/favorites")]
async fn post_add_favorite(claims: Claims, body: web::Json<AddFavoriteRequest>) -> impl Responder {
#[put("image/favorites")]
async fn put_add_favorite(
claims: Claims,
body: web::Json<AddFavoriteRequest>,
favorites_dao: web::Data<Box<dyn FavoriteDao>>,
) -> impl Responder {
if let Ok(user_id) = claims.sub.parse::<i32>() {
add_favorite(user_id, body.path.clone());
let path = 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()
}
}
async fn create_thumbnails() {
#[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()
} else {
error!("Unable to parse sub as i32: {}", claims.sub);
HttpResponse::BadRequest()
}
}
fn create_thumbnails() {
let thumbs = &dotenv::var("THUMBNAILS").expect("THUMBNAILS not defined");
let thumbnail_directory: &Path = Path::new(thumbs);
@@ -257,8 +303,9 @@ async fn create_thumbnails() {
.collect::<Vec<Result<_, _>>>()
.into_par_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file())
.filter(|entry| {
println!("{:?}", entry.path());
debug!("{:?}", entry.path());
if let Some(ext) = entry
.path()
.extension()
@@ -269,12 +316,15 @@ async fn create_thumbnails() {
let thumb_path = Path::new(thumbnail_directory).join(relative_path);
std::fs::create_dir_all(&thumb_path.parent().unwrap())
.expect("Error creating directory");
debug!("Generating video thumbnail: {:?}", thumb_path);
generate_video_thumbnail(entry.path(), &thumb_path);
false
} else {
ext == "jpg" || ext == "jpeg" || ext == "png" || ext == "nef"
is_image(entry)
}
} else {
error!("Unable to get extension for file: {:?}", entry.path());
false
}
})
@@ -285,7 +335,12 @@ async fn create_thumbnails() {
!thumb_path.exists()
})
.map(|entry| (image::open(entry.path()), entry.path().to_path_buf()))
.filter(|(img, _)| img.is_ok())
.filter(|(img, path)| {
if let Err(e) = img {
error!("Unable to open image: {:?}. {}", path, e);
}
img.is_ok()
})
.map(|(img, path)| (img.unwrap(), path))
.map(|(image, path)| (image.thumbnail(200, u32::MAX), path))
.map(|(image, path)| {
@@ -293,19 +348,56 @@ async fn create_thumbnails() {
let thumb_path = Path::new(thumbnail_directory).join(relative_path);
std::fs::create_dir_all(&thumb_path.parent().unwrap())
.expect("There was an issue creating directory");
println!("{:?}", thumb_path);
debug!("Saving thumbnail: {:?}", thumb_path);
image.save(thumb_path).expect("Failure saving thumbnail");
})
.for_each(drop);
println!("Finished");
debug!("Finished making thumbnails");
update_media_counts(&images);
}
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
create_thumbnails().await;
fn update_media_counts(media_dir: &Path) {
let mut image_count = 0;
let mut video_count = 0;
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;
}
}
tokio::spawn(async {
IMAGE_GAUGE.set(image_count);
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 mut watcher = watcher(wtx, std::time::Duration::from_secs(10)).unwrap();
watcher
@@ -313,37 +405,87 @@ async fn main() -> std::io::Result<()> {
.unwrap();
loop {
let ev = wrx.recv_timeout(std::time::Duration::from_secs(5));
match ev {
Ok(event) => {
let ev = wrx.recv();
if let Ok(event) = ev {
match event {
DebouncedEvent::Create(_) => create_thumbnails().await,
DebouncedEvent::Rename(_, _) => create_thumbnails().await,
DebouncedEvent::Create(_) => create_thumbnails(),
DebouncedEvent::Rename(orig, _) | DebouncedEvent::Write(orig) => {
let image_base_path = PathBuf::from(env::var("BASE_PATH").unwrap());
let image_relative = orig.strip_prefix(&image_base_path).unwrap();
if let Ok(old_thumbnail) =
env::var("THUMBNAILS").map(PathBuf::from).map(|mut base| {
base.push(image_relative);
base
})
{
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,
};
}
Err(e) => {
println!("Event: {:?}", e);
// break;
}
}
}
});
HttpServer::new(|| {
let system = actix::System::new("image-api");
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()
.service(login)
.service(list_photos)
.wrap(middleware::Logger::default())
.service(web::resource("/login").route(web::post().to(login)))
.service(web::resource("/photos").route(web::get().to(files::list_photos)))
.service(get_image)
.service(upload_image)
.service(generate_video)
.service(stream_video)
.service(get_video_part)
.service(favorites)
.service(post_add_favorite)
.service(put_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("localhost:8088")?
.run()
.await
.run();
system.run()
}
struct AppState {
stream_manager: Arc<Addr<StreamActor>>,
}

86
src/testhelpers.rs Normal file
View File

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

View File

@@ -1,13 +1,48 @@
use std::io::Result;
use std::path::Path;
use std::process::Command;
use std::process::{Child, Command, ExitStatus, Stdio};
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 "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 fn create_playlist(video_path: &str, playlist_file: &str) {
pub struct StreamActor;
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() {
println!("Playlist already exists: {}", playlist_file);
return;
debug!("Playlist already exists: {}", playlist_file);
return Err(std::io::Error::from(std::io::ErrorKind::AlreadyExists));
}
let result = Command::new("ffmpeg")
@@ -16,7 +51,7 @@ pub fn create_playlist(video_path: &str, playlist_file: &str) {
.arg("-c:v")
.arg("h264")
.arg("-crf")
.arg("23")
.arg("21")
.arg("-preset")
.arg("veryfast")
.arg("-hls_time")
@@ -26,11 +61,22 @@ pub fn create_playlist(video_path: &str, playlist_file: &str) {
.arg("-vf")
.arg("scale=1080:-2,setsar=1:1")
.arg(playlist_file)
.output()
.expect("Expected this to work..");
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
println!("{:?}", result);
println!("Status: {}", String::from_utf8(result.stdout).unwrap())
let start_time = std::time::Instant::now();
loop {
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) {