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": {
|
"database": {
|
||||||
"url": "sqlite://",
|
"url": "sqlite://",
|
||||||
"echo": false
|
"echo": false
|
||||||
},
|
|
||||||
"auth": {
|
|
||||||
"ed25519_private_key": "A base64 encoded EdDSA Private Key"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ let
|
||||||
waitress
|
waitress
|
||||||
sqlalchemy
|
sqlalchemy
|
||||||
argon2-cffi
|
argon2-cffi
|
||||||
pyjwt
|
|
||||||
cryptography
|
|
||||||
]);
|
]);
|
||||||
in
|
in
|
||||||
with pkgs;
|
with pkgs;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
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())}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
37
src/main.py
37
src/main.py
|
|
@ -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
|
||||||
|
|
|
||||||
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 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))
|
||||||
|
|
||||||
|
|
@ -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
|
return response.ok
|
||||||
|
|
||||||
|
|
||||||
def get_email_service(config: EmailConfig) -> EmailService:
|
def get_service(config: EmailConfig) -> EmailService:
|
||||||
return BirdEmailServiceImpl(config)
|
return BirdEmailServiceImpl(config)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue