From a4b8e4fee30b717105dfe598fb8985906c674a79 Mon Sep 17 00:00:00 2001 From: Campbell Alden Date: Sat, 8 Aug 2026 01:35:59 +0900 Subject: [PATCH] Add a link to the newspaper advertising A Study in Scarlett --- content/sherlock-holmes.md | 2 +- src/services/publications/__init__.py | 5 ++ src/services/publications/data.py | 6 ++ src/services/publications/repo.py | 26 +++++++ src/services/publications/service.py | 108 ++++++++++++++++++++++++++ 5 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/services/publications/__init__.py create mode 100644 src/services/publications/data.py create mode 100644 src/services/publications/repo.py create mode 100644 src/services/publications/service.py diff --git a/content/sherlock-holmes.md b/content/sherlock-holmes.md index 702b9de..108b519 100644 --- a/content/sherlock-holmes.md +++ b/content/sherlock-holmes.md @@ -14,7 +14,7 @@ Acceptable Date Justifications: - Unverified Trusted Source (UTS): Something like a bibliography or a physical book being used as a justification which I haven't independently verified. ### A Study in Scarlet -November 21st 1887: [ENA](./evidence/a-study-in-scarlet-ad.png) +November 21st 1887: [ENA](./evidence/a-study-in-scarlet-ad.png) from [The Standard November 21st 1887 Edition](https://www.britishnewspaperarchive.com/image-viewer?issue=BL%2F0000183%2F18871121&page=8) This was released on November 21st 1887 in [Beeton's Christmas Annual](https://archive.org/details/beetons-christmas-annual-nov-1887/Beetons-Christmas-Annual-1887/page/1/mode/1up). ### The Sign of the Four diff --git a/src/services/publications/__init__.py b/src/services/publications/__init__.py new file mode 100644 index 0000000..f733467 --- /dev/null +++ b/src/services/publications/__init__.py @@ -0,0 +1,5 @@ +from .data import User, UserProfile, UserDTO, SignupError, LoginError +from .service import UserService +from .repo import UserRepo + +__all__ = ['User', 'UserProfile', 'UserDTO', 'SignupError', 'LoginError', 'UserService', 'UserRepo'] diff --git a/src/services/publications/data.py b/src/services/publications/data.py new file mode 100644 index 0000000..e40a1e6 --- /dev/null +++ b/src/services/publications/data.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass +class Publication: + id = int diff --git a/src/services/publications/repo.py b/src/services/publications/repo.py new file mode 100644 index 0000000..f50836b --- /dev/null +++ b/src/services/publications/repo.py @@ -0,0 +1,26 @@ +import abc +from src.config.email import EmailAddress + +from .data import User, UserDTO + + +class UserRepo(abc.ABC): + @abc.abstractmethod + def create_user(self, user: UserDTO) -> User: + pass + + @abc.abstractmethod + def get_user_by_id(self, user_id: int) -> User | None: + pass + + @abc.abstractmethod + def get_user_by_email(self, email: EmailAddress) -> User | None: + pass + + @abc.abstractmethod + def update_user(self, user: User): + pass + + @abc.abstractmethod + def auth_as_user(self, user: UserDTO) -> User | None: + pass diff --git a/src/services/publications/service.py b/src/services/publications/service.py new file mode 100644 index 0000000..d481cc0 --- /dev/null +++ b/src/services/publications/service.py @@ -0,0 +1,108 @@ +from email.headerregistry import Address +from typing import TypedDict, Unpack + +from jinja2 import Environment, PackageLoader, select_autoescape + +from src.constants.routes import ROUTES +from src.services.auth import AuthService, EmailConfirmationClaim, JWT +from src.services.router import Router +from src.services.email import EmailService, EmailDTO + +from .data import ( + UserDTO, + User, + UserProfile, + SignupError, + LoginError, + EmailConfirmationTokenInvalid, +) +from .repo import UserRepo + + +def is_valid_email(email: str) -> bool: + try: + parsed = Address(addr_spec=email) + return bool(parsed.username and parsed.domain and '.' in parsed.domain) + except (ValueError, TypeError): + return False + + +def is_valid_password(password: str) -> bool: + # TODO: Enforce other or saner rules? + long_enough = len(password) > 8 + short_enough = len(password) < 32 + has_symbol = any([s in password for s in list('@#$%^&*!?/')]) + return long_enough and short_enough and has_symbol + + +class ServiceDeps(TypedDict): + repo: UserRepo + email_service: EmailService + auth_service: AuthService + router: Router + + +class UserService: + def __init__(self, **kwargs: Unpack[ServiceDeps]): + self._repo = kwargs['repo'] + self._email_service = kwargs['email_service'] + self._auth_service = kwargs['auth_service'] + self._router = kwargs['router'] + + def login(self, user: UserDTO) -> UserProfile: + full_user = self._repo.auth_as_user(user) + if full_user: + return full_user.to_profile() + else: + raise LoginError('No user found for that email or password') + + def confirm_email_for_user(self, user_id: int, confirmation_token: JWT): + """ + Attempt to confirm that the user at the given ID has confirmed their email by returning the JWT that was + minted for this purpose. + """ + claim = self._auth_service.validate_token(EmailConfirmationClaim, confirmation_token) + if not claim or claim.sub != user_id: + raise EmailConfirmationTokenInvalid + + user = self._repo.get_user_by_id(user_id) + # If there is no user or the users emails don't match or the user is already confirmed then consider + # the token invalid for this request. + if not user or user.email != claim.email or user.email_confirmed: + raise EmailConfirmationTokenInvalid + + user.email_confirmed = True + self._repo.update_user(user) + + def _send_confirmation_email(self, user: User): + env = Environment(loader=PackageLoader('src'), autoescape=select_autoescape()) + text_template = env.get_template('mail/confirmation_email.txt') + html_template = env.get_template('mail/confirmation_email.html') + token = self._auth_service.mint_claim_from_user(EmailConfirmationClaim, user) + + url = self._router.get_url(ROUTES['confirm_email'], token=token) + text_content = text_template.render(confirmation_link=url) + html_content = html_template.render(confirmation_link=url) + + email = EmailDTO(to=user.email, subject='Confirm Your Email Address', text=text_content, html=html_content) + + self._email_service.send_email(email) + + def signup(self, user: UserDTO) -> UserProfile: + if not is_valid_email(user.email): + raise SignupError(f'{user.email} was not an acceptable email address') + + if not is_valid_password(user.raw_password.expose_secret()): + raise SignupError('The given password was not acceptable') + + # Create a user in persistence + created_user = self._repo.create_user(user) + # send a confirmation email + self._send_confirmation_email(created_user) + + return created_user.to_profile() + + def get_user_by_id(self, user_id: int) -> UserProfile | None: + user = self._repo.get_user_by_id(user_id) + if user: + return user.to_profile()