Compare commits
No commits in common. "72ece8af72d3d7e21b84c041a0a3c02331e49168" and "0af1b827aa4000bc07c31253ddc76536bcb56028" have entirely different histories.
72ece8af72
...
0af1b827aa
14 changed files with 70 additions and 148 deletions
|
|
@ -11,8 +11,5 @@
|
|||
"database": {
|
||||
"url": "sqlite://",
|
||||
"echo": false
|
||||
},
|
||||
"auth": {
|
||||
"ed25519_private_key": "A base64 encoded EdDSA Private Key"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ let
|
|||
waitress
|
||||
sqlalchemy
|
||||
argon2-cffi
|
||||
pyjwt
|
||||
cryptography
|
||||
]);
|
||||
in
|
||||
with pkgs;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
@ -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())}'
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
37
src/main.py
37
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
|
||||
|
|
|
|||
0
src/notifications/__init__.py
Normal file
0
src/notifications/__init__.py
Normal file
38
src/notifications/data.py
Normal file
38
src/notifications/data.py
Normal 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
|
||||
|
|
@ -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))
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue