Compare commits

...

4 commits

Author SHA1 Message Date
Campbell Alden
72ece8af72 Change how services are provided so that they don't go via "extensions" 2026-08-02 22:50:47 +09:00
Campbell Alden
7b6d351282 Move the notification service into the services directory 2026-08-02 22:50:34 +09:00
Campbell Alden
387e2fa490 Add an auth service for minting and decoding JWTs 2026-08-02 22:50:05 +09:00
Campbell Alden
328f430309 Rename parsing error to be more reusable 2026-08-02 22:49:38 +09:00
14 changed files with 148 additions and 70 deletions

View file

@ -11,5 +11,8 @@
"database": { "database": {
"url": "sqlite://", "url": "sqlite://",
"echo": false "echo": false
},
"auth": {
"ed25519_private_key": "A base64 encoded EdDSA Private Key"
} }
} }

View file

@ -3,7 +3,7 @@ with python313Packages;
buildPythonApplication { buildPythonApplication {
pname = "cereal"; pname = "cereal";
version = "0.0.1"; version = "0.0.1";
propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi]; propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi pyjwt cryptography];
src = ./.; src = ./.;
pyproject = true; pyproject = true;
build-system = [setuptools]; build-system = [setuptools];

View file

@ -6,6 +6,8 @@ let
waitress waitress
sqlalchemy sqlalchemy
argon2-cffi argon2-cffi
pyjwt
cryptography
]); ]);
in in
with pkgs; with pkgs;

View file

@ -4,6 +4,7 @@ from typing import Any
from .logging import Logging from .logging import Logging
from .email import Email from .email import Email
from .auth import Auth
from .database import Database from .database import Database
from .parse import assert_key_of_type, parse_nested_config from .parse import assert_key_of_type, parse_nested_config
@ -15,19 +16,25 @@ class Config:
logging: Logging logging: Logging
email: Email email: Email
database: Database database: Database
auth: Auth
@classmethod @classmethod
def from_dict(cls, config: dict[str, Any]) -> 'Config': 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, 'host', str)
assert_key_of_type(config, 'port', int) assert_key_of_type(config, 'port', int)
log_config = parse_nested_config(config, 'logging', Logging.from_dict) log_config = parse_nested_config(config, 'logging', Logging.from_dict)
email_config = parse_nested_config(config, 'email', Email.from_dict) email_config = parse_nested_config(config, 'email', Email.from_dict)
db_config = parse_nested_config(config, 'database', Database.from_dict) db_config = parse_nested_config(config, 'database', Database.from_dict)
auth_config = parse_nested_config(config, 'auth', Auth.from_dict)
return Config( return Config(
host=config['host'], port=config['port'], email=email_config, logging=log_config, database=db_config host=config['host'],
port=config['port'],
email=email_config,
logging=log_config,
database=db_config,
auth=auth_config,
) )

16
src/config/auth.py Normal file
View file

@ -0,0 +1,16 @@
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))

View file

@ -2,7 +2,7 @@ from typing import Any
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from .parse import assert_key_of_type, ConfigParseError from .parse import assert_key_of_type, ParseError
LOG_LEVELS = { LOG_LEVELS = {
'critical': logging.CRITICAL, 'critical': logging.CRITICAL,
@ -25,7 +25,7 @@ class Logging:
assert_key_of_type(config, 'level', str) assert_key_of_type(config, 'level', str)
log_level_setting = config['level'] log_level_setting = config['level']
if log_level_setting not in LOG_LEVELS: if log_level_setting not in LOG_LEVELS:
raise ConfigParseError( raise ParseError(
['level'], f'unknown log level "{log_level_setting}". Expected one of: {", ".join(LOG_LEVELS.keys())}' ['level'], f'unknown log level "{log_level_setting}". Expected one of: {", ".join(LOG_LEVELS.keys())}'
) )

View file

@ -1,7 +1,7 @@
from typing import Any, TypeVar, Callable from typing import Any, TypeVar, Callable
class ConfigParseError(Exception): class ParseError(Exception):
"""An error for when parsing a config fails""" """An error for when parsing a config fails"""
def __init__(self, keypath: list[str], issue: str): def __init__(self, keypath: list[str], issue: str):
@ -14,10 +14,10 @@ class ConfigParseError(Exception):
def assert_key_of_type(config: dict[str, Any], key: str, kind: Any): def assert_key_of_type(config: dict[str, Any], key: str, kind: Any):
if key not in config: if key not in config:
raise ConfigParseError([key], 'missing') raise ParseError([key], 'missing')
if not isinstance(config[key], kind): if not isinstance(config[key], kind):
raise ConfigParseError([key], f'type of "{key}" was {type(config[key])}, expected {kind}') raise ParseError([key], f'type of "{key}" was {type(config[key])}, expected {kind}')
T = TypeVar('T') 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) assert_key_of_type(config, key, dict)
try: try:
return parse(config[key]) return parse(config[key])
except ConfigParseError as e: except ParseError as e:
raise ConfigParseError([key, *e.keypath], e.issue) raise ParseError([key, *e.keypath], e.issue)

View file

@ -1,8 +1,10 @@
#!/usr/bin/env python #!/usr/bin/env python
from src.notifications.service import make_notification_service from src.services.notifications import NotificationService, get_notification_service
from src.services.email import get_service as get_email_service from src.services.email import EmailService, get_email_service
from src.services.users import get_service as get_user_service from src.services.auth import AuthService
from src.services.users import UserService
from dataclasses import dataclass
from src.infra.db import get_database from src.infra.db import get_database
from src.infra.users import UserRepoImpl from src.infra.users import UserRepoImpl
from waitress import serve from waitress import serve
@ -15,20 +17,31 @@ from src.config import parse_config, Config
logger = logging.getLogger(__name__) 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: def create_app(name: str, config: Config) -> Flask:
logging.basicConfig(level=config.logging.level, format=('%(asctime)s %(levelname)s [%(name)s] %(message)s')) logging.basicConfig(level=config.logging.level, format=('%(asctime)s %(levelname)s [%(name)s] %(message)s'))
app = Flask(name) app = MyFlask(name)
email_service = get_email_service(config.email)
notification_service = make_notification_service(email_service)
database = get_database(config.database) database = get_database(config.database)
user_repo = UserRepoImpl(database) user_repo = UserRepoImpl(database)
user_service = get_user_service(user_repo) email_service = get_email_service(config.email)
app.services = AppServices(
app.extensions['email'] = email_service users=UserService(user_repo),
app.extensions['notifications'] = notification_service auth=AuthService(config.auth),
app.extensions['user_service'] = user_service email=email_service,
notifications=get_notification_service(email_service),
)
if config.host: if config.host:
app.config['SERVER_NAME'] = config.host app.config['SERVER_NAME'] = config.host

View file

@ -1,38 +0,0 @@
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

45
src/services/auth.py Normal file
View file

@ -0,0 +1,45 @@
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

View file

@ -42,5 +42,5 @@ class BirdEmailServiceImpl(EmailService):
return response.ok return response.ok
def get_service(config: EmailConfig) -> EmailService: def get_email_service(config: EmailConfig) -> EmailService:
return BirdEmailServiceImpl(config) return BirdEmailServiceImpl(config)

View file

@ -2,11 +2,45 @@ import abc
from typing import Any from typing import Any
from src.services.email import EmailService from src.services.email import EmailService
from src.config import Config
from .data import Notification from dataclasses import dataclass, field
# MUSTFIX: Needs real users! 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
class NotificationSender(abc.ABC): class NotificationSender(abc.ABC):
@ -48,7 +82,7 @@ class EmailNotifier(NotificationSender):
pass pass
def make_notification_service(email_service: EmailService): def get_notification_service(email_service: EmailService):
service = NotificationService() service = NotificationService()
service.register_sender(EmailNotifier(email_service)) service.register_sender(EmailNotifier(email_service))

View file

@ -96,7 +96,3 @@ class UserService:
user = self._repo.get_user_by_id(user_id) user = self._repo.get_user_by_id(user_id)
if user: if user:
return user.to_profile() return user.to_profile()
def get_service(repo: UserRepo) -> UserService:
return UserService(repo)