2022-12-20 02:22:32 +02:00
import re
2022-12-20 14:28:50 +02:00
import pickle
2022-12-20 02:22:32 +02:00
from secrets import token_urlsafe
2023-01-05 17:38:00 +02:00
from shutil import move
2023-02-14 15:31:56 +02:00
from threading import Thread
2023-02-16 15:55:03 +02:00
from typing import Union
2023-02-18 01:47:00 +02:00
from uuid import uuid4
2022-12-20 02:22:32 +02:00
from magic import Magic
2023-01-25 17:02:28 +02:00
from datetime import datetime , timedelta , timezone
2022-12-20 23:24:35 +02:00
from os import makedirs , path , remove , system
2023-02-18 01:19:46 +02:00
from pydantic import ValidationError
from classes . exceptions import AccessTokenInvalidError , AlbumNameNotFoundError , PhotoNotFoundError , PhotoSearchQueryEmptyError , SearchPageInvalidError , SearchTokenInvalidError
2023-02-16 15:55:03 +02:00
from classes . models import Photo , PhotoPublic , SearchResultsPhoto
2023-01-02 16:08:46 +02:00
from modules . exif_reader import extract_location
2022-12-20 02:22:32 +02:00
from modules . hasher import get_phash , get_duplicates
2022-12-20 23:24:35 +02:00
from modules . scheduler import scheduler
2023-02-18 01:19:46 +02:00
from modules . security import ALGORITHM , SECRET_KEY , TokenData , User , create_access_token , get_current_active_user , get_user
2022-12-20 14:28:50 +02:00
from modules . app import app
2022-12-20 02:22:32 +02:00
from modules . database import col_photos , col_albums , col_tokens
2023-01-12 15:43:17 +02:00
from pymongo import DESCENDING
2022-12-20 02:22:32 +02:00
from bson . objectid import ObjectId
from bson . errors import InvalidId
2023-02-14 15:31:56 +02:00
from plum . exceptions import UnpackError
2023-02-18 01:19:46 +02:00
from jose import JWTError , jwt
2022-12-20 02:22:32 +02:00
2023-02-16 16:44:54 +02:00
from fastapi import UploadFile , Security
2022-12-20 02:22:32 +02:00
from fastapi . responses import UJSONResponse , Response
2023-02-18 01:19:46 +02:00
from fastapi . exceptions import HTTPException
from starlette . status import HTTP_204_NO_CONTENT , HTTP_401_UNAUTHORIZED , HTTP_409_CONFLICT
2022-12-20 02:22:32 +02:00
2022-12-20 23:24:35 +02:00
from modules . utils import logWrite
async def compress_image ( image_path : str ) :
image_type = Magic ( mime = True ) . from_file ( image_path )
if image_type not in [ " image/jpeg " , " image/png " ] :
logWrite ( f " Not compressing { image_path } because its mime is ' { image_type } ' " )
return
size_before = path . getsize ( image_path ) / 1024
if image_type == " image/jpeg " :
2023-02-14 15:31:56 +02:00
task = Thread ( target = system , kwargs = { " command " : f ' jpegoptim " { image_path } " -o --max=55 -p --strip-none ' } )
2022-12-20 23:24:35 +02:00
elif image_type == " image/png " :
2023-02-14 15:31:56 +02:00
task = Thread ( target = system , kwargs = { " command " : f ' optipng -o3 " { image_path } " ' } )
else :
return
task . start ( )
logWrite ( f " Compressing ' { path . split ( image_path ) [ - 1 ] } ' ... " )
task . join ( )
2022-12-20 23:24:35 +02:00
size_after = path . getsize ( image_path ) / 1024
logWrite ( f " Compressed ' { path . split ( image_path ) [ - 1 ] } ' from { size_before } Kb to { size_after } Kb " )
2023-02-16 16:44:54 +02:00
photo_post_responses = {
2023-02-16 15:55:03 +02:00
404 : AlbumNameNotFoundError ( " name " ) . openapi ,
409 : {
" description " : " Image Duplicates Found " ,
" content " : {
" application/json " : {
" example " : {
" detail " : " Image duplicates found. Pass ' ignore_duplicates=true ' to ignore. " ,
" duplicates " : [
" string "
2023-02-18 01:19:46 +02:00
] ,
" access_token " : " string "
2023-02-16 15:55:03 +02:00
}
}
}
}
}
2023-02-16 16:44:54 +02:00
@app.post ( " /albums/ {album} /photos " , description = " Upload a photo to album " , response_class = UJSONResponse , response_model = Photo , responses = photo_post_responses )
2023-01-17 15:39:21 +02:00
async def photo_upload ( file : UploadFile , album : str , ignore_duplicates : bool = False , compress : bool = True , caption : Union [ str , None ] = None , current_user : User = Security ( get_current_active_user , scopes = [ " photos.write " ] ) ) :
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
if col_albums . find_one ( { " user " : current_user . user , " name " : album } ) is None :
2023-02-16 15:55:03 +02:00
raise AlbumNameNotFoundError ( album )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
makedirs ( path . join ( " data " , " users " , current_user . user , " albums " , album ) , exist_ok = True )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
filename = file . filename
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
if path . exists ( path . join ( " data " , " users " , current_user . user , " albums " , album , file . filename ) ) :
base_name = file . filename . split ( " . " ) [ : - 1 ]
extension = file . filename . split ( " . " ) [ - 1 ]
filename = " . " . join ( base_name ) + f " _ { int ( datetime . now ( ) . timestamp ( ) ) } . " + extension
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
with open ( path . join ( " data " , " users " , current_user . user , " albums " , album , filename ) , " wb " ) as f :
f . write ( await file . read ( ) )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
file_hash = await get_phash ( path . join ( " data " , " users " , current_user . user , " albums " , album , filename ) )
duplicates = await get_duplicates ( file_hash , album )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
if len ( duplicates ) > 0 and ignore_duplicates is False :
2023-02-18 01:19:46 +02:00
duplicates_ids = [ ]
for entry in duplicates :
duplicates_ids . append ( entry [ " id " ] )
2023-02-18 01:47:00 +02:00
access_token = create_access_token ( data = { " sub " : current_user . user , " scopes " : [ " me " , " photos.read " ] , " allowed " : duplicates_ids } , expires_delta = timedelta ( hours = 1 ) )
access_token_short = uuid4 ( ) . hex [ : 12 ] . lower ( )
col_tokens . insert_one ( { " short " : access_token_short , " access_token " : access_token , " photos " : duplicates_ids } )
2022-12-20 02:22:32 +02:00
return UJSONResponse (
{
2022-12-20 14:28:50 +02:00
" detail " : " Image duplicates found. Pass ' ignore_duplicates=true ' to ignore. " ,
2023-02-18 01:19:46 +02:00
" duplicates " : duplicates ,
2023-02-18 01:47:00 +02:00
" access_token " : access_token_short
2022-12-20 14:28:50 +02:00
} ,
status_code = HTTP_409_CONFLICT
2022-12-20 02:22:32 +02:00
)
2023-02-14 15:31:56 +02:00
try :
coords = extract_location ( path . join ( " data " , " users " , current_user . user , " albums " , album , filename ) )
except ( UnpackError , ValueError ) :
coords = {
" lng " : 0.0 ,
" lat " : 0.0 ,
" alt " : 0.0
}
2023-01-10 16:23:49 +02:00
uploaded = col_photos . insert_one (
{
" user " : current_user . user ,
" album " : album ,
" hash " : file_hash ,
" filename " : filename ,
" dates " : {
2023-01-25 17:02:28 +02:00
" uploaded " : datetime . now ( tz = timezone . utc ) ,
" modified " : datetime . now ( tz = timezone . utc )
2023-01-10 16:23:49 +02:00
} ,
" location " : [
coords [ " lng " ] ,
coords [ " lat " ] ,
coords [ " alt " ]
2023-01-17 15:39:21 +02:00
] ,
" caption " : caption
2023-01-10 16:23:49 +02:00
}
)
2022-12-20 02:22:32 +02:00
2022-12-20 23:24:35 +02:00
if compress is True :
scheduler . add_job ( compress_image , trigger = " date " , run_date = datetime . now ( ) + timedelta ( seconds = 1 ) , args = [ path . join ( " data " , " users " , current_user . user , " albums " , album , filename ) ] )
2022-12-20 14:28:50 +02:00
return UJSONResponse (
{
" id " : uploaded . inserted_id . __str__ ( ) ,
" album " : album ,
" hash " : file_hash ,
" filename " : filename
}
)
2022-12-20 02:22:32 +02:00
2023-02-18 01:19:46 +02:00
photo_get_token_responses = {
401 : AccessTokenInvalidError ( ) . openapi ,
404 : PhotoNotFoundError ( " id " ) . openapi
}
2023-02-18 01:47:00 +02:00
@app.get ( " /token/photo/ {token} " , description = " Get a photo by id " , responses = photo_get_token_responses )
async def photo_get_token ( token : str , id : int ) :
db_entry = col_tokens . find_one ( { " short " : token } )
if db_entry is None :
raise AccessTokenInvalidError ( )
token = db_entry [ " access_token " ]
id = db_entry [ " photos " ] [ id ]
2023-02-18 01:19:46 +02:00
try :
payload = jwt . decode ( token , SECRET_KEY , algorithms = [ ALGORITHM ] )
user : str = payload . get ( " sub " )
if user is None :
raise AccessTokenInvalidError ( )
token_scopes = payload . get ( " scopes " , [ ] )
token_data = TokenData ( scopes = token_scopes , user = user )
except ( JWTError , ValidationError ) as exp :
print ( exp , flush = True )
raise AccessTokenInvalidError ( )
user = get_user ( user = token_data . user )
if id not in payload . get ( " allowed " , [ ] ) :
raise AccessTokenInvalidError ( )
try :
image = col_photos . find_one ( { " _id " : ObjectId ( id ) } )
if image is None :
raise InvalidId ( id )
except InvalidId :
raise PhotoNotFoundError ( id )
image_path = path . join ( " data " , " users " , user . user , " albums " , image [ " album " ] , image [ " filename " ] )
mime = Magic ( mime = True ) . from_file ( image_path )
with open ( image_path , " rb " ) as f : image_file = f . read ( )
return Response ( image_file , media_type = mime )
2023-02-16 16:44:54 +02:00
photo_get_responses = {
404 : PhotoNotFoundError ( " id " ) . openapi
}
@app.get ( " /photos/ {id} " , description = " Get a photo by id " , responses = photo_get_responses )
2023-02-14 15:31:56 +02:00
async def photo_get ( id : str , current_user : User = Security ( get_current_active_user , scopes = [ " photos.read " ] ) ) :
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
try :
image = col_photos . find_one ( { " _id " : ObjectId ( id ) } )
if image is None :
raise InvalidId ( id )
except InvalidId :
2023-02-16 15:55:03 +02:00
raise PhotoNotFoundError ( id )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
image_path = path . join ( " data " , " users " , current_user . user , " albums " , image [ " album " ] , image [ " filename " ] )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
mime = Magic ( mime = True ) . from_file ( image_path )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
with open ( image_path , " rb " ) as f : image_file = f . read ( )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
return Response ( image_file , media_type = mime )
2022-12-20 02:22:32 +02:00
2023-02-16 15:55:03 +02:00
photo_move_responses = {
404 : PhotoNotFoundError ( " id " ) . openapi
}
@app.put ( " /photos/ {id} " , description = " Move a photo to another album " , response_model = PhotoPublic , responses = photo_move_responses )
2023-01-05 17:38:00 +02:00
async def photo_move ( id : str , album : str , current_user : User = Security ( get_current_active_user , scopes = [ " photos.write " ] ) ) :
try :
image = col_photos . find_one ( { " _id " : ObjectId ( id ) } )
if image is None :
raise InvalidId ( id )
except InvalidId :
2023-02-16 15:55:03 +02:00
raise PhotoNotFoundError ( id )
2023-01-05 17:38:00 +02:00
if col_albums . find_one ( { " user " : current_user . user , " name " : album } ) is None :
2023-02-16 15:55:03 +02:00
raise AlbumNameNotFoundError ( album )
2023-01-05 17:38:00 +02:00
if path . exists ( path . join ( " data " , " users " , current_user . user , " albums " , album , image [ " filename " ] ) ) :
base_name = image [ " filename " ] . split ( " . " ) [ : - 1 ]
extension = image [ " filename " ] . split ( " . " ) [ - 1 ]
filename = " . " . join ( base_name ) + f " _ { int ( datetime . now ( ) . timestamp ( ) ) } . " + extension
else :
filename = image [ " filename " ]
2023-01-25 17:02:28 +02:00
col_photos . find_one_and_update ( { " _id " : ObjectId ( id ) } , { " $set " : { " album " : album , " filename " : filename , " dates.modified " : datetime . now ( tz = timezone . utc ) } } )
2023-01-05 17:38:00 +02:00
move (
path . join ( " data " , " users " , current_user . user , " albums " , image [ " album " ] , image [ " filename " ] ) ,
path . join ( " data " , " users " , current_user . user , " albums " , album , filename )
)
return UJSONResponse (
{
" id " : image [ " _id " ] . __str__ ( ) ,
2023-02-16 15:55:03 +02:00
" caption " : image [ " caption " ] ,
2023-01-05 17:38:00 +02:00
" filename " : filename
}
)
2023-02-16 15:55:03 +02:00
photo_patch_responses = {
404 : PhotoNotFoundError ( " id " ) . openapi
}
@app.patch ( " /photos/ {id} " , description = " Change properties of a photo " , response_model = PhotoPublic , responses = photo_patch_responses )
2023-01-17 15:39:21 +02:00
async def photo_patch ( id : str , caption : str , current_user : User = Security ( get_current_active_user , scopes = [ " photos.write " ] ) ) :
try :
image = col_photos . find_one ( { " _id " : ObjectId ( id ) } )
if image is None :
raise InvalidId ( id )
except InvalidId :
2023-02-16 15:55:03 +02:00
raise PhotoNotFoundError ( id )
2023-01-17 15:39:21 +02:00
2023-01-25 17:02:28 +02:00
col_photos . find_one_and_update ( { " _id " : ObjectId ( id ) } , { " $set " : { " caption " : caption , " dates.modified " : datetime . now ( tz = timezone . utc ) } } )
2023-01-17 15:39:21 +02:00
return UJSONResponse (
{
" id " : image [ " _id " ] . __str__ ( ) ,
2023-02-16 15:55:03 +02:00
" caption " : caption ,
" filename " : image [ " filename " ]
2023-01-17 15:39:21 +02:00
}
)
2023-02-16 15:55:03 +02:00
photo_delete_responses = {
404 : PhotoNotFoundError ( " id " ) . openapi
}
@app.delete ( " /photos/ {id} " , description = " Delete a photo by id " , status_code = HTTP_204_NO_CONTENT , responses = photo_delete_responses )
2022-12-20 14:28:50 +02:00
async def photo_delete ( id : str , current_user : User = Security ( get_current_active_user , scopes = [ " photos.write " ] ) ) :
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
try :
image = col_photos . find_one_and_delete ( { " _id " : ObjectId ( id ) } )
if image is None :
raise InvalidId ( id )
except InvalidId :
2023-02-16 15:55:03 +02:00
raise PhotoNotFoundError ( id )
2022-12-20 02:22:32 +02:00
2022-12-21 00:59:47 +02:00
album = col_albums . find_one ( { " name " : image [ " album " ] } )
if album is not None and album [ " cover " ] == image [ " _id " ] . __str__ ( ) :
col_albums . update_one ( { " name " : image [ " album " ] } , { " $set " : { " cover " : None } } )
2022-12-20 14:28:50 +02:00
remove ( path . join ( " data " , " users " , current_user . user , " albums " , image [ " album " ] , image [ " filename " ] ) )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
return Response ( status_code = HTTP_204_NO_CONTENT )
2022-12-20 02:22:32 +02:00
2023-02-16 16:44:54 +02:00
photo_find_responses = {
2023-02-16 15:55:03 +02:00
400 : SearchPageInvalidError ( ) . openapi ,
2023-02-16 16:44:54 +02:00
404 : AlbumNameNotFoundError ( " name " ) . openapi ,
422 : PhotoSearchQueryEmptyError ( ) . openapi
2023-02-16 15:55:03 +02:00
}
2023-02-16 16:44:54 +02:00
@app.get ( " /albums/ {album} /photos " , description = " Find a photo by filename " , response_class = UJSONResponse , response_model = SearchResultsPhoto , responses = photo_find_responses )
2023-01-17 15:39:21 +02:00
async def photo_find ( album : str , q : Union [ str , None ] = None , caption : Union [ str , None ] = None , page : int = 1 , page_size : int = 100 , lat : Union [ float , None ] = None , lng : Union [ float , None ] = None , radius : Union [ int , None ] = None , current_user : User = Security ( get_current_active_user , scopes = [ " photos.list " ] ) ) :
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
if col_albums . find_one ( { " user " : current_user . user , " name " : album } ) is None :
2023-02-16 15:55:03 +02:00
raise AlbumNameNotFoundError ( album )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
if page < = 0 or page_size < = 0 :
2023-02-16 15:55:03 +02:00
raise SearchPageInvalidError ( )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
output = { " results " : [ ] }
skip = ( page - 1 ) * page_size
2023-01-02 16:08:46 +02:00
radius = 5000 if radius is None else radius
if ( lat is not None ) and ( lng is not None ) :
db_query = { " user " : current_user . user , " album " : album , " location " : { " $nearSphere " : { " $geometry " : { " type " : " Point " , " coordinates " : [ lng , lat ] } , " $maxDistance " : radius } } }
db_query_count = { " user " : current_user . user , " album " : album , " location " : { " $geoWithin " : { " $centerSphere " : [ [ lng , lat ] , radius ] } } }
2023-01-17 15:39:21 +02:00
elif q is None and caption is None :
2023-02-16 16:44:54 +02:00
raise PhotoSearchQueryEmptyError ( )
2023-01-17 15:39:21 +02:00
elif q is None and caption is not None :
db_query = { " user " : current_user . user , " album " : album , " caption " : re . compile ( caption ) }
db_query_count = { " user " : current_user . user , " album " : album , " caption " : re . compile ( caption ) }
elif q is not None and caption is None :
2023-01-02 16:08:46 +02:00
db_query = { " user " : current_user . user , " album " : album , " filename " : re . compile ( q ) }
db_query_count = { " user " : current_user . user , " album " : album , " filename " : re . compile ( q ) }
2023-01-17 15:39:21 +02:00
else :
db_query = { " user " : current_user . user , " album " : album , " filename " : re . compile ( q ) , " caption " : re . compile ( caption ) } # type: ignore
db_query_count = { " user " : current_user . user , " album " : album , " filename " : re . compile ( q ) , " caption " : re . compile ( caption ) } # type: ignore
2023-01-02 16:08:46 +02:00
2023-01-12 15:43:17 +02:00
images = list ( col_photos . find ( db_query , limit = page_size , skip = skip ) . sort ( ' dates.uploaded ' , DESCENDING ) )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
for image in images :
2023-02-16 15:11:29 +02:00
output [ " results " ] . append ( { " id " : image [ " _id " ] . __str__ ( ) , " filename " : image [ " filename " ] , " caption " : image [ " caption " ] } )
2022-12-20 02:22:32 +02:00
2023-01-02 16:08:46 +02:00
if col_photos . count_documents ( db_query_count ) > page * page_size :
2022-12-20 14:28:50 +02:00
token = str ( token_urlsafe ( 32 ) )
col_tokens . insert_one ( { " token " : token , " query " : q , " album " : album , " page " : page + 1 , " page_size " : page_size , " user " : pickle . dumps ( current_user ) } )
output [ " next_page " ] = f " /albums/ { album } /photos/token?token= { token } " # type: ignore
2022-12-20 18:07:48 +02:00
else :
output [ " next_page " ] = None # type: ignore
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
return UJSONResponse ( output )
2022-12-20 02:22:32 +02:00
2023-02-16 16:44:54 +02:00
photo_find_token_responses = {
401 : SearchTokenInvalidError ( ) . openapi
}
@app.get ( " /albums/ {album} /photos/token " , description = " Find a photo by token " , response_class = UJSONResponse , response_model = SearchResultsPhoto , responses = photo_find_token_responses )
2022-12-20 02:22:32 +02:00
async def photo_find_token ( token : str ) :
found_record = col_tokens . find_one ( { " token " : token } )
if found_record is None :
2023-02-16 15:55:03 +02:00
raise SearchTokenInvalidError ( )
2022-12-20 02:22:32 +02:00
2022-12-20 14:28:50 +02:00
return await photo_find ( q = found_record [ " query " ] , album = found_record [ " album " ] , page = found_record [ " page " ] , page_size = found_record [ " page_size " ] , current_user = pickle . loads ( found_record [ " user " ] ) )