New secrets system and quotas (#35)

This commit is contained in:
Profitroll 2023-11-25 17:50:09 +01:00
parent b2146b965a
commit 0f423166f1
Signed by: profitroll
GPG Key ID: FA35CAB49DACD3B2
10 changed files with 97 additions and 13 deletions

3
.gitignore vendored
View File

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

View File

@ -47,7 +47,8 @@ First you need to have a Python interpreter, MongoDB and optionally git. You can
1. Copy file `config_example.json` to `config.json` 1. Copy file `config_example.json` to `config.json`
2. Open `config.json` using your favorite text editor. For example `nano 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 3. Change `"database"` keys to match your MongoDB setup
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. 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.
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. 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,3 +286,23 @@ class UserCredentialsInvalid(HTTPException):
status_code=401, status_code=401,
detail=self.openapi["content"]["application/json"]["example"]["detail"], 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,6 +6,7 @@
"user": null, "user": null,
"password": null "password": null
}, },
"secret": "",
"messages": { "messages": {
"email_confirmed": "Email confirmed. You can now log in." "email_confirmed": "Email confirmed. You can now log in."
}, },
@ -14,6 +15,7 @@
"media_token_valid_hours": 12, "media_token_valid_hours": 12,
"registration_enabled": true, "registration_enabled": true,
"registration_requires_confirmation": false, "registration_requires_confirmation": false,
"default_user_quota": 10000,
"mailer": { "mailer": {
"smtp": { "smtp": {
"host": "", "host": "",

View File

@ -3,6 +3,7 @@ from fastapi.responses import UJSONResponse
from starlette.status import ( from starlette.status import (
HTTP_400_BAD_REQUEST, HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED, HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND, HTTP_404_NOT_FOUND,
HTTP_406_NOT_ACCEPTABLE, HTTP_406_NOT_ACCEPTABLE,
HTTP_409_CONFLICT, HTTP_409_CONFLICT,
@ -10,19 +11,20 @@ from starlette.status import (
) )
from classes.exceptions import ( from classes.exceptions import (
AlbumNotFoundError, AccessTokenInvalidError,
AlbumAlreadyExistsError, AlbumAlreadyExistsError,
AlbumIncorrectError, AlbumIncorrectError,
AlbumNotFoundError,
PhotoNotFoundError, PhotoNotFoundError,
PhotoSearchQueryEmptyError, PhotoSearchQueryEmptyError,
VideoNotFoundError,
VideoSearchQueryEmptyError,
SearchPageInvalidError, SearchPageInvalidError,
SearchTokenInvalidError, SearchTokenInvalidError,
AccessTokenInvalidError,
UserEmailCodeInvalid,
UserAlreadyExists, UserAlreadyExists,
UserCredentialsInvalid, UserCredentialsInvalid,
UserEmailCodeInvalid,
UserMediaQuotaReached,
VideoNotFoundError,
VideoSearchQueryEmptyError,
) )
from modules.app import app from modules.app import app
@ -155,3 +157,13 @@ async def user_credentials_invalid_exception_handler(
status_code=HTTP_401_UNAUTHORIZED, status_code=HTTP_401_UNAUTHORIZED,
content={"detail": "Invalid credentials."}, 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,6 +30,7 @@ from classes.exceptions import (
SearchLimitInvalidError, SearchLimitInvalidError,
SearchPageInvalidError, SearchPageInvalidError,
SearchTokenInvalidError, SearchTokenInvalidError,
UserMediaQuotaReached,
) )
from classes.models import ( from classes.models import (
Photo, Photo,
@ -38,7 +39,7 @@ from classes.models import (
SearchResultsPhoto, SearchResultsPhoto,
) )
from modules.app import app from modules.app import app
from modules.database import col_albums, col_photos, col_tokens from modules.database import col_albums, col_photos, col_tokens, col_videos
from modules.exif_reader import extract_location from modules.exif_reader import extract_location
from modules.hasher import get_duplicates, get_phash from modules.hasher import get_duplicates, get_phash
from modules.scheduler import scheduler from modules.scheduler import scheduler
@ -91,6 +92,7 @@ async def compress_image(image_path: str):
photo_post_responses = { photo_post_responses = {
403: UserMediaQuotaReached().openapi,
404: AlbumNameNotFoundError("name").openapi, 404: AlbumNameNotFoundError("name").openapi,
409: { 409: {
"description": "Image Duplicates Found", "description": "Image Duplicates Found",
@ -125,6 +127,13 @@ async def photo_upload(
if (await col_albums.find_one({"user": current_user.user, "name": album})) is None: if (await col_albums.find_one({"user": current_user.user, "name": album})) is None:
raise AlbumNameNotFoundError(album) 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) makedirs(Path(f"data/users/{current_user.user}/albums/{album}"), exist_ok=True)
filename = file.filename filename = file.filename

View File

@ -109,6 +109,7 @@ if configGet("registration_enabled") is True:
{ {
"user": user, "user": user,
"email": email, "email": email,
"quota": None,
"hash": get_password_hash(password), "hash": get_password_hash(password),
"disabled": configGet("registration_requires_confirmation"), "disabled": configGet("registration_requires_confirmation"),
} }

View File

@ -21,6 +21,7 @@ from classes.exceptions import (
SearchLimitInvalidError, SearchLimitInvalidError,
SearchPageInvalidError, SearchPageInvalidError,
SearchTokenInvalidError, SearchTokenInvalidError,
UserMediaQuotaReached,
VideoNotFoundError, VideoNotFoundError,
VideoSearchQueryEmptyError, VideoSearchQueryEmptyError,
) )
@ -31,10 +32,13 @@ from classes.models import (
VideoPublic, VideoPublic,
) )
from modules.app import app from modules.app import app
from modules.database import col_albums, col_tokens, col_videos from modules.database import col_albums, col_photos, col_tokens, col_videos
from modules.security import User, get_current_active_user from modules.security import User, get_current_active_user
video_post_responses = {404: AlbumNameNotFoundError("name").openapi} video_post_responses = {
403: UserMediaQuotaReached().openapi,
404: AlbumNameNotFoundError("name").openapi,
}
@app.post( @app.post(
@ -53,6 +57,13 @@ async def video_upload(
if (await col_albums.find_one({"user": current_user.user, "name": album})) is None: if (await col_albums.find_one({"user": current_user.user, "name": album})) is None:
raise AlbumNameNotFoundError(album) 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) makedirs(Path(f"data/users/{current_user.user}/albums/{album}"), exist_ok=True)
filename = file.filename filename = file.filename

View File

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

View File

@ -1,4 +1,5 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from os import getenv
from typing import List, Union from typing import List, Union
from fastapi import Depends, HTTPException, Security, status from fastapi import Depends, HTTPException, Security, status
@ -8,9 +9,26 @@ from passlib.context import CryptContext
from pydantic import BaseModel, ValidationError from pydantic import BaseModel, ValidationError
from modules.database import col_users 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" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_DAYS = 180 ACCESS_TOKEN_EXPIRE_DAYS = 180
@ -28,6 +46,7 @@ class TokenData(BaseModel):
class User(BaseModel): class User(BaseModel):
user: str user: str
email: Union[str, None] = None email: Union[str, None] = None
quota: Union[int, None] = None
disabled: Union[bool, None] = None disabled: Union[bool, None] = None
@ -71,6 +90,7 @@ async def get_user(user: str) -> UserInDB:
return UserInDB( return UserInDB(
user=found_user["user"], user=found_user["user"],
email=found_user["email"], email=found_user["email"],
quota=found_user["quota"],
disabled=found_user["disabled"], disabled=found_user["disabled"],
hash=found_user["hash"], hash=found_user["hash"],
) )
@ -87,13 +107,16 @@ def create_access_token(
data: dict, expires_delta: Union[timedelta, None] = None data: dict, expires_delta: Union[timedelta, None] = None
) -> str: ) -> str:
to_encode = data.copy() to_encode = data.copy()
if expires_delta: if expires_delta:
expire = datetime.now(tz=timezone.utc) + expires_delta expire = datetime.now(tz=timezone.utc) + expires_delta
else: else:
expire = datetime.now(tz=timezone.utc) + timedelta( expire = datetime.now(tz=timezone.utc) + timedelta(
days=ACCESS_TOKEN_EXPIRE_DAYS days=ACCESS_TOKEN_EXPIRE_DAYS
) )
to_encode["exp"] = expire to_encode["exp"] = expire
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
@ -114,8 +137,10 @@ async def get_current_user(
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user: str = payload.get("sub") user: str = payload.get("sub")
if user is None: if user is None:
raise credentials_exception raise credentials_exception
token_scopes = payload.get("scopes", []) token_scopes = payload.get("scopes", [])
token_data = TokenData(scopes=token_scopes, user=user) token_data = TokenData(scopes=token_scopes, user=user)
except (JWTError, ValidationError) as exc: except (JWTError, ValidationError) as exc:
@ -133,6 +158,7 @@ async def get_current_user(
detail="Not enough permissions", detail="Not enough permissions",
headers={"WWW-Authenticate": authenticate_value}, headers={"WWW-Authenticate": authenticate_value},
) )
return user_record return user_record
@ -141,4 +167,5 @@ async def get_current_active_user(
): ):
if current_user.disabled: if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user") raise HTTPException(status_code=400, detail="Inactive user")
return current_user return current_user