diff --git a/core/addon.py b/core/addon.py index f0121e7..3bc9d8c 100644 --- a/core/addon.py +++ b/core/addon.py @@ -233,6 +233,22 @@ async def async_setup_addon( return True + async def read(self, key): + """read key/value pair from addon.""" + try: + component = importlib.import_module(f"core.addons.{self.domain}") + except ImportError as err: + _LOGGER.error(f"Unable to import addon '{self.domain}': {err}") + return False + except Exception: # pylint: disable=broad-except + _LOGGER.exception(f"Setup failed for {self.domain}: unknown error") + return False + + if hasattr(component, "read"): + await component.read(self.core, key) + else: + _LOGGER.error(f"Unable to read key from addon '{self.domain}'") + async def async_process_images_in_addons(self, images) -> bool: """Trigger image addons with images to process.""" if self.type == AddonType.IMAGE: diff --git a/core/addons/api/__init__.py b/core/addons/api/__init__.py index dac9377..6903b52 100644 --- a/core/addons/api/__init__.py +++ b/core/addons/api/__init__.py @@ -4,13 +4,12 @@ import os import pathlib from datetime import datetime -from typing import Optional from aiohttp import web + from core.addons.api.dto.details import Details from core.addons.api.dto.location import Location -from core.addons.api.dto.photo import (PhotoDetailsResponse, PhotoEncoder, - PhotoResponse) +from core.addons.api.dto.photo import PhotoDetailsResponse, PhotoEncoder, PhotoResponse from core.addons.api.dto.photo_response import PhotosResponse from core.base import Session from core.core import ApplicationCore @@ -63,7 +62,7 @@ class PhotosView(RequestView): async def get(self, core: ApplicationCore, request: web.Request) -> web.Response: """Get a list of all photo resources.""" - _LOGGER.debug(f"GET /v1/photos") + _LOGGER.debug("GET /v1/photos") await core.authentication.check_permission(request, "library:read") user_id = await core.http.get_user_id(request) @@ -79,7 +78,9 @@ async def get(self, core: ApplicationCore, request: web.Request) -> web.Response if "offset" in request.query: offset = int(request.query["offset"]) - _LOGGER.debug(f"read {limit} photos for user_id {user_id} beginning with {offset}") + _LOGGER.debug( + f"read {limit} photos for user_id {user_id} beginning with {offset}" + ) user_photos = await core.storage.read_photos(user_id, offset, limit) results = [] @@ -89,12 +90,16 @@ async def get(self, core: ApplicationCore, request: web.Request) -> web.Response PhotoResponse( id=photo.uuid, name=photo.filename, - image_url=f"{core.config.external_url}/v1/file/{photo.uuid}" + image_url=f"{core.config.external_url}/v1/file/{photo.uuid}", ) ) - response = PhotosResponse(offset=offset, limit=limit, size=len(results), results=results) - return web.Response(text=json.dumps(response, cls=PhotoEncoder), content_type="application/json") + response = PhotosResponse( + offset=offset, limit=limit, size=len(results), results=results + ) + return web.Response( + text=json.dumps(response, cls=PhotoEncoder), content_type="application/json" + ) class PhotoDetailsView(RequestView): @@ -103,7 +108,9 @@ class PhotoDetailsView(RequestView): url = "/v1/photo/{entity_id}" name = "v1:photo" - async def get(self, core: ApplicationCore, request: web.Request, entity_id: str) -> web.Response: + async def get( + self, core: ApplicationCore, request: web.Request, entity_id: str + ) -> web.Response: """Return an entity.""" _LOGGER.debug(f"GET /v1/photo/{entity_id}") @@ -133,9 +140,13 @@ async def get(self, core: ApplicationCore, request: web.Request, entity_id: str) if latitude is not None and longitude is not None: altitude = await core.storage.read("altitude") if altitude is not None: - location = Location(latitude=latitude, longitude=longitude, altitude=altitude) + location = Location( + latitude=latitude, longitude=longitude, altitude=altitude + ) else: - location = Location(latitude=latitude, longitude=longitude, altitude="0.0") + location = Location( + latitude=latitude, longitude=longitude, altitude="0.0" + ) # photo tags tags = await core.storage.read("tags") @@ -143,21 +154,24 @@ async def get(self, core: ApplicationCore, request: web.Request, entity_id: str) result = PhotoDetailsResponse( id=photo.uuid, name=photo.filename, - author=photo.owner, + owner=photo.owner, created_at=ctime.isoformat(), + modified_at=mtime.isoformat(), details=Details( - camera="Nikon Z7", - lens="Nikkor 200mm F1.8", - focal_length="200", - iso="400", - shutter_speed="1/2000", - aperture="4.0", + camera="Nikon Z7", + lens="Nikkor 200mm F1.8", + focal_length="200", + iso="400", + shutter_speed="1/2000", + aperture="4.0", ), tags=tags, location=location, - image_url=f"{core.config.external_url}/v1/file/{entity_id}" + image_url=f"{core.config.external_url}/v1/file/{entity_id}", + ) + return web.Response( + text=json.dumps(result, cls=PhotoEncoder), content_type="application/json" ) - return web.Response(text=json.dumps(result, cls=PhotoEncoder), content_type="application/json") class PhotoView(RequestView): @@ -168,7 +182,9 @@ class PhotoView(RequestView): url = "/v1/file/{entity_id}" name = "v1:file" - async def get(self, core: ApplicationCore, request: web.Request, entity_id: str) -> web.Response: + async def get( + self, core: ApplicationCore, request: web.Request, entity_id: str + ) -> web.Response: """Return an entity.""" _LOGGER.debug(f"GET /v1/file/{entity_id}") @@ -179,10 +195,12 @@ async def get(self, core: ApplicationCore, request: web.Request, entity_id: str) # -d remove exif data result = Session.query(Photo).filter(Photo.uuid == entity_id).first() - - file = os.path.join(result.directory, result.filename) - if os.path.exists(os.path.join(file)): - return web.FileResponse(path=file, status=200) + if result: + file = os.path.join(result.directory, result.filename) + if os.path.exists(os.path.join(file)): + return web.FileResponse(path=file, status=200) + else: + raise web.HTTPNotFound() else: raise web.HTTPNotFound() @@ -224,7 +242,9 @@ async def post(self, core: ApplicationCore, request: web.Request) -> web.Respons status_code = HTTP_CREATED if new_entity_created else HTTP_OK - resp = self.json_message(f"File successfully added with ID: {new_entity_id}", status_code) + resp = self.json_message( + f"File successfully added with ID: {new_entity_id}", status_code + ) resp.headers.add("Location", f"/api/photo/{new_entity_id}") return resp diff --git a/core/addons/api/dto/photo.py b/core/addons/api/dto/photo.py index edf7486..5c744d1 100644 --- a/core/addons/api/dto/photo.py +++ b/core/addons/api/dto/photo.py @@ -9,12 +9,11 @@ def default(self, o): """Encode all properties.""" return o.__dict__ + class PhotoResponse: """Photo response object.""" - def __init__( - self, id, name, image_url - ): + def __init__(self, id, name, image_url): """Initialize photo response object.""" self.id = id self.name = name @@ -25,13 +24,23 @@ class PhotoDetailsResponse: """Photo response object.""" def __init__( - self, id, name, owner, created_at, details, tags, location, image_url + self, + id, + name, + owner, + created_at, + modified_at, + details, + tags, + location, + image_url, ): """Initialize photo response object.""" self.id = id self.name = name self.owner = owner self.created_at = created_at + self.modified_at = modified_at self.details = details self.tags = tags self.location = location diff --git a/core/authentication/__init__.py b/core/authentication/__init__.py index 5314330..a584a92 100644 --- a/core/authentication/__init__.py +++ b/core/authentication/__init__.py @@ -11,7 +11,6 @@ import aiohttp_jinja2 from aiohttp import hdrs, web -from ..authorization import Authorization from ..const import CONF_TOKEN_LIFETIME from .auth_client import AuthenticationClient from .auth_database import AuthDatabase @@ -36,11 +35,17 @@ def __init__( self.auth_database = auth_database # Authorization Endpoint: obtain an authorization grant - self.app.router.add_get(path="/oauth/authorize", handler=self.authorization_endpoint_get) - self.app.router.add_post(path="/oauth/authorize", handler=self.authorization_endpoint_post) + self.app.router.add_get( + path="/oauth/authorize", handler=self.authorization_endpoint_get + ) + self.app.router.add_post( + path="/oauth/authorize", handler=self.authorization_endpoint_post + ) # Token Endpoint: obtain an access token by authorization grant or refresh token - self.app.router.add_post(path="/oauth/token", handler=self.token_endpoint_handler) + self.app.router.add_post( + path="/oauth/token", handler=self.token_endpoint_handler + ) self.app.router.add_post("/revoke", self.revoke_token_handler, name="revoke") self.app.router.add_get("/protected", self.protected_handler, name="protected") @@ -73,7 +78,9 @@ async def protected_handler(self, request: web.Request) -> web.StreamResponse: return response @aiohttp_jinja2.template("authorize.jinja2") - async def authorization_endpoint_get(self, request: web.Request) -> web.StreamResponse: + async def authorization_endpoint_get( + self, request: web.Request + ) -> web.StreamResponse: """ Validate the request to ensure that all required parameters are present and valid. @@ -105,7 +112,9 @@ async def authorization_endpoint_get(self, request: web.Request) -> web.StreamRe # validate response_type if response_type != "code": - _LOGGER.warning(f"The request is using an invalid response_type: {response_type}") + _LOGGER.warning( + f"The request is using an invalid response_type: {response_type}" + ) data = """{ "error":"unsupported_response_type", "error_description":"The request is using an invalid response_type" @@ -120,7 +129,9 @@ async def authorization_endpoint_get(self, request: web.Request) -> web.StreamRe None, ) # validate if redirect_uri is in registered_auth_client - if not any(uri == redirect_uri for uri in registered_auth_client.redirect_uris): + if not any( + uri == redirect_uri for uri in registered_auth_client.redirect_uris + ): _LOGGER.error(f"redirect uri not found: {redirect_uri}") data = """{ "error":"unauthorized_client", @@ -153,7 +164,9 @@ async def authorization_endpoint_get(self, request: web.Request) -> web.StreamRe # check if the requested scope is registered for requested_scope in requested_scopes: if requested_scope not in registered_scopes: - _LOGGER.error(f"The requested scope '{requested_scope}' is invalid, unknown, or malformed.") + _LOGGER.error( + f"The requested scope '{requested_scope}' is invalid, unknown, or malformed." + ) data = """{ "error":"invalid_scope", "error_description":"The requested scope is invalid, unknown, or malformed." @@ -227,7 +240,9 @@ async def authorization_endpoint_get(self, request: web.Request) -> web.StreamRe }""" return web.json_response(json.loads(data)) - async def authorization_endpoint_post(self, request: web.Request) -> web.StreamResponse: + async def authorization_endpoint_post( + self, request: web.Request + ) -> web.StreamResponse: """ Validate the resource owners credentials. @@ -252,7 +267,9 @@ async def authorization_endpoint_post(self, request: web.Request) -> web.StreamR if not any(client.client_id == client_id for client in self.auth_clients): _LOGGER.warning(f"unknown client_id {client_id}") if state is not None: - raise web.HTTPFound(f"{redirect_uri}?error=unauthorized_client&state={state}") + raise web.HTTPFound( + f"{redirect_uri}?error=unauthorized_client&state={state}" + ) else: raise web.HTTPFound(f"{redirect_uri}?error=unauthorized_client") @@ -265,41 +282,57 @@ async def authorization_endpoint_post(self, request: web.Request) -> web.StreamR if not any(uri == redirect_uri for uri in registered_auth_client.redirect_uris): _LOGGER.error(f"invalid redirect_uri {redirect_uri}") if state is not None: - raise web.HTTPFound(f"{redirect_uri}?error=unauthorized_client&state={state}") + raise web.HTTPFound( + f"{redirect_uri}?error=unauthorized_client&state={state}" + ) else: raise web.HTTPFound(f"{redirect_uri}?error=unauthorized_client") - username = data["uname"] + email = data["email"] password = data["password"] # validate credentials - credentials_are_valid = await self.auth_database.check_credentials(username, password) + credentials_are_valid = await self.auth_database.check_credentials( + email, password + ) if credentials_are_valid: # create an authorization code - authorization_code = self.auth_database.create_authorization_code(username, client_id, request.remote) + authorization_code = self.auth_database.create_authorization_code( + email, client_id, request.remote + ) _LOGGER.debug(f"authorization_code: {authorization_code}") if authorization_code is None: _LOGGER.warning("could not create auth code for client!") error_reason = "access_denied" if state is not None: - raise web.HTTPFound(f"{redirect_uri}?error={error_reason}&state={state}") + raise web.HTTPFound( + f"{redirect_uri}?error={error_reason}&state={state}" + ) else: raise web.HTTPFound(f"{redirect_uri}?error={error_reason}") if state is not None: - _LOGGER.debug(f"HTTPFound: {redirect_uri}?code={authorization_code}&state={state}") - redirect_response = web.HTTPFound(f"{redirect_uri}?code={authorization_code}&state={state}") + _LOGGER.debug( + f"HTTPFound: {redirect_uri}?code={authorization_code}&state={state}" + ) + redirect_response = web.HTTPFound( + f"{redirect_uri}?code={authorization_code}&state={state}" + ) else: _LOGGER.debug(f"HTTPFound: {redirect_uri}?code={authorization_code}") - redirect_response = web.HTTPFound(f"{redirect_uri}?code={authorization_code}") + redirect_response = web.HTTPFound( + f"{redirect_uri}?code={authorization_code}" + ) raise redirect_response else: error_reason = "access_denied" _LOGGER.warning(f"redirect with error {error_reason}") if state is not None: - raise web.HTTPFound(f"{redirect_uri}?error={error_reason}&state={state}") + raise web.HTTPFound( + f"{redirect_uri}?error={error_reason}&state={state}" + ) else: raise web.HTTPFound(f"{redirect_uri}?error={error_reason}") @@ -358,13 +391,17 @@ async def _handle_authorization_code_request(self, data) -> web.StreamResponse: return web.json_response(status=400, data=data) client_id = data["client_id"] - client_code_valid = await self.auth_database.validate_authorization_code(code, client_id) + client_code_valid = await self.auth_database.validate_authorization_code( + code, client_id + ) if not client_code_valid: _LOGGER.error("authorization_code invalid!") payload = {"error": "invalid_grant"} return web.json_response(status=400, data=payload) - access_token, refresh_token = await self.auth_database.create_tokens(code, client_id) + access_token, refresh_token = await self.auth_database.create_tokens( + code, client_id + ) payload = { "access_token": access_token, @@ -374,7 +411,9 @@ async def _handle_authorization_code_request(self, data) -> web.StreamResponse: } return web.json_response(status=200, data=payload) - async def _handle_refresh_token_request(self, request: web.Request, data) -> web.StreamResponse: + async def _handle_refresh_token_request( + self, request: web.Request, data + ) -> web.StreamResponse: """ See Section 6: https://tools.ietf.org/html/rfc6749#section-6 """ @@ -414,7 +453,9 @@ async def _handle_refresh_token_request(self, request: web.Request, data) -> web data = {"error": "invalid_client"} return web.json_response(data) - access_token, refresh_token = await self.auth_database.renew_tokens(client_id, refresh_token) + access_token, refresh_token = await self.auth_database.renew_tokens( + client_id, refresh_token + ) if access_token is None: raise web.HTTPForbidden() @@ -436,11 +477,13 @@ def create_client(self): _LOGGER.info(f"generated client_secret: {client_secret}") async def check_authorized(self, request: web.Request) -> Optional[str]: - """Check if authorization header and returns username if valid""" + """Check if authorization header and returns user ID if valid""" if hdrs.AUTHORIZATION in request.headers: try: - auth_type, auth_val = request.headers.get(hdrs.AUTHORIZATION).split(" ", 1) + auth_type, auth_val = request.headers.get(hdrs.AUTHORIZATION).split( + " ", 1 + ) if not await self.auth_database.validate_access_token(auth_val): raise web.HTTPForbidden() diff --git a/core/authentication/auth_database.py b/core/authentication/auth_database.py index 0048eff..4642d8a 100644 --- a/core/authentication/auth_database.py +++ b/core/authentication/auth_database.py @@ -3,7 +3,10 @@ import string import uuid from datetime import datetime, timedelta -from typing import Optional, Tuple +from typing import TYPE_CHECKING, Optional, Tuple + +if TYPE_CHECKING: + from core.core import ApplicationCore import jwt from passlib.hash import sha256_crypt @@ -21,11 +24,10 @@ class AuthDatabase: - def __init__(self): + def __init__(self, core: "ApplicationCore", data_dir: str): Base.metadata.create_all(engine) - self.session = Session() - users = self.session.query(User).all() + users = Session.query(User).all() if len(users) < 1: # insert admin user with generated password into database generated_password = "".join( @@ -33,31 +35,40 @@ def __init__(self): for _ in range(10) ) _LOGGER.warning( - f"generated an admin user with password: '{generated_password}'" + f"generated an admin user with username: 'admin@photos.network' password: '{generated_password}'." ) hashed = sha256_crypt.hash(generated_password) - user = User("admin", hashed, True, False) - self.session.add(user) - self.session.commit() + user = User( + "admin@photos.network", + hashed, + "Administrator", + "Generated User", + True, + False, + ) + Session.add(user) + Session.commit() + + core.storage.create_user_home(user.id) - async def check_credentials(self, username: str, password: str) -> bool: - """Check if the given username is found, not disabled and matches with the hashed password.""" + async def check_credentials(self, email: str, password: str) -> bool: + """Check if the given email is found, not disabled and matches with the hashed password.""" user = ( - self.session.query(User) - .filter(User.login == username) + Session.query(User) + .filter(User.email == email) .filter(User.disabled == false()) .first() ) if user is not None: - return sha256_crypt.verify(password, user.passwd) + return sha256_crypt.verify(password, user.password) else: return False def create_authorization_code( self, - username: str, + email: str, client_id: str, origin: str, ) -> Optional[uuid.UUID]: @@ -67,19 +78,19 @@ def create_authorization_code( authorization_code = uuid.uuid4() user = ( - self.session.query(User) - .filter(User.login == username) + Session.query(User) + .filter(User.email == email) .filter(User.disabled == false()) .first() ) - self.session.query(User).filter(User.login == username).update( + Session.query(User).filter(User.email == email).update( {User.last_login: datetime.utcnow(), User.last_ip: origin}, synchronize_session=False, ) - self.session.add(AuthorizationCode(user.id, client_id, str(authorization_code))) - self.session.commit() + Session.add(AuthorizationCode(user.id, client_id, str(authorization_code))) + Session.commit() return authorization_code @@ -92,7 +103,7 @@ async def validate_authorization_code( Validates the given authorization_code """ count = ( - self.session.query(AuthorizationCode) + Session.query(AuthorizationCode) .filter(AuthorizationCode.authorization_code == authorization_code) .filter(AuthorizationCode.expiration_time < str(datetime.utcnow)) .filter(AuthorizationCode.used == false()) @@ -100,7 +111,7 @@ async def validate_authorization_code( ) if count > 0: - self.session.query(AuthorizationCode).filter( + Session.query(AuthorizationCode).filter( AuthorizationCode.authorization_code == authorization_code ).filter(AuthorizationCode.expiration_time < str(datetime.utcnow)).update( {AuthorizationCode.used: True}, synchronize_session=False @@ -120,7 +131,7 @@ async def create_tokens( Create and persist access and refresh tokens. """ auth_code = ( - self.session.query(AuthorizationCode) + Session.query(AuthorizationCode) .filter(AuthorizationCode.authorization_code == authorization_code) .first() ) @@ -139,8 +150,8 @@ async def create_tokens( access_token=access_token, refresh_token=refresh_token, ) - self.session.add(token) - self.session.commit() + Session.add(token) + Session.commit() return access_token, refresh_token @@ -151,7 +162,7 @@ async def renew_tokens( # validate refresh token count = ( - self.session.query(Token) + Session.query(Token) .filter(Token.client_id == client_id) .filter(Token.refresh_token == refresh_token) .count() @@ -173,7 +184,7 @@ async def renew_tokens( ) # update tokens in database - self.session.query(Token).filter(Token.client_id == client_id).filter( + Session.query(Token).filter(Token.client_id == client_id).filter( Token.token_expiration < str(datetime.utcnow) ).update( { @@ -184,7 +195,7 @@ async def renew_tokens( }, synchronize_session=False, ) - self.session.commit() + Session.commit() return new_access_token, new_refresh_token @@ -194,34 +205,34 @@ async def revoke_token(self, token: str) -> bool: # TODO: validate if the token was issued by the requesting # TODO: if the provided token is a refresh_token, invalidate all access_tokens based with this refresh_token count = ( - self.session.query(Token) + Session.query(Token) .filter(or_(Token.access_token == token, Token.refresh_token == token)) .delete() ) - self.session.commit() + Session.commit() return count > 0 async def validate_access_token(self, access_token: str) -> bool: count = ( - self.session.query(Token) + Session.query(Token) .filter(Token.access_token == access_token) .filter(Token.token_expiration > str(datetime.utcnow())) .count() ) if count > 0: - self.session.query(Token).filter(Token.access_token == access_token).update( + Session.query(Token).filter(Token.access_token == access_token).update( {Token.last_used: datetime.utcnow()}, synchronize_session=False, ) - self.session.commit() + Session.commit() return count > 0 async def user_id_for_token(self, access_token: str) -> str: row = ( - self.session.query(Token) + Session.query(Token) .filter(Token.access_token == access_token) .filter(Token.token_expiration > str(datetime.utcnow())) .first() diff --git a/core/authentication/dto/token.py b/core/authentication/dto/token.py index 95f6aea..8e6deff 100644 --- a/core/authentication/dto/token.py +++ b/core/authentication/dto/token.py @@ -1,17 +1,17 @@ """Access token representation.""" from datetime import datetime, timedelta -from sqlalchemy import Column, DateTime, Integer, String +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String -from ...base import Base +from ...base import Base, generate_uuid from ...const import CONF_TOKEN_LIFETIME class Token(Base): __tablename__ = "tokens" - id = Column(Integer, primary_key=True) - user_id = Column(Integer) + id = Column(String, name="uuid", primary_key=True, default=generate_uuid) + user_id = Column(Integer, ForeignKey("users.uuid"), nullable=False) client_id = Column(String) access_token = Column(String) token_expiration = Column(DateTime) diff --git a/core/authentication/dto/user.py b/core/authentication/dto/user.py index 70a76c8..0324145 100644 --- a/core/authentication/dto/user.py +++ b/core/authentication/dto/user.py @@ -1,9 +1,9 @@ """User representation.""" import datetime -from sqlalchemy import Boolean, Column, DateTime, Integer, String +from sqlalchemy import Boolean, Column, DateTime, String -from ...base import Base +from ...base import Base, generate_uuid class User(Base): @@ -11,20 +11,27 @@ class User(Base): __tablename__ = "users" - id = Column(Integer, primary_key=True) - login = Column(String) - passwd = Column(String) + id = Column(String, name="uuid", primary_key=True, default=generate_uuid) + email = Column(String) + password = Column(String) + lastname = Column(String) + firstname = Column(String) is_superuser = Column(Boolean) disabled = Column(Boolean) create_date = Column(DateTime, default=datetime.datetime.utcnow) + deleted_date = Column(DateTime, nullable=True) last_login = Column(DateTime) last_ip = Column(String) - def __init__(self, login, passwd, is_superuser=False, disabled=False): - self.login = login - self.passwd = passwd + def __init__( + self, email, password, lastname, firstname, is_superuser=False, disabled=False + ): + self.email = email + self.password = password + self.lastname = lastname + self.firstname = firstname self.is_superuser = is_superuser self.disabled = disabled def __repr__(self): - return f"User(id={self.id!r}, login={self.login!r}, disabled={self.disabled!r})" + return f"User(id={self.id!r}, email={self.email!r}, disabled={self.disabled!r})" diff --git a/core/authorization/__init__.py b/core/authorization/__init__.py index 4dd9ddb..3931765 100644 --- a/core/authorization/__init__.py +++ b/core/authorization/__init__.py @@ -1,11 +1,15 @@ """authorization handles permissions / rights""" -import json import logging -from typing import TYPE_CHECKING, Optional +from datetime import datetime, timedelta +from typing import TYPE_CHECKING -from aiohttp import hdrs, web +from aiohttp import web +from passlib.hash import sha256_crypt +from ..authentication.dto.token import Token +from ..authentication.dto.user import User from ..base import Base, Session, engine +from ..const import CONF_DEADLINE if TYPE_CHECKING: from core.core import ApplicationCore @@ -19,99 +23,234 @@ def __init__(self, core: "ApplicationCore", application: web.Application): self.core = core self.app = application - self.app.router.add_put(path="/v1/users/", handler=self.create_user_handler) self.app.router.add_get(path="/v1/users", handler=self.get_users_handler) - self.app.router.add_get(path="/v1/users/{userId}", handler=self.get_user_handler) - self.app.router.add_patch(path="/v1/users/{userId}", handler=self.update_user_handler) - self.app.router.add_delete(path="/v1/users/{userId}", handler=self.delete_user_handler) + self.app.router.add_get(path="/v1/user/", handler=self.me_user_handler) + self.app.router.add_get(path="/v1/user/{userId}", handler=self.get_user_handler) + self.app.router.add_post(path="/v1/user/", handler=self.create_user_handler) + self.app.router.add_patch( + path="/v1/user/{userId}", handler=self.update_user_handler + ) + self.app.router.add_delete( + path="/v1/user/{userId}", handler=self.delete_user_handler + ) Base.metadata.create_all(engine) + async def me_user_handler(self, request: web.Request) -> web.StreamResponse: + """Return detailed information of the current loggedin user.""" + _LOGGER.debug("GET /user/ myself") + + await self.core.authentication.check_permission(request, "openid") + await self.core.authentication.check_permission(request, "profile") + await self.core.authentication.check_permission(request, "email") + + loggedInUser = await self.core.authentication.check_authorized(request) + _LOGGER.debug(f"loggedInUser {loggedInUser}") + + user = Session.query(User).filter(User.id == loggedInUser).first() + _LOGGER.debug(f"user {user}") + + lastSeen = "Never" + if user.last_login is not None: + lastSeen = user.last_login.isoformat() + + data = { + "id": user.id, + "email": user.email, + "lastname": user.lastname, + "firstname": user.firstname, + "lastSeen": lastSeen, + } + + return web.json_response(status=200, data=data) + + async def get_users_handler(self, request: web.Request) -> web.StreamResponse: + """Return a simplified list of registered Users.""" + _LOGGER.debug("GET /users") + await self.core.authentication.check_permission(request, "admin.users:read") + + users = Session.query(User).all() + + if len(users) < 1: + raise web.HTTPInternalServerError() + + result = [] + for user in users: + lastSeen = "Never" + if user.last_login is not None: + lastSeen = user.last_login.isoformat() + + result.append( + { + "id": user.id, + "lastName": user.lastname, + "firstName": user.firstname, + "lastSeen": lastSeen, + } + ) + + return web.json_response(status=200, data=result) + + async def get_user_handler(self, request: web.Request) -> web.StreamResponse: + """Get a user by user ID""" + userId = request.match_info["userId"] + _LOGGER.debug(f"GET /user/{userId}") + + await self.core.authentication.check_permission(request, "openid") + await self.core.authentication.check_permission(request, "profile") + await self.core.authentication.check_permission(request, "email") + + if userId is None: + raise web.HTTPBadRequest() + + user = Session.query(User).filter(User.id == userId).first() + if not user: + raise web.HTTPNotFound + + lastSeen = "Never" + if user.last_login is not None: + lastSeen = user.last_login.isoformat() + + data = { + "id": user.id, + "lastname": user.lastname, + "firstname": user.firstname, + "lastSeen": lastSeen, + } + + return web.json_response(status=200, data=data) + async def update_user_handler(self, request: web.Request) -> web.StreamResponse: + """Update properties of the loggedin user.""" userId = request.match_info["userId"] - _LOGGER.debug(f"PATCH /users/{userId}") - await self.core.authentication.check_permission(request, "admin.users:write") + _LOGGER.debug(f"PATCH /user/{userId}") + + await self.core.authentication.check_permission(request, "openid") + await self.core.authentication.check_permission(request, "profile") + await self.core.authentication.check_permission(request, "email") + + loggedInUser = await self.core.authentication.check_authorized(request) + if loggedInUser != userId: + _LOGGER.error( + f"attempt to change a different user! Authenticated user: {loggedInUser}, user to change: {userId}" + ) + raise web.HTTPForbidden + data = await request.json() - # TODO: update user properties + if "email" in data: + Session.query(User).filter(User.id == loggedInUser).update( + {User.email: data["email"]}, + synchronize_session=False, + ) + if "firstname" in data: + Session.query(User).filter(User.id == loggedInUser).update( + {User.firstname: data["firstname"]}, + synchronize_session=False, + ) + + if "lastname" in data: + Session.query(User).filter(User.id == loggedInUser).update( + {User.lastname: data["lastname"]}, + synchronize_session=False, + ) + + if "password" in data: + hashed = sha256_crypt.hash(data["password"]) + Session.query(User).filter(User.id == loggedInUser).update( + {User.password: hashed}, + synchronize_session=False, + ) + + Session.commit() return web.json_response(status=204) async def delete_user_handler(self, request: web.Request) -> web.StreamResponse: userId = request.match_info["userId"] _LOGGER.debug(f"DELETE /users/{userId}") - await self.core.authentication.check_permission(request, "admin.users:write") + await self.core.authentication.check_permission(request, "profile") - # TODO: delete user + loggedInUser = await self.core.authentication.check_authorized(request) + if loggedInUser != userId: + # admin users can delete other users + try: + await self.core.authentication.check_permission( + request, "admin.users:write" + ) + except web.HTTPForbidden: + _LOGGER.error( + f"attempt to change a different user! Authenticated user: {loggedInUser}, user to change: {userId}" + ) + raise web.HTTPForbidden - return web.json_response(status=204) + Session.query(User).filter(User.id == loggedInUser).update( + {User.deleted_date: datetime.utcnow(), User.disabled: True}, + synchronize_session=False, + ) + + Session.commit() + + deadline = datetime.utcnow() + timedelta(seconds=CONF_DEADLINE) + data = {"deadline": deadline.isoformat()} + + # invalidate all user tokens + Session.query(Token).filter(Token.user_id == loggedInUser).delete() + return web.json_response(status=202, data=data) async def create_user_handler(self, request: web.Request) -> web.StreamResponse: - _LOGGER.warning(f"PUT /users/") + _LOGGER.warning("POST /user/") await self.core.authentication.check_permission(request, "admin.users:write") - data = request.content + data = await request.json() + if ( + "email" not in data + or "lastname" not in data + or "firstname" not in data + or "password" not in data + ): + raise web.HTTPBadRequest - # TODO: create user with given properties - _LOGGER.warning(f"TODO: create/update data: {data}") - login = data["login"] + email = data["email"] + lastname = data["lastname"] + firstname = data["firstname"] + password = data["password"] - user = Session.query(User).filter(User.login == username).filter(User.disabled == false()).first() - - if user is not None: - hashed = sha256_crypt.hash(generated_password) - user = User("max", hashed, True, False) - Session.add(user) - Session.commit() + count = Session.query(User).filter(User.email == email).count() + if count > 0: data = { - "properties.id": "2", - "properties.email": "max.mustermann@photos.network", - "properties.firstName": "Max", - "properties.lastName": "Mustermann", + "error": "alreday_registered", + "error_description": "This email is already registered!", } - - return web.json_response(status=200, data=data) - else: - data = {"error": "not_implemented", "error_description": "This request is missing an implementation"} - return web.json_response(status=400, data=data) - async def get_users_handler(self, request: web.Request) -> web.StreamResponse: - _LOGGER.debug(f"GET /users") - await self.core.authentication.check_permission(request, "admin.users:read") + hashed = sha256_crypt.hash(password) + user = User( + email=email, password=hashed, lastname=lastname, firstname=firstname + ) + Session.add(user) + Session.commit() - # TODO: get users - data = { - "value": [ - { - "id": "1", - "displayName": "Max Mustermann", - "email": "max.mustermann@photos.network", - "firstName": "Max", - "lastName": "Mustermann", - } - ] - } + new_user = Session.query(User).filter(User.email == email).first() - return web.json_response(status=200, data=data) + if new_user: + data = { + "id": new_user.id, + "email": new_user.email, + "firstname": new_user.firstname, + "lastname": new_user.lastname, + } - async def get_user_handler(self, request: web.Request) -> web.StreamResponse: - userId = request.match_info["userId"] - _LOGGER.debug(f"GET /users/{userId}") - await self.core.authentication.check_permission(request, "admin.users:read") + # create user home + self.core.storage.create_user_home(new_user.id) - # TODO: get user properties - data = { - "properties.id": "1", - "properties.email": "max.mustermann@photos.network", - "properties.firstName": "Max", - "properties.lastName": "Mustermann", - } + return web.json_response(status=201, data=data) - return web.json_response(status=200, data=data) + _LOGGER.error(f"Could not find newly created user {email}") + return web.json_response(status=500, data=data) async def check_scope(self, scope: str): + # TODO: check if requested scope has been granted by the user when creating the current token _LOGGER.warning(f"check_scope({scope})") - # TODO: raise if scope is missing # raise web.HTTPForbidden() diff --git a/core/base.py b/core/base.py index 7fdc6df..351b5db 100644 --- a/core/base.py +++ b/core/base.py @@ -1,9 +1,16 @@ +import uuid + from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import scoped_session, sessionmaker -engine = create_engine("sqlite:///data/system.sqlite3") +# TODO: get data directory from config +engine = create_engine("sqlite:///data/system.sqlite3", echo=False) session_factory = sessionmaker(bind=engine) Session = scoped_session(session_factory) Base = declarative_base() + + +def generate_uuid(): + return str(uuid.uuid4()) diff --git a/core/const.py b/core/const.py index 6b6109f..2dfd264 100644 --- a/core/const.py +++ b/core/const.py @@ -13,9 +13,13 @@ URL_API = "/api/" +# token lifetime: 1hour (60sec * 60min) CONF_TOKEN_LIFETIME = 3600 CONF_ACCESS_TOKEN = "access_token" CONF_ADDRESS = "address" CONF_CLIENT_ID = "client_id" CONF_CLIENT_SECRET = "client_secret" + +# token lifetime: 30days (60sec * 60min * 24hour * 30days) +CONF_DEADLINE = 2592000 CORE_VERSION = __version__ diff --git a/core/core.py b/core/core.py index 9b46a03..7a6feba 100644 --- a/core/core.py +++ b/core/core.py @@ -8,8 +8,18 @@ import sys from logging.handlers import TimedRotatingFileHandler from time import monotonic -from typing import (Any, Awaitable, Callable, Dict, Iterable, List, Optional, - Sequence, Set, TypeVar) +from typing import ( + Any, + Awaitable, + Callable, + Dict, + Iterable, + List, + Optional, + Sequence, + Set, + TypeVar, +) from colorlog import ColoredFormatter @@ -17,6 +27,7 @@ from core.addon import Addon from core.authentication import Authentication, AuthenticationClient from core.authorization import Authorization +from core.base import Base, engine from core.configs import Config from core.persistency.persistency import PersistencyManager from core.utils.timeout import TimeoutManager @@ -296,15 +307,17 @@ async def async_block_till_done(self) -> None: self.http = Webserver(self) + Base.metadata.create_all(engine) + # setup addons from config entries await self.async_set_up_addons() + await self.storage.start_directory_observing() + _LOGGER.info("Observe user directories for file changes.") + await self.http.start() _LOGGER.info("Webserver should be up and running...") - await self.storage.start_directory_observing() - _LOGGER.info("Observe user directories.") - while self._pending_tasks: pending = [task for task in self._pending_tasks if not task.done()] self._pending_tasks.clear() diff --git a/core/persistency/dto/directory.py b/core/persistency/dto/directory.py new file mode 100644 index 0000000..b3d9920 --- /dev/null +++ b/core/persistency/dto/directory.py @@ -0,0 +1,30 @@ +"""Directory representation for persistency.""" +import uuid +from datetime import datetime + +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String + +from ...base import Base + + +def generate_uuid(): + return str(uuid.uuid4()) + + +class Directory(Base): + """Directory representation for user directories containing files.""" + + __tablename__ = "directories" + + id = Column(String, name="uuid", primary_key=True, default=generate_uuid) + directory = Column(String) + owner = Column(Integer, ForeignKey("users.uuid"), nullable=False) + added = Column(DateTime, default=datetime.utcnow) + last_scanned = Column(DateTime) + + def __init__(self, directory, owner): + self.directory = directory + self.owner = owner + + def __repr__(self): + return f"Directory({self.directory}, owner={self.owner})" diff --git a/core/persistency/dto/photo.py b/core/persistency/dto/photo.py new file mode 100644 index 0000000..e3f11d0 --- /dev/null +++ b/core/persistency/dto/photo.py @@ -0,0 +1,24 @@ +"""Photo representation for persistency.""" +from sqlalchemy import Boolean, Column, ForeignKey, Integer, String + +from ...base import Base, generate_uuid + + +class Photo(Base): + """Photo representation for photo files.""" + + __tablename__ = "photos" + + uuid = Column(String, name="uuid", primary_key=True, default=generate_uuid) + directory = Column(String) + filename = Column(String) + owner = Column(Integer, ForeignKey("users.uuid"), nullable=False) + is_missing = Column(Boolean, default=False) + hash = Column(String) + + def __init__(self, directory, filename, owner, is_missing=False, hash=""): + self.directory = directory + self.filename = filename + self.owner = owner + self.is_missing = is_missing + self.hash = hash diff --git a/core/persistency/persistency.py b/core/persistency/persistency.py index 13c9043..61f7fa3 100644 --- a/core/persistency/persistency.py +++ b/core/persistency/persistency.py @@ -1,12 +1,23 @@ """Persistency manager""" import logging import os -from typing import TYPE_CHECKING, Set +import time +from queue import Queue +from threading import Thread +from typing import TYPE_CHECKING, List, Optional, Set + +from sqlalchemy import and_ +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer # Typing imports that create a circular dependency from core.addon import AddonType from core.configs import Config +from ..base import Session +from .dto.directory import Directory +from .dto.photo import Photo + if TYPE_CHECKING: from core.core import ApplicationCore @@ -15,27 +26,146 @@ _LOGGER.setLevel(logging.DEBUG) +class FileHandler(FileSystemEventHandler): + def __init__(self, queue: Queue): + self.queue = queue + + def process(self, event: FileSystemEvent): + self.queue.put(event) + + def on_created(self, event: FileSystemEvent): + self.process(event) + + def on_moved(self, event): + self.process(event) + + def on_deleted(self, event): + self.process(event) + + class PersistencyManager: """Class to manage persistency tasks and trigger storage addons.""" def __init__(self, config: Config, core: "ApplicationCore") -> None: """Initialize Persistency Manager.""" - self._config = config - self._core = core - self._addons: Set[str] = config.addons - self._default_path = self._config.data_dir + self.config = config + self.core = core + self.addons: Set[str] = config.addons + self.observer = Observer() + self.watchdog_queue = Queue() + self.event_handler = FileHandler(self.watchdog_queue) + + # Set up a worker thread to process database load + self.worker = Thread( + target=self.process_load_queue, args=(self.watchdog_queue,) + ) + self.worker.setDaemon(True) + + def process_load_queue(self, queue: Queue) -> None: + """Process file/directory changes + event.event_type + 'modified' | 'created' | 'moved' | 'deleted' + event.is_directory + True | False + event.src_path + path/to/observed/file + """ + while True: + if not queue.empty(): + event = queue.get() + file = os.path.join(event.src_path) + parent = os.path.dirname(file) + filename = os.path.basename(file) + + if event.event_type == "created": + if not event.is_directory: + _LOGGER.info(f"added: {event.src_path}") + if os.path.exists(event.src_path): + directory = ( + Session.query(Directory) + .filter(Directory.directory == parent) + .first() + ) + + # TODO: verify the filename does not exist already + photo = Photo(parent, filename, directory.owner) + Session.add(photo) + Session.commit() + elif event.event_type == "moved": + _LOGGER.warning(f"file move not handled: {event.src_path}") + elif event.event_type == "deleted": + Session.query(Photo).filter( + and_(Photo.directory == parent, Photo.filename == filename) + ).delete() + _LOGGER.info(f"deleted: {event.src_path}") + else: + time.sleep(1) + + async def start_directory_observing(self) -> None: + """Start observing user directories for changes.""" + self.worker.start() + + directories = Session.query(Directory).all() + for dir in directories: + path = os.path.join(dir.directory) + self.observer.schedule(self.event_handler, path, recursive=True) + self.observer.start() + + async def stop_directory_observing(self) -> None: + """Stop observing user directories.""" + self.worker.stop() + self.observer.stop() + self.observer.join() + + async def restart_directory_observing(self) -> None: + """stop and re-start directory observing.""" + await self.stop_directory_observing() + await self.start_directory_observing() - def file_exist(self, file_name: str, path: str = None) -> bool: + def create_user_home(self, user_id: str) -> None: + """Create a directory for the user in the configured data directory.""" + # create user_home in filesystem + path = os.path.join(self.config.data_dir, user_id) + os.mkdir(path) + + # add user_home to observing + self.observer.schedule(self.event_handler, path, recursive=True) + + # add user_home to database + directory = Directory(path, user_id) + Session.add(directory) + Session.commit() + + _LOGGER.info(f"user home for user {user_id} created.") + + async def read_photos(self, user_id: int, offset: int = 0, limit: int = 10) -> List: + return ( + Session.query(Photo) + .filter(Photo.owner == user_id) + .offset(offset * limit) + .limit(limit) + .all() + ) + + async def read_photo(self, photo_id) -> Optional[Photo]: + """request a single photo by id from database""" + return Session.query(Photo).filter(Photo.uuid == photo_id).first() + + async def scan_user_directory(self, directory) -> None: + """Scan given directory and add media items into the database.""" + pass + + async def file_exist(self, file_name: str, path: str = None) -> bool: if path is None: - path = self._default_path + path = self.config.data_dir _LOGGER.info(f"check if file {file_name} exists in path {path}") return os.path.exists(os.path.join(file_name, path)) - def create_file(self, file_name: str, path: str = None): + async def create_file(self, file_name: str, path: str = None): if path is None: - default_path = self._default_path + default_path = self.config.data_dir else: default_path = path @@ -44,8 +174,40 @@ def create_file(self, file_name: str, path: str = None): file_object = open(os.path.join(file_name, default_path), "x") file_object.close() - def create_key(self, key): - for addon in self._core.loaded_addons.values(): + async def create(self, key, value): + """ + Create the given key/value pair to all storage addons + """ + for addon in self.core.loaded_addons.values(): + if addon.type == AddonType.STORAGE: + # TODO: write key to persistency + _LOGGER.warning(f" Addon: {addon} {type(addon)} {key}") + await addon.create(key, value) + + async def read(self, key): + """ + Try to read the given key from all storage addons. + """ + for addon in self.core.loaded_addons.values(): + if addon.type == AddonType.STORAGE: + # TODO: read key from persistency + + _LOGGER.warning( + f" Addon: {addon} {type(self.core.loaded_addons)} {key}" + ) + _LOGGER.warning(f" Addon: {addon} {type(addon)} {key}") + await addon.read(key) + + async def update(self, key: str, value: str): + for addon in self.core.loaded_addons.values(): + if addon.type == AddonType.STORAGE: + # TODO: write key to persistency + _LOGGER.warning(f" Addon: {addon} {type(addon)} {key}") + await addon.update(key, value) + + async def delete(self, key: str): + for addon in self.core.loaded_addons.values(): if addon.type == AddonType.STORAGE: # TODO: write key to persistency - _LOGGER.warning(f" Addon: {addon} {key}") + _LOGGER.warning(f" Addon: {addon} {type(addon)} {key}") + await addon.delete(key) diff --git a/core/webserver/__init__.py b/core/webserver/__init__.py index 4c292cb..c9fa6f0 100644 --- a/core/webserver/__init__.py +++ b/core/webserver/__init__.py @@ -11,7 +11,7 @@ from aiohttp.web_middlewares import middleware from .. import const -from ..authentication import Authentication, AuthenticationClient +from ..authentication import Authentication from ..authentication.auth_database import AuthDatabase from ..authorization import Authorization from .request import KEY_AUTHENTICATED, KEY_USER_ID, RequestView # noqa: F401 @@ -80,7 +80,7 @@ async def start(self): _LOGGER.info(f"Webserver is listening on {site._host}:{site._port}") async def init_auth(self): - auth_database = AuthDatabase() + auth_database = AuthDatabase(self.core, self.core.config.data_dir) # setup auth self.core.authorization = Authorization(self.core, self.app) diff --git a/core/webserver/templates/authorize.jinja2 b/core/webserver/templates/authorize.jinja2 index a524e7b..645b89a 100644 --- a/core/webserver/templates/authorize.jinja2 +++ b/core/webserver/templates/authorize.jinja2 @@ -79,7 +79,7 @@ span.password {
- + diff --git a/requirements.txt b/requirements.txt index 12a411b..f14a1f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,4 +13,5 @@ pyyaml>=5.3.1 sqlalchemy>=1.3.20 voluptuous>=0.12.0 voluptuous-serialize>=2.4.0 +watchdog>=2.0.0 wheel>=0.36.2 diff --git a/setup.py b/setup.py index c5887ca..00ca779 100644 --- a/setup.py +++ b/setup.py @@ -64,6 +64,7 @@ "sqlalchemy>=1.3.20", "voluptuous>=0.12.0", "voluptuous-serialize>=2.4.0", + "watchdog>=2.0.0", "wheel>=0.36.2", ], )