Compare commits

..

No commits in common. "72ece8af72d3d7e21b84c041a0a3c02331e49168" and "0af1b827aa4000bc07c31253ddc76536bcb56028" have entirely different histories.

14 changed files with 70 additions and 148 deletions

View file

@ -11,8 +11,5 @@
"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 pyjwt cryptography]; propagatedBuildInputs = [ flask requests waitress sqlalchemy argon2-cffi];
src = ./.; src = ./.;
pyproject = true; pyproject = true;
build-system = [setuptools]; build-system = [setuptools];

View file

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

View file

@ -4,7 +4,6 @@ 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
@ -16,25 +15,19 @@ 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'], host=config['host'], port=config['port'], email=email_config, logging=log_config, database=db_config
port=config['port'],
email=email_config,
logging=log_config,
database=db_config,
auth=auth_config,
) )

View file

@ -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))

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, ParseError from .parse import assert_key_of_type, ConfigParseError
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 ParseError( raise ConfigParseError(
['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 ParseError(Exception): class ConfigParseError(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 ParseError(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 ParseError([key], 'missing') raise ConfigParseError([key], 'missing')
if not isinstance(config[key], kind): 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') 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 ParseError as e: except ConfigParseError as e:
raise ParseError([key, *e.keypath], e.issue) raise ConfigParseError([key, *e.keypath], e.issue)

View file

@ -1,10 +1,8 @@
#!/usr/bin/env python #!/usr/bin/env python
from src.services.notifications import NotificationService, get_notification_service from src.notifications.service import make_notification_service
from src.services.email import EmailService, get_email_service from src.services.email import get_service as get_email_service
from src.services.auth import AuthService from src.services.users import get_service as get_user_service
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
@ -17,31 +15,20 @@ 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 = MyFlask(name) app = Flask(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)
email_service = get_email_service(config.email) user_service = get_user_service(user_repo)
app.services = AppServices(
users=UserService(user_repo), app.extensions['email'] = email_service
auth=AuthService(config.auth), app.extensions['notifications'] = notification_service
email=email_service, app.extensions['user_service'] = user_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

38
src/notifications/data.py Normal file
View file

@ -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

View file

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

View file

@ -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

View file

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

View file

@ -96,3 +96,7 @@ 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)