diff --git a/config.example.json b/config.example.json index b97e6a3..a994526 100644 --- a/config.example.json +++ b/config.example.json @@ -11,8 +11,5 @@ "database": { "url": "sqlite://", "echo": false - }, - "auth": { - "ed25519_private_key": "A base64 encoded EdDSA Private Key" } } diff --git a/derivation.nix b/derivation.nix index 639f015..e5cc219 100644 --- a/derivation.nix +++ b/derivation.nix @@ -3,7 +3,7 @@ with python313Packages; buildPythonApplication { pname = "cereal"; version = "0.0.1"; - propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography]; + propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi]; src = ./.; pyproject = true; build-system = [setuptools]; diff --git a/shell.nix b/shell.nix index 50007eb..2a39902 100644 --- a/shell.nix +++ b/shell.nix @@ -6,8 +6,6 @@ let waitress sqlalchemy argon2-cffi - pyjwt - cryptography ]); in with pkgs; diff --git a/src/config/__init__.py b/src/config/__init__.py index dab2591..32b37b3 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -4,7 +4,6 @@ from typing import Any from .logging import Logging from .email import Email -from .auth import Auth from .database import Database from .parse import assert_key_of_type, parse_nested_config @@ -16,25 +15,19 @@ class Config: logging: Logging email: Email database: Database - auth: Auth @classmethod def from_dict(cls, config: dict[str, Any]) -> 'Config': + assert_key_of_type(config, 'logging', dict) + assert_key_of_type(config, 'email', dict) assert_key_of_type(config, 'host', str) assert_key_of_type(config, 'port', int) - log_config = parse_nested_config(config, 'logging', Logging.from_dict) email_config = parse_nested_config(config, 'email', Email.from_dict) db_config = parse_nested_config(config, 'database', Database.from_dict) - auth_config = parse_nested_config(config, 'auth', Auth.from_dict) return Config( - host=config['host'], - port=config['port'], - email=email_config, - logging=log_config, - database=db_config, - auth=auth_config, + host=config['host'], port=config['port'], email=email_config, logging=log_config, database=db_config ) diff --git a/src/config/auth.py b/src/config/auth.py deleted file mode 100644 index dbc5f33..0000000 --- a/src/config/auth.py +++ /dev/null @@ -1,16 +0,0 @@ -from base64 import b64decode -from typing import Any -from src.config.parse import assert_key_of_type -from src.utils.secret import SecretBox -from dataclasses import dataclass - - -@dataclass -class Auth: - ed25519_private_key: SecretBox[bytes] - - @classmethod - def from_dict(cls, config: dict[str, Any]) -> 'Auth': - assert_key_of_type(config, 'ed25519_private_key', str) - private_key = b64decode(config['ed25519_private_key']) - return Auth(ed25519_private_key=SecretBox(private_key)) diff --git a/src/config/logging.py b/src/config/logging.py index b77dd08..43a3071 100644 --- a/src/config/logging.py +++ b/src/config/logging.py @@ -2,7 +2,7 @@ from typing import Any from dataclasses import dataclass import logging -from .parse import assert_key_of_type, ParseError +from .parse import assert_key_of_type, ConfigParseError LOG_LEVELS = { 'critical': logging.CRITICAL, @@ -25,7 +25,7 @@ class Logging: assert_key_of_type(config, 'level', str) log_level_setting = config['level'] if log_level_setting not in LOG_LEVELS: - raise ParseError( + raise ConfigParseError( ['level'], f'unknown log level "{log_level_setting}". Expected one of: {", ".join(LOG_LEVELS.keys())}' ) diff --git a/src/config/parse.py b/src/config/parse.py index 2faea78..c50df3f 100644 --- a/src/config/parse.py +++ b/src/config/parse.py @@ -1,7 +1,7 @@ from typing import Any, TypeVar, Callable -class ParseError(Exception): +class ConfigParseError(Exception): """An error for when parsing a config fails""" def __init__(self, keypath: list[str], issue: str): @@ -14,10 +14,10 @@ class ParseError(Exception): def assert_key_of_type(config: dict[str, Any], key: str, kind: Any): if key not in config: - raise ParseError([key], 'missing') + raise ConfigParseError([key], 'missing') if not isinstance(config[key], kind): - raise ParseError([key], f'type of "{key}" was {type(config[key])}, expected {kind}') + raise ConfigParseError([key], f'type of "{key}" was {type(config[key])}, expected {kind}') T = TypeVar('T') @@ -27,5 +27,5 @@ def parse_nested_config(config: dict[str, Any], key: str, parse: Callable[[dict[ assert_key_of_type(config, key, dict) try: return parse(config[key]) - except ParseError as e: - raise ParseError([key, *e.keypath], e.issue) + except ConfigParseError as e: + raise ConfigParseError([key, *e.keypath], e.issue) diff --git a/src/main.py b/src/main.py index cfc48d8..d046769 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,8 @@ #!/usr/bin/env python -from src.services.notifications import NotificationService, get_notification_service -from src.services.email import EmailService, get_email_service -from src.services.auth import AuthService -from src.services.users import UserService -from dataclasses import dataclass +from src.notifications.service import make_notification_service +from src.services.email import get_service as get_email_service +from src.services.users import get_service as get_user_service from src.infra.db import get_database from src.infra.users import UserRepoImpl from waitress import serve @@ -17,31 +15,20 @@ from src.config import parse_config, Config logger = logging.getLogger(__name__) -@dataclass -class AppServices: - users: UserService - auth: AuthService - email: EmailService - notifications: NotificationService - - -class MyFlask(Flask): - services: AppServices - - def create_app(name: str, config: Config) -> Flask: logging.basicConfig(level=config.logging.level, format=('%(asctime)s %(levelname)s [%(name)s] %(message)s')) - app = MyFlask(name) + app = Flask(name) + + email_service = get_email_service(config.email) + notification_service = make_notification_service(email_service) database = get_database(config.database) user_repo = UserRepoImpl(database) - email_service = get_email_service(config.email) - app.services = AppServices( - users=UserService(user_repo), - auth=AuthService(config.auth), - email=email_service, - notifications=get_notification_service(email_service), - ) + user_service = get_user_service(user_repo) + + app.extensions['email'] = email_service + app.extensions['notifications'] = notification_service + app.extensions['user_service'] = user_service if config.host: app.config['SERVER_NAME'] = config.host diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/notifications/data.py b/src/notifications/data.py new file mode 100644 index 0000000..a2633c8 --- /dev/null +++ b/src/notifications/data.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass, field + +URL = str + + +@dataclass +class NotificationAction: + # A unique identifier for the action + action: str + # The title to show with the action + title: str + # Where to navigate to when clicked. + navigate: URL | None = None + + +@dataclass +class Notification: + """ + Details to show in a notification + + Much of the data here is intended to match the arguments to the [showNotification](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification) + Web API, but this content is ideally displayable in other contexts (for example in Email or RSS) + """ + + # The main title content to show + title: str + # The content of the notification + content: str + + # The UNIX timestamp in milliseconds since the epoch associated with this notification + timestamp: int + # A list of actions included with this notification + actions: list[NotificationAction] = field(default_factory=list) + # The location of an icon to show as the icon for this notification + icon: URL | None = None + + # The location of an image associated with this notification + image: URL | None = None diff --git a/src/services/notifications.py b/src/notifications/service.py similarity index 50% rename from src/services/notifications.py rename to src/notifications/service.py index 67e8b12..ed64cce 100644 --- a/src/services/notifications.py +++ b/src/notifications/service.py @@ -2,45 +2,11 @@ import abc from typing import Any from src.services.email import EmailService +from src.config import Config -from dataclasses import dataclass, field +from .data import Notification -URL = str - - -@dataclass -class NotificationAction: - # A unique identifier for the action - action: str - # The title to show with the action - title: str - # Where to navigate to when clicked. - navigate: URL | None = None - - -@dataclass -class Notification: - """ - Details to show in a notification - - Much of the data here is intended to match the arguments to the [showNotification](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification) - Web API, but this content is ideally displayable in other contexts (for example in Email or RSS) - """ - - # The main title content to show - title: str - # The content of the notification - content: str - - # The UNIX timestamp in milliseconds since the epoch associated with this notification - timestamp: int - # A list of actions included with this notification - actions: list[NotificationAction] = field(default_factory=list) - # The location of an icon to show as the icon for this notification - icon: URL | None = None - - # The location of an image associated with this notification - image: URL | None = None +# MUSTFIX: Needs real users! class NotificationSender(abc.ABC): @@ -82,7 +48,7 @@ class EmailNotifier(NotificationSender): pass -def get_notification_service(email_service: EmailService): +def make_notification_service(email_service: EmailService): service = NotificationService() service.register_sender(EmailNotifier(email_service)) diff --git a/src/services/auth.py b/src/services/auth.py deleted file mode 100644 index ec38c2c..0000000 --- a/src/services/auth.py +++ /dev/null @@ -1,45 +0,0 @@ -from src.config.parse import assert_key_of_type, ParseError -from typing import Any -from dataclasses import dataclass, asdict -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -import jwt - -from src.services.users import User -from src.config.auth import Auth as AuthConfig - -JWT = str - - -@dataclass -class Claims: - id: int - - @classmethod - def from_user(cls, user: User) -> 'Claims': - return Claims(id=user.id) - - @classmethod - def from_dict(cls, claims: dict[str, Any]) -> 'Claims': - assert_key_of_type(claims, 'id', int) - return Claims(id=claims['id']) - - -class AuthService: - def __init__(self, config: AuthConfig): - self._private_key = Ed25519PrivateKey.from_private_bytes(config.ed25519_private_key.expose_secret()) - self._public_key = self._private_key.public_key() - - def mint_jwt(self, user: User) -> JWT: - claims = Claims.from_user(user) - return jwt.encode(asdict(claims), self._private_key, algorithm='EdDSA') - - def validate_token(self, token: JWT) -> Claims | None: - try: - claims = jwt.decode(token, key=self._public_key, algorithms=['EdDSA']) - except jwt.InvalidTokenError: - return None - - try: - return Claims.from_dict(claims) - except ParseError: - return None diff --git a/src/services/email.py b/src/services/email.py index 918bd28..dc51ad0 100644 --- a/src/services/email.py +++ b/src/services/email.py @@ -42,5 +42,5 @@ class BirdEmailServiceImpl(EmailService): return response.ok -def get_email_service(config: EmailConfig) -> EmailService: +def get_service(config: EmailConfig) -> EmailService: return BirdEmailServiceImpl(config) diff --git a/src/services/users.py b/src/services/users.py index 3132f33..9b4a0c2 100644 --- a/src/services/users.py +++ b/src/services/users.py @@ -96,3 +96,7 @@ class UserService: user = self._repo.get_user_by_id(user_id) if user: return user.to_profile() + + +def get_service(repo: UserRepo) -> UserService: + return UserService(repo)