1 Commits

Author SHA1 Message Date
1bcca0f812 Merge pull request 'Random media requests' (#18) from dev into master
Reviewed-on: #18
2023-06-27 14:54:28 +03:00
19 changed files with 202 additions and 301 deletions

3
.gitignore vendored
View File

@@ -153,6 +153,5 @@ cython_debug/
#.idea/
# Custom
data/
.vscode/
.vscode
config.json

View File

@@ -1,7 +1,7 @@
<h1 align="center">Photos API</h1>
<p align="center">
<a href="https://git.end-play.xyz/profitroll/PhotosAPI/src/branch/master/README.md"><img alt="License: GPL" src="https://img.shields.io/badge/License-GPL-blue"></a>
<a href="https://git.end-play.xyz/profitroll/PhotosAPILICENSE"><img alt="License: GPL" src="https://img.shields.io/badge/License-GPL-blue"></a>
<a href="https://git.end-play.xyz/profitroll/PhotosAPI"><img alt="Code style: black" src="https://img.shields.io/badge/code%20style-black-000000.svg"></a>
</p>
@@ -47,8 +47,7 @@ First you need to have a Python interpreter, MongoDB and optionally git. You can
1. Copy file `config_example.json` to `config.json`
2. Open `config.json` using your favorite text editor. For example `nano config.json`
3. Change `"database"` keys to match your MongoDB setup
4. Set the key `"secret"` to your JWT secret. You can type in anything, but long secrets are recommended. You can also set environment variable `PHOTOSAPI_SECRET` as an alternative
5. Change `"external_address"` to the ip/http address you may get in responses. By default it's `"localhost"`. This is extremely useful when running behind reverse-proxy.
4. Change `"external_address"` to the ip/http address you may get in responses. By default it's `"localhost"`. This is extremely useful when running behind reverse-proxy.
After configuring everything listed above your API will be able to boot, however further configuration can be done. You can read about it in [repository's wiki](https://git.end-play.xyz/profitroll/PhotosAPI/wiki/Configuration). There's no need to focus on that now, it makes more sense to configure it afterwards.

View File

@@ -286,23 +286,3 @@ class UserCredentialsInvalid(HTTPException):
status_code=401,
detail=self.openapi["content"]["application/json"]["example"]["detail"],
)
class UserMediaQuotaReached(HTTPException):
"""Raises HTTP 403 if user's quota has been reached."""
def __init__(self):
self.openapi = {
"description": "Media Quota Reached",
"content": {
"application/json": {
"example": {
"detail": "Media quota has been reached, media upload impossible."
}
}
},
}
super().__init__(
status_code=403,
detail=self.openapi["content"]["application/json"]["example"]["detail"],
)

View File

@@ -6,7 +6,6 @@
"user": null,
"password": null
},
"secret": "",
"messages": {
"email_confirmed": "Email confirmed. You can now log in."
},
@@ -15,7 +14,6 @@
"media_token_valid_hours": 12,
"registration_enabled": true,
"registration_requires_confirmation": false,
"default_user_quota": 10000,
"mailer": {
"smtp": {
"host": "",

View File

@@ -47,12 +47,12 @@ async def album_create(
if 2 > len(title) > 40:
raise AlbumIncorrectError("title", "must be >2 and <40 characters.")
if (await col_albums.find_one({"name": name})) is not None:
if col_albums.find_one({"name": name}) is not None:
raise AlbumAlreadyExistsError(name)
makedirs(Path(f"data/users/{current_user.user}/albums/{name}"), exist_ok=True)
uploaded = await col_albums.insert_one(
uploaded = col_albums.insert_one(
{"user": current_user.user, "name": name, "title": title, "cover": None}
)
@@ -67,10 +67,9 @@ async def album_find(
current_user: User = Security(get_current_active_user, scopes=["albums.list"]),
):
output = {"results": []}
albums = list(col_albums.find({"user": current_user.user, "name": re.compile(q)}))
async for album in col_albums.find(
{"user": current_user.user, "name": re.compile(q)}
):
for album in albums:
output["results"].append(
{
"id": album["_id"].__str__(),
@@ -103,11 +102,11 @@ async def album_patch(
current_user: User = Security(get_current_active_user, scopes=["albums.write"]),
):
try:
album = await col_albums.find_one({"_id": ObjectId(id)})
album = col_albums.find_one({"_id": ObjectId(id)})
if album is None:
raise InvalidId(id)
except InvalidId as exc:
raise AlbumNotFoundError(id) from exc
except InvalidId:
raise AlbumNotFoundError(id)
if title is None:
title = album["title"]
@@ -126,7 +125,7 @@ async def album_patch(
Path(f"data/users/{current_user.user}/albums/{album['name']}"),
Path(f"data/users/{current_user.user}/albums/{name}"),
)
await col_photos.update_many(
col_photos.update_many(
{"user": current_user.user, "album": album["name"]},
{"$set": {"album": name}},
)
@@ -134,14 +133,12 @@ async def album_patch(
name = album["name"]
if cover is not None:
image = await col_photos.find_one(
{"_id": ObjectId(cover), "album": album["name"]}
)
image = col_photos.find_one({"_id": ObjectId(cover), "album": album["name"]})
cover = image["_id"].__str__() if image is not None else album["cover"]
else:
cover = album["cover"]
await col_albums.update_one(
col_albums.update_one(
{"_id": ObjectId(id)}, {"$set": {"name": name, "title": title, "cover": cover}}
)
@@ -169,11 +166,11 @@ async def album_put(
current_user: User = Security(get_current_active_user, scopes=["albums.write"]),
):
try:
album = await col_albums.find_one({"_id": ObjectId(id)})
album = col_albums.find_one({"_id": ObjectId(id)})
if album is None:
raise InvalidId(id)
except InvalidId as exc:
raise AlbumNotFoundError(id) from exc
except InvalidId:
raise AlbumNotFoundError(id)
if re.search(re.compile("^[a-z,0-9,_]*$"), name) is False:
raise AlbumIncorrectError("name", "can only contain a-z, 0-9 and _ characters.")
@@ -184,7 +181,7 @@ async def album_put(
if 2 > len(title) > 40:
raise AlbumIncorrectError("title", "must be >2 and <40 characters.")
image = await col_photos.find_one({"_id": ObjectId(cover), "album": album["name"]})
image = col_photos.find_one({"_id": ObjectId(cover), "album": album["name"]})
cover = image["_id"].__str__() if image is not None else None # type: ignore
rename(
@@ -192,10 +189,10 @@ async def album_put(
Path(f"data/users/{current_user.user}/albums/{name}"),
)
await col_photos.update_many(
col_photos.update_many(
{"user": current_user.user, "album": album["name"]}, {"$set": {"album": name}}
)
await col_albums.update_one(
col_albums.update_one(
{"_id": ObjectId(id)}, {"$set": {"name": name, "title": title, "cover": cover}}
)
@@ -216,13 +213,13 @@ async def album_delete(
current_user: User = Security(get_current_active_user, scopes=["albums.write"]),
):
try:
album = await col_albums.find_one_and_delete({"_id": ObjectId(id)})
album = col_albums.find_one_and_delete({"_id": ObjectId(id)})
if album is None:
raise InvalidId(id)
except InvalidId as exc:
raise AlbumNotFoundError(id) from exc
except InvalidId:
raise AlbumNotFoundError(id)
await col_photos.delete_many({"album": album["name"]})
col_photos.delete_many({"album": album["name"]})
rmtree(Path(f"data/users/{current_user.user}/albums/{album['name']}"))

View File

@@ -3,7 +3,6 @@ from fastapi.responses import UJSONResponse
from starlette.status import (
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_406_NOT_ACCEPTABLE,
HTTP_409_CONFLICT,
@@ -11,20 +10,19 @@ from starlette.status import (
)
from classes.exceptions import (
AccessTokenInvalidError,
AlbumNotFoundError,
AlbumAlreadyExistsError,
AlbumIncorrectError,
AlbumNotFoundError,
PhotoNotFoundError,
PhotoSearchQueryEmptyError,
SearchPageInvalidError,
SearchTokenInvalidError,
UserAlreadyExists,
UserCredentialsInvalid,
UserEmailCodeInvalid,
UserMediaQuotaReached,
VideoNotFoundError,
VideoSearchQueryEmptyError,
SearchPageInvalidError,
SearchTokenInvalidError,
AccessTokenInvalidError,
UserEmailCodeInvalid,
UserAlreadyExists,
UserCredentialsInvalid,
)
from modules.app import app
@@ -157,13 +155,3 @@ async def user_credentials_invalid_exception_handler(
status_code=HTTP_401_UNAUTHORIZED,
content={"detail": "Invalid credentials."},
)
@app.exception_handler(UserMediaQuotaReached)
async def user_media_quota_reached_exception_handler(
request: Request, exc: UserMediaQuotaReached
):
return UJSONResponse(
status_code=HTTP_403_FORBIDDEN,
content={"detail": "Media quota has been reached, media upload impossible."},
)

View File

@@ -30,7 +30,6 @@ from classes.exceptions import (
SearchLimitInvalidError,
SearchPageInvalidError,
SearchTokenInvalidError,
UserMediaQuotaReached,
)
from classes.models import (
Photo,
@@ -39,7 +38,7 @@ from classes.models import (
SearchResultsPhoto,
)
from modules.app import app
from modules.database import col_albums, col_photos, col_tokens, col_videos
from modules.database import col_albums, col_photos, col_tokens
from modules.exif_reader import extract_location
from modules.hasher import get_duplicates, get_phash
from modules.scheduler import scheduler
@@ -92,7 +91,6 @@ async def compress_image(image_path: str):
photo_post_responses = {
403: UserMediaQuotaReached().openapi,
404: AlbumNameNotFoundError("name").openapi,
409: {
"description": "Image Duplicates Found",
@@ -124,16 +122,9 @@ async def photo_upload(
caption: Union[str, None] = None,
current_user: User = Security(get_current_active_user, scopes=["photos.write"]),
):
if (await col_albums.find_one({"user": current_user.user, "name": album})) is None:
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
raise AlbumNameNotFoundError(album)
user_media_count = (
await col_photos.count_documents({"user": current_user.user})
) + (await col_videos.count_documents({"user": current_user.user}))
if user_media_count >= current_user.quota and not current_user.quota == -1: # type: ignore
raise UserMediaQuotaReached()
makedirs(Path(f"data/users/{current_user.user}/albums/{album}"), exist_ok=True)
filename = file.filename
@@ -167,7 +158,7 @@ async def photo_upload(
expires_delta=timedelta(hours=configGet("media_token_valid_hours")),
)
access_token_short = uuid4().hex[:12].lower()
await col_tokens.insert_one(
col_tokens.insert_one(
{
"short": access_token_short,
"access_token": access_token,
@@ -192,7 +183,7 @@ async def photo_upload(
except (UnpackError, ValueError):
coords = {"lng": 0.0, "lat": 0.0, "alt": 0.0}
uploaded = await col_photos.insert_one(
uploaded = col_photos.insert_one(
{
"user": current_user.user,
"album": album,
@@ -240,7 +231,7 @@ if configGet("media_token_access") is True:
responses=photo_get_token_responses,
)
async def photo_get_token(token: str, id: int):
db_entry = await col_tokens.find_one({"short": token})
db_entry = col_tokens.find_one({"short": token})
if db_entry is None:
raise AccessTokenInvalidError()
@@ -255,23 +246,24 @@ if configGet("media_token_access") is True:
raise AccessTokenInvalidError()
token_scopes = payload.get("scopes", [])
token_data = TokenData(scopes=token_scopes, user=user)
except (JWTError, ValidationError) as exc:
raise AccessTokenInvalidError() from exc
except (JWTError, ValidationError) as exp:
print(exp, flush=True)
raise AccessTokenInvalidError()
user_record = await get_user(user=token_data.user)
user = get_user(user=token_data.user)
if id not in payload.get("allowed", []):
raise AccessTokenInvalidError()
try:
image = await col_photos.find_one({"_id": ObjectId(id)})
image = col_photos.find_one({"_id": ObjectId(id)})
if image is None:
raise InvalidId(id)
except InvalidId as exc:
raise PhotoNotFoundError(id) from exc
except InvalidId:
raise PhotoNotFoundError(id)
image_path = Path(
f"data/users/{user_record.user}/albums/{image['album']}/{image['filename']}"
f"data/users/{user.user}/albums/{image['album']}/{image['filename']}"
)
mime = Magic(mime=True).from_file(image_path)
@@ -309,11 +301,11 @@ async def photo_get(
current_user: User = Security(get_current_active_user, scopes=["photos.read"]),
):
try:
image = await col_photos.find_one({"_id": ObjectId(id)})
image = col_photos.find_one({"_id": ObjectId(id)})
if image is None:
raise InvalidId(id)
except InvalidId as exc:
raise PhotoNotFoundError(id) from exc
except InvalidId:
raise PhotoNotFoundError(id)
image_path = Path(
f"data/users/{current_user.user}/albums/{image['album']}/{image['filename']}"
@@ -342,13 +334,13 @@ async def photo_move(
current_user: User = Security(get_current_active_user, scopes=["photos.write"]),
):
try:
image = await col_photos.find_one({"_id": ObjectId(id)})
image = col_photos.find_one({"_id": ObjectId(id)})
if image is None:
raise InvalidId(id)
except InvalidId as exc:
raise PhotoNotFoundError(id) from exc
except InvalidId:
raise PhotoNotFoundError(id)
if (await col_albums.find_one({"user": current_user.user, "name": album})) is None:
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
raise AlbumNameNotFoundError(album)
if Path(
@@ -362,7 +354,7 @@ async def photo_move(
else:
filename = image["filename"]
await col_photos.find_one_and_update(
col_photos.find_one_and_update(
{"_id": ObjectId(id)},
{
"$set": {
@@ -404,13 +396,13 @@ async def photo_patch(
current_user: User = Security(get_current_active_user, scopes=["photos.write"]),
):
try:
image = await col_photos.find_one({"_id": ObjectId(id)})
image = col_photos.find_one({"_id": ObjectId(id)})
if image is None:
raise InvalidId(id)
except InvalidId as exc:
raise PhotoNotFoundError(id) from exc
except InvalidId:
raise PhotoNotFoundError(id)
await col_photos.find_one_and_update(
col_photos.find_one_and_update(
{"_id": ObjectId(id)},
{"$set": {"caption": caption, "dates.modified": datetime.now(tz=timezone.utc)}},
)
@@ -438,16 +430,16 @@ async def photo_delete(
current_user: User = Security(get_current_active_user, scopes=["photos.write"]),
):
try:
image = await col_photos.find_one_and_delete({"_id": ObjectId(id)})
image = col_photos.find_one_and_delete({"_id": ObjectId(id)})
if image is None:
raise InvalidId(id)
except InvalidId as exc:
raise PhotoNotFoundError(id) from exc
except InvalidId:
raise PhotoNotFoundError(id)
album = await col_albums.find_one({"name": image["album"]})
album = col_albums.find_one({"name": image["album"]})
if album is not None and album["cover"] == image["_id"].__str__():
await col_albums.update_one({"name": image["album"]}, {"$set": {"cover": None}})
col_albums.update_one({"name": image["album"]}, {"$set": {"cover": None}})
remove(
Path(
@@ -477,7 +469,7 @@ async def photo_random(
limit: int = 100,
current_user: User = Security(get_current_active_user, scopes=["photos.list"]),
):
if (await col_albums.find_one({"user": current_user.user, "name": album})) is None:
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
raise AlbumNameNotFoundError(album)
if limit <= 0:
@@ -498,16 +490,20 @@ async def photo_random(
}
)
documents_count = await col_photos.count_documents(db_query)
documents_count = col_photos.count_documents(db_query)
skip = randint(0, documents_count - 1) if documents_count > 1 else 0
async for image in col_photos.aggregate(
[
{"$match": db_query},
{"$skip": skip},
{"$limit": limit},
]
):
images = list(
col_photos.aggregate(
[
{"$match": db_query},
{"$skip": skip},
{"$limit": limit},
]
)
)
for image in images:
output["results"].append(
{
"id": image["_id"].__str__(),
@@ -547,7 +543,7 @@ async def photo_find(
current_user: User = Security(get_current_active_user, scopes=["photos.list"]),
):
if token is not None:
found_record = await col_tokens.find_one({"token": token})
found_record = col_tokens.find_one({"token": token})
if found_record is None:
raise SearchTokenInvalidError()
@@ -564,7 +560,7 @@ async def photo_find(
current_user=current_user,
)
if (await col_albums.find_one({"user": current_user.user, "name": album})) is None:
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
raise AlbumNameNotFoundError(album)
if page <= 0 or page_size <= 0:
@@ -616,22 +612,16 @@ async def photo_find(
"filename": re.compile(q),
}
else:
db_query = {
"user": current_user.user,
"album": album,
"filename": re.compile(q),
"caption": re.compile(caption),
}
db_query_count = {
"user": current_user.user,
"album": album,
"filename": re.compile(q),
"caption": re.compile(caption),
}
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
async for image in col_photos.find(db_query, limit=page_size, skip=skip).sort(
"dates.uploaded", direction=DESCENDING
):
images = list(
col_photos.find(db_query, limit=page_size, skip=skip).sort(
"dates.uploaded", DESCENDING
)
)
for image in images:
output["results"].append(
{
"id": image["_id"].__str__(),
@@ -640,9 +630,9 @@ async def photo_find(
}
)
if (await col_photos.count_documents(db_query_count)) > page * page_size:
if col_photos.count_documents(db_query_count) > page * page_size:
token = str(token_urlsafe(32))
await col_tokens.insert_one(
col_tokens.insert_one(
{
"token": token,
"query": q,

View File

@@ -17,7 +17,7 @@ token_post_responses = {401: UserCredentialsInvalid().openapi}
@app.post("/token", response_model=Token, responses=token_post_responses)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
user = await authenticate_user(form_data.username, form_data.password)
user = authenticate_user(form_data.username, form_data.password)
if not user:
raise UserCredentialsInvalid()
access_token_expires = timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)

View File

@@ -41,14 +41,14 @@ async def send_confirmation(user: str, email: str):
+ f"/users/{user}/confirm?code={confirmation_code}"
),
)
await col_emails.insert_one(
col_emails.insert_one(
{"user": user, "email": email, "used": False, "code": confirmation_code}
)
logger.info(
"Sent confirmation email to '%s' with code %s", email, confirmation_code
)
except Exception as exc:
logger.error("Could not send confirmation email to '%s' due to: %s", email, exc)
except Exception as exp:
logger.error("Could not send confirmation email to '%s' due to: %s", email, exp)
@app.get("/users/me/", response_model=User)
@@ -80,15 +80,15 @@ if configGet("registration_requires_confirmation") is True:
responses=user_confirm_responses,
)
async def user_confirm(user: str, code: str):
confirm_record = await col_emails.find_one(
confirm_record = col_emails.find_one(
{"user": user, "code": code, "used": False}
)
if confirm_record is None:
raise UserEmailCodeInvalid()
await col_emails.find_one_and_update(
col_emails.find_one_and_update(
{"_id": confirm_record["_id"]}, {"$set": {"used": True}}
)
await col_users.find_one_and_update(
col_users.find_one_and_update(
{"user": confirm_record["user"]}, {"$set": {"disabled": False}}
)
return UJSONResponse({"detail": configGet("email_confirmed", "messages")})
@@ -103,13 +103,12 @@ if configGet("registration_enabled") is True:
async def user_create(
user: str = Form(), email: str = Form(), password: str = Form()
):
if (await col_users.find_one({"user": user})) is not None:
if col_users.find_one({"user": user}) is not None:
raise UserAlreadyExists()
await col_users.insert_one(
col_users.insert_one(
{
"user": user,
"email": email,
"quota": None,
"hash": get_password_hash(password),
"disabled": configGet("registration_requires_confirmation"),
}
@@ -133,14 +132,14 @@ user_delete_responses = {401: UserCredentialsInvalid().openapi}
async def user_delete(
password: str = Form(), current_user: User = Depends(get_current_active_user)
):
user = await get_user(current_user.user)
user = get_user(current_user.user)
if not user:
return False
if not verify_password(password, user.hash):
raise UserCredentialsInvalid()
await col_users.delete_many({"user": current_user.user})
await col_emails.delete_many({"user": current_user.user})
await col_photos.delete_many({"user": current_user.user})
await col_videos.delete_many({"user": current_user.user})
await col_albums.delete_many({"user": current_user.user})
col_users.delete_many({"user": current_user.user})
col_emails.delete_many({"user": current_user.user})
col_photos.delete_many({"user": current_user.user})
col_videos.delete_many({"user": current_user.user})
col_albums.delete_many({"user": current_user.user})
return Response(status_code=HTTP_204_NO_CONTENT)

View File

@@ -21,7 +21,6 @@ from classes.exceptions import (
SearchLimitInvalidError,
SearchPageInvalidError,
SearchTokenInvalidError,
UserMediaQuotaReached,
VideoNotFoundError,
VideoSearchQueryEmptyError,
)
@@ -32,13 +31,10 @@ from classes.models import (
VideoPublic,
)
from modules.app import app
from modules.database import col_albums, col_photos, col_tokens, col_videos
from modules.database import col_albums, col_tokens, col_videos
from modules.security import User, get_current_active_user
video_post_responses = {
403: UserMediaQuotaReached().openapi,
404: AlbumNameNotFoundError("name").openapi,
}
video_post_responses = {404: AlbumNameNotFoundError("name").openapi}
@app.post(
@@ -54,16 +50,9 @@ async def video_upload(
caption: Union[str, None] = None,
current_user: User = Security(get_current_active_user, scopes=["videos.write"]),
):
if (await col_albums.find_one({"user": current_user.user, "name": album})) is None:
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
raise AlbumNameNotFoundError(album)
user_media_count = (
await col_videos.count_documents({"user": current_user.user})
) + (await col_photos.count_documents({"user": current_user.user}))
if user_media_count >= current_user.quota and not current_user.quota == -1: # type: ignore
raise UserMediaQuotaReached()
makedirs(Path(f"data/users/{current_user.user}/albums/{album}"), exist_ok=True)
filename = file.filename
@@ -84,7 +73,7 @@ async def video_upload(
# Coords extraction should be here
uploaded = await col_videos.insert_one(
uploaded = col_videos.insert_one(
{
"user": current_user.user,
"album": album,
@@ -134,11 +123,11 @@ async def video_get(
current_user: User = Security(get_current_active_user, scopes=["videos.read"]),
):
try:
video = await col_videos.find_one({"_id": ObjectId(id)})
video = col_videos.find_one({"_id": ObjectId(id)})
if video is None:
raise InvalidId(id)
except InvalidId as exc:
raise VideoNotFoundError(id) from exc
except InvalidId:
raise VideoNotFoundError(id)
video_path = Path(
f"data/users/{current_user.user}/albums/{video['album']}/{video['filename']}"
@@ -167,13 +156,13 @@ async def video_move(
current_user: User = Security(get_current_active_user, scopes=["videos.write"]),
):
try:
video = await col_videos.find_one({"_id": ObjectId(id)})
video = col_videos.find_one({"_id": ObjectId(id)})
if video is None:
raise InvalidId(id)
except InvalidId as exc:
raise VideoNotFoundError(id) from exc
except InvalidId:
raise VideoNotFoundError(id)
if (await col_albums.find_one({"user": current_user.user, "name": album})) is None:
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
raise AlbumNameNotFoundError(album)
if Path(
@@ -187,7 +176,7 @@ async def video_move(
else:
filename = video["filename"]
await col_videos.find_one_and_update(
col_videos.find_one_and_update(
{"_id": ObjectId(id)},
{
"$set": {
@@ -229,13 +218,13 @@ async def video_patch(
current_user: User = Security(get_current_active_user, scopes=["videos.write"]),
):
try:
video = await col_videos.find_one({"_id": ObjectId(id)})
video = col_videos.find_one({"_id": ObjectId(id)})
if video is None:
raise InvalidId(id)
except InvalidId as exc:
raise VideoNotFoundError(id) from exc
except InvalidId:
raise VideoNotFoundError(id)
await col_videos.find_one_and_update(
col_videos.find_one_and_update(
{"_id": ObjectId(id)},
{"$set": {"caption": caption, "dates.modified": datetime.now(tz=timezone.utc)}},
)
@@ -263,13 +252,13 @@ async def video_delete(
current_user: User = Security(get_current_active_user, scopes=["videos.write"]),
):
try:
video = await col_videos.find_one_and_delete({"_id": ObjectId(id)})
video = col_videos.find_one_and_delete({"_id": ObjectId(id)})
if video is None:
raise InvalidId(id)
except InvalidId as exc:
raise VideoNotFoundError(id) from exc
except InvalidId:
raise VideoNotFoundError(id)
album = await col_albums.find_one({"name": video["album"]})
album = col_albums.find_one({"name": video["album"]})
remove(
Path(
@@ -299,7 +288,7 @@ async def video_random(
limit: int = 100,
current_user: User = Security(get_current_active_user, scopes=["videos.list"]),
):
if (await col_albums.find_one({"user": current_user.user, "name": album})) is None:
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
raise AlbumNameNotFoundError(album)
if limit <= 0:
@@ -320,16 +309,20 @@ async def video_random(
}
)
documents_count = await col_videos.count_documents(db_query)
documents_count = col_videos.count_documents(db_query)
skip = randint(0, documents_count - 1) if documents_count > 1 else 0
async for video in col_videos.aggregate(
[
{"$match": db_query},
{"$skip": skip},
{"$limit": limit},
]
):
videos = list(
col_videos.aggregate(
[
{"$match": db_query},
{"$skip": skip},
{"$limit": limit},
]
)
)
for video in videos:
output["results"].append(
{
"id": video["_id"].__str__(),
@@ -366,7 +359,7 @@ async def video_find(
current_user: User = Security(get_current_active_user, scopes=["videos.list"]),
):
if token is not None:
found_record = await col_tokens.find_one({"token": token})
found_record = col_tokens.find_one({"token": token})
if found_record is None:
raise SearchTokenInvalidError()
@@ -380,7 +373,7 @@ async def video_find(
current_user=current_user,
)
if (await col_albums.find_one({"user": current_user.user, "name": album})) is None:
if col_albums.find_one({"user": current_user.user, "name": album}) is None:
raise AlbumNameNotFoundError(album)
if page <= 0 or page_size <= 0:
@@ -404,33 +397,29 @@ async def video_find(
"caption": re.compile(caption),
}
elif caption is None:
db_query = {
"user": current_user.user,
"album": album,
"filename": re.compile(q),
}
db_query = list(
col_videos.find(
{"user": current_user.user, "album": album, "filename": re.compile(q)},
limit=page_size,
skip=skip,
).sort("dates.uploaded", DESCENDING)
)
db_query_count = {
"user": current_user.user,
"album": album,
"caption": re.compile(q),
}
else:
db_query = {
"user": current_user.user,
"album": album,
"filename": re.compile(q),
"caption": re.compile(caption),
}
db_query_count = {
"user": current_user.user,
"album": album,
"filename": re.compile(q),
"caption": re.compile(caption),
}
db_query = list(col_videos.find({"user": current_user.user, "album": album, "filename": re.compile(q), "caption": re.compile(caption)}, limit=page_size, skip=skip).sort("dates.uploaded", DESCENDING)) # type: ignore
db_query_count = {"user": current_user.user, "album": album, "filename": re.compile(q), "caption": re.compile(caption)} # type: ignore
async for video in col_videos.find(db_query, limit=page_size, skip=skip).sort(
"dates.uploaded", direction=DESCENDING
):
videos = list(
col_videos.find(db_query, limit=page_size, skip=skip).sort(
"dates.uploaded", DESCENDING
)
)
for video in videos:
output["results"].append(
{
"id": video["_id"].__str__(),
@@ -439,9 +428,9 @@ async def video_find(
}
)
if (await col_videos.count_documents(db_query_count)) > page * page_size:
if col_videos.count_documents(db_query_count) > page * page_size:
token = str(token_urlsafe(32))
await col_tokens.insert_one(
col_tokens.insert_one(
{
"token": token,
"query": q,

View File

@@ -1,7 +1,7 @@
from fastapi import FastAPI
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html
app = FastAPI(title="END PLAY Photos", docs_url=None, redoc_url=None, version="0.6")
app = FastAPI(title="END PLAY Photos", docs_url=None, redoc_url=None, version="0.5")
@app.get("/docs", include_in_schema=False)

View File

@@ -1,4 +1,3 @@
from async_pymongo import AsyncClient
from pymongo import GEOSPHERE, MongoClient
from modules.utils import configGet
@@ -18,11 +17,16 @@ else:
db_config["host"], db_config["port"], db_config["name"]
)
db_client = AsyncClient(con_string)
db_client_sync = MongoClient(con_string)
db_client = MongoClient(con_string)
db = db_client.get_database(name=db_config["name"])
collections = db.list_collection_names()
for collection in ["users", "albums", "photos", "videos", "tokens", "emails"]:
if collection not in collections:
db.create_collection(collection)
col_users = db.get_collection("users")
col_albums = db.get_collection("albums")
col_photos = db.get_collection("photos")
@@ -30,4 +34,4 @@ col_videos = db.get_collection("videos")
col_tokens = db.get_collection("tokens")
col_emails = db.get_collection("emails")
db_client_sync[db_config["name"]]["photos"].create_index([("location", GEOSPHERE)])
col_photos.create_index([("location", GEOSPHERE)])

View File

@@ -1,6 +1,4 @@
import contextlib
from pathlib import Path
from typing import Mapping, Union
from exif import Image
@@ -23,7 +21,7 @@ def decimal_coords(coords: float, ref: str) -> float:
return round(decimal_degrees, 5)
def extract_location(filepath: Union[str, Path]) -> Mapping[str, float]:
def extract_location(filepath: str) -> dict:
"""Get location data from image
### Args:

View File

@@ -1,7 +1,6 @@
from importlib.util import module_from_spec, spec_from_file_location
from os import getcwd, path, walk
from pathlib import Path
from typing import Union
# =================================================================================
@@ -18,15 +17,11 @@ def get_py_files(src):
return py_files
def dynamic_import(module_name: str, py_path: str):
def dynamic_import(module_name, py_path):
try:
module_spec = spec_from_file_location(module_name, py_path)
if module_spec is None:
raise RuntimeError(
f"Module spec from module name {module_name} and path {py_path} is None"
)
module = module_from_spec(module_spec)
module_spec.loader.exec_module(module)
module = module_from_spec(module_spec) # type: ignore
module_spec.loader.exec_module(module) # type: ignore
return module
except SyntaxError:
print(
@@ -34,12 +29,12 @@ def dynamic_import(module_name: str, py_path: str):
flush=True,
)
return
except Exception as exc:
print(f"Could not load extension {module_name} due to {exc}", flush=True)
except Exception as exp:
print(f"Could not load extension {module_name} due to {exp}", flush=True)
return
def dynamic_import_from_src(src: Union[str, Path], star_import=False):
def dynamic_import_from_src(src, star_import=False):
my_py_files = get_py_files(src)
for py_file in my_py_files:
module_name = Path(py_file).stem

View File

@@ -1,5 +1,5 @@
from pathlib import Path
from typing import Any, List, Mapping, Union
from typing import Union
import cv2
import numpy as np
@@ -9,7 +9,7 @@ from scipy import spatial
from modules.database import col_photos
def hash_array_to_hash_hex(hash_array) -> str:
def hash_array_to_hash_hex(hash_array):
# convert hash array of 0 or 1 to hash string in hex
hash_array = np.array(hash_array, dtype=np.uint8)
hash_str = "".join(str(i) for i in 1 * hash_array.flatten())
@@ -23,10 +23,10 @@ def hash_hex_to_hash_array(hash_hex) -> NDArray:
return np.array(list(array_str), dtype=np.float32)
async def get_duplicates_cache(album: str) -> Mapping[str, Any]:
def get_duplicates_cache(album: str) -> dict:
return {
photo["filename"]: [photo["_id"].__str__(), photo["hash"]]
async for photo in col_photos.find({"album": album})
for photo in col_photos.find({"album": album})
}
@@ -52,9 +52,9 @@ async def get_phash(filepath: Union[str, Path]) -> str:
return hash_array_to_hash_hex(dct_block.flatten())
async def get_duplicates(hash_string: str, album: str) -> List[Mapping[str, Any]]:
async def get_duplicates(hash_string: str, album: str) -> list:
duplicates = []
cache = await get_duplicates_cache(album)
cache = get_duplicates_cache(album)
for image_name, image_object in cache.items():
try:
distance = spatial.distance.hamming(

View File

@@ -28,8 +28,8 @@ try:
)
mail_sender.ehlo()
logger.info("Initialized SMTP connection")
except Exception as exc:
logger.error("Could not initialize SMTP connection to: %s", exc)
except Exception as exp:
logger.error("Could not initialize SMTP connection to: %s", exp)
print_exc()
try:
@@ -37,5 +37,5 @@ try:
configGet("login", "mailer", "smtp"), configGet("password", "mailer", "smtp")
)
logger.info("Successfully initialized mailer")
except Exception as exc:
logger.error("Could not login into provided SMTP account due to: %s", exc)
except Exception as exp:
logger.error("Could not login into provided SMTP account due to: %s", exp)

View File

@@ -1,5 +1,4 @@
from datetime import datetime, timedelta, timezone
from os import getenv
from typing import List, Union
from fastapi import Depends, HTTPException, Security, status
@@ -9,26 +8,9 @@ from passlib.context import CryptContext
from pydantic import BaseModel, ValidationError
from modules.database import col_users
from modules.utils import configGet
try:
configGet("secret")
except KeyError as exc:
raise KeyError(
"PhotosAPI secret is not set. Secret key handling has changed in PhotosAPI 0.6.0, so you need to add the config key 'secret' to your config file."
) from exc
if configGet("secret") == "" and getenv("PHOTOSAPI_SECRET") is None:
raise KeyError(
"PhotosAPI secret is not set. Set the config key 'secret' or provide the environment variable 'PHOTOSAPI_SECRET' containing a secret string."
)
SECRET_KEY = (
getenv("PHOTOSAPI_SECRET")
if getenv("PHOTOSAPI_SECRET") is not None
else configGet("secret")
)
with open("secret_key", "r", encoding="utf-8") as f:
SECRET_KEY = f.read()
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 180
@@ -46,7 +28,6 @@ class TokenData(BaseModel):
class User(BaseModel):
user: str
email: Union[str, None] = None
quota: Union[int, None] = None
disabled: Union[bool, None] = None
@@ -73,56 +54,46 @@ oauth2_scheme = OAuth2PasswordBearer(
)
def verify_password(plain_password, hashed_password) -> bool:
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password) -> str:
def get_password_hash(password):
return pwd_context.hash(password)
async def get_user(user: str) -> UserInDB:
found_user = await col_users.find_one({"user": user})
if found_user is None:
raise RuntimeError(f"User {user} does not exist")
def get_user(user: str):
found_user = col_users.find_one({"user": user})
return UserInDB(
user=found_user["user"],
email=found_user["email"],
quota=found_user["quota"],
disabled=found_user["disabled"],
hash=found_user["hash"],
)
async def authenticate_user(user_name: str, password: str) -> Union[UserInDB, bool]:
if user := await get_user(user_name):
def authenticate_user(user_name: str, password: str):
if user := get_user(user_name):
return user if verify_password(password, user.hash) else False
else:
return False
def create_access_token(
data: dict, expires_delta: Union[timedelta, None] = None
) -> str:
def create_access_token(data: dict, expires_delta: Union[timedelta, None] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(tz=timezone.utc) + expires_delta
else:
expire = datetime.now(tz=timezone.utc) + timedelta(
days=ACCESS_TOKEN_EXPIRE_DAYS
)
to_encode["exp"] = expire
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
async def get_current_user(
security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)
) -> UserInDB:
):
if security_scopes.scopes:
authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
else:
@@ -137,18 +108,16 @@ async def get_current_user(
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user: str = payload.get("sub")
if user is None:
raise credentials_exception
token_scopes = payload.get("scopes", [])
token_data = TokenData(scopes=token_scopes, user=user)
except (JWTError, ValidationError) as exc:
raise credentials_exception from exc
except (JWTError, ValidationError):
raise credentials_exception
user_record = await get_user(user=token_data.user)
user = get_user(user=token_data.user)
if user_record is None:
if user is None:
raise credentials_exception
for scope in security_scopes.scopes:
@@ -158,8 +127,7 @@ async def get_current_user(
detail="Not enough permissions",
headers={"WWW-Authenticate": authenticate_value},
)
return user_record
return user
async def get_current_active_user(
@@ -167,5 +135,4 @@ async def get_current_active_user(
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user

View File

@@ -49,8 +49,8 @@ def jsonSave(contents: Union[list, dict], filepath: Union[str, Path]) -> None:
with open(filepath, "w", encoding="utf8") as file:
file.write(dumps(contents, ensure_ascii=False, indent=4))
file.close()
except Exception as exc:
logger.error("Could not save json file %s: %s\n%s", filepath, exc, format_exc())
except Exception as exp:
logger.error("Could not save json file %s: %s\n%s", filepath, exp, format_exc())
return

View File

@@ -1,13 +1,11 @@
aiofiles==23.2.1
aiofiles==23.1.0
apscheduler~=3.10.1
exif==1.6.0
fastapi[all]==0.104.1
opencv-python~=4.8.1.78
fastapi[all]==0.98.0
opencv-python~=4.7.0.72
passlib~=1.7.4
pymongo>=4.3.3
pymongo==4.4.0
python-jose[cryptography]~=3.3.0
python-magic~=0.4.27
scipy~=1.11.0
ujson~=5.8.0
--extra-index-url https://git.end-play.xyz/api/packages/profitroll/pypi/simple
async_pymongo==0.1.4