Add a link to the newspaper advertising A Study in Scarlett

This commit is contained in:
Campbell Alden 2026-08-08 01:35:59 +09:00
parent 5ba29b509e
commit a4b8e4fee3
5 changed files with 146 additions and 1 deletions

View file

@ -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. - 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 ### 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). 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 ### The Sign of the Four

View file

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

View file

@ -0,0 +1,6 @@
from dataclasses import dataclass
@dataclass
class Publication:
id = int

View file

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

View file

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